mirror of
https://github.com/immich-app/immich.git
synced 2025-03-03 15:42:28 +02:00
feat(server,web,mobile): Add optional password option for share links. (#4655)
* feat(server,web,mobile): Add optional password option for share links. Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com> * feat(server,web): Update shared-link.controller and page.svelte for improved cookie handling and metadata updates. Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com> --------- Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com>
This commit is contained in:
parent
b34cbd881a
commit
8a6889529c
60
cli/src/api/open-api/api.ts
generated
60
cli/src/api/open-api/api.ts
generated
@ -3038,6 +3038,12 @@ export interface SharedLinkCreateDto {
|
||||
* @memberof SharedLinkCreateDto
|
||||
*/
|
||||
'expiresAt'?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkCreateDto
|
||||
*/
|
||||
'password'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@ -3089,6 +3095,12 @@ export interface SharedLinkEditDto {
|
||||
* @memberof SharedLinkEditDto
|
||||
*/
|
||||
'expiresAt'?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkEditDto
|
||||
*/
|
||||
'password'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@ -3156,12 +3168,24 @@ export interface SharedLinkResponseDto {
|
||||
* @memberof SharedLinkResponseDto
|
||||
*/
|
||||
'key': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkResponseDto
|
||||
*/
|
||||
'password': string | null;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof SharedLinkResponseDto
|
||||
*/
|
||||
'showMetadata': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkResponseDto
|
||||
*/
|
||||
'token'?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {SharedLinkType}
|
||||
@ -13690,11 +13714,13 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} [password]
|
||||
* @param {string} [token]
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMySharedLink: async (key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
getMySharedLink: async (password?: string, token?: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/shared-link/me`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
@ -13716,6 +13742,14 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (password !== undefined) {
|
||||
localVarQueryParameter['password'] = password;
|
||||
}
|
||||
|
||||
if (token !== undefined) {
|
||||
localVarQueryParameter['token'] = token;
|
||||
}
|
||||
|
||||
if (key !== undefined) {
|
||||
localVarQueryParameter['key'] = key;
|
||||
}
|
||||
@ -13959,12 +13993,14 @@ export const SharedLinkApiFp = function(configuration?: Configuration) {
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} [password]
|
||||
* @param {string} [token]
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getMySharedLink(key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(key, options);
|
||||
async getMySharedLink(password?: string, token?: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(password, token, key, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@ -14053,7 +14089,7 @@ export const SharedLinkApiFactory = function (configuration?: Configuration, bas
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SharedLinkResponseDto> {
|
||||
return localVarFp.getMySharedLink(requestParameters.key, options).then((request) => request(axios, basePath));
|
||||
return localVarFp.getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@ -14142,6 +14178,20 @@ export interface SharedLinkApiCreateSharedLinkRequest {
|
||||
* @interface SharedLinkApiGetMySharedLinkRequest
|
||||
*/
|
||||
export interface SharedLinkApiGetMySharedLinkRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkApiGetMySharedLink
|
||||
*/
|
||||
readonly password?: string
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkApiGetMySharedLink
|
||||
*/
|
||||
readonly token?: string
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@ -14274,7 +14324,7 @@ export class SharedLinkApi extends BaseAPI {
|
||||
* @memberof SharedLinkApi
|
||||
*/
|
||||
public getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig) {
|
||||
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.key, options).then((request) => request(this.axios, this.basePath));
|
||||
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -311,6 +311,8 @@
|
||||
"shared_link_edit_change_expiry": "Change expiration time",
|
||||
"shared_link_edit_description": "Description",
|
||||
"shared_link_edit_description_hint": "Enter the share description",
|
||||
"shared_link_edit_password": "Password",
|
||||
"shared_link_edit_password_hint": "Enter the share password",
|
||||
"shared_link_edit_show_meta": "Show metadata",
|
||||
"shared_link_edit_submit_button": "Update link",
|
||||
"shared_link_empty": "You don't have any shared links",
|
||||
|
@ -9,6 +9,7 @@ class SharedLink {
|
||||
final bool allowUpload;
|
||||
final String? thumbAssetId;
|
||||
final String? description;
|
||||
final String? password;
|
||||
final DateTime? expiresAt;
|
||||
final String key;
|
||||
final bool showMetadata;
|
||||
@ -21,6 +22,7 @@ class SharedLink {
|
||||
required this.allowUpload,
|
||||
required this.thumbAssetId,
|
||||
required this.description,
|
||||
required this.password,
|
||||
required this.expiresAt,
|
||||
required this.key,
|
||||
required this.showMetadata,
|
||||
@ -34,6 +36,7 @@ class SharedLink {
|
||||
bool? allowDownload,
|
||||
bool? allowUpload,
|
||||
String? description,
|
||||
String? password,
|
||||
DateTime? expiresAt,
|
||||
String? key,
|
||||
bool? showMetadata,
|
||||
@ -46,6 +49,7 @@ class SharedLink {
|
||||
allowDownload: allowDownload ?? this.allowDownload,
|
||||
allowUpload: allowUpload ?? this.allowUpload,
|
||||
description: description ?? this.description,
|
||||
password: password ?? this.password,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
key: key ?? this.key,
|
||||
showMetadata: showMetadata ?? this.showMetadata,
|
||||
@ -58,6 +62,7 @@ class SharedLink {
|
||||
allowDownload = dto.allowDownload,
|
||||
allowUpload = dto.allowUpload,
|
||||
description = dto.description,
|
||||
password = dto.password,
|
||||
expiresAt = dto.expiresAt,
|
||||
key = dto.key,
|
||||
showMetadata = dto.showMetadata,
|
||||
@ -75,7 +80,7 @@ class SharedLink {
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
|
||||
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
@ -87,6 +92,7 @@ class SharedLink {
|
||||
other.allowDownload == allowDownload &&
|
||||
other.allowUpload == allowUpload &&
|
||||
other.description == description &&
|
||||
other.password == password &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.key == key &&
|
||||
other.showMetadata == showMetadata &&
|
||||
@ -100,6 +106,7 @@ class SharedLink {
|
||||
allowDownload.hashCode ^
|
||||
allowUpload.hashCode ^
|
||||
description.hashCode ^
|
||||
password.hashCode ^
|
||||
expiresAt.hashCode ^
|
||||
key.hashCode ^
|
||||
showMetadata.hashCode ^
|
||||
|
@ -40,6 +40,7 @@ class SharedLinkService {
|
||||
required bool allowDownload,
|
||||
required bool allowUpload,
|
||||
String? description,
|
||||
String? password,
|
||||
String? albumId,
|
||||
List<String>? assetIds,
|
||||
DateTime? expiresAt,
|
||||
@ -57,6 +58,7 @@ class SharedLinkService {
|
||||
allowUpload: allowUpload,
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
);
|
||||
} else if (assetIds != null) {
|
||||
dto = SharedLinkCreateDto(
|
||||
@ -66,6 +68,7 @@ class SharedLinkService {
|
||||
allowUpload: allowUpload,
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
assetIds: assetIds,
|
||||
);
|
||||
}
|
||||
@ -90,6 +93,7 @@ class SharedLinkService {
|
||||
required bool? allowUpload,
|
||||
bool? changeExpiry = false,
|
||||
String? description,
|
||||
String? password,
|
||||
DateTime? expiresAt,
|
||||
}) async {
|
||||
try {
|
||||
@ -101,6 +105,7 @@ class SharedLinkService {
|
||||
allowUpload: allowUpload,
|
||||
expiresAt: expiresAt,
|
||||
description: description,
|
||||
password: password,
|
||||
changeExpiryTime: changeExpiry,
|
||||
),
|
||||
);
|
||||
|
@ -30,6 +30,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
final descriptionController =
|
||||
useTextEditingController(text: existingLink?.description ?? "");
|
||||
final descriptionFocusNode = useFocusNode();
|
||||
final passwordController =
|
||||
useTextEditingController(text: existingLink?.password ?? "");
|
||||
final showMetadata = useState(existingLink?.showMetadata ?? true);
|
||||
final allowDownload = useState(existingLink?.allowDownload ?? true);
|
||||
final allowUpload = useState(existingLink?.allowUpload ?? false);
|
||||
@ -113,6 +115,31 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildPasswordField() {
|
||||
return TextField(
|
||||
controller: passwordController,
|
||||
enabled: newShareLink.value.isEmpty,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'shared_link_edit_password'.tr(),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: themeData.primaryColor,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.always,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'shared_link_edit_password_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildShowMetaButton() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showMetadata.value,
|
||||
@ -229,7 +256,9 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
void copyLinkToClipboard() {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: newShareLink.value,
|
||||
text: passwordController.text.isEmpty
|
||||
? newShareLink.value
|
||||
: "Link: ${newShareLink.value}\nPassword: ${passwordController.text}",
|
||||
),
|
||||
).then((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@ -302,6 +331,9 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
description: descriptionController.text.isEmpty
|
||||
? null
|
||||
: descriptionController.text,
|
||||
password: passwordController.text.isEmpty
|
||||
? null
|
||||
: passwordController.text,
|
||||
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
|
||||
);
|
||||
ref.invalidate(sharedLinksStateProvider);
|
||||
@ -324,6 +356,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
bool? upload;
|
||||
bool? meta;
|
||||
String? desc;
|
||||
String? password;
|
||||
DateTime? expiry;
|
||||
bool? changeExpiry;
|
||||
|
||||
@ -343,6 +376,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
desc = descriptionController.text;
|
||||
}
|
||||
|
||||
if (passwordController.text != existingLink!.password) {
|
||||
password = passwordController.text;
|
||||
}
|
||||
|
||||
if (editExpiry.value) {
|
||||
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
|
||||
changeExpiry = true;
|
||||
@ -354,6 +391,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
allowDownload: download,
|
||||
allowUpload: upload,
|
||||
description: desc,
|
||||
password: password,
|
||||
expiresAt: expiry,
|
||||
changeExpiry: changeExpiry,
|
||||
);
|
||||
@ -385,6 +423,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(padding),
|
||||
child: buildDescriptionField(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(padding),
|
||||
child: buildPasswordField(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: padding,
|
||||
|
8
mobile/openapi/doc/SharedLinkApi.md
generated
8
mobile/openapi/doc/SharedLinkApi.md
generated
@ -185,7 +185,7 @@ This endpoint does not need any parameter.
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
# **getMySharedLink**
|
||||
> SharedLinkResponseDto getMySharedLink(key)
|
||||
> SharedLinkResponseDto getMySharedLink(password, token, key)
|
||||
|
||||
|
||||
|
||||
@ -208,10 +208,12 @@ import 'package:openapi/api.dart';
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = SharedLinkApi();
|
||||
final password = password; // String |
|
||||
final token = token_example; // String |
|
||||
final key = key_example; // String |
|
||||
|
||||
try {
|
||||
final result = api_instance.getMySharedLink(key);
|
||||
final result = api_instance.getMySharedLink(password, token, key);
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling SharedLinkApi->getMySharedLink: $e\n');
|
||||
@ -222,6 +224,8 @@ try {
|
||||
|
||||
Name | Type | Description | Notes
|
||||
------------- | ------------- | ------------- | -------------
|
||||
**password** | **String**| | [optional]
|
||||
**token** | **String**| | [optional]
|
||||
**key** | **String**| | [optional]
|
||||
|
||||
### Return type
|
||||
|
1
mobile/openapi/doc/SharedLinkCreateDto.md
generated
1
mobile/openapi/doc/SharedLinkCreateDto.md
generated
@ -14,6 +14,7 @@ Name | Type | Description | Notes
|
||||
**assetIds** | **List<String>** | | [optional] [default to const []]
|
||||
**description** | **String** | | [optional]
|
||||
**expiresAt** | [**DateTime**](DateTime.md) | | [optional]
|
||||
**password** | **String** | | [optional]
|
||||
**showMetadata** | **bool** | | [optional] [default to true]
|
||||
**type** | [**SharedLinkType**](SharedLinkType.md) | |
|
||||
|
||||
|
1
mobile/openapi/doc/SharedLinkEditDto.md
generated
1
mobile/openapi/doc/SharedLinkEditDto.md
generated
@ -13,6 +13,7 @@ Name | Type | Description | Notes
|
||||
**changeExpiryTime** | **bool** | Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. | [optional]
|
||||
**description** | **String** | | [optional]
|
||||
**expiresAt** | [**DateTime**](DateTime.md) | | [optional]
|
||||
**password** | **String** | | [optional]
|
||||
**showMetadata** | **bool** | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
2
mobile/openapi/doc/SharedLinkResponseDto.md
generated
2
mobile/openapi/doc/SharedLinkResponseDto.md
generated
@ -17,7 +17,9 @@ Name | Type | Description | Notes
|
||||
**expiresAt** | [**DateTime**](DateTime.md) | |
|
||||
**id** | **String** | |
|
||||
**key** | **String** | |
|
||||
**password** | **String** | |
|
||||
**showMetadata** | **bool** | |
|
||||
**token** | **String** | | [optional]
|
||||
**type** | [**SharedLinkType**](SharedLinkType.md) | |
|
||||
**userId** | **String** | |
|
||||
|
||||
|
20
mobile/openapi/lib/api/shared_link_api.dart
generated
20
mobile/openapi/lib/api/shared_link_api.dart
generated
@ -173,8 +173,12 @@ class SharedLinkApi {
|
||||
/// Performs an HTTP 'GET /shared-link/me' operation and returns the [Response].
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] password:
|
||||
///
|
||||
/// * [String] token:
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<Response> getMySharedLinkWithHttpInfo({ String? key, }) async {
|
||||
Future<Response> getMySharedLinkWithHttpInfo({ String? password, String? token, String? key, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/shared-link/me';
|
||||
|
||||
@ -185,6 +189,12 @@ class SharedLinkApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (password != null) {
|
||||
queryParams.addAll(_queryParams('', 'password', password));
|
||||
}
|
||||
if (token != null) {
|
||||
queryParams.addAll(_queryParams('', 'token', token));
|
||||
}
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
@ -205,9 +215,13 @@ class SharedLinkApi {
|
||||
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [String] password:
|
||||
///
|
||||
/// * [String] token:
|
||||
///
|
||||
/// * [String] key:
|
||||
Future<SharedLinkResponseDto?> getMySharedLink({ String? key, }) async {
|
||||
final response = await getMySharedLinkWithHttpInfo( key: key, );
|
||||
Future<SharedLinkResponseDto?> getMySharedLink({ String? password, String? token, String? key, }) async {
|
||||
final response = await getMySharedLinkWithHttpInfo( password: password, token: token, key: key, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
19
mobile/openapi/lib/model/shared_link_create_dto.dart
generated
19
mobile/openapi/lib/model/shared_link_create_dto.dart
generated
@ -19,6 +19,7 @@ class SharedLinkCreateDto {
|
||||
this.assetIds = const [],
|
||||
this.description,
|
||||
this.expiresAt,
|
||||
this.password,
|
||||
this.showMetadata = true,
|
||||
required this.type,
|
||||
});
|
||||
@ -47,6 +48,14 @@ class SharedLinkCreateDto {
|
||||
|
||||
DateTime? expiresAt;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? password;
|
||||
|
||||
bool showMetadata;
|
||||
|
||||
SharedLinkType type;
|
||||
@ -59,6 +68,7 @@ class SharedLinkCreateDto {
|
||||
other.assetIds == assetIds &&
|
||||
other.description == description &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.password == password &&
|
||||
other.showMetadata == showMetadata &&
|
||||
other.type == type;
|
||||
|
||||
@ -71,11 +81,12 @@ class SharedLinkCreateDto {
|
||||
(assetIds.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(expiresAt == null ? 0 : expiresAt!.hashCode) +
|
||||
(password == null ? 0 : password!.hashCode) +
|
||||
(showMetadata.hashCode) +
|
||||
(type.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SharedLinkCreateDto[albumId=$albumId, allowDownload=$allowDownload, allowUpload=$allowUpload, assetIds=$assetIds, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata, type=$type]';
|
||||
String toString() => 'SharedLinkCreateDto[albumId=$albumId, allowDownload=$allowDownload, allowUpload=$allowUpload, assetIds=$assetIds, description=$description, expiresAt=$expiresAt, password=$password, showMetadata=$showMetadata, type=$type]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -96,6 +107,11 @@ class SharedLinkCreateDto {
|
||||
json[r'expiresAt'] = this.expiresAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'expiresAt'] = null;
|
||||
}
|
||||
if (this.password != null) {
|
||||
json[r'password'] = this.password;
|
||||
} else {
|
||||
// json[r'password'] = null;
|
||||
}
|
||||
json[r'showMetadata'] = this.showMetadata;
|
||||
json[r'type'] = this.type;
|
||||
@ -118,6 +134,7 @@ class SharedLinkCreateDto {
|
||||
: const [],
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
expiresAt: mapDateTime(json, r'expiresAt', ''),
|
||||
password: mapValueOfType<String>(json, r'password'),
|
||||
showMetadata: mapValueOfType<bool>(json, r'showMetadata') ?? true,
|
||||
type: SharedLinkType.fromJson(json[r'type'])!,
|
||||
);
|
||||
|
19
mobile/openapi/lib/model/shared_link_edit_dto.dart
generated
19
mobile/openapi/lib/model/shared_link_edit_dto.dart
generated
@ -18,6 +18,7 @@ class SharedLinkEditDto {
|
||||
this.changeExpiryTime,
|
||||
this.description,
|
||||
this.expiresAt,
|
||||
this.password,
|
||||
this.showMetadata,
|
||||
});
|
||||
|
||||
@ -56,6 +57,14 @@ class SharedLinkEditDto {
|
||||
|
||||
DateTime? expiresAt;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? password;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@ -71,6 +80,7 @@ class SharedLinkEditDto {
|
||||
other.changeExpiryTime == changeExpiryTime &&
|
||||
other.description == description &&
|
||||
other.expiresAt == expiresAt &&
|
||||
other.password == password &&
|
||||
other.showMetadata == showMetadata;
|
||||
|
||||
@override
|
||||
@ -81,10 +91,11 @@ class SharedLinkEditDto {
|
||||
(changeExpiryTime == null ? 0 : changeExpiryTime!.hashCode) +
|
||||
(description == null ? 0 : description!.hashCode) +
|
||||
(expiresAt == null ? 0 : expiresAt!.hashCode) +
|
||||
(password == null ? 0 : password!.hashCode) +
|
||||
(showMetadata == null ? 0 : showMetadata!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]';
|
||||
String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, changeExpiryTime=$changeExpiryTime, description=$description, expiresAt=$expiresAt, password=$password, showMetadata=$showMetadata]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -113,6 +124,11 @@ class SharedLinkEditDto {
|
||||
} else {
|
||||
// json[r'expiresAt'] = null;
|
||||
}
|
||||
if (this.password != null) {
|
||||
json[r'password'] = this.password;
|
||||
} else {
|
||||
// json[r'password'] = null;
|
||||
}
|
||||
if (this.showMetadata != null) {
|
||||
json[r'showMetadata'] = this.showMetadata;
|
||||
} else {
|
||||
@ -134,6 +150,7 @@ class SharedLinkEditDto {
|
||||
changeExpiryTime: mapValueOfType<bool>(json, r'changeExpiryTime'),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
expiresAt: mapDateTime(json, r'expiresAt', ''),
|
||||
password: mapValueOfType<String>(json, r'password'),
|
||||
showMetadata: mapValueOfType<bool>(json, r'showMetadata'),
|
||||
);
|
||||
}
|
||||
|
@ -22,7 +22,9 @@ class SharedLinkResponseDto {
|
||||
required this.expiresAt,
|
||||
required this.id,
|
||||
required this.key,
|
||||
required this.password,
|
||||
required this.showMetadata,
|
||||
this.token,
|
||||
required this.type,
|
||||
required this.userId,
|
||||
});
|
||||
@ -51,8 +53,12 @@ class SharedLinkResponseDto {
|
||||
|
||||
String key;
|
||||
|
||||
String? password;
|
||||
|
||||
bool showMetadata;
|
||||
|
||||
String? token;
|
||||
|
||||
SharedLinkType type;
|
||||
|
||||
String userId;
|
||||
@ -68,7 +74,9 @@ class SharedLinkResponseDto {
|
||||
other.expiresAt == expiresAt &&
|
||||
other.id == id &&
|
||||
other.key == key &&
|
||||
other.password == password &&
|
||||
other.showMetadata == showMetadata &&
|
||||
other.token == token &&
|
||||
other.type == type &&
|
||||
other.userId == userId;
|
||||
|
||||
@ -84,12 +92,14 @@ class SharedLinkResponseDto {
|
||||
(expiresAt == null ? 0 : expiresAt!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(key.hashCode) +
|
||||
(password == null ? 0 : password!.hashCode) +
|
||||
(showMetadata.hashCode) +
|
||||
(token == null ? 0 : token!.hashCode) +
|
||||
(type.hashCode) +
|
||||
(userId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, showMetadata=$showMetadata, type=$type, userId=$userId]';
|
||||
String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, password=$password, showMetadata=$showMetadata, token=$token, type=$type, userId=$userId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -114,7 +124,17 @@ class SharedLinkResponseDto {
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
json[r'key'] = this.key;
|
||||
if (this.password != null) {
|
||||
json[r'password'] = this.password;
|
||||
} else {
|
||||
// json[r'password'] = null;
|
||||
}
|
||||
json[r'showMetadata'] = this.showMetadata;
|
||||
if (this.token != null) {
|
||||
json[r'token'] = this.token;
|
||||
} else {
|
||||
// json[r'token'] = null;
|
||||
}
|
||||
json[r'type'] = this.type;
|
||||
json[r'userId'] = this.userId;
|
||||
return json;
|
||||
@ -137,7 +157,9 @@ class SharedLinkResponseDto {
|
||||
expiresAt: mapDateTime(json, r'expiresAt', ''),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
key: mapValueOfType<String>(json, r'key')!,
|
||||
password: mapValueOfType<String>(json, r'password'),
|
||||
showMetadata: mapValueOfType<bool>(json, r'showMetadata')!,
|
||||
token: mapValueOfType<String>(json, r'token'),
|
||||
type: SharedLinkType.fromJson(json[r'type'])!,
|
||||
userId: mapValueOfType<String>(json, r'userId')!,
|
||||
);
|
||||
@ -195,6 +217,7 @@ class SharedLinkResponseDto {
|
||||
'expiresAt',
|
||||
'id',
|
||||
'key',
|
||||
'password',
|
||||
'showMetadata',
|
||||
'type',
|
||||
'userId',
|
||||
|
2
mobile/openapi/test/shared_link_api_test.dart
generated
2
mobile/openapi/test/shared_link_api_test.dart
generated
@ -32,7 +32,7 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
//Future<SharedLinkResponseDto> getMySharedLink({ String key }) async
|
||||
//Future<SharedLinkResponseDto> getMySharedLink({ String password, String token, String key }) async
|
||||
test('test getMySharedLink', () async {
|
||||
// TODO
|
||||
});
|
||||
|
@ -46,6 +46,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String password
|
||||
test('to test the property `password`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool showMetadata (default value: true)
|
||||
test('to test the property `showMetadata`', () async {
|
||||
// TODO
|
||||
|
@ -42,6 +42,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String password
|
||||
test('to test the property `password`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool showMetadata
|
||||
test('to test the property `showMetadata`', () async {
|
||||
// TODO
|
||||
|
@ -61,11 +61,21 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String password
|
||||
test('to test the property `password`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool showMetadata
|
||||
test('to test the property `showMetadata`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String token
|
||||
test('to test the property `token`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// SharedLinkType type
|
||||
test('to test the property `type`', () async {
|
||||
// TODO
|
||||
|
@ -4263,6 +4263,23 @@
|
||||
"get": {
|
||||
"operationId": "getMySharedLink",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "password",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"example": "password",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "key",
|
||||
"required": false,
|
||||
@ -7910,6 +7927,9 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"showMetadata": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
@ -7943,6 +7963,9 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"showMetadata": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@ -7985,9 +8008,17 @@
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"showMetadata": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"token": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/components/schemas/SharedLinkType"
|
||||
},
|
||||
@ -7999,6 +8030,7 @@
|
||||
"type",
|
||||
"id",
|
||||
"description",
|
||||
"password",
|
||||
"userId",
|
||||
"key",
|
||||
"createdAt",
|
||||
|
@ -4,6 +4,7 @@ export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
|
||||
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
|
||||
export const IMMICH_API_KEY_NAME = 'api_key';
|
||||
export const IMMICH_API_KEY_HEADER = 'x-api-key';
|
||||
export const IMMICH_SHARED_LINK_ACCESS_COOKIE = 'immich_shared_link_token';
|
||||
export enum AuthType {
|
||||
PASSWORD = 'password',
|
||||
OAUTH = 'oauth',
|
||||
|
@ -7,6 +7,8 @@ import { AssetResponseDto, mapAsset } from '../asset';
|
||||
export class SharedLinkResponseDto {
|
||||
id!: string;
|
||||
description!: string | null;
|
||||
password!: string | null;
|
||||
token?: string | null;
|
||||
userId!: string;
|
||||
key!: string;
|
||||
|
||||
@ -31,6 +33,7 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
|
||||
return {
|
||||
id: sharedLink.id,
|
||||
description: sharedLink.description,
|
||||
password: sharedLink.password,
|
||||
userId: sharedLink.userId,
|
||||
key: sharedLink.key.toString('base64url'),
|
||||
type: sharedLink.type,
|
||||
@ -53,6 +56,7 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): Shar
|
||||
return {
|
||||
id: sharedLink.id,
|
||||
description: sharedLink.description,
|
||||
password: sharedLink.password,
|
||||
userId: sharedLink.userId,
|
||||
key: sharedLink.key.toString('base64url'),
|
||||
type: sharedLink.type,
|
||||
|
@ -19,6 +19,10 @@ export class SharedLinkCreateDto {
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
password?: string;
|
||||
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@Optional({ nullable: true })
|
||||
@ -41,6 +45,9 @@ export class SharedLinkEditDto {
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@Optional()
|
||||
password?: string;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@ -62,3 +69,14 @@ export class SharedLinkEditDto {
|
||||
@IsBoolean()
|
||||
changeExpiryTime?: boolean;
|
||||
}
|
||||
|
||||
export class SharedLinkPasswordDto {
|
||||
@IsString()
|
||||
@Optional()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
token?: string;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { SharedLinkType } from '@app/infra/entities';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import {
|
||||
IAccessRepositoryMock,
|
||||
albumStub,
|
||||
@ -48,21 +48,28 @@ 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(shareMock.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the shared link for the public user', async () => {
|
||||
const authDto = authStub.adminSharedLink;
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||
});
|
||||
|
||||
it('should not return metadata', async () => {
|
||||
const authDto = authStub.adminSharedLinkNoExif;
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
|
||||
await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
|
||||
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||
});
|
||||
|
||||
it('should throw an error for an password protected shared link', async () => {
|
||||
const authDto = authStub.adminSharedLink;
|
||||
shareMock.get.mockResolvedValue(sharedLinkStub.passwordRequired);
|
||||
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
|
||||
});
|
||||
});
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { AssetEntity, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AccessCore, Permission } from '../access';
|
||||
import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories';
|
||||
import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto';
|
||||
import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto';
|
||||
import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from './shared-link.dto';
|
||||
|
||||
@Injectable()
|
||||
export class SharedLinkService {
|
||||
@ -23,7 +23,7 @@ export class SharedLinkService {
|
||||
return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
|
||||
}
|
||||
|
||||
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
|
||||
async getMine(authUser: AuthUserDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
|
||||
const { sharedLinkId: id, isPublicUser, isShowMetadata: isShowExif } = authUser;
|
||||
|
||||
if (!isPublicUser || !id) {
|
||||
@ -32,7 +32,15 @@ export class SharedLinkService {
|
||||
|
||||
const sharedLink = await this.findOrFail(authUser, id);
|
||||
|
||||
return this.map(sharedLink, { withExif: isShowExif ?? true });
|
||||
let newToken;
|
||||
if (sharedLink.password) {
|
||||
newToken = this.validateAndRefreshToken(sharedLink, dto);
|
||||
}
|
||||
|
||||
return {
|
||||
...this.map(sharedLink, { withExif: isShowExif ?? true }),
|
||||
token: newToken,
|
||||
};
|
||||
}
|
||||
|
||||
async get(authUser: AuthUserDto, id: string): Promise<SharedLinkResponseDto> {
|
||||
@ -66,6 +74,7 @@ export class SharedLinkService {
|
||||
albumId: dto.albumId || null,
|
||||
assets: (dto.assetIds || []).map((id) => ({ id }) as AssetEntity),
|
||||
description: dto.description || null,
|
||||
password: dto.password,
|
||||
expiresAt: dto.expiresAt || null,
|
||||
allowUpload: dto.allowUpload ?? true,
|
||||
allowDownload: dto.allowDownload ?? true,
|
||||
@ -81,6 +90,7 @@ export class SharedLinkService {
|
||||
id,
|
||||
userId: authUser.id,
|
||||
description: dto.description,
|
||||
password: dto.password,
|
||||
expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt,
|
||||
allowUpload: dto.allowUpload,
|
||||
allowDownload: dto.allowDownload,
|
||||
@ -159,4 +169,17 @@ export class SharedLinkService {
|
||||
private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
|
||||
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
|
||||
}
|
||||
|
||||
private validateAndRefreshToken(sharedLink: SharedLinkEntity, 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(',');
|
||||
}
|
||||
}
|
||||
|
@ -2,13 +2,16 @@ import {
|
||||
AssetIdsDto,
|
||||
AssetIdsResponseDto,
|
||||
AuthUserDto,
|
||||
IMMICH_SHARED_LINK_ACCESS_COOKIE,
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkEditDto,
|
||||
SharedLinkPasswordDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkService,
|
||||
} from '@app/domain';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { AuthUser, Authenticated, SharedLinkRoute } from '../app.guard';
|
||||
import { UseValidation } from '../app.utils';
|
||||
import { UUIDParamDto } from './dto/uuid-param.dto';
|
||||
@ -27,8 +30,25 @@ export class SharedLinkController {
|
||||
|
||||
@SharedLinkRoute()
|
||||
@Get('me')
|
||||
getMySharedLink(@AuthUser() authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
|
||||
return this.service.getMine(authUser);
|
||||
async getMySharedLink(
|
||||
@AuthUser() authUser: AuthUserDto,
|
||||
@Query() dto: SharedLinkPasswordDto,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<SharedLinkResponseDto> {
|
||||
const sharedLinkToken = req.cookies?.[IMMICH_SHARED_LINK_ACCESS_COOKIE];
|
||||
if (sharedLinkToken) {
|
||||
dto.token = sharedLinkToken;
|
||||
}
|
||||
const sharedLinkResponse = await this.service.getMine(authUser, dto);
|
||||
if (sharedLinkResponse.token) {
|
||||
res.cookie(IMMICH_SHARED_LINK_ACCESS_COOKIE, sharedLinkResponse.token, {
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24),
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
}
|
||||
return sharedLinkResponse;
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
@ -21,6 +21,9 @@ export class SharedLinkEntity {
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
password!: string | null;
|
||||
|
||||
@Column()
|
||||
userId!: string;
|
||||
|
||||
|
@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPasswordToSharedLinks1698290827089 implements MigrationInterface {
|
||||
name = 'AddPasswordToSharedLinks1698290827089'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shared_links" ADD "password" character varying`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "password"`);
|
||||
}
|
||||
|
||||
}
|
@ -111,6 +111,34 @@ describe(`${PartnerController.name} (e2e)`, () => {
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.invalidShareKey);
|
||||
});
|
||||
|
||||
it('should return unauthorized for password protected link', async () => {
|
||||
const passwordProtectedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: album.id,
|
||||
password: 'foo',
|
||||
});
|
||||
|
||||
const { status, body } = await request(server).get('/shared-link/me').query({ key: passwordProtectedLink.key });
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.invalidSharePassword);
|
||||
});
|
||||
|
||||
it('should get data for correct password protected link', async () => {
|
||||
const passwordProtectedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
|
||||
type: SharedLinkType.ALBUM,
|
||||
albumId: album.id,
|
||||
password: 'foo',
|
||||
});
|
||||
|
||||
const { status, body } = await request(server)
|
||||
.get('/shared-link/me')
|
||||
.query({ key: passwordProtectedLink.key, password: 'foo' });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /shared-link/:id', () => {
|
||||
|
5
server/test/fixtures/error.stub.ts
vendored
5
server/test/fixtures/error.stub.ts
vendored
@ -24,6 +24,11 @@ export const errorStub = {
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
},
|
||||
invalidSharePassword: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid password',
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
|
23
server/test/fixtures/shared-link.stub.ts
vendored
23
server/test/fixtures/shared-link.stub.ts
vendored
@ -132,6 +132,7 @@ export const sharedLinkStub = {
|
||||
album: undefined,
|
||||
albumId: null,
|
||||
description: null,
|
||||
password: null,
|
||||
assets: [],
|
||||
} as SharedLinkEntity),
|
||||
expired: Object.freeze({
|
||||
@ -146,6 +147,7 @@ export const sharedLinkStub = {
|
||||
allowDownload: true,
|
||||
showExif: true,
|
||||
description: null,
|
||||
password: null,
|
||||
albumId: null,
|
||||
assets: [],
|
||||
} as SharedLinkEntity),
|
||||
@ -161,6 +163,7 @@ export const sharedLinkStub = {
|
||||
allowDownload: false,
|
||||
showExif: false,
|
||||
description: null,
|
||||
password: null,
|
||||
assets: [],
|
||||
albumId: 'album-123',
|
||||
album: {
|
||||
@ -254,6 +257,22 @@ export const sharedLinkStub = {
|
||||
],
|
||||
},
|
||||
}),
|
||||
passwordRequired: Object.freeze<SharedLinkEntity>({
|
||||
id: '123',
|
||||
userId: authStub.admin.id,
|
||||
user: userStub.admin,
|
||||
key: sharedLinkBytes,
|
||||
type: SharedLinkType.ALBUM,
|
||||
createdAt: today,
|
||||
expiresAt: tomorrow,
|
||||
allowUpload: true,
|
||||
allowDownload: true,
|
||||
showExif: true,
|
||||
description: null,
|
||||
password: 'password',
|
||||
assets: [],
|
||||
albumId: null,
|
||||
}),
|
||||
};
|
||||
|
||||
export const sharedLinkResponseStub = {
|
||||
@ -263,6 +282,7 @@ export const sharedLinkResponseStub = {
|
||||
assets: [],
|
||||
createdAt: today,
|
||||
description: null,
|
||||
password: null,
|
||||
expiresAt: tomorrow,
|
||||
id: '123',
|
||||
key: sharedLinkBytes.toString('base64url'),
|
||||
@ -277,6 +297,7 @@ export const sharedLinkResponseStub = {
|
||||
assets: [],
|
||||
createdAt: today,
|
||||
description: null,
|
||||
password: null,
|
||||
expiresAt: yesterday,
|
||||
id: '123',
|
||||
key: sharedLinkBytes.toString('base64url'),
|
||||
@ -292,6 +313,7 @@ export const sharedLinkResponseStub = {
|
||||
createdAt: today,
|
||||
expiresAt: tomorrow,
|
||||
description: null,
|
||||
password: null,
|
||||
allowUpload: false,
|
||||
allowDownload: false,
|
||||
showMetadata: true,
|
||||
@ -306,6 +328,7 @@ export const sharedLinkResponseStub = {
|
||||
createdAt: today,
|
||||
expiresAt: tomorrow,
|
||||
description: null,
|
||||
password: null,
|
||||
allowUpload: false,
|
||||
allowDownload: false,
|
||||
showMetadata: false,
|
||||
|
60
web/src/api/open-api/api.ts
generated
60
web/src/api/open-api/api.ts
generated
@ -3038,6 +3038,12 @@ export interface SharedLinkCreateDto {
|
||||
* @memberof SharedLinkCreateDto
|
||||
*/
|
||||
'expiresAt'?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkCreateDto
|
||||
*/
|
||||
'password'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@ -3089,6 +3095,12 @@ export interface SharedLinkEditDto {
|
||||
* @memberof SharedLinkEditDto
|
||||
*/
|
||||
'expiresAt'?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkEditDto
|
||||
*/
|
||||
'password'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@ -3156,12 +3168,24 @@ export interface SharedLinkResponseDto {
|
||||
* @memberof SharedLinkResponseDto
|
||||
*/
|
||||
'key': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkResponseDto
|
||||
*/
|
||||
'password': string | null;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof SharedLinkResponseDto
|
||||
*/
|
||||
'showMetadata': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkResponseDto
|
||||
*/
|
||||
'token'?: string | null;
|
||||
/**
|
||||
*
|
||||
* @type {SharedLinkType}
|
||||
@ -13690,11 +13714,13 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} [password]
|
||||
* @param {string} [token]
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMySharedLink: async (key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
getMySharedLink: async (password?: string, token?: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/shared-link/me`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
@ -13716,6 +13742,14 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (password !== undefined) {
|
||||
localVarQueryParameter['password'] = password;
|
||||
}
|
||||
|
||||
if (token !== undefined) {
|
||||
localVarQueryParameter['token'] = token;
|
||||
}
|
||||
|
||||
if (key !== undefined) {
|
||||
localVarQueryParameter['key'] = key;
|
||||
}
|
||||
@ -13959,12 +13993,14 @@ export const SharedLinkApiFp = function(configuration?: Configuration) {
|
||||
},
|
||||
/**
|
||||
*
|
||||
* @param {string} [password]
|
||||
* @param {string} [token]
|
||||
* @param {string} [key]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getMySharedLink(key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(key, options);
|
||||
async getMySharedLink(password?: string, token?: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(password, token, key, options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
/**
|
||||
@ -14053,7 +14089,7 @@ export const SharedLinkApiFactory = function (configuration?: Configuration, bas
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SharedLinkResponseDto> {
|
||||
return localVarFp.getMySharedLink(requestParameters.key, options).then((request) => request(axios, basePath));
|
||||
return localVarFp.getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
*
|
||||
@ -14142,6 +14178,20 @@ export interface SharedLinkApiCreateSharedLinkRequest {
|
||||
* @interface SharedLinkApiGetMySharedLinkRequest
|
||||
*/
|
||||
export interface SharedLinkApiGetMySharedLinkRequest {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkApiGetMySharedLink
|
||||
*/
|
||||
readonly password?: string
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof SharedLinkApiGetMySharedLink
|
||||
*/
|
||||
readonly token?: string
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@ -14274,7 +14324,7 @@ export class SharedLinkApi extends BaseAPI {
|
||||
* @memberof SharedLinkApi
|
||||
*/
|
||||
public getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig) {
|
||||
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.key, options).then((request) => request(this.axios, this.basePath));
|
||||
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -24,6 +24,7 @@
|
||||
let allowUpload = false;
|
||||
let showMetadata = true;
|
||||
let expirationTime = '';
|
||||
let password = '';
|
||||
let shouldChangeExpirationTime = false;
|
||||
let canCopyImagesToClipboard = true;
|
||||
const dispatch = createEventDispatcher();
|
||||
@ -40,6 +41,9 @@
|
||||
if (editingLink.description) {
|
||||
description = editingLink.description;
|
||||
}
|
||||
if (editingLink.password) {
|
||||
password = editingLink.password;
|
||||
}
|
||||
allowUpload = editingLink.allowUpload;
|
||||
allowDownload = editingLink.allowDownload;
|
||||
showMetadata = editingLink.showMetadata;
|
||||
@ -66,6 +70,7 @@
|
||||
expiresAt: expirationDate,
|
||||
allowUpload,
|
||||
description,
|
||||
password,
|
||||
allowDownload,
|
||||
showMetadata,
|
||||
},
|
||||
@ -81,7 +86,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
await copyToClipboard(sharedLink);
|
||||
await copyToClipboard(password ? `Link: ${sharedLink}\nPassword: ${password}` : sharedLink);
|
||||
};
|
||||
|
||||
const getExpirationTimeInMillisecond = () => {
|
||||
@ -119,6 +124,7 @@
|
||||
id: editingLink.id,
|
||||
sharedLinkEditDto: {
|
||||
description,
|
||||
password,
|
||||
expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
|
||||
allowUpload,
|
||||
allowDownload,
|
||||
@ -178,12 +184,16 @@
|
||||
<div class="mb-2 mt-4">
|
||||
<p class="text-xs">LINK OPTIONS</p>
|
||||
</div>
|
||||
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40">
|
||||
<div class="rounded-lg bg-gray-100 p-4 dark:bg-black/40 max-h-[330px] overflow-y-scroll">
|
||||
<div class="flex flex-col">
|
||||
<div class="mb-2">
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label="Description" bind:value={description} />
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<SettingInputField inputType={SettingInputFieldType.TEXT} label="Password" bind:value={password} />
|
||||
</div>
|
||||
|
||||
<div class="my-3">
|
||||
<SettingSwitch bind:checked={showMetadata} title={'Show metadata'} />
|
||||
</div>
|
||||
|
@ -2,12 +2,14 @@ import featurePanelUrl from '$lib/assets/feature-panel.png';
|
||||
import { api as clientApi, ThumbnailFormat } from '@api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
export const load = (async ({ params, locals: { api } }) => {
|
||||
export const load = (async ({ params, locals: { api }, cookies }) => {
|
||||
const { key } = params;
|
||||
const token = cookies.get('immich_shared_link_token');
|
||||
|
||||
try {
|
||||
const { data: sharedLink } = await api.sharedLinkApi.getMySharedLink({ key });
|
||||
const { data: sharedLink } = await api.sharedLinkApi.getMySharedLink({ key, token });
|
||||
|
||||
const assetCount = sharedLink.assets.length;
|
||||
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
|
||||
@ -23,6 +25,17 @@ export const load = (async ({ params, locals: { api } }) => {
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
// handle unauthorized error
|
||||
if ((e as AxiosError).response?.status === 401) {
|
||||
return {
|
||||
passwordRequired: true,
|
||||
sharedLinkKey: key,
|
||||
meta: {
|
||||
title: 'Password Required',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
throw error(404, {
|
||||
message: 'Invalid shared link',
|
||||
});
|
||||
|
@ -1,20 +1,79 @@
|
||||
<script lang="ts">
|
||||
import AlbumViewer from '$lib/components/album-page/album-viewer.svelte';
|
||||
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
|
||||
import { SharedLinkType } from '@api';
|
||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
||||
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import { api, SharedLinkType } from '@api';
|
||||
import type { PageData } from './$types';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
|
||||
export let data: PageData;
|
||||
const { sharedLink } = data;
|
||||
let { sharedLink, passwordRequired, sharedLinkKey: key } = data;
|
||||
let { title, description } = data.meta;
|
||||
|
||||
let isOwned = data.user ? data.user.id === sharedLink.userId : false;
|
||||
let isOwned = data.user ? data.user.id === sharedLink?.userId : false;
|
||||
let password = '';
|
||||
|
||||
const handlePasswordSubmit = async () => {
|
||||
try {
|
||||
const result = await api.sharedLinkApi.getMySharedLink({ password, key });
|
||||
passwordRequired = false;
|
||||
sharedLink = result.data;
|
||||
isOwned = data.user ? data.user.id === sharedLink.userId : false;
|
||||
title = (sharedLink.album ? sharedLink.album.albumName : 'Public Share') + ' - Immich';
|
||||
description = sharedLink.description || `${sharedLink.assets.length} shared photos & videos.`;
|
||||
} catch (error) {
|
||||
handleError(error, 'Failed to get shared link');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if sharedLink.type == SharedLinkType.Album}
|
||||
<svelte:head>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
</svelte:head>
|
||||
{#if passwordRequired}
|
||||
<header>
|
||||
<ControlAppBar showBackButton={false}>
|
||||
<svelte:fragment slot="leading">
|
||||
<a
|
||||
data-sveltekit-preload-data="hover"
|
||||
class="ml-6 flex place-items-center gap-2 hover:cursor-pointer"
|
||||
href="https://immich.app"
|
||||
>
|
||||
<ImmichLogo height={30} width={30} />
|
||||
<h1 class="font-immich-title text-lg text-immich-primary dark:text-immich-dark-primary">IMMICH</h1>
|
||||
</a>
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="trailing">
|
||||
<ThemeButton />
|
||||
</svelte:fragment>
|
||||
</ControlAppBar>
|
||||
</header>
|
||||
<main
|
||||
class="relative h-screen overflow-hidden bg-immich-bg px-6 pt-[var(--navbar-height)] dark:bg-immich-dark-bg sm:px-12 md:px-24 lg:px-40"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center mt-20">
|
||||
<div class="text-2xl font-bold text-immich-primary dark:text-immich-dark-primary">Password Required</div>
|
||||
<div class="mt-4 text-lg text-immich-primary dark:text-immich-dark-primary">
|
||||
Please enter the password to view this page.
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<input type="password" class="immich-form-input mr-2" placeholder="Password" bind:value={password} />
|
||||
<Button on:click={handlePasswordSubmit}>Submit</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album}
|
||||
<AlbumViewer {sharedLink} />
|
||||
{/if}
|
||||
|
||||
{#if sharedLink.type == SharedLinkType.Individual}
|
||||
{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual}
|
||||
<div class="immich-scrollbar">
|
||||
<IndividualSharedViewer {sharedLink} {isOwned} />
|
||||
</div>
|
||||
|
Loading…
x
Reference in New Issue
Block a user