diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b22b2c2344..22bb16ffad 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -78,6 +78,16 @@ jobs: type=ref,event=tag type=raw,value=release,enable=${{ github.event_name == 'release' }} + - name: Determine build cache output + id: cache-target + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + # Essentially just ignore the cache output (PR can't write to registry cache) + echo "cache-to=type=local,dest=/tmp/discard,ignore-error=true" >> $GITHUB_OUTPUT + else + echo "cache-to=type=registry,mode=max,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{ matrix.image }}" >> $GITHUB_OUTPUT + fi + - name: Build and push image uses: docker/build-push-action@v3.3.0 with: @@ -85,6 +95,6 @@ jobs: platforms: linux/arm/v7,linux/amd64,linux/arm64 # Skip pushing when PR from a fork push: ${{ !github.event.pull_request.head.repo.fork }} - cache-from: type=gha - cache-to: type=gha,mode=max - tags: ${{ steps.metadata.outputs.tags }} \ No newline at end of file + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}} + cache-to: ${{ steps.cache-target.outputs.cache-to }} + tags: ${{ steps.metadata.outputs.tags }} diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 288ebdd512..3cf6fb6cbd 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 + with: + token: ${{ secrets.ORG_RELEASE_TOKEN }} - name: Bump version run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}" diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml new file mode 100644 index 0000000000..896b740b9b --- /dev/null +++ b/.github/workflows/static_analysis.yml @@ -0,0 +1,31 @@ +name: Static Code Analysis +on: + workflow_dispatch: + pull_request: + push: + branches: [main] + +jobs: + mobile-dart-analyze: + name: Run Dart Code Analysis + + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + flutter-version: '3.3.10' + + - name: Install dependencies + run: dart pub get + working-directory: ./mobile + + - name: Run dart analyze + run: dart analyze --fatal-infos + working-directory: ./mobile + diff --git a/docker/.env.test b/docker/.env.test index d48b4f53fa..23f58fe805 100644 --- a/docker/.env.test +++ b/docker/.env.test @@ -10,9 +10,6 @@ REDIS_HOSTNAME=immich-redis-test # Upload File Config UPLOAD_LOCATION=./upload -# JWT SECRET -JWT_SECRET=randomstringthatissolongandpowerfulthatnoonecanguess - # MAPBOX ## ENABLE_MAPBOX is either true of false -> if true, you have to provide MAPBOX_KEY ENABLE_MAPBOX=false diff --git a/docker/example.env b/docker/example.env index 922a873c05..2cfb1e7351 100644 --- a/docker/example.env +++ b/docker/example.env @@ -30,16 +30,6 @@ REDIS_HOSTNAME=immich_redis UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup -################################################################################### -# JWT SECRET -# -# This JWT_SECRET is used to sign the authentication keys for user login -# You should set it to a long randomly generated value -# You can use this command to generate one: openssl rand -base64 128 -################################################################################### - -JWT_SECRET= - ################################################################################### # Reverse Geocoding # diff --git a/docs/docs/FAQ.md b/docs/docs/FAQ.md index 82a8b68f07..4f4be6f4a8 100644 --- a/docs/docs/FAQ.md +++ b/docs/docs/FAQ.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 --- # FAQ @@ -20,9 +20,9 @@ Immich doesn't have the mechanism to sync an existing directory with the server. The initial approach of Immich is to become a backup tool, primarily for mobile device usage. Thus, all the assets must be uploaded from the mobile client. The app was architectured to perform that job well. -### What happens to existing files after I choose a new [Storage Template](/docs/features/storage-template.mdx)? +### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)? -Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/features/jobs.md) page. +Template changes will only apply to new assets. To retroactively apply the template to previously uploaded assets, run the Storage Migration Job, available on the [Jobs](/docs/administration/jobs.md) page. ### Why is object detection not very good? @@ -42,7 +42,7 @@ The non-root user/group needs read/write access to the volume mounts, including ### How can I reset the admin password? -The admin password can be reset by running the [reset-admin-password](/docs/features/server-commands.md) command on the immich-server. +The admin password can be reset by running the [reset-admin-password](/docs/administration/server-commands.md) command on the immich-server. ### How can I **purge** data from Immich? diff --git a/docs/docs/administration/_category_.json b/docs/docs/administration/_category_.json new file mode 100644 index 0000000000..05f82b434b --- /dev/null +++ b/docs/docs/administration/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Administration", + "position": 4 + } + \ No newline at end of file diff --git a/docs/docs/features/img/admin-jobs-exif.png b/docs/docs/administration/img/admin-jobs-exif.png similarity index 100% rename from docs/docs/features/img/admin-jobs-exif.png rename to docs/docs/administration/img/admin-jobs-exif.png diff --git a/docs/docs/features/img/admin-jobs-objects.png b/docs/docs/administration/img/admin-jobs-objects.png similarity index 100% rename from docs/docs/features/img/admin-jobs-objects.png rename to docs/docs/administration/img/admin-jobs-objects.png diff --git a/docs/docs/features/img/admin-jobs-template.png b/docs/docs/administration/img/admin-jobs-template.png similarity index 100% rename from docs/docs/features/img/admin-jobs-template.png rename to docs/docs/administration/img/admin-jobs-template.png diff --git a/docs/docs/features/img/admin-jobs-thumbnails.png b/docs/docs/administration/img/admin-jobs-thumbnails.png similarity index 100% rename from docs/docs/features/img/admin-jobs-thumbnails.png rename to docs/docs/administration/img/admin-jobs-thumbnails.png diff --git a/docs/docs/features/img/authentik-redirect.png b/docs/docs/administration/img/authentik-redirect.png similarity index 100% rename from docs/docs/features/img/authentik-redirect.png rename to docs/docs/administration/img/authentik-redirect.png diff --git a/docs/docs/features/img/disable-password-login.png b/docs/docs/administration/img/disable-password-login.png similarity index 100% rename from docs/docs/features/img/disable-password-login.png rename to docs/docs/administration/img/disable-password-login.png diff --git a/docs/docs/features/img/enable-password-login.png b/docs/docs/administration/img/enable-password-login.png similarity index 100% rename from docs/docs/features/img/enable-password-login.png rename to docs/docs/administration/img/enable-password-login.png diff --git a/docs/docs/features/img/list-users.png b/docs/docs/administration/img/list-users.png similarity index 100% rename from docs/docs/features/img/list-users.png rename to docs/docs/administration/img/list-users.png diff --git a/docs/docs/features/img/oauth-settings.png b/docs/docs/administration/img/oauth-settings.png similarity index 100% rename from docs/docs/features/img/oauth-settings.png rename to docs/docs/administration/img/oauth-settings.png diff --git a/docs/docs/features/img/password-login-settings.png b/docs/docs/administration/img/password-login-settings.png similarity index 100% rename from docs/docs/features/img/password-login-settings.png rename to docs/docs/administration/img/password-login-settings.png diff --git a/docs/docs/features/img/reset-admin-password.png b/docs/docs/administration/img/reset-admin-password.png similarity index 100% rename from docs/docs/features/img/reset-admin-password.png rename to docs/docs/administration/img/reset-admin-password.png diff --git a/docs/docs/features/img/user-management-update.png b/docs/docs/administration/img/user-management-update.png similarity index 100% rename from docs/docs/features/img/user-management-update.png rename to docs/docs/administration/img/user-management-update.png diff --git a/docs/docs/features/jobs.md b/docs/docs/administration/jobs.md similarity index 85% rename from docs/docs/features/jobs.md rename to docs/docs/administration/jobs.md index 013c122d1e..1b3051d3c5 100644 --- a/docs/docs/features/jobs.md +++ b/docs/docs/administration/jobs.md @@ -18,6 +18,6 @@ Several Immich functionalities are implemented as jobs, which run in the backgro ## Storage Migration -This job can be run after changing the [Storage Template](/docs/features/storage-template.mdx), in order to apply the change to the existing library. +This job can be run after changing the [Storage Template](/docs/administration/storage-template.mdx), in order to apply the change to the existing library. ![Storage Migration](./img/admin-jobs-template.png) diff --git a/docs/docs/features/oauth.md b/docs/docs/administration/oauth.md similarity index 100% rename from docs/docs/features/oauth.md rename to docs/docs/administration/oauth.md diff --git a/docs/docs/features/password-login.md b/docs/docs/administration/password-login.md similarity index 69% rename from docs/docs/features/password-login.md rename to docs/docs/administration/password-login.md index 76ab057e89..ff4852eeee 100644 --- a/docs/docs/features/password-login.md +++ b/docs/docs/administration/password-login.md @@ -14,19 +14,19 @@ To toggle the password login setting via the web, navigate to the "Administratio ### Server Command -There are two [Server Commands](/docs/features/server-commands.md) for password login: +There are two [Server Commands](/docs/administration/server-commands.md) for password login: 1. `enable-password-login` 2. `disable-password-login` -See [Server Commands](/docs/features/server-commands.md) for more details about how to run them. +See [Server Commands](/docs/administration/server-commands.md) for more details about how to run them. ## Password Reset ### Admin -To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/features/server-commands.md). +To reset the administrator password, use the `reset-admin-password` [Server Command](/docs/administration/server-commands.md). ### User -Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/features/user-management.mdx#password-reset) for more information about how to do this. +Immich does not currently support self-service password reset. However, the administration can reset passwords for other users. See [User Management: Password Reset](/docs/administration/user-management.mdx#password-reset) for more information about how to do this. diff --git a/docs/docs/features/server-commands.md b/docs/docs/administration/server-commands.md similarity index 100% rename from docs/docs/features/server-commands.md rename to docs/docs/administration/server-commands.md diff --git a/docs/docs/features/storage-template.mdx b/docs/docs/administration/storage-template.mdx similarity index 100% rename from docs/docs/features/storage-template.mdx rename to docs/docs/administration/storage-template.mdx diff --git a/docs/docs/features/user-management.mdx b/docs/docs/administration/user-management.mdx similarity index 100% rename from docs/docs/features/user-management.mdx rename to docs/docs/administration/user-management.mdx diff --git a/docs/docs/developer/_category_.json b/docs/docs/developer/_category_.json index e5873215e1..502009dc7c 100644 --- a/docs/docs/developer/_category_.json +++ b/docs/docs/developer/_category_.json @@ -1,4 +1,4 @@ { "label": "Developer", - "position": 4 + "position": 5 } diff --git a/docs/docs/developer/setup.md b/docs/docs/developer/setup.md index c48eb32bd6..362c32627a 100644 --- a/docs/docs/developer/setup.md +++ b/docs/docs/developer/setup.md @@ -24,7 +24,7 @@ All the services are packaged to run as with single Docker Compose command. 1. Clone the project repo. 2. Run `cp docker/example.env docker/.env`. -3. Edit `docker/.env` to provide values for the required variables `UPLOAD_LOCATION` and `JWT_SECRET`. +3. Edit `docker/.env` to provide values for the required variable `UPLOAD_LOCATION`. 4. From the root directory, run: ```bash title="Start development server" diff --git a/docs/docs/features/user-settings.md b/docs/docs/features/user-settings.md index fa1f784205..742b7091a3 100644 --- a/docs/docs/features/user-settings.md +++ b/docs/docs/features/user-settings.md @@ -15,9 +15,9 @@ Users can change their own passwords. ![Change Password](./img/user-change-password.png) :::tip Reset Password -The admin can reset a password through the [User Management](/docs/features/user-management.mdx) screen. +The admin can reset a password through the [User Management](/docs/administration/user-management.mdx) screen. ::: :::tip Reset Admin Password -The admin password can be reset using a [Server Command](/docs/features/server-commands.md) +The admin password can be reset using a [Server Command](/docs/administration/server-commands.md) ::: diff --git a/docs/docs/guides/_category_.json b/docs/docs/guides/_category_.json index 951513baa8..6b51b67c73 100644 --- a/docs/docs/guides/_category_.json +++ b/docs/docs/guides/_category_.json @@ -1,4 +1,4 @@ { "label": "Guides", - "position": 5 + "position": 6 } diff --git a/docs/docs/install/docker-compose.md b/docs/docs/install/docker-compose.md index a062233069..1241cec421 100644 --- a/docs/docs/install/docker-compose.md +++ b/docs/docs/install/docker-compose.md @@ -63,15 +63,6 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba LOG_LEVEL=simple -################################################################################### -# JWT SECRET -################################################################################### - -# This JWT_SECRET is used to sign the authentication keys for user login -# You should set it to a long randomly generated value -# You can use this command to generate one: openssl rand -base64 128 -JWT_SECRET= - ################################################################################### # Reverse Geocoding #################################################################################### @@ -102,11 +93,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server" - Populate custom database information if necessary. - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. -- Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key: - -```bash title="Command to generate secure JWT_SECRET key" -openssl rand -base64 128 -``` ### Step 3 - Start the containers diff --git a/docs/docs/install/portainer.md b/docs/docs/install/portainer.md index de3767e780..09c5917f3d 100644 --- a/docs/docs/install/portainer.md +++ b/docs/docs/install/portainer.md @@ -40,11 +40,6 @@ Install Immich using Portainer's Stack feature. * Populate custom database information if necessary. * Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. -* Populate a secret value for `JWT_SECRET`. You can use the command below to generate a secure key: - -```bash title="Generate secure JWT_SECRET key" -openssl rand -base64 128 -``` 11. Click on "**Deploy the stack**". diff --git a/docs/docs/install/unraid.md b/docs/docs/install/unraid.md index 235a47b4de..3cf5f80fa8 100644 --- a/docs/docs/install/unraid.md +++ b/docs/docs/install/unraid.md @@ -55,7 +55,6 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich" 6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**" 7. Past the entire contents of the [Immich example.env](https://raw.githubusercontent.com/immich-app/immich/main/docker/example.env) file into the Unraid editor, then **before saving** edit the following: - - `JWT_SECRET`: Generate a unique secret and paste the value here > Can be generated by either typing `openssl rand -base64 128` in your terminal or copying from [uuidgenerator](https://www.uuidgenerator.net/version1) - `UPLOAD_LOCATION`: Create a folder in your Images Unraid share and place the **absolute** location here > For example my _"images"_ share has a folder within it called _"immich"_. If I browse to this directory in the terminal and type `pwd` the output is `/mnt/user/images/immich`. This is the exact value I need to enter as my `UPLOAD_LOCATION` ON MOBILE DEVICE

