Compare commits

..

15 Commits

Author SHA1 Message Date
Ben Beckford
ce9eff7ade Merge branch 'main' into feat/workflow-exif-filter 2026-07-21 13:18:00 -07:00
Daniel Dietzler
5d7283e44d fix: album update event emitting (#30120) 2026-07-21 19:43:27 +00:00
Lorenzo Rota
73329a8ce8 fix(server): send id_token_hint on OIDC logout (#29720)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-21 19:19:13 +02:00
Ben Beckford
ab3e409ce9 Merge branch 'main' into feat/workflow-exif-filter 2026-07-21 09:49:15 -07:00
Ben Beckford
7146c6001d chore: revert greater/less than comparisons in exif filter 2026-07-21 09:48:01 -07:00
Santo Shakil
d5adfb97dd fix(mobile): treat wired ethernet as unmetered on ios (#29351)
check the metered flags instead of the interface type, so ethernet counts like android already does.
2026-07-21 11:02:36 -05:00
Priyanshu
b70cb58bc8 fix(web): improve slideshow controls visibility on bright backgrounds (#29950)
Co-authored-by: priyanshuANDcoad <priyanshu23154050@akgec.ac.in>
2026-07-21 15:58:51 +00:00
Santo Shakil
943bfafb7a fix(mobile): show real error when an asset can't be added to an album (#29754)
* fix(mobile): show real error when an asset can't be added to an album

* fix(mobile): show added toast when an add-to-album partly succeeds

* docs(mobile): explain why a partial add-to-album shows added
2026-07-21 10:56:50 -05:00
Timon
ee4bd3f833 feat: add album asset event handling (#29008)
* feat: add album asset event handling

* fix medium tests

* patch medium test

* collapse in AlbumUpdate
2026-07-21 10:55:46 -05:00
Adam Gastineau
7a7303aceb fix(mobile): properly group download tasks for Live Photos (#29952)
* fix(mobile): properly group download tasks for Live Photos

* Remove completely unused onLivePhotoDownloadStatus

* Remove dead code
2026-07-21 10:45:15 -05:00
Ben Beckford
1a33917bdf feat: greater/less than comparisons in exif filter 2026-07-08 18:09:42 -07:00
Ben Beckford
2401d7ac7d Merge branch 'main' into feat/workflow-exif-filter 2026-07-08 17:40:00 -07:00
Ben Beckford
777e02e92b chore: update workflow method wrapper type 2026-06-24 21:47:07 -07:00
Ben Beckford
d67313c625 Merge branch 'main' into feat/workflow-exif-filter 2026-06-24 21:43:52 -07:00
Ben Beckford
d954591ca8 feat: exif metadata workflow filter 2026-06-23 16:02:04 -07:00
40 changed files with 503 additions and 273 deletions

View File

@@ -106,7 +106,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
with:
distribution: 'zulu'
java-version: '17'
@@ -241,7 +241,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: '3.3'
bundler-cache: true

View File

@@ -25,7 +25,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View File

@@ -39,7 +39,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -71,7 +71,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: '/language:${{matrix.language}}'

View File

@@ -152,7 +152,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
draft: true
prerelease: ${{ needs.bump_version.outputs.rc }}

View File

@@ -43,15 +43,16 @@ class ConnectivityApiImpl: ConnectivityApi {
capabilities.append(.vpn)
}
// Determine if connection is unmetered:
// - Must be on WiFi (not cellular)
// - Must not be expensive (rules out personal hotspot)
// - Must not be constrained (Low Data Mode)
// Note: VPN over cellular should still be considered metered
// Determine if connection is unmetered from the OS metered flags rather than
// the interface type, so wired ethernet (iPhone USB adapters, Apple Silicon
// Macs) is treated as unmetered like Wi-Fi:
// - Not on cellular
// - Not expensive (also rules out cellular and personal hotspot)
// - Not constrained (Low Data Mode)
// Note: VPN over cellular stays metered because the path is still expensive.
let isOnCellular = path.usesInterfaceType(.cellular)
let isOnWifi = path.usesInterfaceType(.wifi)
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
if !isOnCellular && !path.isExpensive && !path.isConstrained {
capabilities.append(.unmetered)
}

View File

@@ -159,8 +159,8 @@ class RemoteAlbumService {
return updatedAlbum;
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
return _repository.getDateRange(albumId);
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
return _repository.watchDateRange(albumId);
}
Future<List<UserDto>> getSharedUsers(String albumId) {
@@ -175,12 +175,12 @@ class RemoteAlbumService {
return _repository.getAssets(albumId);
}
Future<int> addAssets({required String albumId, required List<String> assetIds}) async {
Future<({int added, int failed})> addAssets({required String albumId, required List<String> assetIds}) async {
final album = await _albumApiRepository.addAssets(albumId, assetIds);
await _repository.addAssets(albumId, album.added);
return album.added.length;
return (added: album.added.length, failed: album.failed.length);
}
/// !TODO The name here is not clear as we have addAssets method above,
@@ -196,7 +196,7 @@ class RemoteAlbumService {
}) async {
int addedCount = 0;
if (candidates.remoteAssetIds.isNotEmpty) {
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
addedCount += (await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds)).added;
}
if (candidates.localAssetsToUpload.isNotEmpty) {
addedCount += await _uploadAndAddLocals(

View File

@@ -217,7 +217,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
});
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.createdAt.min(), _db.remoteAssetEntity.createdAt.max()])
@@ -229,7 +229,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final minDate = row.read(_db.remoteAssetEntity.createdAt.min());
final maxDate = row.read(_db.remoteAssetEntity.createdAt.max());
return (minDate ?? DateTime.now(), maxDate ?? DateTime.now());
}).getSingle();
}).watchSingle();
}
Future<List<UserDto>> getSharedUsers(String albumId) async {

View File

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -154,12 +155,8 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
return;
}
if (result.count == 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
);
} else {
// Only report the failure when nothing was added; if some succeeded we show "added".
if (result.count > 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
@@ -167,6 +164,17 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
// Refresh the "Appears in" list on the asset's info panel.
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
} else if (result.failedCount > 0) {
ImmichToast.show(
context: context,
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failedCount}),
toastType: ToastType.error,
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
);
}
if (!context.mounted) {

View File

@@ -41,7 +41,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
}
final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref
final result = await ref
.read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList());
@@ -52,15 +52,22 @@ class FavoriteBottomSheet extends ConsumerWidget {
);
}
if (addedCount != remoteAssets.length) {
// Only report the failure when nothing was added; if some succeeded we show "added".
if (result.added > 0) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
);
} else if (result.failed > 0) {
ImmichToast.show(
context: context,
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failed}),
toastType: ToastType.error,
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
);
}

