Compare commits

..

1 Commits

Author SHA1 Message Date
Jason Rasmussen
da98e2f8e2 feat: shared link login 2026-01-29 11:41:42 -05:00
35 changed files with 500 additions and 217 deletions

View File

@@ -1,6 +1,6 @@
[tools]
terragrunt = "0.98.0"
opentofu = "1.11.4"
opentofu = "1.10.7"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"

View File

@@ -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.invalidSharePassword);
expect(body).toEqual(errorDto.passwordRequired);
});
it('should get data for correct password protected link', async () => {

View File

@@ -43,10 +43,10 @@ export const errorDto = {
message: 'Invalid share key',
correlationId: expect.any(String),
},
invalidSharePassword: {
passwordRequired: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid password',
message: 'Password required',
correlationId: expect.any(String),
},
badRequest: (message: any = null) => ({

View File

@@ -18,8 +18,8 @@ node = "24.13.0"
flutter = "3.35.7"
pnpm = "10.28.0"
terragrunt = "0.98.0"
opentofu = "1.11.4"
java = "21.0.2"
opentofu = "1.10.7"
java = "25.0.1"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"

3
mobile/.fvmrc Normal file
View File

@@ -0,0 +1,3 @@
{
"flutter": "3.35.7"
}

5
mobile/.gitignore vendored
View File

@@ -55,5 +55,8 @@ default.isar
default.isar.lock
libisar.so
# FVM Version
.fvm/
# Translation file
lib/generated/
lib/generated/

View File

@@ -2,9 +2,7 @@
"dart.flutterSdkPath": ".fvm/versions/3.35.7",
"dart.lineLength": 120,
"[dart]": {
"editor.rulers": [
120
]
"editor.rulers": [120]
},
"search.exclude": {
"**/.fvm": true

View File

@@ -4,12 +4,10 @@ The Immich mobile app is a Flutter-based solution leveraging the Isar Database f
## Setup
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.
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.
## Translation
@@ -31,7 +29,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:

View File

@@ -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;

View File

@@ -15,7 +15,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfo> {
: super(
const ServerInfo(
serverVersion: ServerVersion(major: 0, minor: 0, patch: 0),
latestVersion: null,
latestVersion: ServerVersion(major: 0, minor: 0, patch: 0),
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 regardless of if they are an admin
// using isClientOutOfDate since that will show to users reguardless 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);
}

View File

@@ -170,52 +170,50 @@ class AppBarServerInfo extends HookConsumerWidget {
),
],
),
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,
),
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,
),
],
),
),
),
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,
),
),
),
],
),
],
),
],
),
],
),
),

View File

@@ -414,7 +414,6 @@ class LoginForm extends HookConsumerWidget {
keyboardAction: TextInputAction.next,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
autoCorrect: false,
onSubmit: (ctx, _) => ImmichForm.of(ctx).submit(),
),
),

View File

@@ -256,6 +256,7 @@ 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
@@ -552,6 +553,7 @@ 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)

View File

@@ -292,6 +292,7 @@ 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';

View File

@@ -495,6 +495,77 @@ 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.

View File

@@ -630,6 +630,8 @@ 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':

View File

@@ -0,0 +1,100 @@
//
// 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',
};
}

View File