-
+
65, - "android.injected.version.name" => "1.42.0", + "android.injected.version.code" => 66, + "android.injected.version.name" => "1.43.0", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/integration_test/module_login/login_input_validation_test.dart b/mobile/integration_test/module_login/login_input_validation_test.dart index a70afcbdc3..e8f37acfc2 100644 --- a/mobile/integration_test/module_login/login_input_validation_test.dart +++ b/mobile/integration_test/module_login/login_input_validation_test.dart @@ -2,7 +2,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test_utils/general_helper.dart'; -import '../test_utils/login_helper.dart'; void main() async { await ImmichTestHelper.initialize(); @@ -13,7 +12,7 @@ void main() async { await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.enterCredentials( - email: " demo@immich.app" + email: " demo@immich.app", ); await tester.pump(const Duration(milliseconds: 300)); @@ -21,7 +20,7 @@ void main() async { expect(find.text("login_form_err_leading_whitespace".tr()), findsOneWidget); await helper.loginHelper.enterCredentials( - email: "demo@immich.app " + email: "demo@immich.app ", ); await tester.pump(const Duration(milliseconds: 300)); @@ -34,7 +33,7 @@ void main() async { await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.enterCredentials( - email: "demo.immich.app" + email: "demo.immich.app", ); await tester.pump(const Duration(milliseconds: 300)); diff --git a/mobile/integration_test/module_login/login_test.dart b/mobile/integration_test/module_login/login_test.dart index f317b12ca6..c9e6ecc089 100644 --- a/mobile/integration_test/module_login/login_test.dart +++ b/mobile/integration_test/module_login/login_test.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter_test/flutter_test.dart'; import '../test_utils/general_helper.dart'; @@ -12,8 +10,9 @@ void main() async { immichWidgetTest("Test correct credentials", (tester, helper) async { await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.acknowledgeNewServerVersion(); - await helper.loginHelper - .enterCredentialsOf(LoginCredentials.testInstance); + await helper.loginHelper.enterCredentialsOf( + LoginCredentials.testInstance, + ); await helper.loginHelper.pressLoginButton(); await helper.loginHelper.assertLoginSuccess(); }); @@ -22,16 +21,19 @@ void main() async { await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.enterCredentialsOf( - LoginCredentials.testInstanceButWithWrongPassword); + LoginCredentials.testInstanceButWithWrongPassword, + ); await helper.loginHelper.pressLoginButton(); await helper.loginHelper.assertLoginFailed(); }); - immichWidgetTest("Test login with wrong server URL", (tester, helper) async { + immichWidgetTest("Test login with wrong server URL", + (tester, helper) async { await helper.loginHelper.waitForLoginScreen(); await helper.loginHelper.acknowledgeNewServerVersion(); await helper.loginHelper.enterCredentialsOf( - LoginCredentials.wrongInstanceUrl); + LoginCredentials.wrongInstanceUrl, + ); await helper.loginHelper.pressLoginButton(); await helper.loginHelper.assertLoginFailed(); }); diff --git a/mobile/integration_test/test_utils/general_helper.dart b/mobile/integration_test/test_utils/general_helper.dart index 0555bdda92..0ce776ce97 100644 --- a/mobile/integration_test/test_utils/general_helper.dart +++ b/mobile/integration_test/test_utils/general_helper.dart @@ -1,17 +1,14 @@ - import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hive/hive.dart'; -import 'package:immich_mobile/main.dart'; import 'package:integration_test/integration_test.dart'; +// ignore: depend_on_referenced_packages import 'package:meta/meta.dart'; import 'package:immich_mobile/main.dart' as app; import 'login_helper.dart'; class ImmichTestHelper { - final WidgetTester tester; ImmichTestHelper(this.tester); @@ -43,15 +40,19 @@ class ImmichTestHelper { await tester.pumpAndSettle(); await EasyLocalization.ensureInitialized(); } - } @isTest -void immichWidgetTest(String description, Future Function(WidgetTester, ImmichTestHelper) test) { - - testWidgets(description, (widgetTester) async { - await ImmichTestHelper.loadApp(widgetTester); - await test(widgetTester, ImmichTestHelper(widgetTester)); - }, semanticsEnabled: false); - -} \ No newline at end of file +void immichWidgetTest( + String description, + Future Function(WidgetTester, ImmichTestHelper) test, +) { + testWidgets( + description, + (widgetTester) async { + await ImmichTestHelper.loadApp(widgetTester); + await test(widgetTester, ImmichTestHelper(widgetTester)); + }, + semanticsEnabled: false, + ); +} diff --git a/mobile/integration_test/test_utils/login_helper.dart b/mobile/integration_test/test_utils/login_helper.dart index 244b288b7b..5e6cb547c7 100644 --- a/mobile/integration_test/test_utils/login_helper.dart +++ b/mobile/integration_test/test_utils/login_helper.dart @@ -1,8 +1,6 @@ -import 'dart:async'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; class ImmichTestLoginHelper { final WidgetTester tester; diff --git a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart new file mode 100644 index 0000000000..a98608296b --- /dev/null +++ b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart @@ -0,0 +1,129 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; +import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; +import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; +import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/drag_sheet.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:openapi/api.dart'; + +class AddToAlbumBottomSheet extends HookConsumerWidget { + + /// The asset to add to an album + final List assets; + + const AddToAlbumBottomSheet({ + Key? key, + required this.assets, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(albumProvider); + final albumService = ref.watch(albumServiceProvider); + final sharedAlbums = ref.watch(sharedAlbumProvider); + + useEffect( + () { + // Fetch album updates, e.g., cover image + ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + + return null; + }, + [], + ); + + void addToAlbum(AlbumResponseDto album) async { + final result = await albumService.addAdditionalAssetToAlbum( + assets, + album.id, + ); + + if (result != null) { + if (result.alreadyInAlbum.isNotEmpty) { + ImmichToast.show( + context: context, + msg: 'Already in ${album.albumName}', + ); + } else { + ImmichToast.show( + context: context, + msg: 'Added to ${album.albumName}', + ); + } + } + + ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + + Navigator.pop(context); + } + + + return Card( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), + ), + child: CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Align( + alignment: Alignment.center, + child: CustomDraggingHandle(), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Add to album', + style: Theme.of(context).textTheme.headline2, + ), + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('Create new album'), + onPressed: () { + ref.watch(assetSelectionProvider.notifier).removeAll(); + ref.watch(assetSelectionProvider.notifier).addNewAssets(assets); + AutoRouter.of(context).push( + CreateAlbumRoute( + isSharedAlbum: false, + initialAssets: assets, + ), + ); + }, + ), + ], + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: AddToAlbumSliverList( + albums: albums, + sharedAlbums: sharedAlbums, + onAddToAlbum: addToAlbum, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/modules/album/ui/add_to_album_list.dart b/mobile/lib/modules/album/ui/add_to_album_list.dart new file mode 100644 index 0000000000..c2ba72179c --- /dev/null +++ b/mobile/lib/modules/album/ui/add_to_album_list.dart @@ -0,0 +1,134 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; +import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; +import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; +import 'package:immich_mobile/shared/ui/drag_sheet.dart'; +import 'package:immich_mobile/shared/ui/immich_toast.dart'; +import 'package:openapi/api.dart'; + +class AddToAlbumList extends HookConsumerWidget { + + /// The asset to add to an album + final List assets; + + const AddToAlbumList({ + Key? key, + required this.assets, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final albums = ref.watch(albumProvider); + final albumService = ref.watch(albumServiceProvider); + final sharedAlbums = ref.watch(sharedAlbumProvider); + + useEffect( + () { + // Fetch album updates, e.g., cover image + ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + + return null; + }, + [], + ); + + void addToAlbum(AlbumResponseDto album) async { + final result = await albumService.addAdditionalAssetToAlbum( + assets, + album.id, + ); + + if (result != null) { + if (result.alreadyInAlbum.isNotEmpty) { + ImmichToast.show( + context: context, + msg: 'Already in ${album.albumName}', + ); + } else { + ImmichToast.show( + context: context, + msg: 'Added to ${album.albumName}', + ); + } + } + + ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); + + Navigator.pop(context); + } + + return Card( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(15), + topRight: Radius.circular(15), + ), + ), + child: ListView( + padding: const EdgeInsets.all(18.0), + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Align( + alignment: Alignment.center, + child: CustomDraggingHandle(), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Add to album', + style: Theme.of(context).textTheme.headline2, + ), + TextButton.icon( + icon: const Icon(Icons.add), + label: const Text('New album'), + onPressed: () { + ref.watch(assetSelectionProvider.notifier).removeAll(); + ref.watch(assetSelectionProvider.notifier).addNewAssets(assets); + AutoRouter.of(context).push( + CreateAlbumRoute( + isSharedAlbum: false, + initialAssets: assets, + ), + ); + }, + ), + ], + ), + ], + ), + if (sharedAlbums.isNotEmpty) + ExpansionTile( + title: const Text('Shared'), + tilePadding: const EdgeInsets.symmetric(horizontal: 10.0), + leading: const Icon(Icons.group), + children: sharedAlbums.map((album) => + AlbumThumbnailListTile( + album: album, + onTap: () => addToAlbum(album), + ), + ).toList(), + ), + const SizedBox(height: 12), + ... albums.map((album) => + AlbumThumbnailListTile( + album: album, + onTap: () => addToAlbum(album), + ), + ).toList(), + ], + ), + ); + } +} diff --git a/mobile/lib/modules/album/ui/add_to_album_sliverlist.dart b/mobile/lib/modules/album/ui/add_to_album_sliverlist.dart new file mode 100644 index 0000000000..fd1be2374f --- /dev/null +++ b/mobile/lib/modules/album/ui/add_to_album_sliverlist.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/ui/album_thumbnail_listtile.dart'; +import 'package:openapi/api.dart'; + +class AddToAlbumSliverList extends HookConsumerWidget { + + /// The asset to add to an album + final List albums; + final List sharedAlbums; + final void Function(AlbumResponseDto) onAddToAlbum; + + const AddToAlbumSliverList({ + Key? key, + required this.onAddToAlbum, + required this.albums, + required this.sharedAlbums, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: albums.length + (sharedAlbums.isEmpty ? 0 : 1), + (context, index) { + // Build shared expander + if (index == 0 && sharedAlbums.isNotEmpty) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: ExpansionTile( + title: const Text('Shared'), + tilePadding: const EdgeInsets.symmetric(horizontal: 10.0), + leading: const Icon(Icons.group), + children: sharedAlbums.map((album) => + AlbumThumbnailListTile( + album: album, + onTap: () => onAddToAlbum(album), + ), + ).toList(), + ), + ); + } + + // Build albums list + final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0); + final album = albums[offset]; + return AlbumThumbnailListTile( + album: album, + onTap: () => onAddToAlbum(album), + ); + } + ), + + ); + } +} diff --git a/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart new file mode 100644 index 0000000000..2366924a85 --- /dev/null +++ b/mobile/lib/modules/album/ui/album_thumbnail_listtile.dart @@ -0,0 +1,115 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; +import 'package:openapi/api.dart'; + +class AlbumThumbnailListTile extends StatelessWidget { + const AlbumThumbnailListTile({ + Key? key, + required this.album, + this.onTap, + }) : super(key: key); + + final AlbumResponseDto album; + final void Function()? onTap; + + @override + Widget build(BuildContext context) { + var box = Hive.box(userInfoBox); + var cardSize = 68.0; + var isDarkMode = Theme.of(context).brightness == Brightness.dark; + + buildEmptyThumbnail() { + return Container( + decoration: BoxDecoration( + color: isDarkMode ? Colors.grey[800] : Colors.grey[200], + ), + child: SizedBox( + height: cardSize, + width: cardSize, + child: const Center( + child: Icon(Icons.no_photography), + ), + ), + ); + } + + buildAlbumThumbnail() { + return CachedNetworkImage( + width: cardSize, + height: cardSize, + fit: BoxFit.cover, + fadeInDuration: const Duration(milliseconds: 200), + imageUrl: getAlbumThumbnailUrl( + album, + type: ThumbnailFormat.JPEG, + ), + httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, + cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG), + ); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap ?? () { + AutoRouter.of(context).push(AlbumViewerRoute(albumId: album.id)); + }, + child: Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: album.albumThumbnailAssetId == null + ? buildEmptyThumbnail() + : buildAlbumThumbnail(), + ), + Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + album.albumName, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + album.assetCount == 1 + ? 'album_thumbnail_card_item' + : 'album_thumbnail_card_items', + style: const TextStyle( + fontSize: 12, + ), + ).tr(args: ['${album.assetCount}']), + if (album.shared) + const Text( + 'album_thumbnail_card_shared', + style: TextStyle( + fontSize: 12, + ), + ).tr() + ], + ) + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index f49082559a..5c41b08b6e 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -96,7 +96,7 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget { if (isSuccess) { Navigator.pop(context); ref.watch(assetSelectionProvider.notifier).disableMultiselection(); - ref.refresh(sharedAlbumDetailProvider(albumId)); + ref.invalidate(sharedAlbumDetailProvider(albumId)); } else { Navigator.pop(context); ImmichToast.show( diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 65db82eb6b..78b9896362 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -62,7 +62,7 @@ class AlbumViewerPage extends HookConsumerWidget { if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) { - ref.refresh(sharedAlbumDetailProvider(albumId)); + ref.invalidate(sharedAlbumDetailProvider(albumId)); } ImmichLoadingOverlayController.appLoader.hide(); @@ -88,7 +88,7 @@ class AlbumViewerPage extends HookConsumerWidget { .addAdditionalUserToAlbum(sharedUserIds, albumId); if (isSuccess) { - ref.refresh(sharedAlbumDetailProvider(albumId)); + ref.invalidate(sharedAlbumDetailProvider(albumId)); } ImmichLoadingOverlayController.appLoader.hide(); diff --git a/mobile/lib/modules/album/views/create_album_page.dart b/mobile/lib/modules/album/views/create_album_page.dart index 18d3d978a8..fa3db46968 100644 --- a/mobile/lib/modules/album/views/create_album_page.dart +++ b/mobile/lib/modules/album/views/create_album_page.dart @@ -11,12 +11,18 @@ import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart import 'package:immich_mobile/modules/album/ui/album_title_text_field.dart'; import 'package:immich_mobile/modules/album/ui/shared_album_thumbnail_image.dart'; import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/shared/models/asset.dart'; // ignore: must_be_immutable class CreateAlbumPage extends HookConsumerWidget { - bool isSharedAlbum; + final bool isSharedAlbum; + final List? initialAssets; - CreateAlbumPage({Key? key, required this.isSharedAlbum}) : super(key: key); + const CreateAlbumPage({ + Key? key, + required this.isSharedAlbum, + this.initialAssets, + }) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/mobile/lib/modules/album/views/library_page.dart b/mobile/lib/modules/album/views/library_page.dart index 7bb91bc815..8b40fbf7bc 100644 --- a/mobile/lib/modules/album/views/library_page.dart +++ b/mobile/lib/modules/album/views/library_page.dart @@ -22,7 +22,7 @@ class LibraryPage extends HookConsumerWidget { [], ); - Widget _buildAppBar() { + Widget buildAppBar() { return const SliverAppBar( centerTitle: true, floating: true, @@ -40,7 +40,7 @@ class LibraryPage extends HookConsumerWidget { ); } - Widget _buildCreateAlbumButton() { + Widget buildCreateAlbumButton() { return GestureDetector( onTap: () { AutoRouter.of(context).push(CreateAlbumRoute(isSharedAlbum: false)); @@ -83,7 +83,7 @@ class LibraryPage extends HookConsumerWidget { return Scaffold( body: CustomScrollView( slivers: [ - _buildAppBar(), + buildAppBar(), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(12.0), @@ -99,7 +99,7 @@ class LibraryPage extends HookConsumerWidget { child: Wrap( spacing: 12, children: [ - _buildCreateAlbumButton(), + buildCreateAlbumButton(), for (var album in albums) AlbumThumbnailCard( album: album, diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index f7fa863dde..bdeab2be32 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -188,7 +188,7 @@ class ExifBottomSheet extends HookConsumerWidget { ), ), subtitle: Text( - "ƒ/${exifInfo.fNumber} 1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ", + "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ", ), ), ], diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 0d45719dfe..a5c80dde89 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -11,6 +11,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { required this.onDownloadPressed, required this.onSharePressed, required this.onDeletePressed, + required this.onAddToAlbumPressed, required this.onToggleMotionVideo, required this.isPlayingMotionVideo, }) : super(key: key); @@ -20,6 +21,7 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { final VoidCallback? onDownloadPressed; final VoidCallback onToggleMotionVideo; final VoidCallback onDeletePressed; + final VoidCallback onAddToAlbumPressed; final Function onSharePressed; final bool isPlayingMotionVideo; @@ -80,6 +82,18 @@ class TopControlAppBar extends HookConsumerWidget with PreferredSizeWidget { color: Colors.grey[200], ), ), + if (asset.isRemote) + IconButton( + iconSize: iconSize, + splashRadius: iconSize, + onPressed: () { + onAddToAlbumPressed(); + }, + icon: Icon( + Icons.add, + color: Colors.grey[200], + ), + ), IconButton( iconSize: iconSize, splashRadius: iconSize, diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 000d85c699..b14e5dc81a 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/album/ui/add_to_album_bottom_sheet.dart'; +import 'package:immich_mobile/modules/album/ui/add_to_album_list.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; @@ -105,6 +107,22 @@ class GalleryViewerPage extends HookConsumerWidget { ); } + void addToAlbum(Asset addToAlbumAsset) { + showModalBottomSheet( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + barrierColor: Colors.transparent, + backgroundColor: Colors.transparent, + context: context, + builder: (BuildContext _) { + return AddToAlbumBottomSheet( + assets: [addToAlbumAsset], + ); + }, + ); + } + return Scaffold( backgroundColor: Colors.black, appBar: TopControlAppBar( @@ -130,6 +148,7 @@ class GalleryViewerPage extends HookConsumerWidget { isPlayingMotionVideo.value = !isPlayingMotionVideo.value; }), onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])), + onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]), ), body: SafeArea( child: PageView.builder( diff --git a/mobile/lib/modules/home/services/asset_cache.service.dart b/mobile/lib/modules/home/services/asset_cache.service.dart index 3eb684f6ea..d7a6af5ccb 100644 --- a/mobile/lib/modules/home/services/asset_cache.service.dart +++ b/mobile/lib/modules/home/services/asset_cache.service.dart @@ -8,7 +8,8 @@ class AssetCacheService extends JsonCache> { AssetCacheService() : super("asset_cache"); static Future>> _computeSerialize( - List assets) async { + List assets, + ) async { return assets.map((e) => e.toJson()).toList(); } diff --git a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart index a2461eb385..9c68cf7cbd 100644 --- a/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart +++ b/mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart @@ -42,8 +42,13 @@ class _AssetGroupsToRenderListComputeParameters { final Map> groups; final int perRow; - _AssetGroupsToRenderListComputeParameters(this.monthFormat, this.dayFormat, - this.dayFormatYear, this.groups, this.perRow); + _AssetGroupsToRenderListComputeParameters( + this.monthFormat, + this.dayFormat, + this.dayFormatYear, + this.groups, + this.perRow, + ); } class RenderList { @@ -52,7 +57,8 @@ class RenderList { RenderList(this.elements); static Future _processAssetGroupData( - _AssetGroupsToRenderListComputeParameters data) async { + _AssetGroupsToRenderListComputeParameters data, + ) async { final monthFormat = DateFormat(data.monthFormat); final dayFormatSameYear = DateFormat(data.dayFormat); final dayFormatOtherYear = DateFormat(data.dayFormatYear); diff --git a/mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart b/mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart index f61d06cac3..7cee410a19 100644 --- a/mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart +++ b/mobile/lib/modules/home/ui/asset_grid/daily_title_text.dart @@ -1,4 +1,3 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 22d47b71ae..edc608637c 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -1,12 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/constants/hive_box.dart'; +import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; -import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:openapi/api.dart'; class ControlBottomAppBar extends ConsumerWidget { @@ -16,11 +13,13 @@ class ControlBottomAppBar extends ConsumerWidget { final void Function() onCreateNewAlbum; final List albums; + final List sharedAlbums; const ControlBottomAppBar({ Key? key, required this.onShare, required this.onDelete, + required this.sharedAlbums, required this.albums, required this.onAddToAlbum, required this.onCreateNewAlbum, @@ -56,60 +55,6 @@ class ControlBottomAppBar extends ConsumerWidget { ); } - Widget renderAlbums() { - Widget renderAlbum(AlbumResponseDto album) { - final box = Hive.box(userInfoBox); - - return Padding( - padding: const EdgeInsets.only(left: 8.0), - child: GestureDetector( - onTap: () => onAddToAlbum(album), - child: Container( - width: 112, - padding: const EdgeInsets.all(6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - width: 100, - height: 100, - fit: BoxFit.cover, - imageUrl: getAlbumThumbnailUrl(album), - httpHeaders: { - "Authorization": "Bearer ${box.get(accessTokenKey)}" - }, - cacheKey: getAlbumThumbNailCacheKey(album), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - album.albumName, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 12.0, - ), - ), - ), - ], - ), - ), - ), - ); - } - - return SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemBuilder: (buildContext, i) => renderAlbum(albums[i]), - itemCount: albums.length, - ), - ); - } - return DraggableScrollableSheet( initialChildSize: 0.30, minChildSize: 0.15, @@ -119,42 +64,53 @@ class ControlBottomAppBar extends ConsumerWidget { BuildContext context, ScrollController scrollController, ) { - return SingleChildScrollView( - controller: scrollController, - child: Card( - elevation: 12.0, - shape: const RoundedRectangleBorder( + return Card( + elevation: 12.0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + margin: const EdgeInsets.all(0), + child: Container( + decoration: const BoxDecoration( borderRadius: BorderRadius.only( topLeft: Radius.circular(12), topRight: Radius.circular(12), ), ), - margin: const EdgeInsets.all(0), - child: Container( - decoration: const BoxDecoration( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(12), - topRight: Radius.circular(12), + child: CustomScrollView( + controller: scrollController, + slivers: [ + SliverToBoxAdapter( + child: Column( + children: [ + const SizedBox(height: 12), + const CustomDraggingHandle(), + const SizedBox(height: 12), + renderActionButtons(), + const Divider( + indent: 16, + endIndent: 16, + thickness: 1, + ), + AddToAlbumTitleRow(onCreateNewAlbum: onCreateNewAlbum), + ], + ), ), - ), - child: Column( - children: [ - const SizedBox(height: 12), - const CustomDraggingHandle(), - const SizedBox(height: 12), - renderActionButtons(), - const Divider( - indent: 16, - endIndent: 16, - thickness: 1, + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: AddToAlbumSliverList( + albums: albums, + sharedAlbums: sharedAlbums, + onAddToAlbum: onAddToAlbum, ), - AddToAlbumTitleRow( - onCreateNewAlbum: () => onCreateNewAlbum(), - ), - renderAlbums(), - const SizedBox(height: 200), - ], - ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: 200), + ) + ], ), ), ); @@ -185,9 +141,10 @@ class AddToAlbumTitleRow extends StatelessWidget { fontWeight: FontWeight.bold, ), ).tr(), - TextButton( + TextButton.icon( onPressed: onCreateNewAlbum, - child: Text( + icon: const Icon(Icons.add), + label: Text( "control_bottom_app_bar_create_new_album", style: TextStyle( color: Theme.of(context).primaryColor, diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 1a6170a064..d032a61651 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; @@ -37,6 +38,7 @@ class HomePage extends HookConsumerWidget { final selection = useState({}); final albums = ref.watch(albumProvider); + final sharedAlbums = ref.watch(sharedAlbumProvider); final albumService = ref.watch(albumServiceProvider); final tipOneOpacity = useState(0.0); @@ -46,6 +48,7 @@ class HomePage extends HookConsumerWidget { ref.read(websocketProvider.notifier).connect(); ref.read(assetProvider.notifier).getAllAsset(); ref.read(albumProvider.notifier).getAllAlbums(); + ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); ref.watch(serverInfoProvider.notifier).getServerVersion(); selectionEnabledHook.addListener(() { @@ -147,6 +150,7 @@ class HomePage extends HookConsumerWidget { if (result != null) { ref.watch(albumProvider.notifier).getAllAlbums(); + ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums(); selectionEnabledHook.value = false; AutoRouter.of(context).push(AlbumViewerRoute(albumId: result.id)); @@ -220,6 +224,7 @@ class HomePage extends HookConsumerWidget { onDelete: onDelete, onAddToAlbum: onAddToAlbum, albums: albums, + sharedAlbums: sharedAlbums, onCreateNewAlbum: onCreateNewAlbum, ), ], diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index e98927ef0d..b47e64bd0d 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.dart @@ -235,7 +235,7 @@ class ServerEndpointInput extends StatelessWidget { labelText: 'login_form_endpoint_url'.tr(), border: const OutlineInputBorder(), hintText: 'login_form_endpoint_hint'.tr(), - errorMaxLines: 4 + errorMaxLines: 4, ), validator: _validateInput, autovalidateMode: AutovalidateMode.always, diff --git a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart index dde68a4a3e..80b18ca96c 100644 --- a/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart +++ b/mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart @@ -24,6 +24,7 @@ class TilesPerRow extends HookConsumerWidget { void sliderChangedEnd(double _) { ref.invalidate(assetProvider); + ref.watch(assetProvider.notifier).getAllAsset(); } useEffect( diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index d2100398f7..897b532225 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -60,7 +60,8 @@ class _$AppRouter extends RootStackRouter { isZoomedFunction: args.isZoomedFunction, isZoomedListener: args.isZoomedListener, loadPreview: args.loadPreview, - loadOriginal: args.loadOriginal)); + loadOriginal: args.loadOriginal, + showExifSheet: args.showExifSheet)); }, VideoViewerRoute.name: (routeData) { final args = routeData.argsAs(); @@ -87,7 +88,9 @@ class _$AppRouter extends RootStackRouter { return MaterialPageX( routeData: routeData, child: CreateAlbumPage( - key: args.key, isSharedAlbum: args.isSharedAlbum)); + key: args.key, + isSharedAlbum: args.isSharedAlbum, + initialAssets: args.initialAssets)); }, AssetSelectionRoute.name: (routeData) { return CustomPage( @@ -307,7 +310,8 @@ class ImageViewerRoute extends PageRouteInfo { required void Function() isZoomedFunction, required ValueNotifier isZoomedListener, required bool loadPreview, - required bool loadOriginal}) + required bool loadOriginal, + void Function()? showExifSheet}) : super(ImageViewerRoute.name, path: '/image-viewer-page', args: ImageViewerRouteArgs( @@ -318,7 +322,8 @@ class ImageViewerRoute extends PageRouteInfo { isZoomedFunction: isZoomedFunction, isZoomedListener: isZoomedListener, loadPreview: loadPreview, - loadOriginal: loadOriginal)); + loadOriginal: loadOriginal, + showExifSheet: showExifSheet)); static const String name = 'ImageViewerRoute'; } @@ -332,7 +337,8 @@ class ImageViewerRouteArgs { required this.isZoomedFunction, required this.isZoomedListener, required this.loadPreview, - required this.loadOriginal}); + required this.loadOriginal, + this.showExifSheet}); final Key? key; @@ -350,9 +356,11 @@ class ImageViewerRouteArgs { final bool loadOriginal; + final void Function()? showExifSheet; + @override String toString() { - return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal}'; + return 'ImageViewerRouteArgs{key: $key, heroTag: $heroTag, asset: $asset, authToken: $authToken, isZoomedFunction: $isZoomedFunction, isZoomedListener: $isZoomedListener, loadPreview: $loadPreview, loadOriginal: $loadOriginal, showExifSheet: $showExifSheet}'; } } @@ -432,24 +440,31 @@ class SearchResultRouteArgs { /// generated route for /// [CreateAlbumPage] class CreateAlbumRoute extends PageRouteInfo { - CreateAlbumRoute({Key? key, required bool isSharedAlbum}) + CreateAlbumRoute( + {Key? key, required bool isSharedAlbum, List? initialAssets}) : super(CreateAlbumRoute.name, path: '/create-album-page', - args: CreateAlbumRouteArgs(key: key, isSharedAlbum: isSharedAlbum)); + args: CreateAlbumRouteArgs( + key: key, + isSharedAlbum: isSharedAlbum, + initialAssets: initialAssets)); static const String name = 'CreateAlbumRoute'; } class CreateAlbumRouteArgs { - const CreateAlbumRouteArgs({this.key, required this.isSharedAlbum}); + const CreateAlbumRouteArgs( + {this.key, required this.isSharedAlbum, this.initialAssets}); final Key? key; final bool isSharedAlbum; + final List? initialAssets; + @override String toString() { - return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum}'; + return 'CreateAlbumRouteArgs{key: $key, isSharedAlbum: $isSharedAlbum, initialAssets: $initialAssets}'; } } diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart index 25db941848..1085082713 100644 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ b/mobile/lib/routing/tab_navigation_observer.dart @@ -30,8 +30,8 @@ class TabNavigationObserver extends AutoRouterObserver { // Perform tasks on re-visit to SearchRoute if (route.name == 'SearchRoute') { // Refresh Location State - ref.refresh(getCuratedLocationProvider); - ref.refresh(getCuratedObjectProvider); + ref.invalidate(getCuratedLocationProvider); + ref.invalidate(getCuratedObjectProvider); } if (route.name == 'SharingRoute') { diff --git a/mobile/lib/shared/services/immich_logger.service.dart b/mobile/lib/shared/services/immich_logger.service.dart index 4e7d3bf71c..75e7cf8d7e 100644 --- a/mobile/lib/shared/services/immich_logger.service.dart +++ b/mobile/lib/shared/services/immich_logger.service.dart @@ -83,6 +83,7 @@ class ImmichLogger { } // Share file + // ignore: deprecated_member_use await Share.shareFiles( [filePath], subject: "Immich logs $dateTime", diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart index 007d76d1d0..c0e61c9d9d 100644 --- a/mobile/lib/shared/services/share.service.dart +++ b/mobile/lib/shared/services/share.service.dart @@ -40,6 +40,7 @@ class ShareService { } }); + // ignore: deprecated_member_use Share.shareFiles( await Future.wait(downloadedFilePaths), sharePositionOrigin: Rect.zero, diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 592c8ebbc5..5fb6408d27 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -10,8 +10,10 @@ String getThumbnailUrl( return _getThumbnailUrl(asset.id, type: type); } -String getThumbnailCacheKey(final AssetResponseDto asset, - {ThumbnailFormat type = ThumbnailFormat.WEBP}) { +String getThumbnailCacheKey( + final AssetResponseDto asset, { + ThumbnailFormat type = ThumbnailFormat.WEBP, +}) { return _getThumbnailCacheKey(asset.id, type); } diff --git a/mobile/lib/utils/immich_app_theme.dart b/mobile/lib/utils/immich_app_theme.dart index a180627d2c..b9fa16f0df 100644 --- a/mobile/lib/utils/immich_app_theme.dart +++ b/mobile/lib/utils/immich_app_theme.dart @@ -31,6 +31,11 @@ ThemeData immichDarkTheme = ThemeData( snackBarTheme: const SnackBarThemeData( contentTextStyle: TextStyle(fontFamily: 'WorkSans'), ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: immichDarkThemePrimaryColor, + ), + ), appBarTheme: AppBarTheme( titleTextStyle: TextStyle( fontFamily: 'WorkSans', @@ -59,7 +64,7 @@ ThemeData immichDarkTheme = ThemeData( headline2: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, - color: Color.fromARGB(255, 148, 151, 155), + color: Color.fromARGB(255, 255, 255, 255), ), headline3: TextStyle( fontSize: 12, diff --git a/mobile/lib/utils/openapi_extensions.dart b/mobile/lib/utils/openapi_extensions.dart index 2959be3d10..96623514dc 100644 --- a/mobile/lib/utils/openapi_extensions.dart +++ b/mobile/lib/utils/openapi_extensions.dart @@ -31,7 +31,9 @@ extension WithETag on AssetApi { final responseBody = await _decodeBodyBytes(response); final etag = response.headers[HttpHeaders.etagHeader]; final data = (await apiClient.deserializeAsync( - responseBody, 'List') as List) + responseBody, + 'List', + ) as List) .cast() .toList(); return Pair(data, etag); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 30bcbc8b3f..0d94740bd5 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -102,8 +102,6 @@ Class | Method | HTTP request | Description *AuthenticationApi* | [**login**](doc//AuthenticationApi.md#login) | **POST** /auth/login | *AuthenticationApi* | [**logout**](doc//AuthenticationApi.md#logout) | **POST** /auth/logout | *AuthenticationApi* | [**validateAccessToken**](doc//AuthenticationApi.md#validateaccesstoken) | **POST** /auth/validateToken | -*DeviceInfoApi* | [**createDeviceInfo**](doc//DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info | -*DeviceInfoApi* | [**updateDeviceInfo**](doc//DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info | *DeviceInfoApi* | [**upsertDeviceInfo**](doc//DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info | *JobApi* | [**getAllJobsStatus**](doc//JobApi.md#getalljobsstatus) | **GET** /jobs | *JobApi* | [**sendJobCommand**](doc//JobApi.md#sendjobcommand) | **PUT** /jobs/{jobId} | diff --git a/mobile/openapi/doc/DeviceInfoApi.md b/mobile/openapi/doc/DeviceInfoApi.md index 1ee91414a3..47dbd7712b 100644 --- a/mobile/openapi/doc/DeviceInfoApi.md +++ b/mobile/openapi/doc/DeviceInfoApi.md @@ -9,109 +9,9 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- -[**createDeviceInfo**](DeviceInfoApi.md#createdeviceinfo) | **POST** /device-info | -[**updateDeviceInfo**](DeviceInfoApi.md#updatedeviceinfo) | **PATCH** /device-info | [**upsertDeviceInfo**](DeviceInfoApi.md#upsertdeviceinfo) | **PUT** /device-info | -# **createDeviceInfo** -> DeviceInfoResponseDto createDeviceInfo(upsertDeviceInfoDto) - - - -@deprecated - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = DeviceInfoApi(); -final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto | - -try { - final result = api_instance.createDeviceInfo(upsertDeviceInfoDto); - print(result); -} catch (e) { - print('Exception when calling DeviceInfoApi->createDeviceInfo: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| | - -### Return type - -[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md) - -### Authorization - -[bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **updateDeviceInfo** -> DeviceInfoResponseDto updateDeviceInfo(upsertDeviceInfoDto) - - - -@deprecated - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = DeviceInfoApi(); -final upsertDeviceInfoDto = UpsertDeviceInfoDto(); // UpsertDeviceInfoDto | - -try { - final result = api_instance.updateDeviceInfo(upsertDeviceInfoDto); - print(result); -} catch (e) { - print('Exception when calling DeviceInfoApi->updateDeviceInfo: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **upsertDeviceInfoDto** | [**UpsertDeviceInfoDto**](UpsertDeviceInfoDto.md)| | - -### Return type - -[**DeviceInfoResponseDto**](DeviceInfoResponseDto.md) - -### Authorization - -[bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **upsertDeviceInfo** > DeviceInfoResponseDto upsertDeviceInfo(upsertDeviceInfoDto) diff --git a/mobile/openapi/doc/ExifResponseDto.md b/mobile/openapi/doc/ExifResponseDto.md index af4bb349ec..3cea9375b4 100644 --- a/mobile/openapi/doc/ExifResponseDto.md +++ b/mobile/openapi/doc/ExifResponseDto.md @@ -22,7 +22,7 @@ Name | Type | Description | Notes **fNumber** | **num** | | [optional] **focalLength** | **num** | | [optional] **iso** | **num** | | [optional] -**exposureTime** | **num** | | [optional] +**exposureTime** | **String** | | [optional] **latitude** | **num** | | [optional] **longitude** | **num** | | [optional] **city** | **String** | | [optional] diff --git a/mobile/openapi/doc/JobCommandDto.md b/mobile/openapi/doc/JobCommandDto.md index 4e87fde8e8..68cbee51a8 100644 --- a/mobile/openapi/doc/JobCommandDto.md +++ b/mobile/openapi/doc/JobCommandDto.md @@ -9,6 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **command** | [**JobCommand**](JobCommand.md) | | +**includeAllAssets** | **bool** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api/device_info_api.dart b/mobile/openapi/lib/api/device_info_api.dart index 4cde7c5e4a..ac3c81842d 100644 --- a/mobile/openapi/lib/api/device_info_api.dart +++ b/mobile/openapi/lib/api/device_info_api.dart @@ -16,110 +16,6 @@ class DeviceInfoApi { final ApiClient apiClient; - /// @deprecated - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required): - Future createDeviceInfoWithHttpInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async { - // ignore: prefer_const_declarations - final path = r'/device-info'; - - // ignore: prefer_final_locals - Object? postBody = upsertDeviceInfoDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// @deprecated - /// - /// Parameters: - /// - /// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required): - Future createDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async { - final response = await createDeviceInfoWithHttpInfo(upsertDeviceInfoDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DeviceInfoResponseDto',) as DeviceInfoResponseDto; - - } - return null; - } - - /// @deprecated - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required): - Future updateDeviceInfoWithHttpInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async { - // ignore: prefer_const_declarations - final path = r'/device-info'; - - // ignore: prefer_final_locals - Object? postBody = upsertDeviceInfoDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'PATCH', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// @deprecated - /// - /// Parameters: - /// - /// * [UpsertDeviceInfoDto] upsertDeviceInfoDto (required): - Future updateDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto,) async { - final response = await updateDeviceInfoWithHttpInfo(upsertDeviceInfoDto,); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DeviceInfoResponseDto',) as DeviceInfoResponseDto; - - } - return null; - } - /// /// /// Note: This method returns the HTTP [Response]. diff --git a/mobile/openapi/lib/model/exif_response_dto.dart b/mobile/openapi/lib/model/exif_response_dto.dart index 8423aa56a7..6c80667d5d 100644 --- a/mobile/openapi/lib/model/exif_response_dto.dart +++ b/mobile/openapi/lib/model/exif_response_dto.dart @@ -63,7 +63,7 @@ class ExifResponseDto { num? iso; - num? exposureTime; + String? exposureTime; num? latitude; @@ -273,9 +273,7 @@ class ExifResponseDto { iso: json[r'iso'] == null ? null : num.parse(json[r'iso'].toString()), - exposureTime: json[r'exposureTime'] == null - ? null - : num.parse(json[r'exposureTime'].toString()), + exposureTime: mapValueOfType(json, r'exposureTime'), latitude: json[r'latitude'] == null ? null : num.parse(json[r'latitude'].toString()), diff --git a/mobile/openapi/lib/model/job_command_dto.dart b/mobile/openapi/lib/model/job_command_dto.dart index e3e5d41da8..adf9fc3344 100644 --- a/mobile/openapi/lib/model/job_command_dto.dart +++ b/mobile/openapi/lib/model/job_command_dto.dart @@ -14,25 +14,31 @@ class JobCommandDto { /// Returns a new [JobCommandDto] instance. JobCommandDto({ required this.command, + required this.includeAllAssets, }); JobCommand command; + bool includeAllAssets; + @override bool operator ==(Object other) => identical(this, other) || other is JobCommandDto && - other.command == command; + other.command == command && + other.includeAllAssets == includeAllAssets; @override int get hashCode => // ignore: unnecessary_parenthesis - (command.hashCode); + (command.hashCode) + + (includeAllAssets.hashCode); @override - String toString() => 'JobCommandDto[command=$command]'; + String toString() => 'JobCommandDto[command=$command, includeAllAssets=$includeAllAssets]'; Map toJson() { final json = {}; json[r'command'] = this.command; + json[r'includeAllAssets'] = this.includeAllAssets; return json; } @@ -56,6 +62,7 @@ class JobCommandDto { return JobCommandDto( command: JobCommand.fromJson(json[r'command'])!, + includeAllAssets: mapValueOfType(json, r'includeAllAssets')!, ); } return null; @@ -106,6 +113,7 @@ class JobCommandDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'command', + 'includeAllAssets', }; } diff --git a/mobile/openapi/test/device_info_api_test.dart b/mobile/openapi/test/device_info_api_test.dart index 5bfbb4c328..94897849a9 100644 --- a/mobile/openapi/test/device_info_api_test.dart +++ b/mobile/openapi/test/device_info_api_test.dart @@ -17,20 +17,6 @@ void main() { // final instance = DeviceInfoApi(); group('tests for DeviceInfoApi', () { - // @deprecated - // - //Future createDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async - test('test createDeviceInfo', () async { - // TODO - }); - - // @deprecated - // - //Future updateDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async - test('test updateDeviceInfo', () async { - // TODO - }); - // // //Future upsertDeviceInfo(UpsertDeviceInfoDto upsertDeviceInfoDto) async diff --git a/mobile/openapi/test/exif_response_dto_test.dart b/mobile/openapi/test/exif_response_dto_test.dart index a7ee8c52ec..8d38e88e08 100644 --- a/mobile/openapi/test/exif_response_dto_test.dart +++ b/mobile/openapi/test/exif_response_dto_test.dart @@ -86,7 +86,7 @@ void main() { // TODO }); - // num exposureTime + // String exposureTime test('to test the property `exposureTime`', () async { // TODO }); diff --git a/mobile/openapi/test/job_command_dto_test.dart b/mobile/openapi/test/job_command_dto_test.dart index fc31170277..fe847827ac 100644 --- a/mobile/openapi/test/job_command_dto_test.dart +++ b/mobile/openapi/test/job_command_dto_test.dart @@ -21,6 +21,11 @@ void main() { // TODO }); + // bool includeAllAssets + test('to test the property `includeAllAssets`', () async { + // TODO + }); + }); diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2562836133..43ea5e4254 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.42.0+65 +version: 1.43.0+66 environment: sdk: ">=2.17.0 <3.0.0" diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index af02ff6ab0..891c0c708f 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -29,6 +29,8 @@ export interface IAssetRepository { livePhotoAssetEntity?: AssetEntity, ): Promise; update(userId: string, asset: AssetEntity, dto: UpdateAssetDto): Promise; + getAll(): Promise; + getAllVideos(): Promise; getAllByUserId(userId: string, dto: AssetSearchDto): Promise; getAllByDeviceId(userId: string, deviceId: string): Promise; getById(assetId: string): Promise; @@ -61,6 +63,22 @@ export class AssetRepository implements IAssetRepository { @Inject(ITagRepository) private _tagRepository: ITagRepository, ) {} + async getAllVideos(): Promise { + return await this.assetRepository.find({ + where: { type: AssetType.VIDEO }, + }); + } + + async getAll(): Promise { + return await this.assetRepository.find({ + where: { isVisible: true }, + relations: { + exifInfo: true, + smartInfo: true, + }, + }); + } + async getAssetWithNoSmartInfo(): Promise { return await this.assetRepository .createQueryBuilder('asset') diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 2176ebe26a..5db2aee25b 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -19,7 +19,7 @@ import { import { Authenticated } from '../../decorators/authenticated.decorator'; import { AssetService } from './asset.service'; import { FileFieldsInterceptor } from '@nestjs/platform-express'; -import { assetUploadOption } from '../../config/asset-upload.config'; +import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { ServeFileDto } from './dto/serve-file.dto'; import { Response as Res } from 'express'; @@ -80,7 +80,7 @@ export class AssetController { }) async uploadFile( @GetAuthUser() authUser: AuthUserDto, - @UploadedFiles() files: { assetData: Express.Multer.File[]; livePhotoData?: Express.Multer.File[] }, + @UploadedFiles() files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] }, @Body(ValidationPipe) createAssetDto: CreateAssetDto, @Response({ passthrough: true }) res: Res, ): Promise { diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 44f84e3556..7dfb31cbec 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -123,6 +123,8 @@ describe('AssetService', () => { assetRepositoryMock = { create: jest.fn(), update: jest.fn(), + getAll: jest.fn(), + getAllVideos: jest.fn(), getAllByUserId: jest.fn(), getAllByDeviceId: jest.fn(), getAssetCountByTimeBucket: jest.fn(), diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 534cf5234a..b7765f49fd 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -55,6 +55,7 @@ import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto'; import { mapSharedLink, SharedLinkResponseDto } from '@app/domain'; import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto'; import { AssetSearchDto } from './dto/asset-search.dto'; +import { ImmichFile } from '../../config/asset-upload.config'; const fileInfo = promisify(stat); @@ -82,16 +83,16 @@ export class AssetService { authUser: AuthUserDto, createAssetDto: CreateAssetDto, res: Res, - originalAssetData: Express.Multer.File, - livePhotoAssetData?: Express.Multer.File, + originalAssetData: ImmichFile, + livePhotoAssetData?: ImmichFile, ) { - const checksum = await this.calculateChecksum(originalAssetData.path); + const checksum = originalAssetData.checksum; const isLivePhoto = livePhotoAssetData !== undefined; let livePhotoAssetEntity: AssetEntity | undefined; try { if (isLivePhoto) { - const livePhotoChecksum = await this.calculateChecksum(livePhotoAssetData.path); + const livePhotoChecksum = livePhotoAssetData.checksum; livePhotoAssetEntity = await this.createUserAsset( authUser, createAssetDto, diff --git a/server/apps/immich/src/api-v1/communication/communication.gateway.ts b/server/apps/immich/src/api-v1/communication/communication.gateway.ts index 9ca2a3e23f..11eccab01d 100644 --- a/server/apps/immich/src/api-v1/communication/communication.gateway.ts +++ b/server/apps/immich/src/api-v1/communication/communication.gateway.ts @@ -19,8 +19,7 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco async handleConnection(client: Socket) { try { this.logger.log(`New websocket connection: ${client.id}`); - - const user = await this.authService.validateSocket(client); + const user = await this.authService.validate(client.request.headers); if (user) { client.join(user.id); } else { @@ -28,7 +27,8 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco client.disconnect(); } } catch (e) { - // Logger.error(`Error establish websocket conneciton ${e}`, 'HandleWebscoketConnection'); + client.emit('error', 'unauthorized'); + client.disconnect(); } } } diff --git a/server/apps/immich/src/api-v1/device-info/device-info.controller.ts b/server/apps/immich/src/api-v1/device-info/device-info.controller.ts index 3e57f47e5f..779b3fbe91 100644 --- a/server/apps/immich/src/api-v1/device-info/device-info.controller.ts +++ b/server/apps/immich/src/api-v1/device-info/device-info.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Patch, Post, Put, ValidationPipe } from '@nestjs/common'; +import { Body, Controller, Put, ValidationPipe } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator'; @@ -13,24 +13,6 @@ import { DeviceInfoResponseDto, mapDeviceInfoResponse } from './response-dto/dev export class DeviceInfoController { constructor(private readonly deviceInfoService: DeviceInfoService) {} - /** @deprecated */ - @Post() - public async createDeviceInfo( - @GetAuthUser() user: AuthUserDto, - @Body(ValidationPipe) dto: UpsertDeviceInfoDto, - ): Promise { - return this.upsertDeviceInfo(user, dto); - } - - /** @deprecated */ - @Patch() - public async updateDeviceInfo( - @GetAuthUser() user: AuthUserDto, - @Body(ValidationPipe) dto: UpsertDeviceInfoDto, - ): Promise { - return this.upsertDeviceInfo(user, dto); - } - @Put() public async upsertDeviceInfo( @GetAuthUser() user: AuthUserDto, diff --git a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts index f63f0fa517..5984404184 100644 --- a/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts +++ b/server/apps/immich/src/api-v1/job/dto/job-command.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsIn, IsNotEmpty } from 'class-validator'; +import { IsBoolean, IsIn, IsNotEmpty, IsOptional } from 'class-validator'; export class JobCommandDto { @IsNotEmpty() @@ -9,4 +9,8 @@ export class JobCommandDto { enumName: 'JobCommand', }) command!: string; + + @IsOptional() + @IsBoolean() + includeAllAssets!: boolean; } diff --git a/server/apps/immich/src/api-v1/job/job.controller.ts b/server/apps/immich/src/api-v1/job/job.controller.ts index 5dcb3e7a0c..a6228ddcc1 100644 --- a/server/apps/immich/src/api-v1/job/job.controller.ts +++ b/server/apps/immich/src/api-v1/job/job.controller.ts @@ -21,12 +21,12 @@ export class JobController { @Put('/:jobId') async sendJobCommand( @Param(ValidationPipe) params: GetJobDto, - @Body(ValidationPipe) body: JobCommandDto, + @Body(ValidationPipe) dto: JobCommandDto, ): Promise { - if (body.command === 'start') { - return await this.jobService.start(params.jobId); + if (dto.command === 'start') { + return await this.jobService.start(params.jobId, dto.includeAllAssets); } - if (body.command === 'stop') { + if (dto.command === 'stop') { return await this.jobService.stop(params.jobId); } return 0; diff --git a/server/apps/immich/src/api-v1/job/job.service.ts b/server/apps/immich/src/api-v1/job/job.service.ts index ea45f7aca8..ca31d3562e 100644 --- a/server/apps/immich/src/api-v1/job/job.service.ts +++ b/server/apps/immich/src/api-v1/job/job.service.ts @@ -5,7 +5,7 @@ import { IAssetRepository } from '../asset/asset-repository'; import { AssetType } from '@app/infra'; import { JobId } from './dto/get-job.dto'; import { MACHINE_LEARNING_ENABLED } from '@app/common'; - +import { getFileNameWithoutExtension } from '../../utils/file-name.util'; const jobIds = Object.values(JobId) as JobId[]; @Injectable() @@ -19,8 +19,8 @@ export class JobService { } } - start(jobId: JobId): Promise { - return this.run(this.asQueueName(jobId)); + start(jobId: JobId, includeAllAssets: boolean): Promise { + return this.run(this.asQueueName(jobId), includeAllAssets); } async stop(jobId: JobId): Promise { @@ -36,7 +36,7 @@ export class JobService { return response; } - private async run(name: QueueName): Promise { + private async run(name: QueueName, includeAllAssets: boolean): Promise { const isActive = await this.jobRepository.isActive(name); if (isActive) { throw new BadRequestException(`Job is already running`); @@ -44,7 +44,9 @@ export class JobService { switch (name) { case QueueName.VIDEO_CONVERSION: { - const assets = await this._assetRepository.getAssetWithNoEncodedVideo(); + const assets = includeAllAssets + ? await this._assetRepository.getAllVideos() + : await this._assetRepository.getAssetWithNoEncodedVideo(); for (const asset of assets) { await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); } @@ -61,7 +63,10 @@ export class JobService { throw new BadRequestException('Machine learning is not enabled.'); } - const assets = await this._assetRepository.getAssetWithNoSmartInfo(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoSmartInfo(); + for (const asset of assets) { await this.jobRepository.add({ name: JobName.IMAGE_TAGGING, data: { asset } }); await this.jobRepository.add({ name: JobName.OBJECT_DETECTION, data: { asset } }); @@ -70,19 +75,37 @@ export class JobService { } case QueueName.METADATA_EXTRACTION: { - const assets = await this._assetRepository.getAssetWithNoEXIF(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoEXIF(); + for (const asset of assets) { if (asset.type === AssetType.VIDEO) { - await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } }); + await this.jobRepository.add({ + name: JobName.EXTRACT_VIDEO_METADATA, + data: { + asset, + fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath), + }, + }); } else { - await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } }); + await this.jobRepository.add({ + name: JobName.EXIF_EXTRACTION, + data: { + asset, + fileName: asset.exifInfo?.imageName ?? getFileNameWithoutExtension(asset.originalPath), + }, + }); } } return assets.length; } case QueueName.THUMBNAIL_GENERATION: { - const assets = await this._assetRepository.getAssetWithNoThumbnail(); + const assets = includeAllAssets + ? await this._assetRepository.getAll() + : await this._assetRepository.getAssetWithNoThumbnail(); + for (const asset of assets) { await this.jobRepository.add({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { asset } }); } diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index d6100f6342..1001f80d82 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -1,7 +1,6 @@ import { immichAppConfig } from '@app/common/config'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { AssetModule } from './api-v1/asset/asset.module'; -import { ImmichJwtModule } from './modules/immich-jwt/immich-jwt.module'; import { DeviceInfoModule } from './api-v1/device-info/device-info.module'; import { ConfigModule } from '@nestjs/config'; import { ServerInfoModule } from './api-v1/server-info/server-info.module'; @@ -23,6 +22,9 @@ import { SystemConfigController, UserController, } from './controllers'; +import { PublicShareStrategy } from './modules/immich-auth/strategies/public-share.strategy'; +import { APIKeyStrategy } from './modules/immich-auth/strategies/api-key.strategy'; +import { UserAuthStrategy } from './modules/immich-auth/strategies/user-auth.strategy'; @Module({ imports: [ @@ -34,8 +36,6 @@ import { AssetModule, - ImmichJwtModule, - DeviceInfoModule, ServerInfoModule, @@ -64,7 +64,7 @@ import { SystemConfigController, UserController, ], - providers: [], + providers: [UserAuthStrategy, APIKeyStrategy, PublicShareStrategy], }) export class AppModule implements NestModule { // TODO: check if consumer is needed or remove diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index 0d9c980279..01ec1286d3 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/server/apps/immich/src/config/asset-upload.config.ts @@ -1,10 +1,10 @@ import { APP_UPLOAD_LOCATION } from '@app/common/constants'; import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; -import { randomUUID } from 'crypto'; +import { createHash, randomUUID } from 'crypto'; import { Request } from 'express'; import { existsSync, mkdirSync } from 'fs'; -import { diskStorage } from 'multer'; +import { diskStorage, StorageEngine } from 'multer'; import { extname, join } from 'path'; import sanitize from 'sanitize-filename'; import { AuthUserDto } from '../decorators/auth-user.decorator'; @@ -12,14 +12,40 @@ import { patchFormData } from '../utils/path-form-data.util'; const logger = new Logger('AssetUploadConfig'); +export interface ImmichFile extends Express.Multer.File { + /** sha1 hash of file */ + checksum: Buffer; +} + export const assetUploadOption: MulterOptions = { fileFilter, - storage: diskStorage({ - destination, - filename, - }), + storage: customStorage(), }; +export function customStorage(): StorageEngine { + const storage = diskStorage({ destination, filename }); + + return { + _handleFile(req, file, callback) { + const hash = createHash('sha1'); + file.stream.on('data', (chunk) => hash.update(chunk)); + + storage._handleFile(req, file, (error, response) => { + if (error) { + hash.destroy(); + callback(error); + } else { + callback(null, { ...response, checksum: hash.digest() } as ImmichFile); + } + }); + }, + + _removeFile(req, file, callback) { + storage._removeFile(req, file, callback); + }, + }; +} + export const multerUtils = { fileFilter, filename, destination }; function fileFilter(req: Request, file: any, cb: any) { diff --git a/server/apps/immich/src/decorators/authenticated.decorator.ts b/server/apps/immich/src/decorators/authenticated.decorator.ts index 4939ec5f20..6e3009e2f5 100644 --- a/server/apps/immich/src/decorators/authenticated.decorator.ts +++ b/server/apps/immich/src/decorators/authenticated.decorator.ts @@ -1,7 +1,7 @@ import { UseGuards } from '@nestjs/common'; import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware'; import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware'; -import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard'; +import { AuthGuard } from '../modules/immich-auth/guards/auth.guard'; interface AuthenticatedOptions { admin?: boolean; diff --git a/server/apps/immich/src/global.d.ts b/server/apps/immich/src/global.d.ts index a5867b0ed6..ff1f5fa03c 100644 --- a/server/apps/immich/src/global.d.ts +++ b/server/apps/immich/src/global.d.ts @@ -4,5 +4,8 @@ declare global { namespace Express { // eslint-disable-next-line @typescript-eslint/no-empty-interface interface User extends AuthUserDto {} + export interface Request { + user: AuthUserDto; + } } } diff --git a/server/apps/immich/src/main.ts b/server/apps/immich/src/main.ts index d1d3ed1d0b..37dd5ecfad 100644 --- a/server/apps/immich/src/main.ts +++ b/server/apps/immich/src/main.ts @@ -40,9 +40,6 @@ async function bootstrap() { .addBearerAuth({ type: 'http', scheme: 'Bearer', - bearerFormat: 'JWT', - name: 'JWT', - description: 'Enter JWT token', in: 'header', }) .addServer('/api') diff --git a/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts b/server/apps/immich/src/modules/immich-auth/guards/auth.guard.ts similarity index 72% rename from server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts rename to server/apps/immich/src/modules/immich-auth/guards/auth.guard.ts index 6bb237725d..9babdf8175 100644 --- a/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts +++ b/server/apps/immich/src/modules/immich-auth/guards/auth.guard.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; import { API_KEY_STRATEGY } from '../strategies/api-key.strategy'; -import { JWT_STRATEGY } from '../strategies/jwt.strategy'; +import { AUTH_COOKIE_STRATEGY } from '../strategies/user-auth.strategy'; import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy'; @Injectable() -export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, JWT_STRATEGY, API_KEY_STRATEGY]) {} +export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, AUTH_COOKIE_STRATEGY, API_KEY_STRATEGY]) {} diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts b/server/apps/immich/src/modules/immich-auth/strategies/api-key.strategy.ts similarity index 100% rename from server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts rename to server/apps/immich/src/modules/immich-auth/strategies/api-key.strategy.ts diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts b/server/apps/immich/src/modules/immich-auth/strategies/public-share.strategy.ts similarity index 100% rename from server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts rename to server/apps/immich/src/modules/immich-auth/strategies/public-share.strategy.ts diff --git a/server/apps/immich/src/modules/immich-auth/strategies/user-auth.strategy.ts b/server/apps/immich/src/modules/immich-auth/strategies/user-auth.strategy.ts new file mode 100644 index 0000000000..3ce1d9c670 --- /dev/null +++ b/server/apps/immich/src/modules/immich-auth/strategies/user-auth.strategy.ts @@ -0,0 +1,24 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { AuthService, AuthUserDto, UserService } from '@app/domain'; +import { Strategy } from 'passport-custom'; +import { Request } from 'express'; + +export const AUTH_COOKIE_STRATEGY = 'auth-cookie'; + +@Injectable() +export class UserAuthStrategy extends PassportStrategy(Strategy, AUTH_COOKIE_STRATEGY) { + constructor(private userService: UserService, private authService: AuthService) { + super(); + } + + async validate(request: Request): Promise { + const authUser = await this.authService.validate(request.headers); + + if (!authUser) { + throw new UnauthorizedException('Incorrect token provided'); + } + + return authUser; + } +} diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts deleted file mode 100644 index e3922d5fc1..0000000000 --- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { APIKeyStrategy } from './strategies/api-key.strategy'; -import { JwtStrategy } from './strategies/jwt.strategy'; -import { PublicShareStrategy } from './strategies/public-share.strategy'; - -@Module({ - providers: [JwtStrategy, APIKeyStrategy, PublicShareStrategy], -}) -export class ImmichJwtModule {} diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts deleted file mode 100644 index 1468dbfec9..0000000000 --- a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { AuthService, AuthUserDto, JwtPayloadDto, jwtSecret } from '@app/domain'; -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; -import { ExtractJwt, Strategy, StrategyOptions } from 'passport-jwt'; - -export const JWT_STRATEGY = 'jwt'; - -@Injectable() -export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { - constructor(private authService: AuthService) { - super({ - jwtFromRequest: ExtractJwt.fromExtractors([ - (req) => authService.extractJwtFromCookie(req.cookies), - (req) => authService.extractJwtFromHeader(req.headers), - ]), - ignoreExpiration: false, - secretOrKey: jwtSecret, - } as StrategyOptions); - } - - async validate(payload: JwtPayloadDto): Promise { - return this.authService.validatePayload(payload); - } -} diff --git a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts index e77163bf02..55b7ce6864 100644 --- a/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts +++ b/server/apps/immich/src/modules/schedule-tasks/schedule-tasks.service.ts @@ -1,9 +1,8 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Not, Repository } from 'typeorm'; -import { AssetEntity, AssetType, ExifEntity, UserEntity } from '@app/infra'; -import { ConfigService } from '@nestjs/config'; +import { UserEntity } from '@app/infra'; import { userUtils } from '@app/common'; import { IJobRepository, JobName } from '@app/domain'; @@ -13,93 +12,8 @@ export class ScheduleTasksService { @InjectRepository(UserEntity) private userRepository: Repository, - @InjectRepository(AssetEntity) - private assetRepository: Repository, - - @InjectRepository(ExifEntity) - private exifRepository: Repository, - @Inject(IJobRepository) private jobRepository: IJobRepository, - - private configService: ConfigService, ) {} - - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async webpConversion() { - const assets = await this.assetRepository.find({ - where: { - webpPath: '', - }, - }); - - if (assets.length == 0) { - Logger.log('All assets has webp file - aborting task', 'CronjobWebpGenerator'); - return; - } - - for (const asset of assets) { - await this.jobRepository.add({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { asset } }); - } - } - - @Cron(CronExpression.EVERY_DAY_AT_1AM) - async videoConversion() { - const assets = await this.assetRepository.find({ - where: { - type: AssetType.VIDEO, - mimeType: 'video/quicktime', - encodedVideoPath: '', - }, - order: { - createdAt: 'DESC', - }, - }); - - for (const asset of assets) { - await this.jobRepository.add({ name: JobName.VIDEO_CONVERSION, data: { asset } }); - } - } - - @Cron(CronExpression.EVERY_DAY_AT_2AM) - async reverseGeocoding() { - const isGeocodingEnabled = this.configService.get('DISABLE_REVERSE_GEOCODING') !== 'true'; - - if (isGeocodingEnabled) { - const exifInfo = await this.exifRepository.find({ - where: { - city: IsNull(), - longitude: Not(IsNull()), - latitude: Not(IsNull()), - }, - }); - - for (const exif of exifInfo) { - await this.jobRepository.add({ - name: JobName.REVERSE_GEOCODING, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - data: { exifId: exif.id, latitude: exif.latitude!, longitude: exif.longitude! }, - }); - } - } - } - - @Cron(CronExpression.EVERY_DAY_AT_3AM) - async extractExif() { - const exifAssets = await this.assetRepository - .createQueryBuilder('asset') - .leftJoinAndSelect('asset.exifInfo', 'ei') - .where('ei."assetId" IS NULL') - .getMany(); - - for (const asset of exifAssets) { - if (asset.type === AssetType.VIDEO) { - await this.jobRepository.add({ name: JobName.EXTRACT_VIDEO_METADATA, data: { asset, fileName: asset.id } }); - } else { - await this.jobRepository.add({ name: JobName.EXIF_EXTRACTION, data: { asset, fileName: asset.id } }); - } - } - } - @Cron(CronExpression.EVERY_DAY_AT_11PM) async deleteUserAndRelatedAssets() { const usersToDelete = await this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); diff --git a/server/apps/immich/src/utils/file-name.util.ts b/server/apps/immich/src/utils/file-name.util.ts new file mode 100644 index 0000000000..b575ae5f0b --- /dev/null +++ b/server/apps/immich/src/utils/file-name.util.ts @@ -0,0 +1,5 @@ +import { basename, extname } from 'node:path'; + +export function getFileNameWithoutExtension(path: string): string { + return basename(path, extname(path)); +} diff --git a/server/apps/immich/test/album.e2e-spec.ts b/server/apps/immich/test/album.e2e-spec.ts index e9de296efe..17a2bd23aa 100644 --- a/server/apps/immich/test/album.e2e-spec.ts +++ b/server/apps/immich/test/album.e2e-spec.ts @@ -5,10 +5,10 @@ import { clearDb, getAuthUser, authCustom } from './test-utils'; import { InfraModule } from '@app/infra'; import { AlbumModule } from '../src/api-v1/album/album.module'; import { CreateAlbumDto } from '../src/api-v1/album/dto/create-album.dto'; -import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module'; import { AuthUserDto } from '../src/decorators/auth-user.decorator'; import { AuthService, DomainModule, UserService } from '@app/domain'; import { DataSource } from 'typeorm'; +import { AppModule } from '../src/app.module'; function _createAlbum(app: INestApplication, data: CreateAlbumDto) { return request(app.getHttpServer()).post('/album').send(data); @@ -21,7 +21,7 @@ describe('Album', () => { describe('without auth', () => { beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [DomainModule.register({ imports: [InfraModule] }), AlbumModule, ImmichJwtModule], + imports: [DomainModule.register({ imports: [InfraModule] }), AppModule], }).compile(); app = moduleFixture.createNestApplication(); diff --git a/server/apps/immich/test/jest-e2e.json b/server/apps/immich/test/jest-e2e.json index c0014051c5..6867cf956e 100644 --- a/server/apps/immich/test/jest-e2e.json +++ b/server/apps/immich/test/jest-e2e.json @@ -1,5 +1,6 @@ { "moduleFileExtensions": ["js", "json", "ts"], + "modulePaths": ["", "../../../"], "rootDir": ".", "testEnvironment": "node", "testRegex": ".e2e-spec.ts$", diff --git a/server/apps/immich/test/test-utils.ts b/server/apps/immich/test/test-utils.ts index 9ab27ff3c9..67de60d19f 100644 --- a/server/apps/immich/test/test-utils.ts +++ b/server/apps/immich/test/test-utils.ts @@ -2,7 +2,7 @@ import { CanActivate, ExecutionContext } from '@nestjs/common'; import { TestingModuleBuilder } from '@nestjs/testing'; import { DataSource } from 'typeorm'; import { AuthUserDto } from '../src/decorators/auth-user.decorator'; -import { AuthGuard } from '../src/modules/immich-jwt/guards/auth.guard'; +import { AuthGuard } from '../src/modules/immich-auth/guards/auth.guard'; type CustomAuthCallback = () => AuthUserDto; diff --git a/server/apps/immich/test/user.e2e-spec.ts b/server/apps/immich/test/user.e2e-spec.ts index dde23b141a..173f2e357d 100644 --- a/server/apps/immich/test/user.e2e-spec.ts +++ b/server/apps/immich/test/user.e2e-spec.ts @@ -3,11 +3,11 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { clearDb, authCustom } from './test-utils'; import { InfraModule } from '@app/infra'; -import { ImmichJwtModule } from '../src/modules/immich-jwt/immich-jwt.module'; import { DomainModule, CreateUserDto, UserService, AuthUserDto } from '@app/domain'; import { DataSource } from 'typeorm'; import { UserController } from '../src/controllers'; import { AuthService } from '@app/domain'; +import { AppModule } from '../src/app.module'; function _createUser(userService: UserService, data: CreateUserDto) { return userService.createUser(data); @@ -25,7 +25,7 @@ describe('User', () => { describe('without auth', () => { beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [DomainModule.register({ imports: [InfraModule] }), ImmichJwtModule], + imports: [DomainModule.register({ imports: [InfraModule] }), AppModule], controllers: [UserController], }).compile(); diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index b409e6af07..36f9ca3cb6 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -154,13 +154,6 @@ export class MetadataExtractionProcessor { return exifDate.toDate(); }; - const getExposureTimeDenominator = (exposureTime: string | undefined) => { - if (!exposureTime) return null; - - const exposureTimeSplit = exposureTime.split('/'); - return exposureTimeSplit.length === 2 ? parseInt(exposureTimeSplit[1]) : null; - }; - const createdAt = exifToDate(exifData?.DateTimeOriginal ?? exifData?.CreateDate ?? asset.createdAt); const modifyDate = exifToDate(exifData?.ModifyDate ?? asset.modifiedAt); const fileStats = fs.statSync(asset.originalPath); @@ -174,7 +167,7 @@ export class MetadataExtractionProcessor { newExif.model = exifData?.Model || null; newExif.exifImageHeight = exifData?.ExifImageHeight || exifData?.ImageHeight || null; newExif.exifImageWidth = exifData?.ExifImageWidth || exifData?.ImageWidth || null; - newExif.exposureTime = getExposureTimeDenominator(exifData?.ExposureTime); + newExif.exposureTime = exifData?.ExposureTime || null; newExif.orientation = exifData?.Orientation?.toString() || null; newExif.dateTimeOriginal = createdAt; newExif.modifyDate = modifyDate; @@ -223,7 +216,7 @@ export class MetadataExtractionProcessor { } } - await this.exifRepository.save(newExif); + await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); } catch (error: any) { this.logger.error(`Error extracting EXIF ${error}`, error?.stack); } @@ -334,7 +327,7 @@ export class MetadataExtractionProcessor { } } - await this.exifRepository.save(newExif); + await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); await this.assetRepository.update({ id: asset.id }, { duration: durationString, createdAt: createdAt }); } catch (err) { // do nothing diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 52557e62fe..34ea3e297a 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/server/apps/microservices/src/processors/video-transcode.processor.ts @@ -11,6 +11,7 @@ import { Repository } from 'typeorm'; @Processor(QueueName.VIDEO_CONVERSION) export class VideoTranscodeProcessor { + readonly logger = new Logger(VideoTranscodeProcessor.name); constructor( @InjectRepository(AssetEntity) private assetRepository: Repository, @@ -20,7 +21,6 @@ export class VideoTranscodeProcessor { @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 }) async videoConversion(job: Job) { const { asset } = job.data; - const basePath = APP_UPLOAD_LOCATION; const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`; @@ -30,17 +30,14 @@ export class VideoTranscodeProcessor { const savedEncodedPath = `${encodedVideoPath}/${asset.id}.mp4`; - if (!asset.encodedVideoPath) { - // Put the processing into its own async function to prevent the job exist right away - await this.runVideoEncode(asset, savedEncodedPath); - } + await this.runVideoEncode(asset, savedEncodedPath); } async runFFProbePipeline(asset: AssetEntity): Promise { return new Promise((resolve, reject) => { ffmpeg.ffprobe(asset.originalPath, (err, data) => { if (err || !data) { - Logger.error(`Cannot probe video ${err}`, 'mp4Conversion'); + this.logger.error(`Cannot probe video ${err}`, 'runFFProbePipeline'); reject(err); } @@ -88,14 +85,14 @@ export class VideoTranscodeProcessor { ]) .output(savedEncodedPath) .on('start', () => { - Logger.log('Start Converting Video', 'mp4Conversion'); + this.logger.log('Start Converting Video'); }) .on('error', (error) => { - Logger.error(`Cannot Convert Video ${error}`, 'mp4Conversion'); + this.logger.error(`Cannot Convert Video ${error}`); reject(); }) .on('end', async () => { - Logger.log(`Converting Success ${asset.id}`, 'mp4Conversion'); + this.logger.log(`Converting Success ${asset.id}`); await this.assetRepository.update({ id: asset.id }, { encodedVideoPath: savedEncodedPath }); resolve(); }) diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 73aba74a58..dca5d80492 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2506,78 +2506,6 @@ } }, "/device-info": { - "post": { - "operationId": "createDeviceInfo", - "description": "@deprecated", - "deprecated": true, - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertDeviceInfoDto" - } - } - } - }, - "responses": { - "201": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceInfoResponseDto" - } - } - } - } - }, - "tags": [ - "Device Info" - ], - "security": [ - { - "bearer": [] - } - ] - }, - "patch": { - "operationId": "updateDeviceInfo", - "description": "@deprecated", - "deprecated": true, - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertDeviceInfoDto" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeviceInfoResponseDto" - } - } - } - } - }, - "tags": [ - "Device Info" - ], - "security": [ - { - "bearer": [] - } - ] - }, "put": { "operationId": "upsertDeviceInfo", "description": "", @@ -2794,8 +2722,6 @@ "scheme": "Bearer", "bearerFormat": "JWT", "type": "http", - "name": "JWT", - "description": "Enter JWT token", "in": "header" } }, @@ -3207,7 +3133,7 @@ "default": null }, "exposureTime": { - "type": "number", + "type": "string", "nullable": true, "default": null }, @@ -4610,10 +4536,14 @@ "properties": { "command": { "$ref": "#/components/schemas/JobCommand" + }, + "includeAllAssets": { + "type": "boolean" } }, "required": [ - "command" + "command", + "includeAllAssets" ] } } diff --git a/server/libs/common/src/config/app.config.ts b/server/libs/common/src/config/app.config.ts index 71a47b0b18..db619b32da 100644 --- a/server/libs/common/src/config/app.config.ts +++ b/server/libs/common/src/config/app.config.ts @@ -1,20 +1,5 @@ -import { Logger } from '@nestjs/common'; import { ConfigModuleOptions } from '@nestjs/config'; import Joi from 'joi'; -import { createSecretKey, generateKeySync } from 'node:crypto'; - -const jwtSecretValidator: Joi.CustomValidator = (value) => { - const key = createSecretKey(value, 'base64'); - const keySizeBits = (key.symmetricKeySize ?? 0) * 8; - - if (keySizeBits < 128) { - const newKey = generateKeySync('hmac', { length: 256 }).export().toString('base64'); - Logger.warn('The current JWT_SECRET key is insecure. It should be at least 128 bits long!'); - Logger.warn(`Here is a new, securely generated key that you can use instead: ${newKey}`); - } - - return value; -}; const WHEN_DB_URL_SET = Joi.when('DB_URL', { is: Joi.exist(), @@ -31,7 +16,6 @@ export const immichAppConfig: ConfigModuleOptions = { DB_PASSWORD: WHEN_DB_URL_SET, DB_DATABASE_NAME: WHEN_DB_URL_SET, DB_URL: Joi.string().optional(), - JWT_SECRET: Joi.string().required().custom(jwtSecretValidator), DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'), diff --git a/server/libs/domain/src/api-key/api-key.repository.ts b/server/libs/domain/src/api-key/api-key.repository.ts index 961d521648..76182fe1da 100644 --- a/server/libs/domain/src/api-key/api-key.repository.ts +++ b/server/libs/domain/src/api-key/api-key.repository.ts @@ -10,7 +10,7 @@ export interface IKeyRepository { * Includes the hashed `key` for verification * @param id */ - getKey(id: number): Promise; + getKey(hashedToken: string): Promise; getById(userId: string, id: number): Promise; getByUserId(userId: string): Promise; } diff --git a/server/libs/domain/src/api-key/api-key.service.spec.ts b/server/libs/domain/src/api-key/api-key.service.spec.ts index 0b9516af74..4761734c3a 100644 --- a/server/libs/domain/src/api-key/api-key.service.spec.ts +++ b/server/libs/domain/src/api-key/api-key.service.spec.ts @@ -1,6 +1,6 @@ import { APIKeyEntity } from '@app/infra/db/entities'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; -import { authStub, entityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test'; +import { authStub, userEntityStub, newCryptoRepositoryMock, newKeyRepositoryMock } from '../../test'; import { ICryptoRepository } from '../auth'; import { IKeyRepository } from './api-key.repository'; import { APIKeyService } from './api-key.service'; @@ -10,10 +10,10 @@ const adminKey = Object.freeze({ name: 'My Key', key: 'my-api-key (hashed)', userId: authStub.admin.id, - user: entityStub.admin, + user: userEntityStub.admin, } as APIKeyEntity); -const token = Buffer.from('1:my-api-key', 'utf8').toString('base64'); +const token = Buffer.from('my-api-key', 'utf8').toString('base64'); describe(APIKeyService.name, () => { let sut: APIKeyService; @@ -38,7 +38,7 @@ describe(APIKeyService.name, () => { userId: authStub.admin.id, }); expect(cryptoMock.randomBytes).toHaveBeenCalled(); - expect(cryptoMock.hash).toHaveBeenCalled(); + expect(cryptoMock.hashSha256).toHaveBeenCalled(); }); it('should not require a name', async () => { @@ -52,7 +52,7 @@ describe(APIKeyService.name, () => { userId: authStub.admin.id, }); expect(cryptoMock.randomBytes).toHaveBeenCalled(); - expect(cryptoMock.hash).toHaveBeenCalled(); + expect(cryptoMock.hashSha256).toHaveBeenCalled(); }); }); @@ -126,8 +126,7 @@ describe(APIKeyService.name, () => { await expect(sut.validate(token)).rejects.toBeInstanceOf(UnauthorizedException); - expect(keyMock.getKey).toHaveBeenCalledWith(1); - expect(cryptoMock.compareSync).not.toHaveBeenCalled(); + expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)'); }); it('should validate the token', async () => { @@ -135,8 +134,7 @@ describe(APIKeyService.name, () => { await expect(sut.validate(token)).resolves.toEqual(authStub.admin); - expect(keyMock.getKey).toHaveBeenCalledWith(1); - expect(cryptoMock.compareSync).toHaveBeenCalledWith('my-api-key', 'my-api-key (hashed)'); + expect(keyMock.getKey).toHaveBeenCalledWith('bXktYXBpLWtleQ== (hashed)'); }); }); }); diff --git a/server/libs/domain/src/api-key/api-key.service.ts b/server/libs/domain/src/api-key/api-key.service.ts index aefff8f64d..c5bf096933 100644 --- a/server/libs/domain/src/api-key/api-key.service.ts +++ b/server/libs/domain/src/api-key/api-key.service.ts @@ -1,4 +1,3 @@ -import { UserEntity } from '@app/infra/db/entities'; import { BadRequestException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { AuthUserDto, ICryptoRepository } from '../auth'; import { IKeyRepository } from './api-key.repository'; @@ -14,15 +13,13 @@ export class APIKeyService { ) {} async create(authUser: AuthUserDto, dto: APIKeyCreateDto): Promise { - const key = this.crypto.randomBytes(24).toString('base64').replace(/\W/g, ''); + const secret = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, ''); const entity = await this.repository.create({ - key: await this.crypto.hash(key, 10), + key: this.crypto.hashSha256(secret), name: dto.name || 'API Key', userId: authUser.id, }); - const secret = Buffer.from(`${entity.id}:${key}`, 'utf8').toString('base64'); - return { secret, apiKey: mapKey(entity) }; } @@ -60,22 +57,18 @@ export class APIKeyService { } async validate(token: string): Promise { - const [_id, key] = Buffer.from(token, 'base64').toString('utf8').split(':'); - const id = Number(_id); + const hashedToken = this.crypto.hashSha256(token); + const keyEntity = await this.repository.getKey(hashedToken); + if (keyEntity?.user) { + const user = keyEntity.user; - if (id && key) { - const entity = await this.repository.getKey(id); - if (entity?.user && entity?.key && this.crypto.compareSync(key, entity.key)) { - const user = entity.user as UserEntity; - - return { - id: user.id, - email: user.email, - isAdmin: user.isAdmin, - isPublicUser: false, - isAllowUpload: true, - }; - } + return { + id: user.id, + email: user.email, + isAdmin: user.isAdmin, + isPublicUser: false, + isAllowUpload: true, + }; } throw new UnauthorizedException('Invalid API Key'); diff --git a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts index 0dd97a3111..1695157af5 100644 --- a/server/libs/domain/src/asset/response-dto/exif-response.dto.ts +++ b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts @@ -19,7 +19,7 @@ export class ExifResponseDto { fNumber?: number | null = null; focalLength?: number | null = null; iso?: number | null = null; - exposureTime?: number | null = null; + exposureTime?: string | null = null; latitude?: number | null = null; longitude?: number | null = null; city?: string | null = null; diff --git a/server/libs/domain/src/auth/auth.config.ts b/server/libs/domain/src/auth/auth.config.ts deleted file mode 100644 index 71dcd3a98c..0000000000 --- a/server/libs/domain/src/auth/auth.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { JwtModuleOptions } from '@nestjs/jwt'; -import { jwtSecret } from './auth.constant'; - -export const jwtConfig: JwtModuleOptions = { - secret: jwtSecret, - signOptions: { expiresIn: '30d' }, -}; diff --git a/server/libs/domain/src/auth/auth.constant.ts b/server/libs/domain/src/auth/auth.constant.ts index fbab227755..2bf04f6721 100644 --- a/server/libs/domain/src/auth/auth.constant.ts +++ b/server/libs/domain/src/auth/auth.constant.ts @@ -1,4 +1,3 @@ -export const jwtSecret = process.env.JWT_SECRET; export const IMMICH_ACCESS_COOKIE = 'immich_access_token'; export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type'; export enum AuthType { diff --git a/server/libs/domain/src/auth/auth.core.ts b/server/libs/domain/src/auth/auth.core.ts index 109fac8dab..7cf7ac8e0a 100644 --- a/server/libs/domain/src/auth/auth.core.ts +++ b/server/libs/domain/src/auth/auth.core.ts @@ -4,8 +4,9 @@ import { ISystemConfigRepository } from '../system-config'; import { SystemConfigCore } from '../system-config/system-config.core'; import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant'; import { ICryptoRepository } from './crypto.repository'; -import { JwtPayloadDto } from './dto/jwt-payload.dto'; import { LoginResponseDto, mapLoginResponse } from './response-dto'; +import { IUserTokenRepository, UserTokenCore } from '@app/domain'; +import cookieParser from 'cookie'; export type JwtValidationResult = { status: boolean; @@ -13,11 +14,14 @@ export type JwtValidationResult = { }; export class AuthCore { + private userTokenCore: UserTokenCore; constructor( private cryptoRepository: ICryptoRepository, configRepository: ISystemConfigRepository, + userTokenRepository: IUserTokenRepository, private config: SystemConfig, ) { + this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository); const configCore = new SystemConfigCore(configRepository); configCore.config$.subscribe((config) => (this.config = config)); } @@ -33,8 +37,8 @@ export class AuthCore { let accessTokenCookie = ''; if (isSecure) { - accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; - authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; + accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; + authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; } else { accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Strict;`; @@ -42,9 +46,8 @@ export class AuthCore { return [accessTokenCookie, authTypeCookie]; } - public createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) { - const payload: JwtPayloadDto = { userId: user.id, email: user.email }; - const accessToken = this.generateToken(payload); + public async createLoginResponse(user: UserEntity, authType: AuthType, isSecure: boolean) { + const accessToken = await this.userTokenCore.createToken(user); const response = mapLoginResponse(user, accessToken); const cookie = this.getCookies(response, authType, isSecure); return { response, cookie }; @@ -54,12 +57,12 @@ export class AuthCore { if (!user || !user.password) { return false; } - return this.cryptoRepository.compareSync(inputPassword, user.password); + return this.cryptoRepository.compareBcrypt(inputPassword, user.password); } - extractJwtFromHeader(headers: IncomingHttpHeaders) { + extractTokenFromHeader(headers: IncomingHttpHeaders) { if (!headers.authorization) { - return null; + return this.extractTokenFromCookie(cookieParser.parse(headers.cookie || '')); } const [type, accessToken] = headers.authorization.split(' '); @@ -70,11 +73,7 @@ export class AuthCore { return accessToken; } - extractJwtFromCookie(cookies: Record) { + extractTokenFromCookie(cookies: Record) { return cookies?.[IMMICH_ACCESS_COOKIE] || null; } - - private generateToken(payload: JwtPayloadDto) { - return this.cryptoRepository.signJwt({ ...payload }); - } } diff --git a/server/libs/domain/src/auth/auth.service.spec.ts b/server/libs/domain/src/auth/auth.service.spec.ts index db71cb54b9..486d26d82a 100644 --- a/server/libs/domain/src/auth/auth.service.spec.ts +++ b/server/libs/domain/src/auth/auth.service.spec.ts @@ -3,13 +3,13 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { generators, Issuer } from 'openid-client'; import { Socket } from 'socket.io'; import { - authStub, - entityStub, + userEntityStub, loginResponseStub, newCryptoRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, systemConfigStub, + userTokenEntityStub, } from '../../test'; import { ISystemConfigRepository } from '../system-config'; import { IUserRepository } from '../user'; @@ -17,6 +17,9 @@ import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth. import { AuthService } from './auth.service'; import { ICryptoRepository } from './crypto.repository'; import { SignUpDto } from './dto'; +import { IUserTokenRepository } from '@app/domain'; +import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock'; +import { IncomingHttpHeaders } from 'http'; const email = 'test@immich.com'; const sub = 'my-auth-user-sub'; @@ -47,6 +50,7 @@ describe('AuthService', () => { let cryptoMock: jest.Mocked; let userMock: jest.Mocked; let configMock: jest.Mocked; + let userTokenMock: jest.Mocked; let callbackMock: jest.Mock; let create: (config: SystemConfig) => AuthService; @@ -76,8 +80,9 @@ describe('AuthService', () => { cryptoMock = newCryptoRepositoryMock(); userMock = newUserRepositoryMock(); configMock = newSystemConfigRepositoryMock(); + userTokenMock = newUserTokenRepositoryMock(); - create = (config) => new AuthService(cryptoMock, configMock, userMock, config); + create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, config); sut = create(systemConfigStub.enabled); }); @@ -106,13 +111,15 @@ describe('AuthService', () => { }); it('should successfully log the user in', async () => { - userMock.getByEmail.mockResolvedValue(entityStub.user1); + userMock.getByEmail.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); await expect(sut.login(fixtures.login, CLIENT_IP, true)).resolves.toEqual(loginResponseStub.user1password); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); it('should generate the cookie headers (insecure)', async () => { - userMock.getByEmail.mockResolvedValue(entityStub.user1); + userMock.getByEmail.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); await expect(sut.login(fixtures.login, CLIENT_IP, false)).resolves.toEqual(loginResponseStub.user1insecure); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); }); @@ -131,7 +138,7 @@ describe('AuthService', () => { await sut.changePassword(authUser, dto); expect(userMock.getByEmail).toHaveBeenCalledWith(authUser.email, true); - expect(cryptoMock.compareSync).toHaveBeenCalledWith('old-password', 'hash-password'); + expect(cryptoMock.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password'); }); it('should throw when auth user email is not found', async () => { @@ -147,7 +154,7 @@ describe('AuthService', () => { const authUser = { email: 'test@imimch.com' } as UserEntity; const dto = { password: 'old-password', newPassword: 'new-password' }; - cryptoMock.compareSync.mockReturnValue(false); + cryptoMock.compareBcrypt.mockReturnValue(false); userMock.getByEmail.mockResolvedValue({ email: 'test@immich.com', @@ -161,8 +168,6 @@ describe('AuthService', () => { const authUser = { email: 'test@imimch.com' } as UserEntity; const dto = { password: 'old-password', newPassword: 'new-password' }; - cryptoMock.compareSync.mockReturnValue(false); - userMock.getByEmail.mockResolvedValue({ email: 'test@immich.com', password: '', @@ -212,52 +217,64 @@ describe('AuthService', () => { }); }); - describe('validateSocket', () => { + describe('validate - socket connections', () => { it('should validate using authorization header', async () => { - userMock.get.mockResolvedValue(entityStub.user1); - const client = { handshake: { headers: { authorization: 'Bearer jwt-token' } } }; - await expect(sut.validateSocket(client as Socket)).resolves.toEqual(entityStub.user1); + userMock.get.mockResolvedValue(userEntityStub.user1); + userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken); + const client = { request: { headers: { authorization: 'Bearer auth_token' } } }; + await expect(sut.validate((client as Socket).request.headers)).resolves.toEqual(userEntityStub.user1); }); }); - describe('validatePayload', () => { + describe('validate - api request', () => { it('should throw if no user is found', async () => { userMock.get.mockResolvedValue(null); - await expect(sut.validatePayload({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException); + await expect(sut.validate({ email: 'a', userId: 'test' })).rejects.toBeInstanceOf(UnauthorizedException); }); it('should return an auth dto', async () => { - userMock.get.mockResolvedValue(entityStub.admin); - await expect(sut.validatePayload({ email: 'a', userId: 'test' })).resolves.toEqual(authStub.admin); + userMock.get.mockResolvedValue(userEntityStub.user1); + userTokenMock.get.mockResolvedValue(userTokenEntityStub.userToken); + await expect( + sut.validate({ cookie: 'immich_access_token=auth_token', email: 'a', userId: 'test' }), + ).resolves.toEqual(userEntityStub.user1); }); }); - describe('extractJwtFromCookie', () => { + describe('extractTokenFromHeader - Cookie', () => { it('should extract the access token', () => { - const cookie = { [IMMICH_ACCESS_COOKIE]: 'signed-jwt', [IMMICH_AUTH_TYPE_COOKIE]: 'password' }; - expect(sut.extractJwtFromCookie(cookie)).toEqual('signed-jwt'); + const cookie: IncomingHttpHeaders = { + cookie: `${IMMICH_ACCESS_COOKIE}=signed-jwt;${IMMICH_AUTH_TYPE_COOKIE}=password`, + }; + expect(sut.extractTokenFromHeader(cookie)).toEqual('signed-jwt'); }); it('should work with no cookies', () => { - expect(sut.extractJwtFromCookie(undefined as any)).toBeNull(); + const cookie: IncomingHttpHeaders = { + cookie: undefined, + }; + expect(sut.extractTokenFromHeader(cookie)).toBeNull(); }); it('should work on empty cookies', () => { - expect(sut.extractJwtFromCookie({})).toBeNull(); + const cookie: IncomingHttpHeaders = { + cookie: '', + }; + expect(sut.extractTokenFromHeader(cookie)).toBeNull(); }); }); - describe('extractJwtFromHeader', () => { + describe('extractTokenFromHeader - Bearer Auth', () => { it('should extract the access token', () => { - expect(sut.extractJwtFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt'); + expect(sut.extractTokenFromHeader({ authorization: `Bearer signed-jwt` })).toEqual('signed-jwt'); }); it('should work without the auth header', () => { - expect(sut.extractJwtFromHeader({})).toBeNull(); + expect(sut.extractTokenFromHeader({})).toBeNull(); }); it('should ignore basic auth', () => { - expect(sut.extractJwtFromHeader({ authorization: `Basic stuff` })).toBeNull(); + expect(sut.extractTokenFromHeader({ authorization: `Basic stuff` })).toBeNull(); }); }); }); diff --git a/server/libs/domain/src/auth/auth.service.ts b/server/libs/domain/src/auth/auth.service.ts index 0fcb799672..6872cbcecd 100644 --- a/server/libs/domain/src/auth/auth.service.ts +++ b/server/libs/domain/src/auth/auth.service.ts @@ -7,20 +7,20 @@ import { Logger, UnauthorizedException, } from '@nestjs/common'; -import * as cookieParser from 'cookie'; import { IncomingHttpHeaders } from 'http'; -import { Socket } from 'socket.io'; import { OAuthCore } from '../oauth/oauth.core'; import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; -import { IUserRepository, UserCore, UserResponseDto } from '../user'; -import { AuthType, jwtSecret } from './auth.constant'; +import { IUserRepository, UserCore } from '../user'; +import { AuthType } from './auth.constant'; import { AuthCore } from './auth.core'; import { ICryptoRepository } from './crypto.repository'; -import { AuthUserDto, ChangePasswordDto, JwtPayloadDto, LoginCredentialDto, SignUpDto } from './dto'; +import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto'; import { AdminSignupResponseDto, LoginResponseDto, LogoutResponseDto, mapAdminSignupResponse } from './response-dto'; +import { IUserTokenRepository, UserTokenCore } from '@app/domain/user-token'; @Injectable() export class AuthService { + private userTokenCore: UserTokenCore; private authCore: AuthCore; private oauthCore: OAuthCore; private userCore: UserCore; @@ -31,11 +31,14 @@ export class AuthService { @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) userRepository: IUserRepository, - @Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig, + @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository, + @Inject(INITIAL_SYSTEM_CONFIG) + initialConfig: SystemConfig, ) { - this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig); + this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository); + this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig); this.oauthCore = new OAuthCore(configRepository, initialConfig); - this.userCore = new UserCore(userRepository); + this.userCore = new UserCore(userRepository, cryptoRepository); } public async login( @@ -49,7 +52,7 @@ export class AuthService { let user = await this.userCore.getByEmail(loginCredential.email, true); if (user) { - const isAuthenticated = await this.authCore.validatePassword(loginCredential.password, user); + const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user); if (!isAuthenticated) { user = null; } @@ -81,7 +84,7 @@ export class AuthService { throw new UnauthorizedException(); } - const valid = await this.authCore.validatePassword(password, user); + const valid = this.authCore.validatePassword(password, user); if (!valid) { throw new BadRequestException('Wrong password'); } @@ -112,49 +115,28 @@ export class AuthService { } } - async validateSocket(client: Socket): Promise { - try { - const headers = client.handshake.headers; - const accessToken = - this.extractJwtFromCookie(cookieParser.parse(headers.cookie || '')) || this.extractJwtFromHeader(headers); - - if (accessToken) { - const payload = await this.cryptoRepository.verifyJwtAsync(accessToken, { secret: jwtSecret }); - if (payload?.userId && payload?.email) { - const user = await this.userCore.get(payload.userId); - if (user) { - return user; - } - } - } - } catch (e) { - return null; - } - return null; - } - - async validatePayload(payload: JwtPayloadDto) { - const { userId } = payload; - const user = await this.userCore.get(userId); - if (!user) { - throw new UnauthorizedException('Failure to validate JWT payload'); + public async validate(headers: IncomingHttpHeaders): Promise { + const tokenValue = this.extractTokenFromHeader(headers); + if (!tokenValue) { + throw new UnauthorizedException('No access token provided in request'); } - const authUser = new AuthUserDto(); - authUser.id = user.id; - authUser.email = user.email; - authUser.isAdmin = user.isAdmin; - authUser.isPublicUser = false; - authUser.isAllowUpload = true; + const hashedToken = this.cryptoRepository.hashSha256(tokenValue); + const user = await this.userTokenCore.getUserByToken(hashedToken); + if (user) { + return { + ...user, + isPublicUser: false, + isAllowUpload: true, + isAllowDownload: true, + isShowExif: true, + }; + } - return authUser; + throw new UnauthorizedException('Invalid access token provided'); } - extractJwtFromCookie(cookies: Record) { - return this.authCore.extractJwtFromCookie(cookies); - } - - extractJwtFromHeader(headers: IncomingHttpHeaders) { - return this.authCore.extractJwtFromHeader(headers); + extractTokenFromHeader(headers: IncomingHttpHeaders) { + return this.authCore.extractTokenFromHeader(headers); } } diff --git a/server/libs/domain/src/auth/crypto.repository.ts b/server/libs/domain/src/auth/crypto.repository.ts index e12240c74d..d400b017da 100644 --- a/server/libs/domain/src/auth/crypto.repository.ts +++ b/server/libs/domain/src/auth/crypto.repository.ts @@ -1,11 +1,8 @@ -import { JwtSignOptions, JwtVerifyOptions } from '@nestjs/jwt'; - export const ICryptoRepository = 'ICryptoRepository'; export interface ICryptoRepository { randomBytes(size: number): Buffer; - hash(data: string | Buffer, saltOrRounds: string | number): Promise; - compareSync(data: Buffer | string, encrypted: string): boolean; - signJwt(payload: string | Buffer | object, options?: JwtSignOptions): string; - verifyJwtAsync(token: string, options?: JwtVerifyOptions): Promise; + hashSha256(data: string): string; + hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise; + compareBcrypt(data: string | Buffer, encrypted: string): boolean; } diff --git a/server/libs/domain/src/auth/index.ts b/server/libs/domain/src/auth/index.ts index be6def62bb..118de239ea 100644 --- a/server/libs/domain/src/auth/index.ts +++ b/server/libs/domain/src/auth/index.ts @@ -1,4 +1,3 @@ -export * from './auth.config'; export * from './auth.constant'; export * from './auth.service'; export * from './crypto.repository'; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 8079851276..23b4adf190 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -13,7 +13,6 @@ const providers: Provider[] = [ SystemConfigService, UserService, ShareService, - { provide: INITIAL_SYSTEM_CONFIG, inject: [SystemConfigService], diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 809f8b0618..38b491751d 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -9,3 +9,4 @@ export * from './share'; export * from './system-config'; export * from './tag'; export * from './user'; +export * from './user-token'; diff --git a/server/libs/domain/src/oauth/oauth.service.spec.ts b/server/libs/domain/src/oauth/oauth.service.spec.ts index 5408ee0392..0cf18587d1 100644 --- a/server/libs/domain/src/oauth/oauth.service.spec.ts +++ b/server/libs/domain/src/oauth/oauth.service.spec.ts @@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common'; import { generators, Issuer } from 'openid-client'; import { authStub, - entityStub, + userEntityStub, loginResponseStub, newCryptoRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, systemConfigStub, + userTokenEntityStub, } from '../../test'; import { ICryptoRepository } from '../auth'; import { OAuthService } from '../oauth'; import { ISystemConfigRepository } from '../system-config'; import { IUserRepository } from '../user'; +import { IUserTokenRepository } from '@app/domain'; +import { newUserTokenRepositoryMock } from '../../test/user-token.repository.mock'; const email = 'user@immich.com'; const sub = 'my-auth-user-sub'; @@ -35,6 +38,7 @@ describe('OAuthService', () => { let userMock: jest.Mocked; let cryptoMock: jest.Mocked; let configMock: jest.Mocked; + let userTokenMock: jest.Mocked; let callbackMock: jest.Mock; let create: (config: SystemConfig) => OAuthService; @@ -60,8 +64,9 @@ describe('OAuthService', () => { cryptoMock = newCryptoRepositoryMock(); configMock = newSystemConfigRepositoryMock(); userMock = newUserRepositoryMock(); + userTokenMock = newUserTokenRepositoryMock(); - create = (config) => new OAuthService(cryptoMock, configMock, userMock, config); + create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config); sut = create(systemConfigStub.disabled); }); @@ -106,23 +111,25 @@ describe('OAuthService', () => { it('should link an existing user', async () => { sut = create(systemConfigStub.noAutoRegister); - userMock.getByEmail.mockResolvedValue(entityStub.user1); - userMock.update.mockResolvedValue(entityStub.user1); + userMock.getByEmail.mockResolvedValue(userEntityStub.user1); + userMock.update.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual( loginResponseStub.user1oauth, ); expect(userMock.getByEmail).toHaveBeenCalledTimes(1); - expect(userMock.update).toHaveBeenCalledWith(entityStub.user1.id, { oauthId: sub }); + expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub }); }); it('should allow auto registering by default', async () => { sut = create(systemConfigStub.enabled); userMock.getByEmail.mockResolvedValue(null); - userMock.getAdmin.mockResolvedValue(entityStub.user1); - userMock.create.mockResolvedValue(entityStub.user1); + userMock.getAdmin.mockResolvedValue(userEntityStub.user1); + userMock.create.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, true)).resolves.toEqual( loginResponseStub.user1oauth, @@ -135,7 +142,8 @@ describe('OAuthService', () => { it('should use the mobile redirect override', async () => { sut = create(systemConfigStub.override); - userMock.getByOAuthId.mockResolvedValue(entityStub.user1); + userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1); + userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken); await sut.login({ url: `app.immich:/?code=abc123` }, true); @@ -147,7 +155,7 @@ describe('OAuthService', () => { it('should link an account', async () => { sut = create(systemConfigStub.enabled); - userMock.update.mockResolvedValue(entityStub.user1); + userMock.update.mockResolvedValue(userEntityStub.user1); await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); @@ -171,7 +179,7 @@ describe('OAuthService', () => { it('should unlink an account', async () => { sut = create(systemConfigStub.enabled); - userMock.update.mockResolvedValue(entityStub.user1); + userMock.update.mockResolvedValue(userEntityStub.user1); await sut.unlink(authStub.user1); diff --git a/server/libs/domain/src/oauth/oauth.service.ts b/server/libs/domain/src/oauth/oauth.service.ts index f054f019e8..7d919d75ad 100644 --- a/server/libs/domain/src/oauth/oauth.service.ts +++ b/server/libs/domain/src/oauth/oauth.service.ts @@ -7,6 +7,7 @@ import { IUserRepository, UserCore, UserResponseDto } from '../user'; import { OAuthCallbackDto, OAuthConfigDto } from './dto'; import { OAuthCore } from './oauth.core'; import { OAuthConfigResponseDto } from './response-dto'; +import { IUserTokenRepository } from '@app/domain/user-token'; @Injectable() export class OAuthService { @@ -20,10 +21,11 @@ export class OAuthService { @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IUserRepository) userRepository: IUserRepository, + @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository, @Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig, ) { - this.authCore = new AuthCore(cryptoRepository, configRepository, initialConfig); - this.userCore = new UserCore(userRepository); + this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig); + this.userCore = new UserCore(userRepository, cryptoRepository); this.oauthCore = new OAuthCore(configRepository, initialConfig); } diff --git a/server/libs/domain/src/share/share.service.spec.ts b/server/libs/domain/src/share/share.service.spec.ts index 9f997b4a56..c8dd994fc3 100644 --- a/server/libs/domain/src/share/share.service.spec.ts +++ b/server/libs/domain/src/share/share.service.spec.ts @@ -1,7 +1,7 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { authStub, - entityStub, + userEntityStub, newCryptoRepositoryMock, newSharedLinkRepositoryMock, newUserRepositoryMock, @@ -50,7 +50,7 @@ describe(ShareService.name, () => { it('should accept a valid key', async () => { shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid); - userMock.get.mockResolvedValue(entityStub.admin); + userMock.get.mockResolvedValue(userEntityStub.admin); await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink); }); }); diff --git a/server/libs/domain/src/share/share.service.ts b/server/libs/domain/src/share/share.service.ts index eca46d97ab..e175b6e943 100644 --- a/server/libs/domain/src/share/share.service.ts +++ b/server/libs/domain/src/share/share.service.ts @@ -25,7 +25,7 @@ export class ShareService { @Inject(IUserRepository) userRepository: IUserRepository, ) { this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); - this.userCore = new UserCore(userRepository); + this.userCore = new UserCore(userRepository, cryptoRepository); } async validate(key: string): Promise { diff --git a/server/libs/domain/src/user-token/index.ts b/server/libs/domain/src/user-token/index.ts new file mode 100644 index 0000000000..46c83640c7 --- /dev/null +++ b/server/libs/domain/src/user-token/index.ts @@ -0,0 +1,2 @@ +export * from './user-token.repository'; +export * from './user-token.core'; diff --git a/server/libs/domain/src/user-token/user-token.core.ts b/server/libs/domain/src/user-token/user-token.core.ts new file mode 100644 index 0000000000..7ae3b27835 --- /dev/null +++ b/server/libs/domain/src/user-token/user-token.core.ts @@ -0,0 +1,28 @@ +import { UserEntity } from '@app/infra/db/entities'; +import { Injectable } from '@nestjs/common'; +import { ICryptoRepository } from '../auth'; +import { IUserTokenRepository } from './user-token.repository'; + +@Injectable() +export class UserTokenCore { + constructor(private crypto: ICryptoRepository, private repository: IUserTokenRepository) {} + + public async getUserByToken(tokenValue: string): Promise { + const token = await this.repository.get(tokenValue); + if (token?.user) { + return token.user; + } + return null; + } + + public async createToken(user: UserEntity): Promise { + const key = this.crypto.randomBytes(32).toString('base64').replace(/\W/g, ''); + const token = this.crypto.hashSha256(key); + await this.repository.create({ + token, + user, + }); + + return key; + } +} diff --git a/server/libs/domain/src/user-token/user-token.repository.ts b/server/libs/domain/src/user-token/user-token.repository.ts new file mode 100644 index 0000000000..a084d22e8f --- /dev/null +++ b/server/libs/domain/src/user-token/user-token.repository.ts @@ -0,0 +1,9 @@ +import { UserTokenEntity } from '@app/infra/db/entities'; + +export const IUserTokenRepository = 'IUserTokenRepository'; + +export interface IUserTokenRepository { + create(dto: Partial): Promise; + delete(userToken: string): Promise; + get(userToken: string): Promise; +} diff --git a/server/libs/domain/src/user/user.core.ts b/server/libs/domain/src/user/user.core.ts index a1cc54f42b..30edc160bf 100644 --- a/server/libs/domain/src/user/user.core.ts +++ b/server/libs/domain/src/user/user.core.ts @@ -10,14 +10,14 @@ import { import { hash } from 'bcrypt'; import { constants, createReadStream, ReadStream } from 'fs'; import fs from 'fs/promises'; -import { AuthUserDto } from '../auth'; +import { AuthUserDto, ICryptoRepository } from '../auth'; import { CreateAdminDto, CreateUserDto, CreateUserOAuthDto } from './dto/create-user.dto'; import { IUserRepository, UserListFilter } from './user.repository'; const SALT_ROUNDS = 10; export class UserCore { - constructor(private userRepository: IUserRepository) {} + constructor(private userRepository: IUserRepository, private cryptoRepository: ICryptoRepository) {} async updateUser(authUser: AuthUserDto, id: string, dto: Partial): Promise { if (!(authUser.isAdmin || authUser.id === id)) { @@ -37,7 +37,7 @@ export class UserCore { try { if (dto.password) { - dto.password = await hash(dto.password, SALT_ROUNDS); + dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS); } return this.userRepository.update(id, dto); diff --git a/server/libs/domain/src/user/user.service.spec.ts b/server/libs/domain/src/user/user.service.spec.ts index deb61f16fd..62df6b8ce5 100644 --- a/server/libs/domain/src/user/user.service.spec.ts +++ b/server/libs/domain/src/user/user.service.spec.ts @@ -2,8 +2,8 @@ import { IUserRepository } from './user.repository'; import { UserEntity } from '@app/infra/db/entities'; import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; import { when } from 'jest-when'; -import { newUserRepositoryMock } from '../../test'; -import { AuthUserDto } from '../auth'; +import { newCryptoRepositoryMock, newUserRepositoryMock } from '../../test'; +import { AuthUserDto, ICryptoRepository } from '../auth'; import { UpdateUserDto } from './dto/update-user.dto'; import { UserService } from './user.service'; @@ -77,10 +77,12 @@ const adminUserResponse = Object.freeze({ describe(UserService.name, () => { let sut: UserService; let userRepositoryMock: jest.Mocked; + let cryptoRepositoryMock: jest.Mocked; beforeEach(async () => { userRepositoryMock = newUserRepositoryMock(); - sut = new UserService(userRepositoryMock); + cryptoRepositoryMock = newCryptoRepositoryMock(); + sut = new UserService(userRepositoryMock, cryptoRepositoryMock); when(userRepositoryMock.get).calledWith(adminUser.id).mockResolvedValue(adminUser); when(userRepositoryMock.get).calledWith(adminUser.id, undefined).mockResolvedValue(adminUser); diff --git a/server/libs/domain/src/user/user.service.ts b/server/libs/domain/src/user/user.service.ts index e0d02876b9..74e669fcef 100644 --- a/server/libs/domain/src/user/user.service.ts +++ b/server/libs/domain/src/user/user.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { ReadStream } from 'fs'; -import { AuthUserDto } from '../auth'; +import { AuthUserDto, ICryptoRepository } from '../auth'; import { IUserRepository } from '../user'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; @@ -17,8 +17,11 @@ import { UserCore } from './user.core'; @Injectable() export class UserService { private userCore: UserCore; - constructor(@Inject(IUserRepository) userRepository: IUserRepository) { - this.userCore = new UserCore(userRepository); + constructor( + @Inject(IUserRepository) userRepository: IUserRepository, + @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, + ) { + this.userCore = new UserCore(userRepository, cryptoRepository); } async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise { diff --git a/server/libs/domain/test/crypto.repository.mock.ts b/server/libs/domain/test/crypto.repository.mock.ts index fe7e1dccc9..1e37222e44 100644 --- a/server/libs/domain/test/crypto.repository.mock.ts +++ b/server/libs/domain/test/crypto.repository.mock.ts @@ -3,9 +3,8 @@ import { ICryptoRepository } from '../src'; export const newCryptoRepositoryMock = (): jest.Mocked => { return { randomBytes: jest.fn().mockReturnValue(Buffer.from('random-bytes', 'utf8')), - compareSync: jest.fn().mockReturnValue(true), - hash: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), - signJwt: jest.fn().mockReturnValue('signed-jwt'), - verifyJwtAsync: jest.fn().mockResolvedValue({ userId: 'test', email: 'test' }), + compareBcrypt: jest.fn().mockReturnValue(true), + hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), + hashSha256: jest.fn().mockImplementation((input) => `${input} (hashed)`), }; }; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index f5d622fcc4..510c7e7893 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -1,4 +1,11 @@ -import { AssetType, SharedLinkEntity, SharedLinkType, SystemConfig, UserEntity } from '@app/infra/db/entities'; +import { + AssetType, + SharedLinkEntity, + SharedLinkType, + SystemConfig, + UserEntity, + UserTokenEntity, +} from '@app/infra/db/entities'; import { AlbumResponseDto, AssetResponseDto, AuthUserDto, ExifResponseDto, SharedLinkResponseDto } from '../src'; const today = new Date(); @@ -22,7 +29,7 @@ const assetInfo: ExifResponseDto = { fNumber: 100, focalLength: 100, iso: 100, - exposureTime: 100, + exposureTime: '1/16', latitude: 100, longitude: 100, city: 'city', @@ -81,6 +88,8 @@ export const authStub = { isAdmin: false, isPublicUser: false, isAllowUpload: true, + isAllowDownload: true, + isShowExif: true, }), adminSharedLink: Object.freeze({ id: 'admin_id', @@ -104,7 +113,7 @@ export const authStub = { }), }; -export const entityStub = { +export const userEntityStub = { admin: Object.freeze({ ...authStub.admin, password: 'admin_password', @@ -129,6 +138,16 @@ export const entityStub = { }), }; +export const userTokenEntityStub = { + userToken: Object.freeze({ + id: 'token-id', + token: 'auth_token', + user: userEntityStub.user1, + createdAt: '2021-01-01', + updatedAt: '2021-01-01', + }), +}; + export const systemConfigStub = { defaults: Object.freeze({ ffmpeg: { @@ -204,7 +223,7 @@ export const systemConfigStub = { export const loginResponseStub = { user1oauth: { response: { - accessToken: 'signed-jwt', + accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'immich_id', userEmail: 'immich@test.com', firstName: 'immich_first_name', @@ -214,13 +233,13 @@ export const loginResponseStub = { shouldChangePassword: false, }, cookie: [ - 'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;', - 'immich_auth_type=oauth; Secure; Path=/; Max-Age=604800; SameSite=Strict;', + 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;', + 'immich_auth_type=oauth; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;', ], }, user1password: { response: { - accessToken: 'signed-jwt', + accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'immich_id', userEmail: 'immich@test.com', firstName: 'immich_first_name', @@ -230,13 +249,13 @@ export const loginResponseStub = { shouldChangePassword: false, }, cookie: [ - 'immich_access_token=signed-jwt; Secure; Path=/; Max-Age=604800; SameSite=Strict;', - 'immich_auth_type=password; Secure; Path=/; Max-Age=604800; SameSite=Strict;', + 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;', + 'immich_auth_type=password; HttpOnly; Secure; Path=/; Max-Age=604800; SameSite=Strict;', ], }, user1insecure: { response: { - accessToken: 'signed-jwt', + accessToken: 'cmFuZG9tLWJ5dGVz', userId: 'immich_id', userEmail: 'immich@test.com', firstName: 'immich_first_name', @@ -246,7 +265,7 @@ export const loginResponseStub = { shouldChangePassword: false, }, cookie: [ - 'immich_access_token=signed-jwt; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;', + 'immich_access_token=cmFuZG9tLWJ5dGVz; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;', 'immich_auth_type=password; HttpOnly; Path=/; Max-Age=604800; SameSite=Strict;', ], }, @@ -349,7 +368,7 @@ export const sharedLinkStub = { fNumber: 100, focalLength: 100, iso: 100, - exposureTime: 100, + exposureTime: '1/16', fps: 100, asset: null as any, exifTextSearchableColumn: '', diff --git a/server/libs/domain/test/user-token.repository.mock.ts b/server/libs/domain/test/user-token.repository.mock.ts new file mode 100644 index 0000000000..593f96c0f4 --- /dev/null +++ b/server/libs/domain/test/user-token.repository.mock.ts @@ -0,0 +1,9 @@ +import { IUserTokenRepository } from '../src'; + +export const newUserTokenRepositoryMock = (): jest.Mocked => { + return { + create: jest.fn(), + delete: jest.fn(), + get: jest.fn(), + }; +}; diff --git a/server/libs/infra/src/auth/crypto.repository.ts b/server/libs/infra/src/auth/crypto.repository.ts index 83d99a3ec8..59c7c310ef 100644 --- a/server/libs/infra/src/auth/crypto.repository.ts +++ b/server/libs/infra/src/auth/crypto.repository.ts @@ -1,22 +1,16 @@ import { ICryptoRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; -import { JwtService, JwtVerifyOptions } from '@nestjs/jwt'; import { compareSync, hash } from 'bcrypt'; -import { randomBytes } from 'crypto'; +import { randomBytes, createHash } from 'crypto'; @Injectable() export class CryptoRepository implements ICryptoRepository { - constructor(private jwtService: JwtService) {} - randomBytes = randomBytes; - hash = hash; - compareSync = compareSync; - signJwt(payload: string | Buffer | object) { - return this.jwtService.sign(payload); - } + hashBcrypt = hash; + compareBcrypt = compareSync; - verifyJwtAsync(token: string, options?: JwtVerifyOptions): Promise { - return this.jwtService.verifyAsync(token, options); + hashSha256(value: string) { + return createHash('sha256').update(value).digest('base64'); } } diff --git a/server/libs/infra/src/db/config/database.config.ts b/server/libs/infra/src/db/config/database.config.ts index b98f16d71d..b79ae32cb7 100644 --- a/server/libs/infra/src/db/config/database.config.ts +++ b/server/libs/infra/src/db/config/database.config.ts @@ -1,26 +1,25 @@ import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions'; import { DataSource } from 'typeorm'; -const baseDatabaseConfig: PostgresConnectionOptions = { +const url = process.env.DB_URL; +const urlOrParts = url + ? { url } + : { + host: process.env.DB_HOSTNAME || 'localhost', + port: parseInt(process.env.DB_PORT || '5432'), + username: process.env.DB_USERNAME || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_DATABASE_NAME || 'immich', + }; + +export const databaseConfig: PostgresConnectionOptions = { type: 'postgres', entities: [__dirname + '/../**/*.entity.{js,ts}'], synchronize: false, migrations: [__dirname + '/../migrations/*.{js,ts}'], migrationsRun: true, connectTimeoutMS: 10000, // 10 seconds + ...urlOrParts, }; -const envBasedDatabaseConfig = { - host: process.env.DB_HOSTNAME || 'immich_postgres', - port: parseInt(process.env.DB_PORT || '5432'), - username: process.env.DB_USERNAME, - password: process.env.DB_PASSWORD, - database: process.env.DB_DATABASE_NAME, -}; - -const url = process.env.DB_URL; -const additionalSSLDatabaseConfig = url ? { url } : envBasedDatabaseConfig; - -export const databaseConfig: PostgresConnectionOptions = { ...baseDatabaseConfig, ...additionalSSLDatabaseConfig }; - export const dataSource = new DataSource(databaseConfig); diff --git a/server/libs/infra/src/db/entities/exif.entity.ts b/server/libs/infra/src/db/entities/exif.entity.ts index a086260147..a78323f5ad 100644 --- a/server/libs/infra/src/db/entities/exif.entity.ts +++ b/server/libs/infra/src/db/entities/exif.entity.ts @@ -72,8 +72,8 @@ export class ExifEntity { @Column({ type: 'integer', nullable: true }) iso!: number | null; - @Column({ type: 'float', nullable: true }) - exposureTime!: number | null; + @Column({ type: 'varchar', nullable: true }) + exposureTime!: string | null; /* Video info */ @Column({ type: 'float8', nullable: true }) diff --git a/server/libs/infra/src/db/entities/index.ts b/server/libs/infra/src/db/entities/index.ts index 81073d4ce1..3ea8abcb15 100644 --- a/server/libs/infra/src/db/entities/index.ts +++ b/server/libs/infra/src/db/entities/index.ts @@ -9,4 +9,5 @@ export * from './system-config.entity'; export * from './tag.entity'; export * from './user-album.entity'; export * from './user.entity'; +export * from './user-token.entity'; export * from './shared-link.entity'; diff --git a/server/libs/infra/src/db/entities/user-token.entity.ts b/server/libs/infra/src/db/entities/user-token.entity.ts new file mode 100644 index 0000000000..3418f2c823 --- /dev/null +++ b/server/libs/infra/src/db/entities/user-token.entity.ts @@ -0,0 +1,20 @@ +import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; +import { UserEntity } from './user.entity'; + +@Entity('user_token') +export class UserTokenEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ select: false }) + token!: string; + + @ManyToOne(() => UserEntity) + user!: UserEntity; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt!: string; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt!: string; +} diff --git a/server/libs/infra/src/db/migrations/1674342044239-CreateUserTokenEntity.ts b/server/libs/infra/src/db/migrations/1674342044239-CreateUserTokenEntity.ts new file mode 100644 index 0000000000..e289787f91 --- /dev/null +++ b/server/libs/infra/src/db/migrations/1674342044239-CreateUserTokenEntity.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateUserTokenEntity1674342044239 implements MigrationInterface { + name = 'CreateUserTokenEntity1674342044239' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "user_token" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "token" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "userId" uuid, CONSTRAINT "PK_48cb6b5c20faa63157b3c1baf7f" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "user_token" ADD CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_token" DROP CONSTRAINT "FK_d37db50eecdf9b8ce4eedd2f918"`); + await queryRunner.query(`DROP TABLE "user_token"`); + } + +} diff --git a/server/libs/infra/src/db/migrations/1674757936889-AlterExifExposureTimeToString.ts b/server/libs/infra/src/db/migrations/1674757936889-AlterExifExposureTimeToString.ts new file mode 100644 index 0000000000..de21e180b7 --- /dev/null +++ b/server/libs/infra/src/db/migrations/1674757936889-AlterExifExposureTimeToString.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AlterExifExposureTimeToString1674757936889 implements MigrationInterface { + name = 'AlterExifExposureTimeToString1674757936889' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exposureTime"`); + await queryRunner.query(`ALTER TABLE "exif" ADD "exposureTime" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "exposureTime"`); + await queryRunner.query(`ALTER TABLE "exif" ADD "exposureTime" double precision`); + } + +} diff --git a/server/libs/infra/src/db/migrations/1674774248319-TruncateAPIKeys.ts b/server/libs/infra/src/db/migrations/1674774248319-TruncateAPIKeys.ts new file mode 100644 index 0000000000..efbb5c41af --- /dev/null +++ b/server/libs/infra/src/db/migrations/1674774248319-TruncateAPIKeys.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class TruncateAPIKeys1674774248319 implements MigrationInterface { + name = 'TruncateAPIKeys1674774248319' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`TRUNCATE TABLE "api_keys"`); + } + + public async down(): Promise { + //noop + } + +} diff --git a/server/libs/infra/src/db/repository/api-key.repository.ts b/server/libs/infra/src/db/repository/api-key.repository.ts index 18ee6e6925..35119d2d7c 100644 --- a/server/libs/infra/src/db/repository/api-key.repository.ts +++ b/server/libs/infra/src/db/repository/api-key.repository.ts @@ -21,14 +21,14 @@ export class APIKeyRepository implements IKeyRepository { await this.repository.delete({ userId, id }); } - getKey(id: number): Promise { + getKey(hashedToken: string): Promise { return this.repository.findOne({ select: { id: true, key: true, userId: true, }, - where: { id }, + where: { key: hashedToken }, relations: { user: true, }, diff --git a/server/libs/infra/src/db/repository/index.ts b/server/libs/infra/src/db/repository/index.ts index 899bc21760..056c960573 100644 --- a/server/libs/infra/src/db/repository/index.ts +++ b/server/libs/infra/src/db/repository/index.ts @@ -1,3 +1,4 @@ export * from './api-key.repository'; export * from './shared-link.repository'; export * from './user.repository'; +export * from './user-token.repository'; diff --git a/server/libs/infra/src/db/repository/user-token.repository.ts b/server/libs/infra/src/db/repository/user-token.repository.ts new file mode 100644 index 0000000000..1fd9d03639 --- /dev/null +++ b/server/libs/infra/src/db/repository/user-token.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserTokenEntity } from '@app/infra/db/entities/user-token.entity'; +import { IUserTokenRepository } from '@app/domain/user-token'; + +@Injectable() +export class UserTokenRepository implements IUserTokenRepository { + constructor( + @InjectRepository(UserTokenEntity) + private userTokenRepository: Repository, + ) {} + + async get(userToken: string): Promise { + return this.userTokenRepository.findOne({ where: { token: userToken }, relations: { user: true } }); + } + + async create(userToken: Partial): Promise { + return this.userTokenRepository.save(userToken); + } + + async delete(userToken: string): Promise { + await this.userTokenRepository.delete(userToken); + } +} diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index 67248c4eb4..0f37221f2b 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -7,17 +7,17 @@ import { IUserRepository, QueueName, } from '@app/domain'; -import { databaseConfig, UserEntity } from './db'; +import { databaseConfig, UserEntity, UserTokenEntity } from './db'; import { BullModule } from '@nestjs/bull'; import { Global, Module, Provider } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; import { TypeOrmModule } from '@nestjs/typeorm'; import { APIKeyEntity, SharedLinkEntity, SystemConfigEntity, UserRepository } from './db'; import { APIKeyRepository, SharedLinkRepository } from './db/repository'; -import { jwtConfig } from '@app/domain'; import { CryptoRepository } from './auth/crypto.repository'; import { SystemConfigRepository } from './db/repository/system-config.repository'; import { JobRepository } from './job'; +import { IUserTokenRepository } from '@app/domain/user-token'; +import { UserTokenRepository } from '@app/infra/db/repository/user-token.repository'; const providers: Provider[] = [ { provide: ICryptoRepository, useClass: CryptoRepository }, @@ -26,14 +26,14 @@ const providers: Provider[] = [ { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: IUserRepository, useClass: UserRepository }, + { provide: IUserTokenRepository, useClass: UserTokenRepository }, ]; @Global() @Module({ imports: [ - JwtModule.register(jwtConfig), TypeOrmModule.forRoot(databaseConfig), - TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity]), + TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity, UserTokenEntity]), BullModule.forRootAsync({ useFactory: async () => ({ prefix: 'immich_bull', @@ -64,6 +64,6 @@ const providers: Provider[] = [ ), ], providers: [...providers], - exports: [...providers, BullModule, JwtModule], + exports: [...providers, BullModule], }) export class InfraModule {} diff --git a/server/package-lock.json b/server/package-lock.json index 500c72d19c..0e9ea0f3db 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.42.0", + "version": "1.43.0", "lockfileVersion": 2, "requires": true, "packages": { @@ -13,7 +13,6 @@ "@nestjs/common": "^9.2.1", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.2.1", - "@nestjs/jwt": "^10.0.1", "@nestjs/mapped-types": "1.2.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.2.1", @@ -50,7 +49,6 @@ "passport": "^0.6.0", "passport-custom": "^1.1.1", "passport-http-header-strategy": "^1.1.0", - "passport-jwt": "^4.0.0", "pg": "^8.8.0", "redis": "^4.5.1", "reflect-metadata": "^0.1.13", @@ -83,7 +81,6 @@ "@types/multer": "^1.4.7", "@types/mv": "^2.1.2", "@types/node": "^16.0.0", - "@types/passport-jwt": "^3.0.6", "@types/sharp": "^0.30.2", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.48.1", @@ -1521,18 +1518,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/@nestjs/jwt": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.0.1.tgz", - "integrity": "sha512-LwXBKVYHnFeX6GH/Wt0WDjsWCmNDC6tEdLlwNMAvJgYp+TkiCpEmQLkgRpifdUE29mvYSbjSnVs2kW2ob935NA==", - "dependencies": { - "@types/jsonwebtoken": "8.5.9", - "jsonwebtoken": "9.0.0" - }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0" - } - }, "node_modules/@nestjs/mapped-types": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz", @@ -2714,14 +2699,6 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, - "node_modules/@types/jsonwebtoken": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz", - "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/lodash": { "version": "4.14.178", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", @@ -2770,36 +2747,6 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, - "node_modules/@types/passport": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", - "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", - "dev": true, - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/passport-jwt": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", - "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/jsonwebtoken": "*", - "@types/passport-strategy": "*" - } - }, - "node_modules/@types/passport-strategy": { - "version": "0.2.35", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", - "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", - "dev": true, - "dependencies": { - "@types/express": "*", - "@types/passport": "*" - } - }, "node_modules/@types/prettier": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.3.tgz", @@ -3973,11 +3920,6 @@ "node": "*" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5019,14 +4961,6 @@ "safer-buffer": "^2.1.0" } }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -7895,21 +7829,6 @@ "graceful-fs": "^4.1.6" } }, - "node_modules/jsonwebtoken": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", - "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", - "dependencies": { - "jws": "^3.2.2", - "lodash": "^4.17.21", - "ms": "^2.1.1", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, "node_modules/jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -7924,25 +7843,6 @@ "node": ">=0.6.0" } }, - "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/kdt": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/kdt/-/kdt-0.1.0.tgz", @@ -9005,15 +8905,6 @@ "passport-strategy": "^1.0.0" } }, - "node_modules/passport-jwt": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", - "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", - "dependencies": { - "jsonwebtoken": "^9.0.0", - "passport-strategy": "^1.0.0" - } - }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -12769,15 +12660,6 @@ } } }, - "@nestjs/jwt": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.0.1.tgz", - "integrity": "sha512-LwXBKVYHnFeX6GH/Wt0WDjsWCmNDC6tEdLlwNMAvJgYp+TkiCpEmQLkgRpifdUE29mvYSbjSnVs2kW2ob935NA==", - "requires": { - "@types/jsonwebtoken": "8.5.9", - "jsonwebtoken": "9.0.0" - } - }, "@nestjs/mapped-types": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz", @@ -13715,14 +13597,6 @@ "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=", "dev": true }, - "@types/jsonwebtoken": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.9.tgz", - "integrity": "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg==", - "requires": { - "@types/node": "*" - } - }, "@types/lodash": { "version": "4.14.178", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.178.tgz", @@ -13771,36 +13645,6 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" }, - "@types/passport": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.7.tgz", - "integrity": "sha512-JtswU8N3kxBYgo+n9of7C97YQBT+AYPP2aBfNGTzABqPAZnK/WOAaKfh3XesUYMZRrXFuoPc2Hv0/G/nQFveHw==", - "dev": true, - "requires": { - "@types/express": "*" - } - }, - "@types/passport-jwt": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.6.tgz", - "integrity": "sha512-cmAAMIRTaEwpqxlrZyiEY9kdibk94gP5KTF8AT1Ra4rWNZYHNMreqhKUEeC5WJtuN5SJZjPQmV+XO2P5PlnvNQ==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/jsonwebtoken": "*", - "@types/passport-strategy": "*" - } - }, - "@types/passport-strategy": { - "version": "0.2.35", - "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.35.tgz", - "integrity": "sha512-o5D19Jy2XPFoX2rKApykY15et3Apgax00RRLf0RUotPDUsYrQa7x4howLYr9El2mlUApHmCMv5CZ1IXqKFQ2+g==", - "dev": true, - "requires": { - "@types/express": "*", - "@types/passport": "*" - } - }, "@types/prettier": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.3.tgz", @@ -14727,11 +14571,6 @@ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" }, - "buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" - }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -15545,14 +15384,6 @@ "safer-buffer": "^2.1.0" } }, - "ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "requires": { - "safe-buffer": "^5.0.1" - } - }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -17690,17 +17521,6 @@ "universalify": "^2.0.0" } }, - "jsonwebtoken": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", - "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", - "requires": { - "jws": "^3.2.2", - "lodash": "^4.17.21", - "ms": "^2.1.1", - "semver": "^7.3.8" - } - }, "jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -17712,25 +17532,6 @@ "verror": "1.10.0" } }, - "jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "requires": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "requires": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, "kdt": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/kdt/-/kdt-0.1.0.tgz", @@ -18555,15 +18356,6 @@ "passport-strategy": "^1.0.0" } }, - "passport-jwt": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", - "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", - "requires": { - "jsonwebtoken": "^9.0.0", - "passport-strategy": "^1.0.0" - } - }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", diff --git a/server/package.json b/server/package.json index b433840221..e74effa1f8 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.42.0", + "version": "1.43.0", "description": "", "author": "", "private": true, @@ -29,6 +29,10 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./apps/immich/test/jest-e2e.json --runInBand", "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", + "typeorm:migrations:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate -d ./libs/infra/src/db/config/database.config.ts", + "typeorm:migrations:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d ./libs/infra/src/db/config/database.config.ts", + "typeorm:migrations:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d ./libs/infra/src/db/config/database.config.ts", + "typeorm:schema:drop": "node --require ts-node/register ./node_modules/typeorm/cli.js schema:drop -d ./libs/infra/src/db/config/database.config.ts", "api:typescript": "bash ./bin/generate-open-api.sh web", "api:dart": "bash ./bin/generate-open-api.sh mobile", "api:generate": "bash ./bin/generate-open-api.sh" @@ -38,7 +42,6 @@ "@nestjs/common": "^9.2.1", "@nestjs/config": "^2.2.0", "@nestjs/core": "^9.2.1", - "@nestjs/jwt": "^10.0.1", "@nestjs/mapped-types": "1.2.0", "@nestjs/passport": "^9.0.0", "@nestjs/platform-express": "^9.2.1", @@ -75,7 +78,6 @@ "passport": "^0.6.0", "passport-custom": "^1.1.1", "passport-http-header-strategy": "^1.1.0", - "passport-jwt": "^4.0.0", "pg": "^8.8.0", "redis": "^4.5.1", "reflect-metadata": "^0.1.13", @@ -105,7 +107,6 @@ "@types/multer": "^1.4.7", "@types/mv": "^2.1.2", "@types/node": "^16.0.0", - "@types/passport-jwt": "^3.0.6", "@types/sharp": "^0.30.2", "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.48.1", diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index f3309115f0..a128f4f51e 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1116,10 +1116,10 @@ export interface ExifResponseDto { 'iso'?: number | null; /** * - * @type {number} + * @type {string} * @memberof ExifResponseDto */ - 'exposureTime'?: number | null; + 'exposureTime'?: string | null; /** * * @type {number} @@ -1203,6 +1203,12 @@ export interface JobCommandDto { * @memberof JobCommandDto */ 'command': JobCommand; + /** + * + * @type {boolean} + * @memberof JobCommandDto + */ + 'includeAllAssets': boolean; } /** * @@ -5487,86 +5493,6 @@ export class AuthenticationApi extends BaseAPI { */ export const DeviceInfoApiAxiosParamCreator = function (configuration?: Configuration) { return { - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - createDeviceInfo: async (upsertDeviceInfoDto: UpsertDeviceInfoDto, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'upsertDeviceInfoDto' is not null or undefined - assertParamExists('createDeviceInfo', 'upsertDeviceInfoDto', upsertDeviceInfoDto) - const localVarPath = `/device-info`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(upsertDeviceInfoDto, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - updateDeviceInfo: async (upsertDeviceInfoDto: UpsertDeviceInfoDto, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'upsertDeviceInfoDto' is not null or undefined - assertParamExists('updateDeviceInfo', 'upsertDeviceInfoDto', upsertDeviceInfoDto) - const localVarPath = `/device-info`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - - localVarHeaderParameter['Content-Type'] = 'application/json'; - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(upsertDeviceInfoDto, localVarRequestOptions, configuration) - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, /** * * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto @@ -5616,28 +5542,6 @@ export const DeviceInfoApiAxiosParamCreator = function (configuration?: Configur export const DeviceInfoApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = DeviceInfoApiAxiosParamCreator(configuration) return { - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - async createDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.createDeviceInfo(upsertDeviceInfoDto, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - async updateDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.updateDeviceInfo(upsertDeviceInfoDto, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto @@ -5658,26 +5562,6 @@ export const DeviceInfoApiFp = function(configuration?: Configuration) { export const DeviceInfoApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = DeviceInfoApiFp(configuration) return { - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - createDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: any): AxiosPromise { - return localVarFp.createDeviceInfo(upsertDeviceInfoDto, options).then((request) => request(axios, basePath)); - }, - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - */ - updateDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: any): AxiosPromise { - return localVarFp.updateDeviceInfo(upsertDeviceInfoDto, options).then((request) => request(axios, basePath)); - }, /** * * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto @@ -5697,30 +5581,6 @@ export const DeviceInfoApiFactory = function (configuration?: Configuration, bas * @extends {BaseAPI} */ export class DeviceInfoApi extends BaseAPI { - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - * @memberof DeviceInfoApi - */ - public createDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig) { - return DeviceInfoApiFp(this.configuration).createDeviceInfo(upsertDeviceInfoDto, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * @deprecated - * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto - * @param {*} [options] Override http request option. - * @deprecated - * @throws {RequiredError} - * @memberof DeviceInfoApi - */ - public updateDeviceInfo(upsertDeviceInfoDto: UpsertDeviceInfoDto, options?: AxiosRequestConfig) { - return DeviceInfoApiFp(this.configuration).updateDeviceInfo(upsertDeviceInfoDto, options).then((request) => request(this.axios, this.basePath)); - } - /** * * @param {UpsertDeviceInfoDto} upsertDeviceInfoDto diff --git a/web/src/app.css b/web/src/app.css index a683e10174..9324af9ff7 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -101,4 +101,8 @@ input:focus-visible { display: none; scrollbar-width: none; } + + .job-play-button { + @apply h-full flex flex-col place-items-center place-content-center px-8 text-gray-600 transition-all hover:bg-immich-primary hover:text-white dark:text-gray-200 dark:hover:bg-immich-dark-primary text-sm dark:hover:text-black w-[120px] gap-2; + } } diff --git a/web/src/lib/components/admin-page/jobs/job-tile.svelte b/web/src/lib/components/admin-page/jobs/job-tile.svelte index 134ad611fe..9ea6839d15 100644 --- a/web/src/lib/components/admin-page/jobs/job-tile.svelte +++ b/web/src/lib/components/admin-page/jobs/job-tile.svelte @@ -1,76 +1,102 @@ -
-
-

- {title.toUpperCase()} -

-

{subtitle}

-

- -

- - - - - - - - - - - - - - - - -
StatusActiveWaiting
- {#if jobCounts} - {jobCounts.active > 0 || jobCounts.waiting > 0 ? 'Active' : 'Idle'} - {:else} - - {/if} - +
+
+
+
+ {title.toUpperCase()} +
+ + {#if subtitle.length > 0} +
{subtitle}
+ {/if} +
+ +
+
+

Active

+

{#if jobCounts.active !== undefined} {jobCounts.active} {:else} {/if} -

+

+ + +
+

{#if jobCounts.waiting !== undefined} {jobCounts.waiting} {:else} {/if} -

+

+

Waiting

+
+
+
-
- + {/if} + + {#if !isRunning} + {#if showOptions} + + {:else} - {buttonTitle} + {/if} - + {/if}
diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 901a11a0ee..815cfe6fe9 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -18,20 +18,28 @@ onMount(async () => { await load(); - timer = setInterval(async () => await load(), 5_000); + timer = setInterval(async () => await load(), 1_000); }); onDestroy(() => { clearInterval(timer); }); - const run = async (jobId: JobId, jobName: string, emptyMessage: string) => { + const run = async ( + jobId: JobId, + jobName: string, + emptyMessage: string, + includeAllAssets: boolean + ) => { try { - const { data } = await api.jobApi.sendJobCommand(jobId, { command: JobCommand.Start }); + const { data } = await api.jobApi.sendJobCommand(jobId, { + command: JobCommand.Start, + includeAllAssets + }); if (data) { notificationController.show({ - message: `Started ${jobName}`, + message: includeAllAssets ? `Started ${jobName} for all assets` : `Started ${jobName}`, type: NotificationType.Info }); } else { @@ -43,53 +51,77 @@ }; -
+
{#if jobs} - run(JobId.ThumbnailGeneration, 'thumbnail generation', 'No missing thumbnails found')} + subtitle={'Regenerate JPEG and WebP thumbnails'} + on:click={(e) => { + const { includeAllAssets } = e.detail; + + run( + JobId.ThumbnailGeneration, + 'thumbnail generation', + 'No missing thumbnails found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.ThumbnailGeneration]} /> run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found')} + title={'EXTRACT METADATA'} + subtitle={'Extract metadata information i.e. GPS, resolution...etc'} + on:click={(e) => { + const { includeAllAssets } = e.detail; + run(JobId.MetadataExtraction, 'extract EXIF', 'No missing EXIF found', includeAllAssets); + }} jobCounts={jobs[JobId.MetadataExtraction]} /> - run(JobId.MachineLearning, 'object detection', 'No missing object detection found')} + on:click={(e) => { + const { includeAllAssets } = e.detail; + + run( + JobId.MachineLearning, + 'object detection', + 'No missing object detection found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.MachineLearning]} > - Note that some assets may not have any objects detected, this is normal. + Note that some assets may not have any objects detected + subtitle={'Transcode videos not in the desired format'} + on:click={(e) => { + const { includeAllAssets } = e.detail; run( JobId.VideoConversion, 'video conversion', - 'No videos without an encoded version found' - )} + 'No videos without an encoded version found', + includeAllAssets + ); + }} jobCounts={jobs[JobId.VideoConversion]} /> run( JobId.StorageTemplateMigration, 'storage template migration', - 'All files have been migrated to the new storage template' + 'All files have been migrated to the new storage template', + false )} jobCounts={jobs[JobId.StorageTemplateMigration]} > diff --git a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte index c703aa013a..5079a4f335 100644 --- a/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte +++ b/web/src/lib/components/admin-page/settings/confirm-disable-login.svelte @@ -12,7 +12,7 @@

To re-enable, use a

For more details about this feature, refer to the docs - import { createEventDispatcher, onMount, onDestroy } from 'svelte'; - import { fly } from 'svelte/transition'; - import AssetViewerNavBar from './asset-viewer-nav-bar.svelte'; - import ChevronRight from 'svelte-material-icons/ChevronRight.svelte'; - import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte'; - import PhotoViewer from './photo-viewer.svelte'; - import DetailPanel from './detail-panel.svelte'; import { goto } from '$app/navigation'; import { downloadAssets } from '$lib/stores/download'; - import VideoViewer from './video-viewer.svelte'; - import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte'; import { + AlbumResponseDto, api, AssetResponseDto, AssetTypeEnum, - AlbumResponseDto, SharedLinkResponseDto } from '@api'; + import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte'; + import ChevronRight from 'svelte-material-icons/ChevronRight.svelte'; + import { fly } from 'svelte/transition'; + import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte'; import { notificationController, NotificationType } from '../shared-components/notification/notification'; + import AssetViewerNavBar from './asset-viewer-nav-bar.svelte'; + import DetailPanel from './detail-panel.svelte'; + import PhotoViewer from './photo-viewer.svelte'; + import VideoViewer from './video-viewer.svelte'; import { assetStore } from '$lib/stores/assets.store'; import { addAssetsToAlbum } from '$lib/utils/asset-utils'; @@ -217,6 +217,7 @@ }); asset.isFavorite = data.isFavorite; + assetStore.updateAsset(asset.id, data.isFavorite); }; const openAlbumPicker = (shared: boolean) => { diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 59c0c95348..7e57158705 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -152,7 +152,7 @@

{`ƒ/${asset.exifInfo.fNumber.toLocaleString(locale)}` || ''}

{#if asset.exifInfo.exposureTime} -

{`1/${asset.exifInfo.exposureTime}`}

+

{`${asset.exifInfo.exposureTime}`}

{/if} {#if asset.exifInfo.focalLength} diff --git a/web/src/lib/components/shared-components/immich-thumbnail.svelte b/web/src/lib/components/shared-components/immich-thumbnail.svelte index 2bcea3c2db..0f9390cf44 100644 --- a/web/src/lib/components/shared-components/immich-thumbnail.svelte +++ b/web/src/lib/components/shared-components/immich-thumbnail.svelte @@ -1,14 +1,15 @@ + +
+ +
+ +
+ + + + {#if isMultiSelectionMode} + + +

+ Selected {selectedAssets.size} +

+
+ + + + +
+ {/if} + + + {#if isShowCreateSharedLinkModal} + + {/if} + + +
+
+
+
+

Favorites

+
+
+ +
+
+
+ + + {#if favorites.length === 0} +
+ Empty shared album + +

+ Add favorites to quickly find your best pictures and videos +

+
+ {/if} + + +
+
+
diff --git a/web/src/routes/favorites/[assetId]/+page.server.ts b/web/src/routes/favorites/[assetId]/+page.server.ts new file mode 100644 index 0000000000..a215862c7b --- /dev/null +++ b/web/src/routes/favorites/[assetId]/+page.server.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; +export const prerender = false; + +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, '/auth/login'); + } else { + throw redirect(302, '/favorites'); + } +}; diff --git a/web/src/routes/favorites/[assetId]/+page.svelte b/web/src/routes/favorites/[assetId]/+page.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/web/src/routes/photos/+page.svelte b/web/src/routes/photos/+page.svelte index 43208f38ea..306f21f46c 100644 --- a/web/src/routes/photos/+page.svelte +++ b/web/src/routes/photos/+page.svelte @@ -1,33 +1,33 @@