View File

@@ -1,9 +1,6 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/download/download_state.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/download.service.dart';
class DownloadStateNotifier extends StateNotifier<DownloadState> {
@@ -17,79 +14,9 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
taskProgress: <String, DownloadInfo>{},
),
) {
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
_downloadService.onTaskProgress = _taskProgressCallback;
}
void _updateDownloadStatus(String taskId, TaskStatus status) {
if (status == TaskStatus.canceled) {
return;
}
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..addAll({
taskId: DownloadInfo(
progress: state.taskProgress[taskId]?.progress ?? 0,
fileName: state.taskProgress[taskId]?.fileName ?? '',
status: status,
),
}),
);
}
// Download live photo callback
void _downloadLivePhotoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
if (update.task.metaData.isEmpty) {
return;
}
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
_downloadService.saveLivePhotos(update.task, livePhotosId);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download image callback
void _downloadImageCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveImageWithPath(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download video callback
void _downloadVideoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveVideo(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is canceled or completed
if (update.progress == -2 || update.progress == -1) {
@@ -110,20 +37,6 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
);
}
void _onDownloadComplete(String id) {
Future.delayed(const Duration(seconds: 2), () {
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..remove(id),
);
if (state.taskProgress.isEmpty) {
state = state.copyWith(showProgress: false);
}
});
}
void cancelDownload(String id) async {
final isCanceled = await _downloadService.cancelDownload(id);

View File

@@ -1,7 +1,6 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
@@ -10,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -23,7 +21,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
@@ -37,18 +34,25 @@ class ActionResult {
final bool success;
final String? error;
final List<String> remoteAssetIds;
final int failedCount;
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []});
const ActionResult({
required this.count,
required this.success,
this.error,
this.remoteAssetIds = const [],
this.failedCount = 0,
});
@override
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)';
String toString() =>
'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds, failedCount: $failedCount)';
}
class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier');
late ActionService _service;
late ForegroundUploadService _foregroundUploadService;
late DownloadService _downloadService;
late AssetService _assetService;
ActionNotifier() : super();
@@ -58,29 +62,6 @@ class ActionNotifier extends Notifier<void> {
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
_service = ref.watch(actionServiceProvider);
_assetService = ref.watch(assetServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
}
void _downloadImageCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveImageWithPath(update.task);
}
}
void _downloadVideoCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveVideo(update.task);
}
}
void _downloadLivePhotoCallback(TaskStatusUpdate update) async {
if (update.status == TaskStatus.complete) {
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
unawaited(_downloadService.saveLivePhotos(update.task, livePhotosId));
}
}
List<String> _getRemoteIdsForSource(ActionSource source) {
@@ -393,9 +374,12 @@ class ActionNotifier extends Notifier<void> {
final albumNotifier = ref.read(remoteAlbumProvider.notifier);
int addedRemote = 0;
int failedRemote = 0;
if (remoteIds.isNotEmpty) {
try {
addedRemote = await albumNotifier.addAssets(album.id, remoteIds);
final result = await albumNotifier.addAssets(album.id, remoteIds);
addedRemote = result.added;
failedRemote = result.failed;
} catch (error, stack) {
_logger.severe('Failed to add assets to album ${album.id}', error, stack);
return ActionResult(count: 0, success: false, error: error.toString());
@@ -409,7 +393,7 @@ class ActionNotifier extends Notifier<void> {
}
if (localAssets.isEmpty) {
return ActionResult(count: addedRemote, success: true);
return ActionResult(count: addedRemote, success: true, failedCount: failedRemote);
}
final uploadResult = await upload(
@@ -424,6 +408,7 @@ class ActionNotifier extends Notifier<void> {
count: addedRemote + uploadResult.count,
success: uploadResult.success,
error: uploadResult.error,
failedCount: failedRemote,
);
}

View File

@@ -200,12 +200,12 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return _remoteAlbumService.getAssets(albumId);
}
Future<int> addAssets(String albumId, List<String> assetIds) async {
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (added > 0) {
Future<({int added, int failed})> addAssets(String albumId, List<String> assetIds) async {
final result = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (result.added > 0) {
await _refreshAlbumInState(albumId);
}
return added;
return result;
}
/// Links a freshly-uploaded local asset to an album using its new remote ID,
@@ -313,9 +313,9 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
}
}
final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async {
final remoteAlbumDateRangeProvider = StreamProvider.autoDispose.family<(DateTime, DateTime), String>((ref, albumId) {
final service = ref.watch(remoteAlbumServiceProvider);
return service.getDateRange(albumId);
return service.watchDateRange(albumId);
});
final remoteAlbumSharedUsersProvider = FutureProvider.autoDispose.family<List<UserDto>, String>((ref, albumId) async {

View File

@@ -103,6 +103,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('AssetUploadReadyV2', _handleSyncAssetUploadReadyV2);
socket.on('AssetEditReadyV1', _handleSyncAssetEditReadyV1);
socket.on('AssetEditReadyV2', _handleSyncAssetEditReadyV2);
socket.on('on_album_update', _handleAlbumUpdate);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_new_release', _handleReleaseUpdates);
} catch (e) {
@@ -184,6 +185,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
}
void _handleAlbumUpdate(dynamic _) {
unawaited(_ref.read(backgroundSyncProvider).syncRemote());
}
void _handleSyncAssetEditReadyV2(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
}

View File

@@ -27,10 +27,12 @@ class DownloadRepository {
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
// #29900: `taskStatusCallback` is called before the DB has been updated, causing a race between the two Live Photo tasks
// This callback instead listens directly to DB updates
void Function(TaskRecord)? onLivePhotoRecordComplete;
DownloadRepository() {
_downloader.registerCallbacks(
group: kDownloadGroupImage,
@@ -46,9 +48,12 @@ class DownloadRepository {
_downloader.registerCallbacks(
group: kDownloadGroupLivePhoto,
taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update),
taskProgressCallback: (update) => onTaskProgress?.call(update),
);
_downloader.database.updates
.where((record) => record.group == kDownloadGroupLivePhoto && record.status == TaskStatus.complete)
.listen((record) => onLivePhotoRecordComplete?.call(record));
}
Future<List<bool>> downloadAll(List<DownloadTask> tasks) {

View File

@@ -59,7 +59,7 @@ class DriftAlbumApiRepository extends ApiRepository {
for (final dto in response) {
if (dto.success) {
added.add(dto.id);
} else {
} else if (dto.error.orElse(null) != BulkIdErrorReason.duplicate) {
failed.add(dto.id);
}
}

View File

@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
@@ -18,14 +20,27 @@ class DownloadService {
final Logger _log = Logger("DownloadService");
void Function(TaskStatusUpdate)? onImageDownloadStatus;
void Function(TaskStatusUpdate)? onVideoDownloadStatus;
void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
void Function(TaskProgressUpdate)? onTaskProgress;
/// Active Live Photo IDs undergoing saving
final Set<String> _savingLivePhotoIds = {};
DownloadService(this._fileMediaRepository, this._downloadRepository) {
_downloadRepository.onImageDownloadStatus = _onImageDownloadCallback;
_downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback;
_downloadRepository.onLivePhotoDownloadStatus = _onLivePhotoDownloadCallback;
_downloadRepository.onTaskProgress = _onTaskProgressCallback;
_downloadRepository.onLivePhotoRecordComplete = _onLivePhotoRecordComplete;
unawaited(_savePreviouslyCompletedLivePhotos());
}
Future<void> _savePreviouslyCompletedLivePhotos() async {
// Specifically fetch Live Photo video components only, as to not double fetch assets
final records = await _downloadRepository.getLiveVideoTasks();
final completedIds = records.map((record) => LivePhotosMetadata.fromJson(record.task.metaData).id).toSet();
for (final id in completedIds) {
await _saveLivePhotos(id);
}
}
void _onTaskProgressCallback(TaskProgressUpdate update) {
@@ -33,18 +48,27 @@ class DownloadService {
}
void _onImageDownloadCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
unawaited(_saveImageWithPath(update.task));
}
onImageDownloadStatus?.call(update);
}
void _onVideoDownloadCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
unawaited(_saveVideo(update.task));
}
onVideoDownloadStatus?.call(update);
}
void _onLivePhotoDownloadCallback(TaskStatusUpdate update) {
onLivePhotoDownloadStatus?.call(update);
void _onLivePhotoRecordComplete(TaskRecord record) async {
final livePhotosId = LivePhotosMetadata.fromJson(record.task.metaData).id;
await _saveLivePhotos(livePhotosId);
}
Future<bool> saveImageWithPath(Task task) async {
Future<bool> _saveImageWithPath(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
@@ -65,7 +89,7 @@ class DownloadService {
}
}
Future<bool> saveVideo(Task task) async {
Future<bool> _saveVideo(Task task) async {
final filePath = await task.filePath();
final title = task.filename;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
@@ -83,14 +107,21 @@ class DownloadService {
}
}
Future<bool> saveLivePhotos(Task task, String livePhotosId) async {
Future<bool> _saveLivePhotos(String livePhotosId) async {
final records = await _downloadRepository.getLiveVideoTasks();
if (records.length < 2) {
final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image);
final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video);
if (imageRecord == null || videoRecord == null) {
return false;
}
final imageRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.image);
final videoRecord = _findTaskRecord(records, livePhotosId, LivePhotosPart.video);
// Write semaphore for this `livePhotoId`
if (!_savingLivePhotoIds.add(livePhotosId)) {
return false;
}
final title = imageRecord.task.filename;
final imageFilePath = await imageRecord.task.filePath();
final videoFilePath = await videoRecord.task.filePath();
@@ -98,14 +129,14 @@ class DownloadService {
final result = await _fileMediaRepository.saveLivePhoto(
image: File(imageFilePath),
video: File(videoFilePath),
title: task.filename,
title: title,
);
return result != null;
} on PlatformException catch (error, stack) {
// Handle saving MotionPhotos on iOS
if (error.code.startsWith('PHPhotosErrorDomain')) {
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: task.filename);
final result = await _fileMediaRepository.saveImageWithFile(imageFilePath, title: title);
return result != null;
}
_log.severe("Error saving live photo", error, stack);
@@ -125,6 +156,7 @@ class DownloadService {
}
await _downloadRepository.deleteRecordsWithIds([imageRecord.task.taskId, videoRecord.task.taskId]);
_savingLivePhotoIds.remove(livePhotosId);
}
}
@@ -133,8 +165,8 @@ class DownloadService {
}
}
TaskRecord _findTaskRecord(List<TaskRecord> records, String livePhotosId, LivePhotosPart part) {
return records.firstWhere((record) {
TaskRecord? _findTaskRecord(List<TaskRecord> records, String livePhotosId, LivePhotosPart part) {
return records.firstWhereOrNull((record) {
final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
return metadata.id == livePhotosId && metadata.part == part;
});

View File

@@ -12,7 +12,6 @@ import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:mocktail/mocktail.dart';
@@ -20,8 +19,6 @@ class MockActionService extends Mock implements ActionService {}
class MockAssetService extends Mock implements AssetService {}
class MockDownloadService extends Mock implements DownloadService {}
class MockForegroundUploadService extends Mock implements ForegroundUploadService {}
class MockUserService extends Mock implements UserService {}
@@ -67,7 +64,6 @@ void main() {
overrides: [
actionServiceProvider.overrideWithValue(actionService),
assetServiceProvider.overrideWithValue(assetService),
downloadServiceProvider.overrideWithValue(MockDownloadService()),
foregroundUploadServiceProvider.overrideWithValue(MockForegroundUploadService()),
currentUserProvider.overrideWith((ref) => CurrentUserProvider(userService)),
],

View File

@@ -0,0 +1,82 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:openapi/api.dart';
class _MockAlbumsApi extends Mock implements AlbumsApi {}
void main() {
late _MockAlbumsApi api;
late DriftAlbumApiRepository repo;
setUpAll(() {
registerFallbackValue(BulkIdsDto(ids: const []));
});
setUp(() {
api = _MockAlbumsApi();
repo = DriftAlbumApiRepository(api);
});
void stubResponse(List<BulkIdResponseDto> response) {
when(
() => api.addAssetsToAlbum(any(), any(), abortTrigger: any(named: 'abortTrigger')),
).thenAnswer((_) async => response);
}
test('no_permission failure surfaces as failed, not added (the #22342 bug)', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)),
]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, isEmpty);
expect(result.failed, ['a1']);
});
test('duplicate is neither added nor failed (genuinely already in album)', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)),
]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, isEmpty);
expect(result.failed, isEmpty);
});
test('success is added', () async {
stubResponse([BulkIdResponseDto(id: 'a1', success: true)]);
final result = await repo.addAssets('album1', ['a1']);
expect(result.added, ['a1']);
expect(result.failed, isEmpty);
});
test('not_found and unknown count as failures', () async {
stubResponse([
BulkIdResponseDto(id: 'a1', success: false, error: const Optional.present(BulkIdErrorReason.notFound)),
BulkIdResponseDto(id: 'a2', success: false, error: const Optional.present(BulkIdErrorReason.unknown)),
]);
final result = await repo.addAssets('album1', ['a1', 'a2']);
expect(result.added, isEmpty);
expect(result.failed, ['a1', 'a2']);
});
test('mixed: added kept, no_permission failed, duplicate dropped', () async {
stubResponse([
BulkIdResponseDto(id: 'ok', success: true),
BulkIdResponseDto(id: 'perm', success: false, error: const Optional.present(BulkIdErrorReason.noPermission)),
BulkIdResponseDto(id: 'dup', success: false, error: const Optional.present(BulkIdErrorReason.duplicate)),
]);
final result = await repo.addAssets('album1', ['ok', 'perm', 'dup']);
expect(result.added, ['ok']);
expect(result.failed, ['perm']);
});
}

View File

@@ -308,6 +308,76 @@
},
"uiHints": ["Filter"]
},
{
"name": "assetExifFilter",
"title": "Filter by EXIF metadata",
"description": "Filter assets by their EXIF properties",
"types": ["AssetV1"],
"schema": {
"type": "object",
"properties": {
"property": {
"title": "Property",
"description": "EXIF property to match",
"type": "string",
"enum": [
"make",
"model",
"exifImageWidth",
"exifImageHeight",
"fileSizeInByte",
"orientation",
"lensModel",
"fNumber",
"focalLength",
"iso",
"description",
"fps",
"exposureTime",
"livePhotoCID",
"timeZone",
"projectionType",
"profileDescription",
"colorspace",
"bitsPerSample",
"rating"
],
"uiHint": {
"order": 1
}
},
"pattern": {
"type": "string",
"title": "Pattern",
"description": "Text or regex pattern to match against property value",
"uiHint": {
"order": 2
}
},
"matchType": {
"type": "string",
"title": "Match type",
"enum": ["contains", "startsWith", "exact", "regex"],
"default": "contains",
"description": "Type of pattern matching to perform",
"uiHint": {
"order": 3
}
},
"caseSensitive": {
"type": "boolean",
"default": false,
"title": "Case sensitive",
"description": "Whether matching should be case-sensitive",
"uiHint": {
"order": 4
}
}
},
"required": ["property", "pattern"]
},
"uiHints": ["Filter"]
},
{
"name": "assetArchive",
"title": "Archive asset",

View File

@@ -2,6 +2,42 @@ import { wrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type { Manifest } from '../dist/index.d.ts';
type MatchValueConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
caseSensitive?: boolean;
};
const matchValueResult = (value: string, config: MatchValueConfig) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const searchName = caseSensitive ? value : value.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(value) } };
}
default: {
return {};
}
}
};
const methods = wrapper<Manifest>({
assetAddToAlbums: ({ config, data, functions }) => {
const assetId = data.asset.id;
@@ -53,39 +89,7 @@ const methods = wrapper<Manifest>({
}
},
assetFileFilter: ({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false, usePath = false } = config;
const { asset } = data;
const fileName = usePath ? asset.originalPath : asset.originalFileName;
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
default: {
return {};
}
}
},
assetFileFilter: ({ data, config }) => matchValueResult(data.asset.originalFileName || '', config),
assetLocationFilter: ({ config, data }) => {
if (
@@ -124,6 +128,14 @@ const methods = wrapper<Manifest>({
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
},
assetExifFilter: ({ config, data }) => {
if (!data.asset.exifInfo || data.asset.exifInfo[config.property] === null) {
return { workflow: { continue: false } };
}
return matchValueResult(String(data.asset.exifInfo[config.property]), config);
},
assetDateFilter: ({ config, data }) => {
const assetDate = new Date(data.asset.localDateTime);
let startDate = new Date(config.startDate.year, config.startDate.month - 1, config.startDate.day);
@@ -200,6 +212,7 @@ const {
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetExifFilter,
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,
@@ -217,6 +230,7 @@ export {
assetFavorite,
assetFileFilter,
assetLocationFilter,
assetExifFilter,
assetDateFilter,
assetLock,
assetMissingTimeZoneFilter,

View File

@@ -4,7 +4,8 @@
select
"id",
"expiresAt",
"pinExpiresAt"
"pinExpiresAt",
"oauthBearerToken"
from
"session"
where

View File

@@ -38,7 +38,7 @@ type EventMap = {
ConfigValidate: [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// album events
AlbumUpdate: [{ id: string; recipientId: string }];
AlbumUpdate: [{ id: string; userIds: string[]; recipientIds: string[] }];
AlbumInvite: [{ id: string; userId: string; senderName: string }];
// asset events

View File

@@ -83,7 +83,7 @@ export class OAuthRepository {
url: string,
expectedState: string,
codeVerifier: string,
): Promise<{ profile: OAuthProfile; sid?: string }> {
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
const client = await this.getClient(config);
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
@@ -111,7 +111,7 @@ export class OAuthRepository {
}
}
return { profile, sid };
return { profile, sid, idToken: tokens.id_token };
} catch (error: Error | any) {
if (error.message.includes('unexpected JWT alg received')) {
this.logger.warn(

View File

@@ -32,7 +32,7 @@ export class SessionRepository {
get(id: string) {
return this.db
.selectFrom('session')
.select(['id', 'expiresAt', 'pinExpiresAt'])
.select(['id', 'expiresAt', 'pinExpiresAt', 'oauthBearerToken'])
.where('id', '=', id)
.executeTakeFirst();
}

View File

@@ -37,6 +37,7 @@ export interface ClientEventMap {
on_asset_hidden: [string];
on_asset_restore: [string[]];
on_asset_stack_update: string[];
on_album_update: [string];
on_person_thumbnail: [string];
on_server_version: [ServerVersionResponseDto];
on_config_update: [];

View File

@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" ADD "oauthBearerToken" character varying;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "session" DROP COLUMN "oauthBearerToken";`.execute(db);
}

View File

@@ -55,4 +55,7 @@ export class SessionTable {
@Column({ nullable: true, index: true })
oauthSid!: string | null;
@Column({ nullable: true })
oauthBearerToken!: string | null;
}

View File

@@ -841,7 +841,8 @@ describe(AlbumService.name, () => {
expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album.id,
recipientId: owner.id,
userIds: album.albumUsers.map(({ user }) => user.id),
recipientIds: [owner.id],
});
});
@@ -1091,11 +1092,13 @@ describe(AlbumService.name, () => {
]);
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album1.id,
recipientId: owner1.id,
userIds: album1.albumUsers.map(({ user }) => user.id),
recipientIds: [owner1.id],
});
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album2.id,
recipientId: owner2.id,
userIds: album2.albumUsers.map(({ user }) => user.id),
recipientIds: [owner2.id],
});
});

View File

@@ -190,11 +190,9 @@ export class AlbumService extends BaseService {
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('AlbumUpdate', { id, recipientId });
}
const userIds = album.albumUsers.map(({ user }) => user.id);
const recipientIds = userIds.filter((userId) => userId !== auth.user.id);
await this.eventRepository.emit('AlbumUpdate', { id, userIds, recipientIds });
}
return results;
@@ -223,7 +221,7 @@ export class AlbumService extends BaseService {
}
const albumAssetValues: { albumId: string; assetId: string }[] = [];
const events: { id: string; recipients: string[] }[] = [];
const events: { id: string; userIds: string[]; recipientIds: string[] }[] = [];
for (const albumId of allowedAlbumIds) {
const existingAssetIds = await this.albumRepository.getAssetIds(albumId, [...allowedAssetIds]);
const notPresentAssetIds = [...allowedAssetIds.difference(existingAssetIds)];
@@ -246,15 +244,14 @@ export class AlbumService extends BaseService {
},
auth.user.id,
);
const allUsersExceptUs = album.albumUsers.map(({ user }) => user.id).filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, recipients: allUsersExceptUs });
const userIds = album.albumUsers.map(({ user }) => user.id);
const recipientIds = userIds.filter((userId) => userId !== auth.user.id);
events.push({ id: albumId, userIds, recipientIds });
}
await this.albumRepository.addAssetIdsToAlbums(albumAssetValues);
for (const event of events) {
for (const recipientId of event.recipients) {
await this.eventRepository.emit('AlbumUpdate', { id: event.id, recipientId });
}
await this.eventRepository.emit('AlbumUpdate', event);
}
return results;
@@ -271,8 +268,16 @@ export class AlbumService extends BaseService {
);
const removedIds = results.filter(({ success }) => success).map(({ id }) => id);
if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();
if (removedIds.length > 0) {
if (album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails();
}
await this.eventRepository.emit('AlbumUpdate', {
id,
userIds: album.albumUsers.map(({ user }) => user.id),
recipientIds: [],
});
}
return results;

View File

@@ -351,9 +351,12 @@ export class AssetMediaService extends BaseService {
}
await this.albumRepository.addAssetIds(album.id, [assetId]);
for (const { user } of album.albumUsers) {
await this.eventRepository.emit('AlbumUpdate', { id: album.id, recipientId: user.id });
}
const userIds = album.albumUsers.map(({ user }) => user.id);
await this.eventRepository.emit('AlbumUpdate', {
id: album.id,
userIds,
recipientIds: userIds,
});
}
private requireQuota(auth: AuthDto, size: number) {

View File

@@ -160,7 +160,25 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
redirectUri: 'http://end-session-endpoint/',
});
});
it('should include the id token hint for OAuth sessions', async () => {
const auth = AuthFactory.from().session().build();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.session.get.mockResolvedValue({
id: auth.session!.id,
expiresAt: null,
oauthBearerToken: 'id-token',
pinExpiresAt: null,
});
mocks.session.delete.mockResolvedValue();
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint/?id_token_hint=id-token',
});
});
@@ -173,7 +191,7 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://custom-logout-url',
redirectUri: 'http://custom-logout-url/',
});
});
@@ -186,7 +204,7 @@ describe(AuthService.name, () => {
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
redirectUri: 'http://end-session-endpoint/',
});
});
@@ -201,6 +219,12 @@ describe(AuthService.name, () => {
it('should delete the access token', async () => {
const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto;
mocks.session.get.mockResolvedValue({
id: auth.session!.id,
expiresAt: null,
oauthBearerToken: null,
pinExpiresAt: null,
});
mocks.session.delete.mockResolvedValue();
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
@@ -722,6 +746,27 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
});
it('should store the OAuth bearer token on the new session', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' });
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
expect(mocks.session.create).toHaveBeenCalledWith(
expect.objectContaining({ oauthSid: 'oauth-sid', oauthBearerToken: 'oauth-bearer-token' }),
);
});
it('should normalize the email from the OAuth profile before linking', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
@@ -1125,6 +1170,7 @@ describe(AuthService.name, () => {
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
profile: { sub: 'sub' },
sid: session.oauthSid ?? undefined,
idToken: session.oauthBearerToken ?? undefined,
});
mocks.user.update.mockResolvedValue(user);
mocks.session.update.mockResolvedValue(session);
@@ -1135,7 +1181,10 @@ describe(AuthService.name, () => {
{},
);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
expect(mocks.session.update).toHaveBeenCalledWith(session.id, {
oauthSid: session.oauthSid,
oauthBearerToken: session.oauthBearerToken,
});
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
});
@@ -1169,7 +1218,7 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
it('should unlink an account and remove the oauthSid from the session', async () => {
it('should unlink an account and remove the OAuth data from the session', async () => {
const user = UserFactory.create();
const session = SessionFactory.create();
const auth = AuthFactory.from(user).session(session).build();
@@ -1180,7 +1229,7 @@ describe(AuthService.name, () => {
await sut.unlink(auth);
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null });
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null, oauthBearerToken: null });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
});

View File

@@ -76,14 +76,17 @@ export class AuthService extends BaseService {
}
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
let oauthBearerToken: string | undefined;
if (auth.session) {
const session = await this.sessionRepository.get(auth.session.id);
oauthBearerToken = session?.oauthBearerToken ?? undefined;
await this.sessionRepository.delete(auth.session.id);
await this.eventRepository.emit('SessionDelete', { sessionId: auth.session.id });
}
return {
successful: true,
redirectUri: await this.getLogoutEndpoint(authType),
redirectUri: await this.getLogoutEndpoint(authType, oauthBearerToken),
};
}
@@ -306,12 +309,11 @@ export class AuthService extends BaseService {
}
const url = this.resolveRedirectUri(oauth, dto.url);
const { profile, sid: oauthSid } = await this.oauthRepository.getProfileAndOAuthSid(
oauth,
url,
expectedState,
codeVerifier,
);
const {
profile,
sid: oauthSid,
idToken: oauthBearerToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, url, expectedState, codeVerifier);
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
@@ -378,7 +380,7 @@ export class AuthService extends BaseService {
await this.syncProfilePicture(user, profile.picture);
}
return this.createLoginResponse(user, loginDetails, oauthSid);
return this.createLoginResponse(user, loginDetails, oauthSid, oauthBearerToken);
}
private async syncProfilePicture(user: UserAdmin, url: string) {
@@ -419,6 +421,7 @@ export class AuthService extends BaseService {
const {
profile: { sub: oauthId },
sid,
idToken,
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
const duplicate = await this.userRepository.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== auth.user.id) {
@@ -426,8 +429,11 @@ export class AuthService extends BaseService {
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
if (auth.session && sid) {
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
if (auth.session && (sid || idToken)) {
await this.sessionRepository.update(auth.session.id, {
oauthSid: sid,
oauthBearerToken: idToken,
});
}
const user = await this.userRepository.update(auth.user.id, { oauthId });
@@ -436,14 +442,14 @@ export class AuthService extends BaseService {
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
if (auth.session) {
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null });
}
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
return mapUserAdmin(user);
}
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
private async getLogoutEndpoint(authType: AuthType, oauthBearerToken?: string | null): Promise<string> {
if (authType !== AuthType.OAuth) {
return LOGIN_URL;
}
@@ -453,11 +459,20 @@ export class AuthService extends BaseService {
return LOGIN_URL;
}
if (config.oauth.endSessionEndpoint) {
return config.oauth.endSessionEndpoint;
const endSessionEndpoint =
config.oauth.endSessionEndpoint || (await this.oauthRepository.getLogoutEndpoint(config.oauth));
if (!endSessionEndpoint) {
return LOGIN_URL;
}
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
const url = new URL(endSessionEndpoint);
if (oauthBearerToken) {
url.searchParams.set('id_token_hint', oauthBearerToken);
}
return url.href;
}
private getBearerToken(headers: IncomingHttpHeaders): string | null {
@@ -599,7 +614,12 @@ export class AuthService extends BaseService {
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
}
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails, oauthSid?: string) {
private async createLoginResponse(
user: UserAdmin,
loginDetails: LoginDetails,
oauthSid?: string,
oauthBearerToken?: string,
) {
const token = this.cryptoRepository.randomBytesAsText(32);
const hashed = this.cryptoRepository.hashSha256(token);
@@ -610,6 +630,7 @@ export class AuthService extends BaseService {
appVersion: loginDetails.appVersion,
userId: user.id,
oauthSid: oauthSid ?? null,
oauthBearerToken: oauthBearerToken ?? null,
});
return mapLoginResponse(user, token);

View File

@@ -2,7 +2,6 @@ import { defaults, SystemConfig } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { NotificationService } from 'src/services/notification.service';
import { INotifyAlbumUpdateJob } from 'src/types';
import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { AssetFactory } from 'test/factories/asset.factory';
@@ -157,13 +156,21 @@ describe(NotificationService.name, () => {
});
describe('onAlbumUpdateEvent', () => {
it('should queue notify album update event', async () => {
await sut.onAlbumUpdate({ id: 'album', recipientId: '42' });
expect(mocks.job.queue).toHaveBeenCalledWith({
it('should send a websocket event to every user and queue notify jobs for recipients', async () => {
await sut.onAlbumUpdate({ id: 'album', userIds: ['1', '42'], recipientIds: ['42'] });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '1', 'album');
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '42', 'album');
expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({
name: JobName.NotifyAlbumUpdate,
data: { id: 'album', recipientId: '42', delay: 300_000 },
});
});
it('should not queue email jobs when there are no recipients', async () => {
await sut.onAlbumUpdate({ id: 'album', userIds: ['1'], recipientIds: [] });
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_album_update', '1', 'album');
expect(mocks.job.queue).not.toHaveBeenCalled();
});
});
describe('onAlbumInviteEvent', () => {
@@ -522,7 +529,7 @@ describe(NotificationService.name, () => {
});
it('should add new recipients for new images if job is already queued', async () => {
await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob);
await sut.onAlbumUpdate({ id: '1', userIds: ['2'], recipientIds: ['2'] });
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NotifyAlbumUpdate, '1/2');
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NotifyAlbumUpdate,

View File

@@ -217,12 +217,18 @@ export class NotificationService extends BaseService {
}
@OnEvent({ name: 'AlbumUpdate' })
async onAlbumUpdate({ id, recipientId }: ArgOf<'AlbumUpdate'>) {
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);
await this.jobRepository.queue({
name: JobName.NotifyAlbumUpdate,
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
});
async onAlbumUpdate({ id, userIds, recipientIds }: ArgOf<'AlbumUpdate'>) {
for (const userId of userIds) {
this.websocketRepository.clientSend('on_album_update', userId, id);
}
for (const recipientId of recipientIds) {
await this.jobRepository.removeJob(JobName.NotifyAlbumUpdate, `${id}/${recipientId}`);
await this.jobRepository.queue({
name: JobName.NotifyAlbumUpdate,
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
});
}
}
@OnEvent({ name: 'AlbumInvite' })

View File

@@ -26,6 +26,7 @@ export class SessionFactory {
updatedAt: newDate(),
userId: newUuid(),
oauthSid: newUuid(),
oauthBearerToken: 'oauth-bearer-token',
...dto,
});
}

View File

@@ -216,7 +216,8 @@ describe(AssetService.name, () => {
expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AlbumUpdate', {
id: album.id,
recipientId: user.id,
userIds: [user.id],
recipientIds: [user.id],
});
});

View File

@@ -9,6 +9,7 @@ import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
@@ -39,7 +40,7 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
UserRepository,
WorkflowRepository,
],
mock: [ConfigRepository],
mock: [ConfigRepository, EventRepository],
});
}
@@ -52,6 +53,7 @@ class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
mockData.resourcePaths.corePlugin = '../packages/plugin-core';
mockData.plugins.external.allow = false;
this.getMock(ConfigRepository).getEnv.mockReturnValue(mockData);
this.getMock(EventRepository).emit.mockResolvedValue();
this.get(LoggingRepository).setLogLevel(LogLevel.Verbose);
await this.sut.onPluginSync();

View File

@@ -22,7 +22,7 @@ const authFactory = ({
user,
}: {
apiKey?: Partial<AuthApiKey>;
session?: { id?: string; hasElevatedPermission?: boolean };
session?: { id?: string; hasElevatedPermission?: boolean; oauthBearerToken?: string | null };
user?: Omit<
Partial<UserAdmin>,
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'

View File

@@ -176,7 +176,7 @@
{#if showControls}
<div
class="dark m-4 flex gap-2"
class="dark m-4 flex gap-2 rounded-3xl bg-black/40 px-2 backdrop-blur-sm"
onmouseenter={() => (isOverControls = true)}
onmouseleave={() => (isOverControls = false)}
transition:fly={{ duration: 150 }}