@@ -12,7 +12,6 @@ class ImmichTextInput extends StatefulWidget {
final List<String>? autofillHints;
final Widget? suffixIcon;
final bool obscureText;
final bool autoCorrect;
const ImmichTextInput({
super.key,
@@ -27,7 +26,6 @@ class ImmichTextInput extends StatefulWidget {
this.autofillHints,
this.suffixIcon,
this.obscureText = false,
this.autoCorrect = true,
});
@override
@@ -81,7 +79,6 @@ 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(),

View File

@@ -11224,6 +11224,78 @@
"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.",
@@ -21669,6 +21741,19 @@
},
"type": "object"
},
"SharedLinkLoginDto": {
"properties": {
"password": {
"description": "Shared link password",
"example": "password",
"type": "string"
}
},
"required": [
"password"
],
"type": "object"
},
"SharedLinkResponseDto": {
"properties": {
"album": {
@@ -21727,9 +21812,25 @@
"type": "string"
},
"token": {
"deprecated": true,
"description": "Access token",
"nullable": true,
"type": "string"
"type": "string",
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v2",
"state": "Stable"
},
{
"version": "v2.6.0",
"state": "Deprecated"
}
],
"x-immich-state": "Deprecated"
},
"type": {
"allOf": [

View File

@@ -2285,6 +2285,10 @@ export type SharedLinkCreateDto = {
/** Shared link type */
"type": SharedLinkType;
};
export type SharedLinkLoginDto = {
/** Shared link password */
password: string;
};
export type SharedLinkEditDto = {
/** Allow downloads */
allowDownload?: boolean;
@@ -5859,6 +5863,26 @@ 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
*/

View File

@@ -22,21 +22,39 @@ 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) {}
constructor(
private service: SharedLinkService,
private logger: LoggingRepository,
) {}
@Get()
@Authenticated({ permission: Permission.SharedLinkRead })
@@ -49,6 +67,28 @@ 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({
@@ -59,19 +99,19 @@ export class SharedLinkController {
async getMySharedLink(
@Auth() auth: AuthDto,
@Query() dto: SharedLinkPasswordDto,
@Req() request: Request,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
@GetLoginDetails() loginDetails: LoginDetails,
): Promise<SharedLinkResponseDto> {
const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken];
if (sharedLinkToken) {
dto.token = sharedLinkToken;
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 body = await this.service.getMine(auth, dto);
return respondWithCookie(res, body, {
isSecure: loginDetails.isSecure,
values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [],
});
return this.service.getMine(auth, getAuthTokens(req.cookies));
}
@Get(':id')

View File

@@ -1,11 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString } from 'class-validator';
import { SharedLink } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { HistoryBuilder, Property } 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, ValidateUUID } from 'src/validation';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
export class SharedLinkSearchDto {
@ValidateUUID({ optional: true, description: 'Filter by album ID' })
@@ -94,6 +94,11 @@ 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()
@@ -112,7 +117,10 @@ export class SharedLinkResponseDto {
description!: string | null;
@ApiProperty({ description: 'Has password' })
password!: string | null;
@ApiPropertyOptional({ description: 'Access token' })
@Property({
description: 'Access token',
history: new HistoryBuilder().added('v1').stable('v2').deprecated('v2.6.0'),
})
token?: string | null;
@ApiProperty({ description: 'Owner user ID' })
userId!: string;

View File

@@ -44,7 +44,6 @@ 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 {
@@ -635,50 +634,15 @@ 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: Math.round(p1.x),
boundingBoxX2: Math.round(p2.x),
boundingBoxY1: Math.round(p1.y),
boundingBoxY2: Math.round(p2.y),
boundingBoxX1: dto.x,
boundingBoxX2: dto.x + dto.width,
boundingBoxY1: dto.y,
boundingBoxY2: dto.y + dto.height,
sourceType: SourceType.Manual,
});
}

View File

@@ -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,21 +55,22 @@ 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 an invalid password protected shared link', async () => {
it('should throw an error for a request without a shared link auth token', 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 allow a correct password on a password protected shared link', async () => {
it('should accept a valid shared link auth token', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
await expect(sut.getMine(authStub.adminSharedLink, { password: '123' })).resolves.toBeDefined();
mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token');
await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined();
expect(mocks.sharedLink.get).toHaveBeenCalledWith(
authStub.adminSharedLink.user.id,
authStub.adminSharedLink.sharedLink?.id,

View File

@@ -1,6 +1,5 @@
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';
@@ -8,7 +7,7 @@ import {
mapSharedLink,
SharedLinkCreateDto,
SharedLinkEditDto,
SharedLinkPasswordDto,
SharedLinkLoginDto,
SharedLinkResponseDto,
SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto';
@@ -24,18 +23,41 @@ export class SharedLinkService extends BaseService {
.then((links) => links.map((link) => mapSharedLink(link, { stripAssetMetadata: false })));
}
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
async login(auth: AuthDto, dto: SharedLinkLoginDto) {
if (!auth.sharedLink) {
throw new ForbiddenException();
}
const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
const response = mapSharedLink(sharedLink, { stripAssetMetadata: !sharedLink.showExif });
if (sharedLink.password) {
response.token = this.validateAndRefreshToken(sharedLink, dto);
const { id, password } = sharedLink;
if (!password) {
throw new BadRequestException('Shared link is not password protected');
}
return response;
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 });
}
async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
@@ -213,16 +235,7 @@ export class SharedLinkService extends BaseService {
};
}
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(',');
private asToken(sharedLink: { id: string; password: string }) {
return this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
}
}

View File

@@ -130,7 +130,7 @@ describe(VersionService.name, () => {
});
});
describe('onWebsocketConnection', () => {
describe('onWebsocketConnectionEvent', () => {
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,12 +143,5 @@ 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));
});
});
});

View File

@@ -105,12 +105,6 @@ 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));

View File

