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.

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.

:::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}
-
-
-
-
-
-
-
- Status
- Active
- Waiting
-
-
-
-
-
- {#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
+
+
+
-
-
dispatch('click')}
- class="px-6 py-3 text-sm bg-immich-primary dark:bg-immich-dark-primary font-medium rounded-2xl hover:bg-immich-primary/50 transition-all hover:cursor-pointer disabled:cursor-not-allowed shadow-sm text-immich-bg dark:text-immich-dark-gray"
- disabled={jobCounts.active > 0 && jobCounts.waiting > 0}
- >
- {#if jobCounts.active > 0 || jobCounts.waiting > 0}
+
+ {#if isRunning}
+
+
+ {/if}
+
+ {#if !isRunning}
+ {#if showOptions}
+
run(true)}
+ >
+ ALL
+
+
run(false)}
+ >
+ MISSING
+
{:else}
- {buttonTitle}
+
run(true)}
+ >
+
+
{/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}
+
+
+
+
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 @@