1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-31 18:04:55 +02:00

Merge branch 'immich-app:main' into feat/samsung-raw-and-fujifilm-raf

This commit is contained in:
Skyler Mäntysaari 2023-01-26 16:38:03 +02:00 committed by GitHub
commit 2dc2c05ffb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
90 changed files with 1741 additions and 1149 deletions

1
.github/FUNDING.yml vendored
View File

@ -1,4 +1,5 @@
# These are supported funding model platforms
github: alextran1502
liberapay: alex.tran1502
custom: https://www.buymeacoffee.com/altran1502

View File

@ -39,3 +39,56 @@ jobs:
- name: Run tests
run: cd web && npm ci && npm run check:all
mobile-unit-tests:
name: Run mobile unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.3.10'
- name: Run tests
working-directory: ./mobile
run: flutter test
mobile-integration-tests:
name: Run mobile end-to-end integration tests
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '11'
- name: Cache android SDK
uses: actions/cache@v3
id: android-sdk
with:
key: android-sdk
path: |
/usr/local/lib/android/
~/.android
- name: Setup Android SDK
if: steps.android-sdk.outputs.cache-hit != 'true'
uses: android-actions/setup-android@v2
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
flutter-version: '3.3.10'
- name: Run integration tests
uses: reactivecircus/android-emulator-runner@v2.27.0
with:
working-directory: ./mobile
api-level: 29
arch: x86_64
profile: pixel
target: default
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
disable-linux-hw-accel: false
script: |
flutter pub get
flutter test integration_test

View File

@ -1,44 +0,0 @@
name: Flutter Integration Tests
on:
push:
branches: [ "main" ]
pull_request:
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v3
with:
distribution: 'adopt'
java-version: '11'
- name: Cache android SDK
uses: actions/cache@v3
id: android-sdk
with:
key: android-sdk
path: |
/usr/local/lib/android/
~/.android
- name: Setup Android SDK
if: steps.android-sdk.outputs.cache-hit != 'true'
uses: android-actions/setup-android@v2
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
channel: 'stable'
- name: Run integration tests
uses: reactivecircus/android-emulator-runner@v2.27.0
with:
working-directory: ./mobile
api-level: 29
arch: x86_64
profile: pixel
target: default
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim
disable-linux-hw-accel: false
script: |
flutter pub get
flutter test integration_test

View File

