mirror of
https://github.com/immich-app/immich.git
synced 2026-01-31 01:04:49 -08:00
Compare commits
7 Commits
feat/share
...
fix/create
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ebba759d3 | ||
|
|
855817514c | ||
|
|
d5ad35ea52 | ||
|
|
e63213d774 | ||
|
|
0be1ffade6 | ||
|
|
1a04caee29 | ||
|
|
3ace578fc0 |
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.10.7"
|
||||
opentofu = "1.11.4"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -239,7 +239,7 @@ describe('/shared-links', () => {
|
||||
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithPassword.key });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorDto.passwordRequired);
|
||||
expect(body).toEqual(errorDto.invalidSharePassword);
|
||||
});
|
||||
|
||||
it('should get data for correct password protected link', async () => {
|
||||
|
||||
@@ -43,10 +43,10 @@ export const errorDto = {
|
||||
message: 'Invalid share key',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
passwordRequired: {
|
||||
invalidSharePassword: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Password required',
|
||||
message: 'Invalid password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
|
||||
@@ -18,8 +18,8 @@ node = "24.13.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.28.0"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.10.7"
|
||||
java = "25.0.1"
|
||||
opentofu = "1.11.4"
|
||||
java = "21.0.2"
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.30.0"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"flutter": "3.35.7"
|
||||
}
|
||||
5
mobile/.gitignore
vendored
5
mobile/.gitignore
vendored
@@ -55,8 +55,5 @@ default.isar
|
||||
default.isar.lock
|
||||
libisar.so
|
||||
|
||||
# FVM Version
|
||||
.fvm/
|
||||
|
||||
# Translation file
|
||||
lib/generated/
|
||||
lib/generated/
|
||||
|
||||
4
mobile/.vscode/settings.json
vendored
4
mobile/.vscode/settings.json
vendored
@@ -2,7 +2,9 @@
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.35.7",
|
||||
"dart.lineLength": 120,
|
||||
"[dart]": {
|
||||
"editor.rulers": [120]
|
||||
"editor.rulers": [
|
||||
120
|
||||
]
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/.fvm": true
|
||||
|
||||
@@ -4,10 +4,12 @@ The Immich mobile app is a Flutter-based solution leveraging the Isar Database f
|
||||
|
||||
## Setup
|
||||
|
||||
1. Setup Flutter toolchain using FVM.
|
||||
2. Run `flutter pub get` to install the dependencies.
|
||||
3. Run `make translation` to generate the translation file.
|
||||
4. Run `fvm flutter run` to start the app.
|
||||
1. [Install mise](https://mise.jdx.dev/installing-mise.html).
|
||||
2. Change to the immich directory and trust the mise config with `mise trust`.
|
||||
3. Install tools with mise: `mise install`.
|
||||
4. Run `flutter pub get` to install the dependencies.
|
||||
5. Run `make translation` to generate the translation file.
|
||||
6. Run `flutter run` to start the app.
|
||||
|
||||
## Translation
|
||||
|
||||
@@ -29,7 +31,7 @@ dcm analyze lib
|
||||
```
|
||||
|
||||
[DCM](https://dcm.dev/) is a vendor tool that needs to be downloaded manually to run locally.
|
||||
Immich was provided an open source license.
|
||||
Immich was provided an open source license.
|
||||
To use it, it is important that you do not have an active free tier license (can be verified with `dcm license`).
|
||||
If you have write-access to the Immich repository directly, running dcm in your clone should just work.
|
||||
If you are working on a clone of a fork, you need to connect to the main Immich repository as remote first:
|
||||
|
||||
@@ -20,7 +20,7 @@ enum VersionStatus {
|
||||
|
||||
class ServerInfo {
|
||||
final ServerVersion serverVersion;
|
||||
final ServerVersion latestVersion;
|
||||
final ServerVersion? latestVersion;
|
||||
final ServerFeatures serverFeatures;
|
||||
final ServerConfig serverConfig;
|
||||
final ServerDiskInfo serverDiskInfo;
|
||||
|
||||
@@ -15,7 +15,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
: super(
|
||||
const ServerInfo(
|
||||
serverVersion: ServerVersion(major: 0, minor: 0, patch: 0),
|
||||
latestVersion: ServerVersion(major: 0, minor: 0, patch: 0),
|
||||
latestVersion: null,
|
||||
serverFeatures: ServerFeatures(map: true, trash: true, oauthEnabled: false, passwordLogin: true),
|
||||
serverConfig: ServerConfig(
|
||||
trashDays: 30,
|
||||
@@ -43,7 +43,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
try {
|
||||
final serverVersion = await _serverInfoService.getServerVersion();
|
||||
|
||||
// using isClientOutOfDate since that will show to users reguardless of if they are an admin
|
||||
// using isClientOutOfDate since that will show to users regardless of if they are an admin
|
||||
if (serverVersion == null) {
|
||||
state = state.copyWith(versionStatus: VersionStatus.error);
|
||||
return;
|
||||
@@ -76,7 +76,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
|
||||
state = state.copyWith(versionStatus: VersionStatus.upToDate);
|
||||
}
|
||||
|
||||
handleReleaseInfo(ServerVersion serverVersion, ServerVersion latestVersion) {
|
||||
handleReleaseInfo(ServerVersion serverVersion, ServerVersion? latestVersion) {
|
||||
// Update local server version
|
||||
_checkServerVersionMismatch(serverVersion, latestVersion: latestVersion);
|
||||
}
|
||||
|
||||
@@ -170,50 +170,52 @@ class AppBarServerInfo extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 5.0),
|
||||
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12),
|
||||
if (serverInfoState.latestVersion != null) ...[
|
||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 10), child: Divider(thickness: 1)),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (serverInfoState.versionStatus == VersionStatus.serverOutOfDate)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 5.0),
|
||||
child: Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: 12),
|
||||
),
|
||||
Text(
|
||||
"latest_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"latest_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
serverInfoState.latestVersion.major > 0
|
||||
? "${serverInfoState.latestVersion.major}.${serverInfoState.latestVersion.minor}.${serverInfoState.latestVersion.patch}"
|
||||
: "--",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
serverInfoState.latestVersion!.major > 0
|
||||
? "${serverInfoState.latestVersion!.major}.${serverInfoState.latestVersion!.minor}.${serverInfoState.latestVersion!.patch}"
|
||||
: "--",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -414,6 +414,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
keyboardAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.url,
|
||||
autofillHints: const [AutofillHints.url],
|
||||
autoCorrect: false,
|
||||
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
|
||||
),
|
||||
),
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -256,7 +256,6 @@ Class | Method | HTTP request | Description
|
||||
*SharedLinksApi* | [**getSharedLinkById**](doc//SharedLinksApi.md#getsharedlinkbyid) | **GET** /shared-links/{id} | Retrieve a shared link
|
||||
*SharedLinksApi* | [**removeSharedLink**](doc//SharedLinksApi.md#removesharedlink) | **DELETE** /shared-links/{id} | Delete a shared link
|
||||
*SharedLinksApi* | [**removeSharedLinkAssets**](doc//SharedLinksApi.md#removesharedlinkassets) | **DELETE** /shared-links/{id}/assets | Remove assets from a shared link
|
||||
*SharedLinksApi* | [**sharedLinkLogin**](doc//SharedLinksApi.md#sharedlinklogin) | **POST** /shared-links/login | Shared link login
|
||||
*SharedLinksApi* | [**updateSharedLink**](doc//SharedLinksApi.md#updatesharedlink) | **PATCH** /shared-links/{id} | Update a shared link
|
||||
*StacksApi* | [**createStack**](doc//StacksApi.md#createstack) | **POST** /stacks | Create a stack
|
||||
*StacksApi* | [**deleteStack**](doc//StacksApi.md#deletestack) | **DELETE** /stacks/{id} | Delete a stack
|
||||
@@ -553,7 +552,6 @@ Class | Method | HTTP request | Description
|
||||
- [SetMaintenanceModeDto](doc//SetMaintenanceModeDto.md)
|
||||
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
|
||||
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
|
||||
- [SharedLinkLoginDto](doc//SharedLinkLoginDto.md)
|
||||
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
|
||||
- [SharedLinkType](doc//SharedLinkType.md)
|
||||
- [SharedLinksResponse](doc//SharedLinksResponse.md)
|
||||
|
||||
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
@@ -292,7 +292,6 @@ part 'model/session_update_dto.dart';
|
||||
part 'model/set_maintenance_mode_dto.dart';
|
||||
part 'model/shared_link_create_dto.dart';
|
||||
part 'model/shared_link_edit_dto.dart';
|
||||
part 'model/shared_link_login_dto.dart';
|
||||
part 'model/shared_link_response_dto.dart';
|
||||
part 'model/shared_link_type.dart';
|
||||
part 'model/shared_links_response.dart';
|
||||
|
||||
71
mobile/openapi/lib/api/shared_links_api.dart
generated
71
mobile/openapi/lib/api/shared_links_api.dart
generated
@@ -495,77 +495,6 @@ class SharedLinksApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Shared link login
|
||||
///
|
||||
/// Login to a password protected shared link
|
||||
///
|
||||
/// Note: This method returns the HTTP [Response].
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [SharedLinkLoginDto] sharedLinkLoginDto (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> sharedLinkLoginWithHttpInfo(SharedLinkLoginDto sharedLinkLoginDto, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/shared-links/login';
|
||||
|
||||
// ignore: prefer_final_locals
|
||||
Object? postBody = sharedLinkLoginDto;
|
||||
|
||||
final queryParams = <QueryParam>[];
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
if (slug != null) {
|
||||
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||
}
|
||||
|
||||
const contentTypes = <String>['application/json'];
|
||||
|
||||
|
||||
return apiClient.invokeAPI(
|
||||
apiPath,
|
||||
'POST',
|
||||
queryParams,
|
||||
postBody,
|
||||
headerParams,
|
||||
formParams,
|
||||
contentTypes.isEmpty ? null : contentTypes.first,
|
||||
);
|
||||
}
|
||||
|
||||
/// Shared link login
|
||||
///
|
||||
/// Login to a password protected shared link
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [SharedLinkLoginDto] sharedLinkLoginDto (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<SharedLinkResponseDto?> sharedLinkLogin(SharedLinkLoginDto sharedLinkLoginDto, { String? key, String? slug, }) async {
|
||||
final response = await sharedLinkLoginWithHttpInfo(sharedLinkLoginDto, key: key, slug: slug, );
|
||||
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), 'SharedLinkResponseDto',) as SharedLinkResponseDto;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Update a shared link
|
||||
///
|
||||
/// Update an existing shared link by its ID.
|
||||
|
||||
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
@@ -630,8 +630,6 @@ class ApiClient {
|
||||
return SharedLinkCreateDto.fromJson(value);
|
||||
case 'SharedLinkEditDto':
|
||||
return SharedLinkEditDto.fromJson(value);
|
||||
case 'SharedLinkLoginDto':
|
||||
return SharedLinkLoginDto.fromJson(value);
|
||||
case 'SharedLinkResponseDto':
|
||||
return SharedLinkResponseDto.fromJson(value);
|
||||
case 'SharedLinkType':
|
||||
|
||||
100
mobile/openapi/lib/model/shared_link_login_dto.dart
generated
100
mobile/openapi/lib/model/shared_link_login_dto.dart
generated
@@ -1,100 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SharedLinkLoginDto {
|
||||
/// Returns a new [SharedLinkLoginDto] instance.
|
||||
SharedLinkLoginDto({
|
||||
required this.password,
|
||||
});
|
||||
|
||||
/// Shared link password
|
||||
String password;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SharedLinkLoginDto &&
|
||||
other.password == password;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(password.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SharedLinkLoginDto[password=$password]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'password'] = this.password;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SharedLinkLoginDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SharedLinkLoginDto? fromJson(dynamic value) {
|
||||
upgradeDto(value, "SharedLinkLoginDto");
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SharedLinkLoginDto(
|
||||
password: mapValueOfType<String>(json, r'password')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SharedLinkLoginDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SharedLinkLoginDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SharedLinkLoginDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SharedLinkLoginDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SharedLinkLoginDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SharedLinkLoginDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SharedLinkLoginDto-objects as value to a dart map
|
||||
static Map<String, List<SharedLinkLoginDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SharedLinkLoginDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SharedLinkLoginDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'password',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class ImmichTextInput extends StatefulWidget {
|
||||
final List<String>? autofillHints;
|
||||
final Widget? suffixIcon;
|
||||
final bool obscureText;
|
||||
final bool autoCorrect;
|
||||
|
||||
const ImmichTextInput({
|
||||
super.key,
|
||||
@@ -26,6 +27,7 @@ class ImmichTextInput extends StatefulWidget {
|
||||
this.autofillHints,
|
||||
this.suffixIcon,
|
||||
this.obscureText = false,
|
||||
this.autoCorrect = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -79,6 +81,7 @@ class _ImmichTextInputState extends State<ImmichTextInput> {
|
||||
validator: _validateInput,
|
||||
keyboardType: widget.keyboardType,
|
||||
textInputAction: widget.keyboardAction,
|
||||
autocorrect: widget.autoCorrect,
|
||||
autofillHints: widget.autofillHints,
|
||||
onTap: () => setState(() => _error = null),
|
||||
onTapOutside: (_) => _focusNode.unfocus(),
|
||||
|
||||
@@ -11224,78 +11224,6 @@
|
||||
"x-immich-state": "Stable"
|
||||
}
|
||||
},
|
||||
"/shared-links/login": {
|
||||
"post": {
|
||||
"description": "Login to a password protected shared link",
|
||||
"operationId": "sharedLinkLogin",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "slug",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SharedLinkLoginDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SharedLinkResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Shared link login",
|
||||
"tags": [
|
||||
"Shared links"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Beta"
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Beta"
|
||||
}
|
||||
},
|
||||
"/shared-links/me": {
|
||||
"get": {
|
||||
"description": "Retrieve the current shared link associated with authentication method.",
|
||||
@@ -21741,19 +21669,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SharedLinkLoginDto": {
|
||||
"properties": {
|
||||
"password": {
|
||||
"description": "Shared link password",
|
||||
"example": "password",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SharedLinkResponseDto": {
|
||||
"properties": {
|
||||
"album": {
|
||||
@@ -21812,25 +21727,9 @@
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"deprecated": true,
|
||||
"description": "Access token",
|
||||
"nullable": true,
|
||||
"type": "string",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2",
|
||||
"state": "Stable"
|
||||
},
|
||||
{
|
||||
"version": "v2.6.0",
|
||||
"state": "Deprecated"
|
||||
}
|
||||
],
|
||||
"x-immich-state": "Deprecated"
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"allOf": [
|
||||
|
||||
@@ -2285,10 +2285,6 @@ export type SharedLinkCreateDto = {
|
||||
/** Shared link type */
|
||||
"type": SharedLinkType;
|
||||
};
|
||||
export type SharedLinkLoginDto = {
|
||||
/** Shared link password */
|
||||
password: string;
|
||||
};
|
||||
export type SharedLinkEditDto = {
|
||||
/** Allow downloads */
|
||||
allowDownload?: boolean;
|
||||
@@ -5863,26 +5859,6 @@ export function createSharedLink({ sharedLinkCreateDto }: {
|
||||
body: sharedLinkCreateDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Shared link login
|
||||
*/
|
||||
export function sharedLinkLogin({ key, slug, sharedLinkLoginDto }: {
|
||||
key?: string;
|
||||
slug?: string;
|
||||
sharedLinkLoginDto: SharedLinkLoginDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 201;
|
||||
data: SharedLinkResponseDto;
|
||||
}>(`/shared-links/login${QS.query(QS.explode({
|
||||
key,
|
||||
slug
|
||||
}))}`, oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: sharedLinkLoginDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Retrieve current shared link
|
||||
*/
|
||||
|
||||
@@ -22,39 +22,21 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkEditDto,
|
||||
SharedLinkLoginDto,
|
||||
SharedLinkPasswordDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkSearchDto,
|
||||
} from 'src/dtos/shared-link.dto';
|
||||
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
const getAuthTokens = (cookies: Record<string, string> | undefined) => {
|
||||
return cookies?.[ImmichCookie.SharedLinkToken]?.split(',') || [];
|
||||
};
|
||||
|
||||
const merge = (cookies: Record<string, string> | undefined, token: string) => {
|
||||
const authTokens = getAuthTokens(cookies);
|
||||
if (!authTokens.includes(token)) {
|
||||
authTokens.push(token);
|
||||
}
|
||||
|
||||
return authTokens.join(',');
|
||||
};
|
||||
|
||||
@ApiTags(ApiTag.SharedLinks)
|
||||
@Controller('shared-links')
|
||||
export class SharedLinkController {
|
||||
constructor(
|
||||
private service: SharedLinkService,
|
||||
private logger: LoggingRepository,
|
||||
) {}
|
||||
constructor(private service: SharedLinkService) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.SharedLinkRead })
|
||||
@@ -67,28 +49,6 @@ export class SharedLinkController {
|
||||
return this.service.getAll(auth, dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Authenticated({ sharedLink: true })
|
||||
@Endpoint({
|
||||
summary: 'Shared link login',
|
||||
description: 'Login to a password protected shared link',
|
||||
history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'),
|
||||
})
|
||||
async sharedLinkLogin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: SharedLinkLoginDto,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<SharedLinkResponseDto> {
|
||||
const { sharedLink, token } = await this.service.login(auth, dto);
|
||||
|
||||
return respondWithCookie(res, sharedLink, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.SharedLinkToken, value: merge(req.cookies, token) }],
|
||||
});
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@Authenticated({ sharedLink: true })
|
||||
@Endpoint({
|
||||
@@ -99,19 +59,19 @@ export class SharedLinkController {
|
||||
async getMySharedLink(
|
||||
@Auth() auth: AuthDto,
|
||||
@Query() dto: SharedLinkPasswordDto,
|
||||
@Req() req: Request,
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<SharedLinkResponseDto> {
|
||||
if (dto.password) {
|
||||
this.logger.deprecate(
|
||||
'Passing shared link password via query parameters is deprecated and will be removed in the next major release. Please use POST /shared-links/login instead.',
|
||||
);
|
||||
|
||||
return this.sharedLinkLogin(auth, { password: dto.password }, req, res, loginDetails);
|
||||
const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken];
|
||||
if (sharedLinkToken) {
|
||||
dto.token = sharedLinkToken;
|
||||
}
|
||||
|
||||
return this.service.getMine(auth, getAuthTokens(req.cookies));
|
||||
const body = await this.service.getMine(auth, dto);
|
||||
return respondWithCookie(res, body, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [],
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
import { SharedLink } from 'src/database';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class SharedLinkSearchDto {
|
||||
@ValidateUUID({ optional: true, description: 'Filter by album ID' })
|
||||
@@ -94,11 +94,6 @@ export class SharedLinkEditDto {
|
||||
changeExpiryTime?: boolean;
|
||||
}
|
||||
|
||||
export class SharedLinkLoginDto {
|
||||
@ValidateString({ description: 'Shared link password', example: 'password' })
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class SharedLinkPasswordDto {
|
||||
@ApiPropertyOptional({ example: 'password', description: 'Link password' })
|
||||
@IsString()
|
||||
@@ -117,10 +112,7 @@ export class SharedLinkResponseDto {
|
||||
description!: string | null;
|
||||
@ApiProperty({ description: 'Has password' })
|
||||
password!: string | null;
|
||||
@Property({
|
||||
description: 'Access token',
|
||||
history: new HistoryBuilder().added('v1').stable('v2').deprecated('v2.6.0'),
|
||||
})
|
||||
@ApiPropertyOptional({ description: 'Access token' })
|
||||
token?: string | null;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
userId!: string;
|
||||
|
||||
@@ -44,6 +44,7 @@ import { getDimensions } from 'src/utils/asset.util';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { Point, transformPoints } from 'src/utils/transform';
|
||||
|
||||
@Injectable()
|
||||
export class PersonService extends BaseService {
|
||||
@@ -634,15 +635,50 @@ export class PersonService extends BaseService {
|
||||
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
|
||||
]);
|
||||
|
||||
const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true });
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
const edits = asset.edits || [];
|
||||
|
||||
let p1: Point = { x: dto.x, y: dto.y };
|
||||
let p2: Point = { x: dto.x + dto.width, y: dto.y + dto.height };
|
||||
|
||||
// the coordinates received from the client are based on the edited preview image
|
||||
// we need to convert them to the coordinate space of the original unedited image
|
||||
if (edits.length > 0) {
|
||||
if (!asset.width || !asset.height || !asset.exifInfo?.exifImageWidth || !asset.exifInfo?.exifImageHeight) {
|
||||
throw new BadRequestException('Asset does not have valid dimensions');
|
||||
}
|
||||
|
||||
// convert from preview to full dimensions
|
||||
const scaleFactor = asset.width / dto.imageWidth;
|
||||
p1 = { x: p1.x * scaleFactor, y: p1.y * scaleFactor };
|
||||
p2 = { x: p2.x * scaleFactor, y: p2.y * scaleFactor };
|
||||
|
||||
const {
|
||||
points: [invertedP1, invertedP2],
|
||||
} = transformPoints([p1, p2], edits, { width: asset.width, height: asset.height }, { inverse: true });
|
||||
|
||||
// make sure p1 is top-left and p2 is bottom-right
|
||||
p1 = { x: Math.min(invertedP1.x, invertedP2.x), y: Math.min(invertedP1.y, invertedP2.y) };
|
||||
p2 = { x: Math.max(invertedP1.x, invertedP2.x), y: Math.max(invertedP1.y, invertedP2.y) };
|
||||
|
||||
// now coordinates are in original image space
|
||||
dto.imageHeight = asset.exifInfo.exifImageHeight;
|
||||
dto.imageWidth = asset.exifInfo.exifImageWidth;
|
||||
}
|
||||
|
||||
await this.personRepository.createAssetFace({
|
||||
personId: dto.personId,
|
||||
assetId: dto.assetId,
|
||||
imageHeight: dto.imageHeight,
|
||||
imageWidth: dto.imageWidth,
|
||||
boundingBoxX1: dto.x,
|
||||
boundingBoxX2: dto.x + dto.width,
|
||||
boundingBoxY1: dto.y,
|
||||
boundingBoxY2: dto.y + dto.height,
|
||||
boundingBoxX1: Math.round(p1.x),
|
||||
boundingBoxX2: Math.round(p2.x),
|
||||
boundingBoxY1: Math.round(p1.y),
|
||||
boundingBoxY2: Math.round(p2.y),
|
||||
sourceType: SourceType.Manual,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,14 +35,14 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
describe('getMine', () => {
|
||||
it('should only work for a public user', async () => {
|
||||
await expect(sut.getMine(authStub.admin, [])).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(mocks.sharedLink.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the shared link for the public user', async () => {
|
||||
const authDto = authStub.adminSharedLink;
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||
});
|
||||
|
||||
@@ -55,22 +55,21 @@ describe(SharedLinkService.name, () => {
|
||||
},
|
||||
});
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
|
||||
const response = await sut.getMine(authDto, []);
|
||||
const response = await sut.getMine(authDto, {});
|
||||
expect(response.assets[0]).toMatchObject({ hasMetadata: false });
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||
});
|
||||
|
||||
it('should throw an error for a request without a shared link auth token', async () => {
|
||||
it('should throw an error for an invalid password protected shared link', async () => {
|
||||
const authDto = authStub.adminSharedLink;
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired);
|
||||
await expect(sut.getMine(authDto, [])).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||
});
|
||||
|
||||
it('should accept a valid shared link auth token', async () => {
|
||||
it('should allow a correct password on a password protected shared link', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
|
||||
mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token');
|
||||
await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined();
|
||||
await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined();
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(
|
||||
authStub.adminSharedLink.user.id,
|
||||
authStub.adminSharedLink.sharedLink?.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PostgresError } from 'postgres';
|
||||
import { SharedLink } from 'src/database';
|
||||
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
mapSharedLink,
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkEditDto,
|
||||
SharedLinkLoginDto,
|
||||
SharedLinkPasswordDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkSearchDto,
|
||||
} from 'src/dtos/shared-link.dto';
|
||||
@@ -23,41 +24,18 @@ export class SharedLinkService extends BaseService {
|
||||
.then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
|
||||
}
|
||||
|
||||
async login(auth: AuthDto, dto: SharedLinkLoginDto) {
|
||||
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
|
||||
if (!auth.sharedLink) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
|
||||
const { id, password } = sharedLink;
|
||||
|
||||
if (!password) {
|
||||
throw new BadRequestException('Shared link is not password protected');
|
||||
const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
|
||||
if (sharedLink.password) {
|
||||
response.token = this.validateAndRefreshToken(sharedLink, dto);
|
||||
}
|
||||
|
||||
if (password !== dto.password) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
return {
|
||||
sharedLink: mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif }),
|
||||
token: this.asToken({ id, password }),
|
||||
};
|
||||
}
|
||||
|
||||
async getMine(auth: AuthDto, authTokens: string[]) {
|
||||
if (!auth.sharedLink) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
|
||||
const { id, password } = sharedLink;
|
||||
|
||||
if (password && !authTokens.includes(this.asToken({ id, password }))) {
|
||||
throw new UnauthorizedException('Password required');
|
||||
}
|
||||
|
||||
return mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
|
||||
return response;
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
|
||||
@@ -235,7 +213,16 @@ export class SharedLinkService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private asToken(sharedLink: { id: string; password: string }) {
|
||||
return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
|
||||
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
|
||||
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
|
||||
const sharedLinkTokens = dto.token?.split(',') || [];
|
||||
if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) {
|
||||
throw new UnauthorizedException('Invalid password');
|
||||
}
|
||||
|
||||
if (!sharedLinkTokens.includes(token)) {
|
||||
sharedLinkTokens.push(token);
|
||||
}
|
||||
return sharedLinkTokens.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ describe(VersionService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onWebsocketConnectionEvent', () => {
|
||||
describe('onWebsocketConnection', () => {
|
||||
it('should send on_server_version client event', async () => {
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
@@ -143,5 +143,12 @@ describe(VersionService.name, () => {
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
|
||||
});
|
||||
|
||||
it('should not send a release notification when the version check is disabled', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValueOnce({ newVersionCheck: { enabled: false } });
|
||||
await sut.onWebsocketConnection({ userId: '42' });
|
||||
expect(mocks.websocket.clientSend).toHaveBeenCalledWith('on_server_version', '42', expect.any(SemVer));
|
||||
expect(mocks.websocket.clientSend).not.toHaveBeenCalledWith('on_new_release', '42', expect.any(Object));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,6 +105,12 @@ export class VersionService extends BaseService {
|
||||
@OnEvent({ name: 'WebsocketConnect' })
|
||||
async onWebsocketConnection({ userId }: ArgOf<'WebsocketConnect'>) {
|
||||
this.websocketRepository.clientSend('on_server_version', userId, serverVersion);
|
||||
|
||||
const { newVersionCheck } = await this.getConfig({ withCache: true });
|
||||
if (!newVersionCheck.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
|
||||
if (metadata) {
|
||||
this.websocketRepository.clientSend('on_new_release', userId, asNotification(metadata));
|
||||
|
||||
@@ -61,7 +61,7 @@ export const createAffineMatrix = (
|
||||
);
|
||||
};
|
||||
|
||||
type Point = { x: number; y: number };
|
||||
export type Point = { x: number; y: number };
|
||||
|
||||
type TransformState = {
|
||||
points: Point[];
|
||||
@@ -73,29 +73,33 @@ type TransformState = {
|
||||
* Transforms an array of points through a series of edit operations (crop, rotate, mirror).
|
||||
* Points should be in absolute pixel coordinates relative to the starting dimensions.
|
||||
*/
|
||||
const transformPoints = (
|
||||
export const transformPoints = (
|
||||
points: Point[],
|
||||
edits: AssetEditActionItem[],
|
||||
startingDimensions: ImageDimensions,
|
||||
{ inverse = false } = {},
|
||||
): TransformState => {
|
||||
let currentWidth = startingDimensions.width;
|
||||
let currentHeight = startingDimensions.height;
|
||||
let transformedPoints = [...points];
|
||||
|
||||
// Handle crop first
|
||||
const crop = edits.find((edit) => edit.action === 'crop');
|
||||
if (crop) {
|
||||
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
|
||||
transformedPoints = transformedPoints.map((p) => ({
|
||||
x: p.x - cropX,
|
||||
y: p.y - cropY,
|
||||
}));
|
||||
currentWidth = cropWidth;
|
||||
currentHeight = cropHeight;
|
||||
// Handle crop first if not inverting
|
||||
if (!inverse) {
|
||||
const crop = edits.find((edit) => edit.action === 'crop');
|
||||
if (crop) {
|
||||
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
|
||||
transformedPoints = transformedPoints.map((p) => ({
|
||||
x: p.x - cropX,
|
||||
y: p.y - cropY,
|
||||
}));
|
||||
currentWidth = cropWidth;
|
||||
currentHeight = cropHeight;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rotate and mirror transforms
|
||||
for (const edit of edits) {
|
||||
const editSequence = inverse ? edits.toReversed() : edits;
|
||||
for (const edit of editSequence) {
|
||||
let matrix: Matrix = identity();
|
||||
if (edit.action === 'rotate') {
|
||||
const angleDegrees = edit.parameters.angle;
|
||||
@@ -105,7 +109,7 @@ const transformPoints = (
|
||||
|
||||
matrix = compose(
|
||||
translate(newWidth / 2, newHeight / 2),
|
||||
rotate(angleRadians),
|
||||
rotate(inverse ? -angleRadians : angleRadians),
|
||||
translate(-currentWidth / 2, -currentHeight / 2),
|
||||
);
|
||||
|
||||
@@ -125,6 +129,18 @@ const transformPoints = (
|
||||
transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p));
|
||||
}
|
||||
|
||||
// Handle crop last if inverting
|
||||
if (inverse) {
|
||||
const crop = edits.find((edit) => edit.action === 'crop');
|
||||
if (crop) {
|
||||
const { x: cropX, y: cropY } = crop.parameters;
|
||||
transformedPoints = transformedPoints.map((p) => ({
|
||||
x: p.x + cropX,
|
||||
y: p.y + cropY,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
points: transformedPoints,
|
||||
currentWidth,
|
||||
|
||||
@@ -90,7 +90,7 @@ describe(SharedLinkService.name, () => {
|
||||
assetIds: assets.map(({ asset }) => asset.id),
|
||||
});
|
||||
|
||||
await expect(sut.getMine({ user, sharedLink }, [])).resolves.toMatchObject({
|
||||
await expect(sut.getMine({ user, sharedLink }, {})).resolves.toMatchObject({
|
||||
assets: assets.map(({ asset }) => expect.objectContaining({ id: asset.id })),
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,7 @@ describe(SharedLinkService.name, () => {
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
|
||||
await expect(sut.getMine({ user, sharedLink }, [])).resolves.toMatchObject({
|
||||
await expect(sut.getMine({ user, sharedLink }, {})).resolves.toMatchObject({
|
||||
assets: [expect.objectContaining({ id: asset.id })],
|
||||
});
|
||||
|
||||
@@ -122,6 +122,6 @@ describe(SharedLinkService.name, () => {
|
||||
assetIds: [asset.id],
|
||||
});
|
||||
|
||||
await expect(sut.getMine({ user, sharedLink }, [])).resolves.toHaveProperty('assets', []);
|
||||
await expect(sut.getMine({ user, sharedLink }, {})).resolves.toHaveProperty('assets', []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,9 +194,7 @@
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
console.log(asset);
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
console.log(refreshedAsset);
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewingStore.setAsset(refreshedAsset);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => editManager.resetAllChanges()}
|
||||
disabled={!editManager.hasChanges}
|
||||
disabled={!editManager.canReset}
|
||||
class="self-start"
|
||||
shape="round"
|
||||
size="small"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { setSharedLink } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { sharedLinkLogin, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { getMySharedLink, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
|
||||
import { Button, Logo, PasswordInput } from '@immich/ui';
|
||||
import { tick } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -39,7 +39,7 @@
|
||||
|
||||
const handlePasswordSubmit = async () => {
|
||||
try {
|
||||
sharedLink = await sharedLinkLogin({ key, slug, sharedLinkLoginDto: { password } });
|
||||
sharedLink = await getMySharedLink({ password, key, slug });
|
||||
setSharedLink(sharedLink);
|
||||
passwordRequired = false;
|
||||
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface EditToolManager {
|
||||
onDeactivate: () => void;
|
||||
resetAllChanges: () => Promise<void>;
|
||||
hasChanges: boolean;
|
||||
canReset: boolean;
|
||||
edits: EditAction[];
|
||||
}
|
||||
|
||||
@@ -41,19 +42,22 @@ export class EditManager {
|
||||
|
||||
currentAsset = $state<AssetResponseDto | null>(null);
|
||||
selectedTool = $state<EditTool | null>(null);
|
||||
hasChanges = $derived(this.tools.some((t) => t.manager.hasChanges));
|
||||
|
||||
// used to disable multiple confirm dialogs and mouse events while one is open
|
||||
isShowingConfirmDialog = $state(false);
|
||||
isApplyingEdits = $state(false);
|
||||
hasAppliedEdits = $state(false);
|
||||
|
||||
hasUnsavedChanges = $derived(this.tools.some((t) => t.manager.hasChanges) && !this.hasAppliedEdits);
|
||||
canReset = $derived(this.tools.some((t) => t.manager.canReset));
|
||||
|
||||
async closeConfirm(): Promise<boolean> {
|
||||
// Prevent multiple dialogs (usually happens with rapid escape key presses)
|
||||
if (this.isShowingConfirmDialog) {
|
||||
return false;
|
||||
}
|
||||
if (!this.hasChanges || this.hasAppliedEdits) {
|
||||
|
||||
if (!this.hasUnsavedChanges) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ type RegionConvertParams = {
|
||||
};
|
||||
|
||||
class TransformManager implements EditToolManager {
|
||||
hasChanges: boolean = $derived.by(() => this.checkEdits());
|
||||
canReset: boolean = $derived.by(() => this.checkEdits());
|
||||
hasChanges: boolean = $state(false);
|
||||
|
||||
darkenLevel = $state(0.65);
|
||||
isInteracting = $state(false);
|
||||
@@ -56,7 +57,7 @@ class TransformManager implements EditToolManager {
|
||||
cropAspectRatio = $state('free');
|
||||
originalImageSize = $state<ImageDimensions>({ width: 1000, height: 1000 });
|
||||
region = $state({ x: 0, y: 0, width: 100, height: 100 });
|
||||
preveiwImgSize = $derived({
|
||||
previewImageSize = $derived({
|
||||
width: this.cropImageSize.width * this.cropImageScale,
|
||||
height: this.cropImageSize.height * this.cropImageScale,
|
||||
});
|
||||
@@ -73,6 +74,7 @@ class TransformManager implements EditToolManager {
|
||||
edits = $derived.by(() => this.getEdits());
|
||||
|
||||
setAspectRatio(aspectRatio: string) {
|
||||
this.hasChanges = true;
|
||||
this.cropAspectRatio = aspectRatio;
|
||||
|
||||
if (!this.imgElement || !this.cropAreaEl) {
|
||||
@@ -88,8 +90,8 @@ class TransformManager implements EditToolManager {
|
||||
|
||||
checkEdits() {
|
||||
return (
|
||||
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
|
||||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2 ||
|
||||
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
|
||||
Math.abs(this.previewImageSize.height - this.region.height) > 2 ||
|
||||
this.mirrorHorizontal ||
|
||||
this.mirrorVertical ||
|
||||
this.normalizedRotation !== 0
|
||||
@@ -98,8 +100,8 @@ class TransformManager implements EditToolManager {
|
||||
|
||||
checkCropEdits() {
|
||||
return (
|
||||
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
|
||||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2
|
||||
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
|
||||
Math.abs(this.previewImageSize.height - this.region.height) > 2
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,9 +234,12 @@ class TransformManager implements EditToolManager {
|
||||
this.originalImageSize = { width: 1000, height: 1000 };
|
||||
this.cropImageScale = 1;
|
||||
this.cropAspectRatio = 'free';
|
||||
this.hasChanges = false;
|
||||
}
|
||||
|
||||
mirror(axis: 'horizontal' | 'vertical') {
|
||||
this.hasChanges = true;
|
||||
|
||||
if (this.imageRotation % 180 !== 0) {
|
||||
axis = axis === 'horizontal' ? 'vertical' : 'horizontal';
|
||||
}
|
||||
@@ -247,6 +252,8 @@ class TransformManager implements EditToolManager {
|
||||
}
|
||||
|
||||
async rotate(angle: number) {
|
||||
this.hasChanges = true;
|
||||
|
||||
this.imageRotation += angle;
|
||||
await tick();
|
||||
this.onImageLoad();
|
||||
@@ -760,6 +767,7 @@ class TransformManager implements EditToolManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.hasChanges = true;
|
||||
const newX = Math.max(0, Math.min(mouseX - this.dragOffset.x, cropArea.clientWidth - this.region.width));
|
||||
const newY = Math.max(0, Math.min(mouseY - this.dragOffset.y, cropArea.clientHeight - this.region.height));
|
||||
|
||||
@@ -781,6 +789,7 @@ class TransformManager implements EditToolManager {
|
||||
}
|
||||
this.fadeOverlay(false);
|
||||
|
||||
this.hasChanges = true;
|
||||
const { x, y, width, height } = crop;
|
||||
const minSize = 50;
|
||||
let newRegion = { ...crop };
|
||||
|
||||
@@ -49,7 +49,7 @@ export const loadSharedLink = async ({
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isHttpError(error) && error.data.message === 'Password required') {
|
||||
if (isHttpError(error) && error.data.message === 'Invalid password') {
|
||||
return {
|
||||
...common,
|
||||
passwordRequired: true,
|
||||
|
||||
Reference in New Issue
Block a user