@@ -61,7 +61,7 @@ export const createAffineMatrix = (
);
};
export type Point = { x: number; y: number };
type Point = { x: number; y: number };
type TransformState = {
points: Point[];
@@ -73,33 +73,29 @@ 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.
*/
export const transformPoints = (
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 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;
}
// 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;
}
// Apply rotate and mirror transforms
const editSequence = inverse ? edits.toReversed() : edits;
for (const edit of editSequence) {
for (const edit of edits) {
let matrix: Matrix = identity();
if (edit.action === 'rotate') {
const angleDegrees = edit.parameters.angle;
@@ -109,7 +105,7 @@ export const transformPoints = (
matrix = compose(
translate(newWidth / 2, newHeight / 2),
rotate(inverse ? -angleRadians : angleRadians),
rotate(angleRadians),
translate(-currentWidth / 2, -currentHeight / 2),
);
@@ -129,18 +125,6 @@ export 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,

View File

@@ -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', []);
});
});

View File

@@ -194,7 +194,9 @@
const closeEditor = async () => {
if (editManager.hasAppliedEdits) {
console.log(asset);
const refreshedAsset = await getAssetInfo({ id: asset.id });
console.log(refreshedAsset);
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
}

View File

@@ -75,7 +75,7 @@
<Button
variant="outline"
onclick={() => editManager.resetAllChanges()}
disabled={!editManager.canReset}
disabled={!editManager.hasChanges}
class="self-start"
shape="round"
size="small"

View File

@@ -8,7 +8,7 @@
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { navigate } from '$lib/utils/navigation';
import { getMySharedLink, SharedLinkType, type AssetResponseDto, type SharedLinkResponseDto } from '@immich/sdk';
import { sharedLinkLogin, 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 getMySharedLink({ password, key, slug });
sharedLink = await sharedLinkLogin({ key, slug, sharedLinkLoginDto: { password } });
setSharedLink(sharedLink);
passwordRequired = false;
title = (sharedLink.album ? sharedLink.album.albumName : $t('public_share')) + ' - Immich';

View File

@@ -15,7 +15,6 @@ export interface EditToolManager {
onDeactivate: () => void;
resetAllChanges: () => Promise<void>;
hasChanges: boolean;
canReset: boolean;
edits: EditAction[];
}
@@ -42,22 +41,19 @@ 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.hasUnsavedChanges) {
if (!this.hasChanges || this.hasAppliedEdits) {
return true;
}

View File

@@ -38,8 +38,7 @@ type RegionConvertParams = {
};
class TransformManager implements EditToolManager {
canReset: boolean = $derived.by(() => this.checkEdits());
hasChanges: boolean = $state(false);
hasChanges: boolean = $derived.by(() => this.checkEdits());
darkenLevel = $state(0.65);
isInteracting = $state(false);
@@ -57,7 +56,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 });
previewImageSize = $derived({
preveiwImgSize = $derived({
width: this.cropImageSize.width * this.cropImageScale,
height: this.cropImageSize.height * this.cropImageScale,
});
@@ -74,7 +73,6 @@ class TransformManager implements EditToolManager {
edits = $derived.by(() => this.getEdits());
setAspectRatio(aspectRatio: string) {
this.hasChanges = true;
this.cropAspectRatio = aspectRatio;
if (!this.imgElement || !this.cropAreaEl) {
@@ -90,8 +88,8 @@ class TransformManager implements EditToolManager {
checkEdits() {
return (
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
Math.abs(this.previewImageSize.height - this.region.height) > 2 ||
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2 ||
this.mirrorHorizontal ||
this.mirrorVertical ||
this.normalizedRotation !== 0
@@ -100,8 +98,8 @@ class TransformManager implements EditToolManager {
checkCropEdits() {
return (
Math.abs(this.previewImageSize.width - this.region.width) > 2 ||
Math.abs(this.previewImageSize.height - this.region.height) > 2
Math.abs(this.preveiwImgSize.width - this.region.width) > 2 ||
Math.abs(this.preveiwImgSize.height - this.region.height) > 2
);
}
@@ -234,12 +232,9 @@ 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';
}
@@ -252,8 +247,6 @@ class TransformManager implements EditToolManager {
}
async rotate(angle: number) {
this.hasChanges = true;
this.imageRotation += angle;
await tick();
this.onImageLoad();
@@ -767,7 +760,6 @@ 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));
@@ -789,7 +781,6 @@ class TransformManager implements EditToolManager {
}
this.fadeOverlay(false);
this.hasChanges = true;
const { x, y, width, height } = crop;
const minSize = 50;
let newRegion = { ...crop };

View File

@@ -49,7 +49,7 @@ export const loadSharedLink = async ({
},
};
} catch (error) {
if (isHttpError(error) && error.data.message === 'Invalid password') {
if (isHttpError(error) && error.data.message === 'Password required') {
return {
...common,
passwordRequired: true,