@ -93,6 +93,9 @@ If you feel like this is the right cause and the app is something you are seeing
- [Monthly donation](https://github.com/sponsors/alextran1502) via GitHub Sponsors
- [One-time donation](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via Github Sponsors
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
# Known Issues

View File

@ -1,5 +1,5 @@
---
sidebar_position: 99
sidebar_position: 70
---
# All-In-One [Community]

View File

@ -1,5 +1,5 @@
---
sidebar_position: 3
sidebar_position: 30
---
# Docker Compose [Recommended]

View File

@ -0,0 +1,24 @@
---
sidebar_position: 40
---
# Kubernetes
You can deploy Immich on Kubernetes using [the official Helm chart](https://github.com/immich-app/immich-charts/tree/main/charts/apps/immich).
If you want examples of how other people run Immich on Kubernetes, using the official chart or otherwise, you can find them at https://nanne.dev/k8s-at-home-search/#/immich.
:::caution DNS in Alpine containers
Immich makes use of Alpine container images. These can encounter [a DNS resolution bug](https://stackoverflow.com/a/65593511) on Kubernetes clusters if the host
nodes have a search domain set, like:
```
$ cat /etc/resolv.conf
search home.lan
nameserver 192.168.1.1
```
When you encounter this bug, it will cause the immich-microservices to crash on startup because it cannot download
the geocoder data. This can be solved in one of two ways: Either reconfigure your nodes to remove the searchdomain from
`resolv.conf`, or set the `DISABLE_REVERSE_GEOCODING` environment variable for Immich to `true` to disable the geocoder.
:::

View File

@ -1,5 +1,5 @@
---
sidebar_position: 4
sidebar_position: 50
---
# Portainer

View File

@ -1,5 +1,5 @@
---
sidebar_position: 1
sidebar_position: 10
---

View File

@ -1,5 +1,5 @@
---
sidebar_position: 2
sidebar_position: 20
---
# Install Script [Experimental]

View File

@ -1,5 +1,5 @@
---
sidebar_position: 5
sidebar_position: 60
---
# Unraid

View File

@ -14,6 +14,10 @@ If you feel like this is the right cause and the app is something you see yourse
- Monthly donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502)
- One-time donation via [GitHub Sponsors](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502)
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
## Contributing

View File

@ -110,6 +110,8 @@
"experimental_settings_title": "Experimental",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",
"home_page_building_timeline": "Building the timeline",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"library_page_albums": "Albums",
"library_page_new_album": "New album",
"login_form_button_text": "Login",

View File

@ -8,12 +8,11 @@ void main() async {
await ImmichTestHelper.initialize();
group("Login input validation test", () {
immichWidgetTest("Test leading/trailing whitespace", (tester) async {
await ImmichTestLoginHelper.waitForLoginScreen(tester);
await ImmichTestLoginHelper.acknowledgeNewServerVersion(tester);
immichWidgetTest("Test leading/trailing whitespace", (tester, helper) async {
await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion();
await ImmichTestLoginHelper.enterLoginCredentials(
tester,
await helper.loginHelper.enterCredentials(
email: " demo@immich.app"
);
@ -21,8 +20,7 @@ void main() async {
expect(find.text("login_form_err_leading_whitespace".tr()), findsOneWidget);
await ImmichTestLoginHelper.enterLoginCredentials(
tester,
await helper.loginHelper.enterCredentials(
email: "demo@immich.app "
);
@ -31,12 +29,11 @@ void main() async {
expect(find.text("login_form_err_trailing_whitespace".tr()), findsOneWidget);
});
immichWidgetTest("Test invalid email", (tester) async {
await ImmichTestLoginHelper.waitForLoginScreen(tester);
await ImmichTestLoginHelper.acknowledgeNewServerVersion(tester);
immichWidgetTest("Test invalid email", (tester, helper) async {
await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion();
await ImmichTestLoginHelper.enterLoginCredentials(
tester,
await helper.loginHelper.enterCredentials(
email: "demo.immich.app"
);

View File

@ -0,0 +1,39 @@
import 'dart:io';
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();
group("Login tests", () {
immichWidgetTest("Test correct credentials", (tester, helper) async {
await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper
.enterCredentialsOf(LoginCredentials.testInstance);
await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginSuccess();
});
immichWidgetTest("Test login with wrong password", (tester, helper) async {
await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentialsOf(
LoginCredentials.testInstanceButWithWrongPassword);
await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginFailed();
});
immichWidgetTest("Test login with wrong server URL", (tester, helper) async {
await helper.loginHelper.waitForLoginScreen();
await helper.loginHelper.acknowledgeNewServerVersion();
await helper.loginHelper.enterCredentialsOf(
LoginCredentials.wrongInstanceUrl);
await helper.loginHelper.pressLoginButton();
await helper.loginHelper.assertLoginFailed();
});
});
}

View File

@ -1,14 +1,28 @@
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';
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);
ImmichTestLoginHelper? _loginHelper;
ImmichTestLoginHelper get loginHelper {
_loginHelper ??= ImmichTestLoginHelper(tester);
return _loginHelper!;
}
static Future<IntegrationTestWidgetsFlutterBinding> initialize() async {
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive;
@ -32,9 +46,12 @@ class ImmichTestHelper {
}
void immichWidgetTest(String description, Future<void> Function(WidgetTester) test) {
testWidgets(description, (widgetTester) async {
await ImmichTestHelper.loadApp(widgetTester);
await test(widgetTester);
});
@isTest
void immichWidgetTest(String description, Future<void> Function(WidgetTester, ImmichTestHelper) test) {
testWidgets(description, (widgetTester) async {
await ImmichTestHelper.loadApp(widgetTester);
await test(widgetTester, ImmichTestHelper(widgetTester));
}, semanticsEnabled: false);
}

View File

@ -1,10 +1,15 @@
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 {
static Future<void> waitForLoginScreen(WidgetTester tester,
{int timeoutSeconds = 20}) async {
final WidgetTester tester;
ImmichTestLoginHelper(this.tester);
Future<void> waitForLoginScreen({int timeoutSeconds = 20}) async {
for (var i = 0; i < timeoutSeconds; i++) {
// Search for "IMMICH" test in the app bar
final result = find.text("IMMICH");
@ -21,7 +26,7 @@ class ImmichTestLoginHelper {
fail("Timeout while waiting for login screen");
}
static Future<bool> acknowledgeNewServerVersion(WidgetTester tester) async {
Future<bool> acknowledgeNewServerVersion() async {
final result = find.text("Acknowledge");
if (!tester.any(result)) {
return false;
@ -33,8 +38,7 @@ class ImmichTestLoginHelper {
return true;
}
static Future<void> enterLoginCredentials(
WidgetTester tester, {
Future<void> enterCredentials({
String server = "",
String email = "",
String password = "",
@ -50,6 +54,70 @@ class ImmichTestLoginHelper {
await tester.pump(const Duration(milliseconds: 500));
await tester.enterText(loginForms.at(2), server);
await tester.pump(const Duration(milliseconds: 500));
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
}
Future<void> enterCredentialsOf(LoginCredentials credentials) async {
await enterCredentials(
server: credentials.server,
email: credentials.email,
password: credentials.password,
);
}
Future<void> pressLoginButton() async {
final button = find.textContaining("login_form_button_text".tr());
await tester.tap(button);
}
Future<void> assertLoginSuccess({int timeoutSeconds = 15}) async {
for (var i = 0; i < timeoutSeconds * 2; i++) {
if (tester.any(find.text("home_page_building_timeline".tr()))) {
return;
}
await tester.pump(const Duration(milliseconds: 500));
}
fail("Login failed.");
}
Future<void> assertLoginFailed({int timeoutSeconds = 15}) async {
for (var i = 0; i < timeoutSeconds * 2; i++) {
if (tester.any(find.text("login_form_failed_login".tr()))) {
return;
}
await tester.pump(const Duration(milliseconds: 500));
}
fail("Timeout.");
}
}
enum LoginCredentials {
testInstance(
"https://flutter-int-test.preview.immich.app",
"demo@immich.app",
"demo",
),
testInstanceButWithWrongPassword(
"https://flutter-int-test.preview.immich.app",
"demo@immich.app",
"wrong",
),
wrongInstanceUrl(
"https://does-not-exist.preview.immich.app",
"demo@immich.app",
"demo",
);
const LoginCredentials(this.server, this.email, this.password);
final String server;
final String email;
final String password;
}

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
@ -52,7 +53,10 @@ class HomePage extends HookConsumerWidget {
});
return () {
selectionEnabledHook.dispose();
// This does not work in tests
if (kReleaseMode) {
selectionEnabledHook.dispose();
}
};
},
[],
@ -162,28 +166,28 @@ class HomePage extends HookConsumerWidget {
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'Building the timeline',
'home_page_building_timeline',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Theme.of(context).primaryColor,
),
),
).tr(),
),
AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: tipOneOpacity.value,
child: const SizedBox(
child: SizedBox(
width: 250,
child: Padding(
padding: EdgeInsets.only(top: 8.0),
child: Text(
'If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).',
padding: const EdgeInsets.only(top: 8.0),
child: const Text(
'home_page_first_time_notice',
textAlign: TextAlign.justify,
style: TextStyle(
fontSize: 12,
),
),
).tr(),
),
),
)

View File

@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**albumId** | **String** | |
**expiredAt** | **String** | | [optional]
**expiresAt** | **String** | | [optional]
**allowUpload** | **bool** | | [optional]
**allowDownload** | **bool** | | [optional]
**showExif** | **bool** | | [optional]

View File

@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**assetIds** | **List<String>** | | [default to const []]
**expiredAt** | **String** | | [optional]
**expiresAt** | **String** | | [optional]
**allowUpload** | **bool** | | [optional]
**allowDownload** | **bool** | | [optional]
**showExif** | **bool** | | [optional]

View File

@ -9,11 +9,10 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**description** | **String** | | [optional]
**expiredAt** | **String** | | [optional]
**expiresAt** | **String** | | [optional]
**allowUpload** | **bool** | | [optional]
**allowDownload** | **bool** | | [optional]
**showExif** | **bool** | | [optional]
**isEditExpireTime** | **bool** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -183,7 +183,7 @@ No authorization required
[[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)
# **removeSharedLink**
> String removeSharedLink(id)
> removeSharedLink(id)
@ -197,8 +197,7 @@ final api_instance = ShareApi();
final id = id_example; // String |
try {
final result = api_instance.removeSharedLink(id);
print(result);
api_instance.removeSharedLink(id);
} catch (e) {
print('Exception when calling ShareApi->removeSharedLink: $e\n');
}
@ -212,7 +211,7 @@ Name | Type | Description | Notes
### Return type
**String**
void (empty response body)
### Authorization
@ -221,7 +220,7 @@ No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
- **Accept**: Not defined
[[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)

View File

@ -255,18 +255,10 @@ class ShareApi {
/// Parameters:
///
/// * [String] id (required):
Future<String?> removeSharedLink(String id,) async {
Future<void> removeSharedLink(String id,) async {
final response = await removeSharedLinkWithHttpInfo(id,);
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), 'String',) as String;
}
return null;
}
}

View File

@ -14,7 +14,7 @@ class CreateAlbumShareLinkDto {
/// Returns a new [CreateAlbumShareLinkDto] instance.
CreateAlbumShareLinkDto({
required this.albumId,
this.expiredAt,
this.expiresAt,
this.allowUpload,
this.allowDownload,
this.showExif,
@ -29,7 +29,7 @@ class CreateAlbumShareLinkDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? expiredAt;
String? expiresAt;
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -66,7 +66,7 @@ class CreateAlbumShareLinkDto {
@override
bool operator ==(Object other) => identical(this, other) || other is CreateAlbumShareLinkDto &&
other.albumId == albumId &&
other.expiredAt == expiredAt &&
other.expiresAt == expiresAt &&
other.allowUpload == allowUpload &&
other.allowDownload == allowDownload &&
other.showExif == showExif &&
@ -76,22 +76,22 @@ class CreateAlbumShareLinkDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(albumId.hashCode) +
(expiredAt == null ? 0 : expiredAt!.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(allowUpload == null ? 0 : allowUpload!.hashCode) +
(allowDownload == null ? 0 : allowDownload!.hashCode) +
(showExif == null ? 0 : showExif!.hashCode) +
(description == null ? 0 : description!.hashCode);
@override
String toString() => 'CreateAlbumShareLinkDto[albumId=$albumId, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
String toString() => 'CreateAlbumShareLinkDto[albumId=$albumId, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'albumId'] = this.albumId;
if (this.expiredAt != null) {
json[r'expiredAt'] = this.expiredAt;
if (this.expiresAt != null) {
json[r'expiresAt'] = this.expiresAt;
} else {
// json[r'expiredAt'] = null;
// json[r'expiresAt'] = null;
}
if (this.allowUpload != null) {
json[r'allowUpload'] = this.allowUpload;
@ -136,7 +136,7 @@ class CreateAlbumShareLinkDto {
return CreateAlbumShareLinkDto(
albumId: mapValueOfType<String>(json, r'albumId')!,
expiredAt: mapValueOfType<String>(json, r'expiredAt'),
expiresAt: mapValueOfType<String>(json, r'expiresAt'),
allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
showExif: mapValueOfType<bool>(json, r'showExif'),

View File

@ -14,7 +14,7 @@ class CreateAssetsShareLinkDto {
/// Returns a new [CreateAssetsShareLinkDto] instance.
CreateAssetsShareLinkDto({
this.assetIds = const [],
this.expiredAt,
this.expiresAt,
this.allowUpload,
this.allowDownload,
this.showExif,
@ -29,7 +29,7 @@ class CreateAssetsShareLinkDto {
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? expiredAt;
String? expiresAt;
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -66,7 +66,7 @@ class CreateAssetsShareLinkDto {
@override
bool operator ==(Object other) => identical(this, other) || other is CreateAssetsShareLinkDto &&
other.assetIds == assetIds &&
other.expiredAt == expiredAt &&
other.expiresAt == expiresAt &&
other.allowUpload == allowUpload &&
other.allowDownload == allowDownload &&
other.showExif == showExif &&
@ -76,22 +76,22 @@ class CreateAssetsShareLinkDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(assetIds.hashCode) +
(expiredAt == null ? 0 : expiredAt!.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(allowUpload == null ? 0 : allowUpload!.hashCode) +
(allowDownload == null ? 0 : allowDownload!.hashCode) +
(showExif == null ? 0 : showExif!.hashCode) +
(description == null ? 0 : description!.hashCode);
@override
String toString() => 'CreateAssetsShareLinkDto[assetIds=$assetIds, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
String toString() => 'CreateAssetsShareLinkDto[assetIds=$assetIds, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assetIds'] = this.assetIds;
if (this.expiredAt != null) {
json[r'expiredAt'] = this.expiredAt;
if (this.expiresAt != null) {
json[r'expiresAt'] = this.expiresAt;
} else {
// json[r'expiredAt'] = null;
// json[r'expiresAt'] = null;
}
if (this.allowUpload != null) {
json[r'allowUpload'] = this.allowUpload;
@ -138,7 +138,7 @@ class CreateAssetsShareLinkDto {
assetIds: json[r'assetIds'] is List
? (json[r'assetIds'] as List).cast<String>()
: const [],
expiredAt: mapValueOfType<String>(json, r'expiredAt'),
expiresAt: mapValueOfType<String>(json, r'expiresAt'),
allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
showExif: mapValueOfType<bool>(json, r'showExif'),

View File

@ -14,11 +14,10 @@ class EditSharedLinkDto {
/// Returns a new [EditSharedLinkDto] instance.
EditSharedLinkDto({
this.description,
this.expiredAt,
this.expiresAt,
this.allowUpload,
this.allowDownload,
this.showExif,
this.isEditExpireTime,
});
///
@ -29,13 +28,7 @@ class EditSharedLinkDto {
///
String? description;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? expiredAt;
String? expiresAt;
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -61,35 +54,25 @@ class EditSharedLinkDto {
///
bool? showExif;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isEditExpireTime;
@override
bool operator ==(Object other) => identical(this, other) || other is EditSharedLinkDto &&
other.description == description &&
other.expiredAt == expiredAt &&
other.expiresAt == expiresAt &&
other.allowUpload == allowUpload &&
other.allowDownload == allowDownload &&
other.showExif == showExif &&
other.isEditExpireTime == isEditExpireTime;
other.showExif == showExif;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(description == null ? 0 : description!.hashCode) +
(expiredAt == null ? 0 : expiredAt!.hashCode) +
(expiresAt == null ? 0 : expiresAt!.hashCode) +
(allowUpload == null ? 0 : allowUpload!.hashCode) +
(allowDownload == null ? 0 : allowDownload!.hashCode) +
(showExif == null ? 0 : showExif!.hashCode) +
(isEditExpireTime == null ? 0 : isEditExpireTime!.hashCode);
(showExif == null ? 0 : showExif!.hashCode);
@override
String toString() => 'EditSharedLinkDto[description=$description, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, isEditExpireTime=$isEditExpireTime]';
String toString() => 'EditSharedLinkDto[description=$description, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -98,10 +81,10 @@ class EditSharedLinkDto {
} else {
// json[r'description'] = null;
}
if (this.expiredAt != null) {
json[r'expiredAt'] = this.expiredAt;
if (this.expiresAt != null) {
json[r'expiresAt'] = this.expiresAt;
} else {
// json[r'expiredAt'] = null;
// json[r'expiresAt'] = null;
}
if (this.allowUpload != null) {
json[r'allowUpload'] = this.allowUpload;
@ -118,11 +101,6 @@ class EditSharedLinkDto {
} else {
// json[r'showExif'] = null;
}
if (this.isEditExpireTime != null) {
json[r'isEditExpireTime'] = this.isEditExpireTime;
} else {
// json[r'isEditExpireTime'] = null;
}
return json;
}
@ -146,11 +124,10 @@ class EditSharedLinkDto {
return EditSharedLinkDto(
description: mapValueOfType<String>(json, r'description'),
expiredAt: mapValueOfType<String>(json, r'expiredAt'),
expiresAt: mapValueOfType<String>(json, r'expiresAt'),
allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
showExif: mapValueOfType<bool>(json, r'showExif'),
isEditExpireTime: mapValueOfType<bool>(json, r'isEditExpireTime'),
);
}
return null;

View File

@ -21,8 +21,8 @@ void main() {
// TODO
});
// String expiredAt
test('to test the property `expiredAt`', () async {
// String expiresAt
test('to test the property `expiresAt`', () async {
// TODO
});

View File

@ -21,8 +21,8 @@ void main() {
// TODO
});
// String expiredAt
test('to test the property `expiredAt`', () async {
// String expiresAt
test('to test the property `expiresAt`', () async {
// TODO
});

View File

@ -21,8 +21,8 @@ void main() {
// TODO
});
// String expiredAt
test('to test the property `expiredAt`', () async {
// String expiresAt
test('to test the property `expiresAt`', () async {
// TODO
});
@ -41,11 +41,6 @@ void main() {
// TODO
});
// bool isEditExpireTime
test('to test the property `isEditExpireTime`', () async {
// TODO
});
});

View File

@ -47,7 +47,7 @@ void main() {
//
//
//Future<String> removeSharedLink(String id) async
//Future removeSharedLink(String id) async
test('test removeSharedLink', () async {
// TODO
});

View File

@ -1,7 +1,6 @@
import { DomainModule } from '@app/domain';
import { InfraModule, SystemConfigEntity } from '@app/infra';
import { InfraModule } from '@app/infra';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ListUsersCommand } from './commands/list-users.command';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from './commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/reset-admin-password.command';
@ -11,7 +10,6 @@ import { PromptPasswordQuestions, ResetAdminPasswordCommand } from './commands/r
DomainModule.register({
imports: [InfraModule],
}),
TypeOrmModule.forFeature([SystemConfigEntity]),
],
providers: [
ResetAdminPasswordCommand,

View File

@ -1,20 +1,20 @@
import { SystemConfigEntity, SystemConfigKey } from '@app/infra';
import { InjectRepository } from '@nestjs/typeorm';
import { SystemConfigService } from '@app/domain';
import axios from 'axios';
import { Command, CommandRunner } from 'nest-commander';
import { Repository } from 'typeorm';
@Command({
name: 'enable-password-login',
description: 'Enable password login',
})
export class EnablePasswordLoginCommand extends CommandRunner {
constructor(@InjectRepository(SystemConfigEntity) private repository: Repository<SystemConfigEntity>) {
constructor(private configService: SystemConfigService) {
super();
}
async run(): Promise<void> {
await this.repository.delete({ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED });
const config = await this.configService.getConfig();
config.passwordLogin.enabled = true;
await this.configService.updateConfig(config);
await axios.post('http://localhost:3001/refresh-config');
console.log('Password login has been enabled.');
}
@ -25,12 +25,14 @@ export class EnablePasswordLoginCommand extends CommandRunner {
description: 'Disable password login',
})
export class DisablePasswordLoginCommand extends CommandRunner {
constructor(@InjectRepository(SystemConfigEntity) private repository: Repository<SystemConfigEntity>) {
constructor(private configService: SystemConfigService) {
super();
}
async run(): Promise<void> {
await this.repository.save({ key: SystemConfigKey.PASSWORD_LOGIN_ENABLED, value: false });
const config = await this.configService.getConfig();
config.passwordLogin.enabled = false;
await this.configService.updateConfig(config);
await axios.post('http://localhost:3001/refresh-config');
console.log('Password login has been disabled.');
}

View File

@ -23,7 +23,7 @@ import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AlbumResponseDto } from '@app/domain';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { Response as Res } from 'express';

View File

@ -6,7 +6,6 @@ import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/infra';
import { AlbumRepository, IAlbumRepository } from './album-repository';
import { DownloadModule } from '../../modules/download/download.module';
import { AssetModule } from '../asset/asset.module';
import { ShareModule } from '../share/share.module';
const ALBUM_REPOSITORY_PROVIDER = {
provide: IAlbumRepository,
@ -18,7 +17,6 @@ const ALBUM_REPOSITORY_PROVIDER = {
TypeOrmModule.forFeature([AlbumEntity, AssetAlbumEntity, UserAlbumEntity]),
DownloadModule,
forwardRef(() => AssetModule),
ShareModule,
],
controllers: [AlbumController],
providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER],

View File

@ -2,17 +2,19 @@ import { AlbumService } from './album.service';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity } from '@app/infra';
import { AlbumResponseDto } from './response-dto/album-response.dto';
import { AlbumResponseDto, ICryptoRepository } from '@app/domain';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository';
import { DownloadService } from '../../modules/download/download.service';
import { ISharedLinkRepository } from '../share/shared-link.repository';
import { ISharedLinkRepository } from '@app/domain';
import { newCryptoRepositoryMock, newSharedLinkRepositoryMock } from '@app/domain/../test';
describe('Album service', () => {
let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
const authUser: AuthUserDto = Object.freeze({
id: '1111',
@ -129,22 +131,20 @@ describe('Album service', () => {
getSharedWithUserAlbumCount: jest.fn(),
};
sharedLinkRepositoryMock = {
create: jest.fn(),
remove: jest.fn(),
get: jest.fn(),
getById: jest.fn(),
getByKey: jest.fn(),
save: jest.fn(),
hasAssetAccess: jest.fn(),
getByIdAndUserId: jest.fn(),
};
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
downloadServiceMock = {
downloadArchive: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, sharedLinkRepositoryMock, downloadServiceMock as DownloadService);
cryptoMock = newCryptoRepositoryMock();
sut = new AlbumService(
albumRepositoryMock,
sharedLinkRepositoryMock,
downloadServiceMock as DownloadService,
cryptoMock,
);
});
it('creates album', async () => {

View File

@ -6,16 +6,14 @@ import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { AddAssetsDto } from './dto/add-assets.dto';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { ShareCore } from '../share/share.core';
import { ISharedLinkRepository } from '../share/shared-link.repository';
import { mapSharedLink, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
import { ShareCore, ISharedLinkRepository, mapSharedLink, SharedLinkResponseDto, ICryptoRepository } from '@app/domain';
import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
import _ from 'lodash';
@ -26,10 +24,11 @@ export class AlbumService {
constructor(
@Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
private downloadService: DownloadService,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository);
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
}
private async _getAlbum({
@ -102,7 +101,7 @@ export class AlbumService {
const album = await this._getAlbum({ authUser, albumId });
for (const sharedLink of album.sharedLinks) {
await this.shareCore.removeSharedLink(sharedLink.id, authUser.id);
await this.shareCore.remove(sharedLink.id, authUser.id);
}
await this._albumRepository.delete(album);
@ -203,11 +202,11 @@ export class AlbumService {
async createAlbumSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise<SharedLinkResponseDto> {
const album = await this._getAlbum({ authUser, albumId: dto.albumId });
const sharedLink = await this.shareCore.createSharedLink(authUser.id, {
sharedType: SharedLinkType.ALBUM,
expiredAt: dto.expiredAt,
const sharedLink = await this.shareCore.create(authUser.id, {
type: SharedLinkType.ALBUM,
expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload,
album: album,
album,
assets: [],
description: dto.description,
allowDownload: dto.allowDownload,

View File

@ -7,7 +7,7 @@ export class CreateAlbumShareLinkDto {
@IsString()
@IsOptional()
expiredAt?: string;
expiresAt?: string;
@IsBoolean()
@IsOptional()

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { AlbumResponseDto } from './album-response.dto';
import { AlbumResponseDto } from '@app/domain';
export class AddAssetsResponseDto {
@ApiProperty({ type: 'integer' })

View File

@ -30,7 +30,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { AssetResponseDto } from './response-dto/asset-response.dto';
import { AssetResponseDto } from '@app/domain';
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
import { CreateAssetDto } from './dto/create-asset.dto';
@ -52,7 +52,7 @@ import {
} from '../../constants/download.constant';
import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
import { SharedLinkResponseDto } from '@app/domain';
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
import { AssetSearchDto } from './dto/asset-search.dto';

View File

@ -11,7 +11,6 @@ import { DownloadModule } from '../../modules/download/download.module';
import { TagModule } from '../tag/tag.module';
import { AlbumModule } from '../album/album.module';
import { StorageModule } from '@app/storage';
import { ShareModule } from '../share/share.module';
const ASSET_REPOSITORY_PROVIDER = {
provide: IAssetRepository,
@ -27,7 +26,6 @@ const ASSET_REPOSITORY_PROVIDER = {
TagModule,
StorageModule,
forwardRef(() => AlbumModule),
ShareModule,
],
controllers: [AssetController],
providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],

View File

@ -9,11 +9,19 @@ import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
import { DownloadService } from '../../modules/download/download.service';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { IAlbumRepository } from '../album/album-repository';
import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
import { ISharedLinkRepository } from '../share/shared-link.repository';
import { IJobRepository } from '@app/domain';
import { newJobRepositoryMock } from '@app/domain/../test';
import { ICryptoRepository, IJobRepository, ISharedLinkRepository } from '@app/domain';
import {
authStub,
newCryptoRepositoryMock,
newJobRepositoryMock,
newSharedLinkRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '@app/domain/../test';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
describe('AssetService', () => {
let sui: AssetService;
@ -24,6 +32,7 @@ describe('AssetService', () => {
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
let storageSeriveMock: jest.Mocked<StorageService>;
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
const authUser: AuthUserDto = Object.freeze({
id: 'user_id_1',
@ -132,22 +141,18 @@ describe('AssetService', () => {
countByIdAndUser: jest.fn(),
};
albumRepositoryMock = {
getSharedWithUserAlbumCount: jest.fn(),
} as unknown as jest.Mocked<AlbumRepository>;
downloadServiceMock = {
downloadArchive: jest.fn(),
};
sharedLinkRepositoryMock = {
create: jest.fn(),
get: jest.fn(),
getById: jest.fn(),
getByKey: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
hasAssetAccess: jest.fn(),
getByIdAndUserId: jest.fn(),
};
sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
sui = new AssetService(
assetRepositoryMock,
@ -158,9 +163,64 @@ describe('AssetService', () => {
storageSeriveMock,
sharedLinkRepositoryMock,
jobMock,
cryptoMock,
);
});
describe('createAssetsSharedLink', () => {
it('should create an individual share link', async () => {
const asset1 = _getAsset_1();
const dto: CreateAssetsShareLinkDto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
await expect(sui.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id);
});
});
describe('updateAssetsInSharedLink', () => {
it('should require a valid shared link', async () => {
const asset1 = _getAsset_1();
const authDto = authStub.adminSharedLink;
const dto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(null);
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
await expect(sui.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.hasAssetAccess).toHaveBeenCalledWith(authDto.sharedLinkId, asset1.id);
expect(sharedLinkRepositoryMock.save).not.toHaveBeenCalled();
});
it('should remove assets from a shared link', async () => {
const asset1 = _getAsset_1();
const authDto = authStub.adminSharedLink;
const dto = { assetIds: [asset1.id] };
assetRepositoryMock.getById.mockResolvedValue(asset1);
sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid);
await expect(sui.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
expect(sharedLinkRepositoryMock.hasAssetAccess).toHaveBeenCalledWith(authDto.sharedLinkId, asset1.id);
});
});
// Currently failing due to calculate checksum from a file
it('create an asset', async () => {
const assetEntity = _getAsset_1();
@ -224,4 +284,14 @@ describe('AssetService', () => {
expect(result).toEqual(assetCount);
});
describe('checkDownloadAccess', () => {
it('should validate download access', async () => {
await sui.checkDownloadAccess(authStub.adminSharedLink);
});
it('should not allow when user is not allowed to download', async () => {
expect(() => sui.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
});
});
});

View File

@ -23,7 +23,7 @@ import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from './response-dto/asset-response.dto';
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '@app/domain';
import { CreateAssetDto } from './dto/create-asset.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
@ -43,16 +43,16 @@ import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-as
import { UpdateAssetDto } from './dto/update-asset.dto';
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { IJobRepository, JobName } from '@app/domain';
import { ICryptoRepository, IJobRepository, JobName } from '@app/domain';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
import { ShareCore } from '../share/share.core';
import { ISharedLinkRepository } from '../share/shared-link.repository';
import { ShareCore } from '@app/domain';
import { ISharedLinkRepository } from '@app/domain';
import { DownloadFilesDto } from './dto/download-files.dto';
import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
import { mapSharedLink, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
import { AssetSearchDto } from './dto/asset-search.dto';
@ -73,8 +73,9 @@ export class AssetService {
private storageService: StorageService,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository);
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
}
public async handleUploadedAsset(
@ -669,23 +670,24 @@ export class AssetService {
// Step 1: Check if asset is part of a public shared
if (authUser.sharedLinkId) {
const canAccess = await this.shareCore.hasAssetAccess(authUser.sharedLinkId, assetId);
if (!canAccess) {
throw new ForbiddenException();
}
}
// Step 2: Check if user owns asset
if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
continue;
}
// Avoid additional checks if ownership is required
if (!mustBeOwner) {
// Step 2: Check if asset is part of an album shared with me
if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
if (canAccess) {
continue;
}
} else {
// Step 2: Check if user owns asset
if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
continue;
}
// Avoid additional checks if ownership is required
if (!mustBeOwner) {
// Step 2: Check if asset is part of an album shared with me
if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
continue;
}
}
}
throw new ForbiddenException();
}
}
@ -703,11 +705,11 @@ export class AssetService {
assets.push(asset);
}
const sharedLink = await this.shareCore.createSharedLink(authUser.id, {
sharedType: SharedLinkType.INDIVIDUAL,
expiredAt: dto.expiredAt,
const sharedLink = await this.shareCore.create(authUser.id, {
type: SharedLinkType.INDIVIDUAL,
expiresAt: dto.expiresAt,
allowUpload: dto.allowUpload,
assets: assets,
assets,
description: dto.description,
allowDownload: dto.allowDownload,
showExif: dto.showExif,
@ -720,15 +722,19 @@ export class AssetService {
authUser: AuthUserDto,
dto: UpdateAssetsToSharedLinkDto,
): Promise<SharedLinkResponseDto> {
if (!authUser.sharedLinkId) throw new ForbiddenException();
if (!authUser.sharedLinkId) {
throw new ForbiddenException();
}
const assets = [];
await this.checkAssetsAccess(authUser, dto.assetIds);
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assets.push(asset);
}
const updatedLink = await this.shareCore.updateAssetsInSharedLink(authUser.sharedLinkId, assets);
const updatedLink = await this.shareCore.updateAssets(authUser.id, authUser.sharedLinkId, assets);
return mapSharedLink(updatedLink);
}

View File

@ -19,7 +19,7 @@ export class CreateAssetsShareLinkDto {
@IsString()
@IsOptional()
expiredAt?: string;
expiresAt?: string;
@IsBoolean()
@IsOptional()

View File

@ -1,101 +0,0 @@
import { SharedLinkEntity } from '@app/infra';
import { CreateSharedLinkDto } from './dto/create-shared-link.dto';
import { ISharedLinkRepository } from './shared-link.repository';
import crypto from 'node:crypto';
import { BadRequestException, ForbiddenException, InternalServerErrorException, Logger } from '@nestjs/common';
import { AssetEntity } from '@app/infra';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
export class ShareCore {
readonly logger = new Logger(ShareCore.name);
constructor(private sharedLinkRepository: ISharedLinkRepository) {}
async createSharedLink(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
try {
const sharedLink = new SharedLinkEntity();
sharedLink.key = Buffer.from(crypto.randomBytes(50));
sharedLink.description = dto.description;
sharedLink.userId = userId;
sharedLink.createdAt = new Date().toISOString();
sharedLink.expiresAt = dto.expiredAt ?? null;
sharedLink.type = dto.sharedType;
sharedLink.assets = dto.assets;
sharedLink.album = dto.album;
sharedLink.allowUpload = dto.allowUpload ?? false;
sharedLink.allowDownload = dto.allowDownload ?? true;
sharedLink.showExif = dto.showExif ?? true;
return this.sharedLinkRepository.create(sharedLink);
} catch (error: any) {
this.logger.error(error, error.stack);
throw new InternalServerErrorException('failed to create shared link');
}
}
getSharedLinks(userId: string): Promise<SharedLinkEntity[]> {
return this.sharedLinkRepository.get(userId);
}
async removeSharedLink(id: string, userId: string): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return await this.sharedLinkRepository.remove(link);
}
getSharedLinkById(id: string): Promise<SharedLinkEntity | null> {
return this.sharedLinkRepository.getById(id);
}
getSharedLinkByKey(key: string): Promise<SharedLinkEntity | null> {
return this.sharedLinkRepository.getByKey(key);
}
async updateAssetsInSharedLink(sharedLinkId: string, assets: AssetEntity[]) {
const link = await this.getSharedLinkById(sharedLinkId);
if (!link) {
throw new BadRequestException('Shared link not found');
}
link.assets = assets;
return await this.sharedLinkRepository.save(link);
}
async updateSharedLink(id: string, userId: string, dto: EditSharedLinkDto): Promise<SharedLinkEntity> {
const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId);
if (!link) {
throw new BadRequestException('Shared link not found');
}
link.description = dto.description ?? link.description;
link.allowUpload = dto.allowUpload ?? link.allowUpload;
link.allowDownload = dto.allowDownload ?? link.allowDownload;
link.showExif = dto.showExif ?? link.showExif;
if (dto.isEditExpireTime && dto.expiredAt) {
link.expiresAt = dto.expiredAt;
} else if (dto.isEditExpireTime && !dto.expiredAt) {
link.expiresAt = null;
}
return await this.sharedLinkRepository.save(link);
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
return this.sharedLinkRepository.hasAssetAccess(id, assetId);
}
checkDownloadAccess(user: AuthUserDto) {
if (user.isPublicUser && !user.isAllowDownload) {
throw new ForbiddenException();
}
}
}

View File

@ -1,19 +0,0 @@
import { Module } from '@nestjs/common';
import { ShareService } from './share.service';
import { ShareController } from './share.controller';
import { SharedLinkEntity } from '@app/infra';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SharedLinkRepository, ISharedLinkRepository } from './shared-link.repository';
const SHARED_LINK_REPOSITORY_PROVIDER = {
provide: ISharedLinkRepository,
useClass: SharedLinkRepository,
};
@Module({
imports: [TypeOrmModule.forFeature([SharedLinkEntity])],
controllers: [ShareController],
providers: [ShareService, SHARED_LINK_REPOSITORY_PROVIDER],
exports: [SHARED_LINK_REPOSITORY_PROVIDER, ShareService],
})
export class ShareModule {}

View File

@ -1,137 +0,0 @@
import { SharedLinkEntity } from '@app/infra';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Logger } from '@nestjs/common';
export interface ISharedLinkRepository {
get(userId: string): Promise<SharedLinkEntity[]>;
getById(id: string): Promise<SharedLinkEntity | null>;
getByIdAndUserId(id: string, userId: string): Promise<SharedLinkEntity | null>;
getByKey(key: string): Promise<SharedLinkEntity | null>;
create(payload: SharedLinkEntity): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
save(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
}
export const ISharedLinkRepository = 'ISharedLinkRepository';
export class SharedLinkRepository implements ISharedLinkRepository {
readonly logger = new Logger(SharedLinkRepository.name);
constructor(
@InjectRepository(SharedLinkEntity)
private readonly sharedLinkRepository: Repository<SharedLinkEntity>,
) {}
async getByIdAndUserId(id: string, userId: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
userId: userId,
id: id,
},
order: {
createdAt: 'DESC',
},
});
}
async get(userId: string): Promise<SharedLinkEntity[]> {
return await this.sharedLinkRepository.find({
where: {
userId: userId,
},
relations: ['assets', 'album'],
order: {
createdAt: 'DESC',
},
});
}
async create(payload: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.save(payload);
}
async getById(id: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
id: id,
},
relations: {
assets: {
exifInfo: true,
},
album: {
assets: {
assetInfo: {
exifInfo: true,
},
},
},
},
order: {
createdAt: 'DESC',
assets: {
createdAt: 'ASC',
},
album: {
assets: {
assetInfo: {
createdAt: 'ASC',
},
},
},
},
});
}
async getByKey(key: string): Promise<SharedLinkEntity | null> {
return await this.sharedLinkRepository.findOne({
where: {
key: Buffer.from(key, 'hex'),
},
relations: {
assets: true,
album: {
assets: {
assetInfo: true,
},
},
},
order: {
createdAt: 'DESC',
},
});
}
async remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.remove(entity);
}
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return await this.sharedLinkRepository.save(entity);
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
const count1 = await this.sharedLinkRepository.count({
where: {
id,
assets: {
id: assetId,
},
},
});
const count2 = await this.sharedLinkRepository.count({
where: {
id,
album: {
assets: {
assetId,
},
},
},
});
return Boolean(count1 + count2);
}
}

View File

@ -5,7 +5,7 @@ import { UpdateTagDto } from './dto/update-tag.dto';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { mapTag, TagResponseDto } from './response-dto/tag-response.dto';
import { mapTag, TagResponseDto } from '@app/domain';
@Authenticated()
@ApiTags('Tag')

View File

@ -4,7 +4,7 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { CreateTagDto } from './dto/create-tag.dto';
import { UpdateTagDto } from './dto/update-tag.dto';
import { ITagRepository } from './tag.repository';
import { mapTag, TagResponseDto } from './response-dto/tag-response.dto';
import { mapTag, TagResponseDto } from '@app/domain';
@Injectable()
export class TagService {

View File

@ -13,13 +13,13 @@ import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { JobModule } from './api-v1/job/job.module';
import { TagModule } from './api-v1/tag/tag.module';
import { ShareModule } from './api-v1/share/share.module';
import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra';
import {
APIKeyController,
AuthController,
OAuthController,
ShareController,
SystemConfigController,
UserController,
} from './controllers';
@ -53,8 +53,6 @@ import {
JobModule,
TagModule,
ShareModule,
],
controllers: [
//
@ -62,6 +60,7 @@ import {
APIKeyController,
AuthController,
OAuthController,
ShareController,
SystemConfigController,
UserController,
],

View File

@ -23,7 +23,7 @@ export const assetUploadOption: MulterOptions = {
export const multerUtils = { fileFilter, filename, destination };
function fileFilter(req: Request, file: any, cb: any) {
if (!req.user) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}
// TODO: Create new API endpoint for mimetypes and use that here as browser's
@ -43,16 +43,12 @@ function fileFilter(req: Request, file: any, cb: any) {
}
function destination(req: Request, file: Express.Multer.File, cb: any) {
if (!req.user) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}
const user = req.user as AuthUserDto;
if (user.isPublicUser && !user.isAllowUpload) {
return cb(new UnauthorizedException());
}
const basePath = APP_UPLOAD_LOCATION;
const sanitizedDeviceId = sanitize(String(req.body['deviceId']));
const originalUploadFolder = join(basePath, user.id, 'original', sanitizedDeviceId);
@ -66,7 +62,7 @@ function destination(req: Request, file: Express.Multer.File, cb: any) {
}
function filename(req: Request, file: Express.Multer.File, cb: any) {
if (!req.user) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException());
}

View File

@ -1,5 +1,6 @@
export * from './api-key.controller';
export * from './auth.controller';
export * from './oauth.controller';
export * from './share.controller';
export * from './system-config.controller';
export * from './user.controller';

View File

@ -1,10 +1,8 @@
import { Body, Controller, Delete, Get, Param, Patch, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
import { SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
import { ShareService } from './share.service';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { AuthUserDto, EditSharedLinkDto, SharedLinkResponseDto, ShareService } from '@app/domain';
@ApiTags('share')
@Controller('share')
@ -24,23 +22,23 @@ export class ShareController {
@Authenticated()
@Get(':id')
getSharedLinkById(@Param('id') id: string): Promise<SharedLinkResponseDto> {
return this.shareService.getById(id, true);
getSharedLinkById(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string): Promise<SharedLinkResponseDto> {
return this.shareService.getById(authUser, id, true);
}
@Authenticated()
@Delete(':id')
removeSharedLink(@Param('id') id: string, @GetAuthUser() authUser: AuthUserDto): Promise<string> {
return this.shareService.remove(id, authUser.id);
removeSharedLink(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string): Promise<void> {
return this.shareService.remove(authUser, id);
}
@Authenticated()
@Patch(':id')
editSharedLink(
@Param('id') id: string,
@GetAuthUser() authUser: AuthUserDto,
@Param('id') id: string,
@Body(new ValidationPipe()) dto: EditSharedLinkDto,
): Promise<SharedLinkResponseDto> {
return this.shareService.edit(id, authUser, dto);
return this.shareService.edit(authUser, id, dto);
}
}

View File

@ -1,11 +1,9 @@
import { Module } from '@nestjs/common';
import { ShareModule } from '../../api-v1/share/share.module';
import { APIKeyStrategy } from './strategies/api-key.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { PublicShareStrategy } from './strategies/public-share.strategy';
@Module({
imports: [ShareModule],
providers: [JwtStrategy, APIKeyStrategy, PublicShareStrategy],
})
export class ImmichJwtModule {}

View File

@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
import { ShareService } from '../../../api-v1/share/share.service';
import { AuthUserDto } from '../../../decorators/auth-user.decorator';
import { AuthUserDto, ShareService } from '@app/domain';
export const PUBLIC_SHARE_STRATEGY = 'public-share';

View File

@ -4,7 +4,7 @@ import { WebpGeneratorProcessor, JpegGeneratorProcessor, QueueName, JobName } fr
import { InjectQueue, Process, Processor } from '@nestjs/bull';
import { Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
import { mapAsset } from '@app/domain';
import { Job, Queue } from 'bull';
import ffmpeg from 'fluent-ffmpeg';
import { existsSync, mkdirSync } from 'node:fs';

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
import { AssetEntity } from '@app/infra';
import { AssetResponseDto } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
import { AssetResponseDto } from '@app/domain';
import fs from 'fs';
const deleteFiles = (asset: AssetEntity | AssetResponseDto) => {

View File

@ -0,0 +1 @@
export * from './response-dto';

View File

@ -1,7 +1,7 @@
import { AlbumEntity } from '@app/infra';
import { UserResponseDto, mapUser } from '@app/domain';
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
import { AlbumEntity } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
import { AssetResponseDto, mapAsset } from '../../asset';
import { mapUser, UserResponseDto } from '../../user';
export class AlbumResponseDto {
id!: string;

View File

@ -0,0 +1 @@
export * from './album-response.dto';

View File

@ -0,0 +1 @@
export * from './response-dto';

View File

@ -1,6 +1,6 @@
import { AssetEntity, AssetType } from '@app/infra';
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
import { mapTag, TagResponseDto } from '../../tag/response-dto/tag-response.dto';
import { mapTag, TagResponseDto } from '../../tag';
import { ExifResponseDto, mapExif } from './exif-response.dto';
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';

View File

@ -1,4 +1,4 @@
import { ExifEntity } from '@app/infra';
import { ExifEntity } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
export class ExifResponseDto {
@ -29,7 +29,7 @@ export class ExifResponseDto {
export function mapExif(entity: ExifEntity): ExifResponseDto {
return {
id: parseInt(entity.id),
id: entity.id,
make: entity.make,
model: entity.model,
imageName: entity.imageName,

View File

@ -0,0 +1,3 @@
export * from './asset-response.dto';
export * from './exif-response.dto';
export * from './smart-info-response.dto';

View File

@ -1,4 +1,4 @@
import { SmartInfoEntity } from '@app/infra';
import { SmartInfoEntity } from '@app/infra/db/entities';
export class SmartInfoResponseDto {
id?: string;

View File

@ -1,5 +1,6 @@
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
import { APIKeyService } from './api-key';
import { ShareService } from './share';
import { AuthService } from './auth';
import { OAuthService } from './oauth';
import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
@ -11,6 +12,7 @@ const providers: Provider[] = [
OAuthService,
SystemConfigService,
UserService,
ShareService,
{
provide: INITIAL_SYSTEM_CONFIG,

View File

@ -1,7 +1,11 @@
export * from './album';
export * from './api-key';
export * from './asset';
export * from './auth';
export * from './domain.module';
export * from './job';
export * from './oauth';
export * from './share';
export * from './system-config';
export * from './tag';
export * from './user';

View File

@ -25,7 +25,7 @@ export interface IVideoLengthExtractionProcessor {
}
export interface IReverseGeocodingProcessor {
exifId: string;
exifId: number;
latitude: number;
longitude: number;
}

View File

@ -1,10 +1,9 @@
import { AlbumEntity, AssetEntity } from '@app/infra';
import { SharedLinkType } from '@app/infra';
import { AlbumEntity, AssetEntity, SharedLinkType } from '@app/infra/db/entities';
export class CreateSharedLinkDto {
description?: string;
expiredAt?: string;
sharedType!: SharedLinkType;
expiresAt?: string;
type!: SharedLinkType;
assets!: AssetEntity[];
album?: AlbumEntity;
allowUpload?: boolean;

View File

@ -1,11 +1,11 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
import { IsOptional } from 'class-validator';
export class EditSharedLinkDto {
@IsOptional()
description?: string;
@IsOptional()
expiredAt?: string;
expiresAt?: string | null;
@IsOptional()
allowUpload?: boolean;
@ -15,7 +15,4 @@ export class EditSharedLinkDto {
@IsOptional()
showExif?: boolean;
@IsNotEmpty()
isEditExpireTime?: boolean;
}

View File

@ -0,0 +1,2 @@
export * from './create-shared-link.dto';
export * from './edit-shared-link.dto';

View File

@ -0,0 +1,5 @@
export * from './dto';
export * from './response-dto';
export * from './share.core';
export * from './share.service';
export * from './shared-link.repository';

View File

@ -0,0 +1 @@
export * from './shared-link-response.dto';

View File

@ -1,8 +1,8 @@
import { SharedLinkEntity, SharedLinkType } from '@app/infra';
import { SharedLinkEntity, SharedLinkType } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
import _ from 'lodash';
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto';
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset/response-dto/asset-response.dto';
import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album';
import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset';
export class SharedLinkResponseDto {
id!: string;

View File

@ -0,0 +1,81 @@
import { AssetEntity, SharedLinkEntity } from '@app/infra/db/entities';
import { BadRequestException, ForbiddenException, InternalServerErrorException, Logger } from '@nestjs/common';
import { AuthUserDto, ICryptoRepository } from '../auth';
import { CreateSharedLinkDto } from './dto';
import { ISharedLinkRepository } from './shared-link.repository';
export class ShareCore {
readonly logger = new Logger(ShareCore.name);
constructor(private repository: ISharedLinkRepository, private cryptoRepository: ICryptoRepository) {}
getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.repository.getAll(userId);
}
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
return this.repository.get(userId, id);
}
getByKey(key: string): Promise<SharedLinkEntity | null> {
return this.repository.getByKey(key);
}
create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
try {
return this.repository.create({
key: Buffer.from(this.cryptoRepository.randomBytes(50)),
description: dto.description,
userId,
createdAt: new Date().toISOString(),
expiresAt: dto.expiresAt ?? null,
type: dto.type,
assets: dto.assets,
album: dto.album,
allowUpload: dto.allowUpload ?? false,
allowDownload: dto.allowDownload ?? true,
showExif: dto.showExif ?? true,
});
} catch (error: any) {
this.logger.error(error, error.stack);
throw new InternalServerErrorException('failed to create shared link');
}
}
async save(userId: string, id: string, entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
const link = await this.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return this.repository.save({ ...entity, userId, id });
}
async remove(userId: string, id: string): Promise<SharedLinkEntity> {
const link = await this.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return this.repository.remove(link);
}
async updateAssets(userId: string, id: string, assets: AssetEntity[]) {
const link = await this.get(userId, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return this.repository.save({ ...link, assets });
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
return this.repository.hasAssetAccess(id, assetId);
}
checkDownloadAccess(user: AuthUserDto) {
if (user.isPublicUser && !user.isAllowDownload) {
throw new ForbiddenException();
}
}
}

View File

@ -0,0 +1,170 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import {
authStub,
entityStub,
newCryptoRepositoryMock,
newSharedLinkRepositoryMock,
newUserRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '../../test';
import { ICryptoRepository } from '../auth';
import { IUserRepository } from '../user';
import { ShareService } from './share.service';
import { ISharedLinkRepository } from './shared-link.repository';
describe(ShareService.name, () => {
let sut: ShareService;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let shareMock: jest.Mocked<ISharedLinkRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(async () => {
cryptoMock = newCryptoRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
userMock = newUserRepositoryMock();
sut = new ShareService(cryptoMock, shareMock, userMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('validate', () => {
it('should not accept a non-existant key', async () => {
shareMock.getByKey.mockResolvedValue(null);
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should not accept an expired key', async () => {
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should not accept a key without a user', async () => {
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
userMock.get.mockResolvedValue(null);
await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should accept a valid key', async () => {
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
userMock.get.mockResolvedValue(entityStub.admin);
await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink);
});
});
describe('getAll', () => {
it('should return all keys for a user', async () => {
shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([
sharedLinkResponseStub.expired,
sharedLinkResponseStub.valid,
]);
expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
});
describe('getMine', () => {
it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin)).rejects.toBeInstanceOf(ForbiddenException);
expect(shareMock.get).not.toHaveBeenCalled();
});
it('should return the key for the public user (auth dto)', async () => {
const authDto = authStub.adminSharedLink;
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
});
describe('get', () => {
it('should not work on a missing key', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.getById(authStub.user1, sharedLinkStub.valid.id, true)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.remove).not.toHaveBeenCalled();
});
it('should get a key by id', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getById(authStub.user1, sharedLinkStub.valid.id, false)).resolves.toEqual(
sharedLinkResponseStub.valid,
);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
});
it('should include exif', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.readonly);
await expect(sut.getById(authStub.user1, sharedLinkStub.readonly.id, true)).resolves.toEqual(
sharedLinkResponseStub.readonly,
);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.readonly.id);
});
it('should exclude exif', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.readonly);
await expect(sut.getById(authStub.user1, sharedLinkStub.readonly.id, false)).resolves.toEqual(
sharedLinkResponseStub.readonlyNoExif,
);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.readonly.id);
});
});
describe('remove', () => {
it('should not work on a missing key', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.remove(authStub.user1, sharedLinkStub.valid.id)).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.remove).not.toHaveBeenCalled();
});
it('should remove a key', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
});
});
describe('getByKey', () => {
it('should not work on a missing key', async () => {
shareMock.getByKey.mockResolvedValue(null);
await expect(sut.getByKey('secret-key')).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key');
});
it('should find a key', async () => {
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getByKey('secret-key')).resolves.toEqual(sharedLinkResponseStub.valid);
expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key');
});
});
describe('edit', () => {
it('should not work on a missing key', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.edit(authStub.user1, sharedLinkStub.valid.id, {})).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.save).not.toHaveBeenCalled();
});
it('should edit a key', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.save.mockResolvedValue(sharedLinkStub.valid);
const dto = { allowDownload: false };
await sut.edit(authStub.user1, sharedLinkStub.valid.id, dto);
// await expect(sut.edit(authStub.user1, sharedLinkStub.valid.id, dto)).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.save).toHaveBeenCalledWith({
id: sharedLinkStub.valid.id,
userId: authStub.user1.id,
allowDownload: false,
});
});
});
});

View File

@ -6,10 +6,10 @@ import {
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { UserService } from '@app/domain';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
import { AuthUserDto, ICryptoRepository } from '../auth';
import { IUserRepository, UserCore } from '../user';
import { EditSharedLinkDto } from './dto';
import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
import { ShareCore } from './share.core';
import { ISharedLinkRepository } from './shared-link.repository';
@ -17,20 +17,22 @@ import { ISharedLinkRepository } from './shared-link.repository';
export class ShareService {
readonly logger = new Logger(ShareService.name);
private shareCore: ShareCore;
private userCore: UserCore;
constructor(
@Inject(ISharedLinkRepository)
sharedLinkRepository: ISharedLinkRepository,
private userService: UserService,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
@Inject(IUserRepository) userRepository: IUserRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository);
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
this.userCore = new UserCore(userRepository);
}
async validate(key: string): Promise<AuthUserDto> {
const link = await this.shareCore.getSharedLinkByKey(key);
const link = await this.shareCore.getByKey(key);
if (link) {
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
const user = await this.userService.getUserById(link.userId).catch(() => null);
const user = await this.userCore.get(link.userId);
if (user) {
return {
id: user.id,
@ -49,7 +51,7 @@ export class ShareService {
}
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
const links = await this.shareCore.getSharedLinks(authUser.id);
const links = await this.shareCore.getAll(authUser.id);
return links.map(mapSharedLink);
}
@ -63,11 +65,11 @@ export class ShareService {
allowExif = authUser.isShowExif;
}
return this.getById(authUser.sharedLinkId, allowExif);
return this.getById(authUser, authUser.sharedLinkId, allowExif);
}
async getById(id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.getSharedLinkById(id);
async getById(authUser: AuthUserDto, id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.get(authUser.id, id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
@ -79,21 +81,20 @@ export class ShareService {
}
}
async remove(id: string, userId: string): Promise<string> {
await this.shareCore.removeSharedLink(id, userId);
return id;
}
async getByKey(key: string): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.getSharedLinkByKey(key);
const link = await this.shareCore.getByKey(key);
if (!link) {
throw new BadRequestException('Shared link not found');
}
return mapSharedLink(link);
}
async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) {
const link = await this.shareCore.updateSharedLink(id, authUser.id, dto);
async remove(authUser: AuthUserDto, id: string): Promise<void> {
await this.shareCore.remove(authUser.id, id);
}
async edit(authUser: AuthUserDto, id: string, dto: EditSharedLinkDto) {
const link = await this.shareCore.save(authUser.id, id, dto);
return mapSharedLink(link);
}
}

View File

@ -0,0 +1,13 @@
import { SharedLinkEntity } from '@app/infra/db/entities';
export const ISharedLinkRepository = 'ISharedLinkRepository';
export interface ISharedLinkRepository {
getAll(userId: string): Promise<SharedLinkEntity[]>;
get(userId: string, id: string): Promise<SharedLinkEntity | null>;
getByKey(key: string): Promise<SharedLinkEntity | null>;
create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity>;
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
hasAssetAccess(id: string, assetId: string): Promise<boolean>;
}

View File

@ -0,0 +1 @@
export * from './response-dto';

View File

@ -0,0 +1 @@
export * from './tag-response.dto';

View File

@ -1,4 +1,4 @@
import { TagEntity, TagType } from '@app/infra';
import { TagEntity, TagType } from '@app/infra/db/entities';
import { ApiProperty } from '@nestjs/swagger';
export class TagResponseDto {

View File

@ -1,5 +1,71 @@
import { SystemConfig, UserEntity } from '@app/infra/db/entities';
import { AuthUserDto } from '../src';
import { AssetType, SharedLinkEntity, SharedLinkType, SystemConfig, UserEntity } from '@app/infra/db/entities';
import { AlbumResponseDto, AssetResponseDto, AuthUserDto, ExifResponseDto, SharedLinkResponseDto } from '../src';
const today = new Date();
const tomorrow = new Date();
const yesterday = new Date();
tomorrow.setDate(today.getDate() + 1);
yesterday.setDate(yesterday.getDate() - 1);
const assetInfo: ExifResponseDto = {
id: 1,
make: 'camera-make',
model: 'camera-model',
imageName: 'fancy-image',
exifImageWidth: 500,
exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: 100,
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
};
const assetResponse: AssetResponseDto = {
id: 'id_1',
deviceAssetId: 'device_asset_id_1',
ownerId: 'user_id_1',
deviceId: 'device_id_1',
type: AssetType.VIDEO,
originalPath: 'fake_path/jpeg',
resizePath: '',
createdAt: today.toISOString(),
modifiedAt: today.toISOString(),
isFavorite: false,
mimeType: 'image/jpeg',
smartInfo: {
id: 'should-be-a-number',
tags: [],
objects: ['a', 'b', 'c'],
},
webpPath: '',
encodedVideoPath: '',
duration: '0:00:00.00000',
exifInfo: assetInfo,
livePhotoVideoId: null,
tags: [],
};
const albumResponse: AlbumResponseDto = {
albumName: 'Test Album',
albumThumbnailAssetId: null,
createdAt: today.toISOString(),
id: 'album-123',
ownerId: 'admin_id',
sharedUsers: [],
shared: false,
assets: [],
assetCount: 1,
};
export const authStub = {
admin: Object.freeze<AuthUserDto>({
@ -16,6 +82,26 @@ export const authStub = {
isPublicUser: false,
isAllowUpload: true,
}),
adminSharedLink: Object.freeze<AuthUserDto>({
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
isAllowUpload: true,
isAllowDownload: true,
isPublicUser: true,
isShowExif: true,
sharedLinkId: '123',
}),
readonlySharedLink: Object.freeze<AuthUserDto>({
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
isAllowUpload: false,
isAllowDownload: false,
isPublicUser: true,
isShowExif: true,
sharedLinkId: '123',
}),
};
export const entityStub = {
@ -165,3 +251,175 @@ export const loginResponseStub = {
],
},
};
export const sharedLinkStub = {
valid: Object.freeze({
id: '123',
userId: authStub.admin.id,
key: Buffer.from('secret-key', 'utf8'),
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: tomorrow.toISOString(),
allowUpload: true,
allowDownload: true,
showExif: true,
album: undefined,
assets: [],
} as SharedLinkEntity),
expired: Object.freeze({
id: '123',
userId: authStub.admin.id,
key: Buffer.from('secret-key', 'utf8'),
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: yesterday.toISOString(),
allowUpload: true,
allowDownload: true,
showExif: true,
assets: [],
} as SharedLinkEntity),
readonly: Object.freeze<SharedLinkEntity>({
id: '123',
userId: authStub.admin.id,
key: Buffer.from('secret-key', 'utf8'),
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: tomorrow.toISOString(),
allowUpload: false,
allowDownload: false,
showExif: true,
assets: [],
album: {
id: 'album-123',
ownerId: authStub.admin.id,
albumName: 'Test Album',
createdAt: today.toISOString(),
albumThumbnailAssetId: null,
sharedUsers: [],
sharedLinks: [],
assets: [
{
id: 'album-asset-123',
albumId: 'album-123',
assetId: 'asset-123',
albumInfo: {} as any,
assetInfo: {
id: 'id_1',
userId: 'user_id_1',
deviceAssetId: 'device_asset_id_1',
deviceId: 'device_id_1',
type: AssetType.VIDEO,
originalPath: 'fake_path/jpeg',
resizePath: '',
createdAt: today.toISOString(),
modifiedAt: today.toISOString(),
isFavorite: false,
mimeType: 'image/jpeg',
smartInfo: {
id: 'should-be-a-number',
assetId: 'id_1',
tags: [],
objects: ['a', 'b', 'c'],
asset: null as any,
},
webpPath: '',
encodedVideoPath: '',
duration: null,
isVisible: true,
livePhotoVideoId: null,
exifInfo: {
id: 1,
assetId: 'id_1',
description: 'description',
exifImageWidth: 500,
exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
make: 'camera-make',
model: 'camera-model',
imageName: 'fancy-image',
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: 100,
fps: 100,
asset: null as any,
exifTextSearchableColumn: '',
},
tags: [],
sharedLinks: [],
},
},
],
},
}),
};
export const sharedLinkResponseStub = {
valid: Object.freeze<SharedLinkResponseDto>({
allowDownload: true,
allowUpload: true,
assets: [],
createdAt: today.toISOString(),
description: undefined,
expiresAt: tomorrow.toISOString(),
id: '123',
key: '7365637265742d6b6579',
showExif: true,
type: SharedLinkType.ALBUM,
userId: 'admin_id',
}),
expired: Object.freeze<SharedLinkResponseDto>({
album: undefined,
allowDownload: true,
allowUpload: true,
assets: [],
createdAt: today.toISOString(),
description: undefined,
expiresAt: yesterday.toISOString(),
id: '123',
key: '7365637265742d6b6579',
showExif: true,
type: SharedLinkType.ALBUM,
userId: 'admin_id',
}),
readonly: Object.freeze<SharedLinkResponseDto>({
id: '123',
userId: 'admin_id',
key: '7365637265742d6b6579',
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: tomorrow.toISOString(),
description: undefined,
allowUpload: false,
allowDownload: false,
showExif: true,
album: albumResponse,
assets: [assetResponse],
}),
readonlyNoExif: Object.freeze<SharedLinkResponseDto>({
id: '123',
userId: 'admin_id',
key: '7365637265742d6b6579',
type: SharedLinkType.ALBUM,
createdAt: today.toISOString(),
expiresAt: tomorrow.toISOString(),
description: undefined,
allowUpload: false,
allowDownload: false,
showExif: true,
album: albumResponse,
assets: [{ ...assetResponse, exifInfo: undefined }],
}),
};
// TODO - the constructor isn't used anywhere, so not test coverage
new ExifResponseDto();

View File

@ -2,5 +2,6 @@ export * from './api-key.repository.mock';
export * from './crypto.repository.mock';
export * from './fixtures';
export * from './job.repository.mock';
export * from './shared-link.repository.mock';
export * from './system-config.repository.mock';
export * from './user.repository.mock';

View File

@ -0,0 +1,13 @@
import { ISharedLinkRepository } from '../src';
export const newSharedLinkRepositoryMock = (): jest.Mocked<ISharedLinkRepository> => {
return {
getAll: jest.fn(),
get: jest.fn(),
getByKey: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
hasAssetAccess: jest.fn(),
};
};

View File

@ -7,7 +7,7 @@ import { AssetEntity } from './asset.entity';
@Entity('exif')
export class ExifEntity {
@PrimaryGeneratedColumn()
id!: string;
id!: number;
@Index({ unique: true })
@Column({ type: 'uuid' })

View File

@ -1,2 +1,3 @@
export * from './api-key.repository';
export * from './shared-link.repository';
export * from './user.repository';

View File

@ -0,0 +1,119 @@
import { ISharedLinkRepository } from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SharedLinkEntity } from '../entities';
@Injectable()
export class SharedLinkRepository implements ISharedLinkRepository {
readonly logger = new Logger(SharedLinkRepository.name);
constructor(
@InjectRepository(SharedLinkEntity)
private readonly repository: Repository<SharedLinkEntity>,
) {}
get(userId: string, id: string): Promise<SharedLinkEntity | null> {
return this.repository.findOne({
where: {
id,
userId,
},
relations: {
assets: {
exifInfo: true,
},
album: {
assets: {
assetInfo: {
exifInfo: true,
},
},
},
},
order: {
createdAt: 'DESC',
assets: {
createdAt: 'ASC',
},
album: {
assets: {
assetInfo: {
createdAt: 'ASC',
},
},
},
},
});
}
getAll(userId: string): Promise<SharedLinkEntity[]> {
return this.repository.find({
where: {
userId,
},
relations: {
assets: true,
album: true,
},
order: {
createdAt: 'DESC',
},
});
}
async getByKey(key: string): Promise<SharedLinkEntity | null> {
return await this.repository.findOne({
where: {
key: Buffer.from(key, 'hex'),
},
relations: {
assets: true,
album: {
assets: {
assetInfo: true,
},
},
},
order: {
createdAt: 'DESC',
},
});
}
create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity> {
return this.repository.save(entity);
}
remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
return this.repository.remove(entity);
}
async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
await this.repository.save(entity);
return this.repository.findOneOrFail({ where: { id: entity.id } });
}
async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
const count1 = await this.repository.count({
where: {
id,
assets: {
id: assetId,
},
},
});
const count2 = await this.repository.count({
where: {
id,
album: {
assets: {
assetId,
},
},
},
});
return Boolean(count1 + count2);
}
}

View File

@ -2,6 +2,7 @@ import {
ICryptoRepository,
IJobRepository,
IKeyRepository,
ISharedLinkRepository,
ISystemConfigRepository,
IUserRepository,
QueueName,
@ -11,10 +12,10 @@ 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 { APIKeyEntity, SystemConfigEntity, UserRepository } from './db';
import { APIKeyRepository } from './db/repository';
import { SystemConfigRepository } from './db/repository/system-config.repository';
import { JobRepository } from './job';
@ -22,6 +23,7 @@ const providers: Provider[] = [
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISystemConfigRepository, useClass: SystemConfigRepository },
{ provide: IUserRepository, useClass: UserRepository },
];
@ -31,7 +33,7 @@ const providers: Provider[] = [
imports: [
JwtModule.register(jwtConfig),
TypeOrmModule.forRoot(databaseConfig),
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SystemConfigEntity]),
TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity]),
BullModule.forRootAsync({
useFactory: async () => ({
prefix: 'immich_bull',

View File

@ -658,7 +658,7 @@ export interface CreateAlbumShareLinkDto {
* @type {string}
* @memberof CreateAlbumShareLinkDto
*/
'expiredAt'?: string;
'expiresAt'?: string;
/**
*
* @type {boolean}
@ -701,7 +701,7 @@ export interface CreateAssetsShareLinkDto {
* @type {string}
* @memberof CreateAssetsShareLinkDto
*/
'expiredAt'?: string;
'expiresAt'?: string;
/**
*
* @type {boolean}
@ -1004,7 +1004,7 @@ export interface EditSharedLinkDto {
* @type {string}
* @memberof EditSharedLinkDto
*/
'expiredAt'?: string;
'expiresAt'?: string | null;
/**
*
* @type {boolean}
@ -1023,12 +1023,6 @@ export interface EditSharedLinkDto {
* @memberof EditSharedLinkDto
*/
'showExif'?: boolean;
/**
*
* @type {boolean}
* @memberof EditSharedLinkDto
*/
'isEditExpireTime'?: boolean;
}
/**
*
@ -6745,7 +6739,7 @@ export const ShareApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async removeSharedLink(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>> {
async removeSharedLink(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.removeSharedLink(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
@ -6800,7 +6794,7 @@ export const ShareApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
removeSharedLink(id: string, options?: any): AxiosPromise<string> {
removeSharedLink(id: string, options?: any): AxiosPromise<void> {
return localVarFp.removeSharedLink(id, options).then((request) => request(axios, basePath));
},
};

View File

@ -60,7 +60,7 @@
if (shareType === SharedLinkType.Album && album) {
const { data } = await api.albumApi.createAlbumSharedLink({
albumId: album.id,
expiredAt: expirationDate,
expiresAt: expirationDate,
allowUpload: isAllowUpload,
description: description,
allowDownload: isAllowDownload,
@ -70,7 +70,7 @@
} else {
const { data } = await api.assetApi.createAssetsSharedLink({
assetIds: sharedAssets.map((a) => a.id),
expiredAt: expirationDate,
expiresAt: expirationDate,
allowUpload: isAllowUpload,
description: description,
allowDownload: isAllowDownload,
@ -128,19 +128,14 @@
try {
const expirationTime = getExpirationTimeInMillisecond();
const currentTime = new Date().getTime();
let expirationDate = expirationTime
const expirationDate: string | null = expirationTime
? new Date(currentTime + expirationTime).toISOString()
: undefined;
if (expirationTime === 0) {
expirationDate = undefined;
}
: null;
await api.shareApi.editSharedLink(editingLink.id, {
description: description,
expiredAt: expirationDate,
description,
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
allowUpload: isAllowUpload,
isEditExpireTime: shouldChangeExpirationTime,
allowDownload: isAllowDownload,
showExif: shouldShowExif
});