Compare commits

..

1 Commits

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

View File

@@ -1,33 +0,0 @@
# v2.5.3
## Highlights
{{RELEASE HIGHLIGHTS}}
As always, please consider supporting the project.
🎉 Cheers! 🎉
----
And as always, bugs are fixed, and many other improvements also come with this release.
<!-- Release notes generated using configuration in .github/release.yml at main -->
## What's Changed
### 🐛 Bug fixes
* chore: remove random code snippet by @jrasm91 in https://github.com/immich-app/immich/pull/25677
* fix: reset and unsaved change states in editor by @bwees in https://github.com/immich-app/immich/pull/25588
* fix: no notification if release check is disabled by @jrasm91 in https://github.com/immich-app/immich/pull/25688
* fix(mobile): hide latest version if disabled by @uhthomas in https://github.com/immich-app/immich/pull/25691
### 📚 Documentation
* docs(openapi): Add descriptions to OpenAPI specification by @timonrieger in https://github.com/immich-app/immich/pull/25185
* fix(docs): clarify supported vector version by @mmomjian in https://github.com/immich-app/immich/pull/25753
**Full Changelog**: https://github.com/immich-app/immich/compare/v2.5.2...v2.5.3
---

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.5.3",
"version": "2.5.2",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

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

@@ -88,7 +88,7 @@ The easiest option is to have both extensions installed during the migration:
<details>
<summary>Migration steps (automatic)</summary>
1. Ensure you still have pgvecto.rs installed
2. Install `pgvector` (`>= 0.7, < 0.9`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`)
2. Install `pgvector` (`>= 0.7.0, < 1.0.0`). The easiest way to do this is on Debian/Ubuntu by adding the [PostgreSQL Apt repository][pg-apt] and then running `apt install postgresql-NN-pgvector`, where `NN` is your Postgres version (e.g., `16`)
3. [Install VectorChord][vchord-install]
4. Add `shared_preload_libraries= 'vchord.so, vectors.so'` to your `postgresql.conf`, making sure to include _both_ `vchord.so` and `vectors.so`. You may include other libraries here as well if needed
5. Restart the Postgres database

View File

@@ -1,7 +1,7 @@
[
{
"label": "v2.5.3",
"url": "https://docs.v2.5.3.archive.immich.app"
"label": "v2.5.2",
"url": "https://docs.v2.5.2.archive.immich.app"
},
{
"label": "v2.4.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.5.3",
"version": "2.5.2",
"description": "",
"main": "index.js",
"type": "module",

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

@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.5.3",
"version": "2.5.2",
"private": true,
"scripts": {
"format": "prettier --check .",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.5.3"
version = "2.5.2"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"

View File

@@ -919,7 +919,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.5.3"
version = "2.5.2"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },

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

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3034,
"android.injected.version.name" => "2.5.3",
"android.injected.version.code" => 3033,
"android.injected.version.name" => "2.5.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.5.3</string>
<string>2.5.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

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

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.5.3
- API version: 2.5.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -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

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.5.3+3034
version: 2.5.2+3033
environment:
sdk: '>=3.8.0 <4.0.0'

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.",
@@ -15057,7 +15129,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.5.3",
"version": "2.5.2",
"contact": {}
},
"tags": [
@@ -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

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.5.3",
"version": "2.5.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 2.5.3
* 2.5.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -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

@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.5.3",
"version": "2.5.2",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.5.3",
"version": "2.5.2",
"description": "",
"author": "",
"private": true,

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

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

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

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.5.3",
"version": "2.5.2",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {

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,