diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index e8cfda0b04..b1714e2764 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -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 => { + getMySharedLink: async (password?: string, token?: string, key?: string, options: AxiosRequestConfig = {}): Promise => { 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> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(key, options); + async getMySharedLink(password?: string, token?: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + 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 { - 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)); } /** diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 66489c42ab..be576aa5c2 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -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", diff --git a/mobile/lib/modules/shared_link/models/shared_link.dart b/mobile/lib/modules/shared_link/models/shared_link.dart index 5beabb566c..a107dd892a 100644 --- a/mobile/lib/modules/shared_link/models/shared_link.dart +++ b/mobile/lib/modules/shared_link/models/shared_link.dart @@ -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 ^ diff --git a/mobile/lib/modules/shared_link/services/shared_link.service.dart b/mobile/lib/modules/shared_link/services/shared_link.service.dart index 2e28c20dac..3ea1d411b2 100644 --- a/mobile/lib/modules/shared_link/services/shared_link.service.dart +++ b/mobile/lib/modules/shared_link/services/shared_link.service.dart @@ -40,6 +40,7 @@ class SharedLinkService { required bool allowDownload, required bool allowUpload, String? description, + String? password, String? albumId, List? 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, ), ); diff --git a/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart index d2a1aaeed4..499b2c29d3 100644 --- a/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart +++ b/mobile/lib/modules/shared_link/views/shared_link_edit_page.dart @@ -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, diff --git a/mobile/openapi/doc/SharedLinkApi.md b/mobile/openapi/doc/SharedLinkApi.md index 34b8e1e719..873ffc5825 100644 Binary files a/mobile/openapi/doc/SharedLinkApi.md and b/mobile/openapi/doc/SharedLinkApi.md differ diff --git a/mobile/openapi/doc/SharedLinkCreateDto.md b/mobile/openapi/doc/SharedLinkCreateDto.md index 852610ae12..8f845dfa49 100644 Binary files a/mobile/openapi/doc/SharedLinkCreateDto.md and b/mobile/openapi/doc/SharedLinkCreateDto.md differ diff --git a/mobile/openapi/doc/SharedLinkEditDto.md b/mobile/openapi/doc/SharedLinkEditDto.md index ccd0d3b543..36af31b475 100644 Binary files a/mobile/openapi/doc/SharedLinkEditDto.md and b/mobile/openapi/doc/SharedLinkEditDto.md differ diff --git a/mobile/openapi/doc/SharedLinkResponseDto.md b/mobile/openapi/doc/SharedLinkResponseDto.md index 24b76c86c8..89f7c7ac82 100644 Binary files a/mobile/openapi/doc/SharedLinkResponseDto.md and b/mobile/openapi/doc/SharedLinkResponseDto.md differ diff --git a/mobile/openapi/lib/api/shared_link_api.dart b/mobile/openapi/lib/api/shared_link_api.dart index 029f7bc8a7..661da45eb8 100644 Binary files a/mobile/openapi/lib/api/shared_link_api.dart and b/mobile/openapi/lib/api/shared_link_api.dart differ diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 8ce045ca1a..9f7b8edcd8 100644 Binary files a/mobile/openapi/lib/model/shared_link_create_dto.dart and b/mobile/openapi/lib/model/shared_link_create_dto.dart differ diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 108734999d..4d7330f7d6 100644 Binary files a/mobile/openapi/lib/model/shared_link_edit_dto.dart and b/mobile/openapi/lib/model/shared_link_edit_dto.dart differ diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 33aa0577d7..fff364e547 100644 Binary files a/mobile/openapi/lib/model/shared_link_response_dto.dart and b/mobile/openapi/lib/model/shared_link_response_dto.dart differ diff --git a/mobile/openapi/test/shared_link_api_test.dart b/mobile/openapi/test/shared_link_api_test.dart index 05843bad7a..edc2c55d0a 100644 Binary files a/mobile/openapi/test/shared_link_api_test.dart and b/mobile/openapi/test/shared_link_api_test.dart differ diff --git a/mobile/openapi/test/shared_link_create_dto_test.dart b/mobile/openapi/test/shared_link_create_dto_test.dart index e02cbe481e..df57e089f5 100644 Binary files a/mobile/openapi/test/shared_link_create_dto_test.dart and b/mobile/openapi/test/shared_link_create_dto_test.dart differ diff --git a/mobile/openapi/test/shared_link_edit_dto_test.dart b/mobile/openapi/test/shared_link_edit_dto_test.dart index 893d12efe0..f5c45190c3 100644 Binary files a/mobile/openapi/test/shared_link_edit_dto_test.dart and b/mobile/openapi/test/shared_link_edit_dto_test.dart differ diff --git a/mobile/openapi/test/shared_link_response_dto_test.dart b/mobile/openapi/test/shared_link_response_dto_test.dart index fbe26b9ae3..0eb4ed50a7 100644 Binary files a/mobile/openapi/test/shared_link_response_dto_test.dart and b/mobile/openapi/test/shared_link_response_dto_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index b97bdc1cf0..ab9b16170b 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -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", diff --git a/server/src/domain/auth/auth.constant.ts b/server/src/domain/auth/auth.constant.ts index 6f63cc1b3f..d237a19cd5 100644 --- a/server/src/domain/auth/auth.constant.ts +++ b/server/src/domain/auth/auth.constant.ts @@ -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', diff --git a/server/src/domain/shared-link/shared-link-response.dto.ts b/server/src/domain/shared-link/shared-link-response.dto.ts index 4e35f65462..b16a578f41 100644 --- a/server/src/domain/shared-link/shared-link-response.dto.ts +++ b/server/src/domain/shared-link/shared-link-response.dto.ts @@ -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, diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts index ed38cf984d..bb5b61820a 100644 --- a/server/src/domain/shared-link/shared-link.dto.ts +++ b/server/src/domain/shared-link/shared-link.dto.ts @@ -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; +} diff --git a/server/src/domain/shared-link/shared-link.service.spec.ts b/server/src/domain/shared-link/shared-link.service.spec.ts index f902d7a68a..863e3a3534 100644 --- a/server/src/domain/shared-link/shared-link.service.spec.ts +++ b/server/src/domain/shared-link/shared-link.service.spec.ts @@ -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); }); }); diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts index 2cb87c8ebc..d3fd89661b 100644 --- a/server/src/domain/shared-link/shared-link.service.ts +++ b/server/src/domain/shared-link/shared-link.service.ts @@ -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 { + async getMine(authUser: AuthUserDto, dto: SharedLinkPasswordDto): Promise { 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 { @@ -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(','); + } } diff --git a/server/src/immich/controllers/shared-link.controller.ts b/server/src/immich/controllers/shared-link.controller.ts index afd8c81ea3..15c0803dd4 100644 --- a/server/src/immich/controllers/shared-link.controller.ts +++ b/server/src/immich/controllers/shared-link.controller.ts @@ -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 { - return this.service.getMine(authUser); + async getMySharedLink( + @AuthUser() authUser: AuthUserDto, + @Query() dto: SharedLinkPasswordDto, + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise { + 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') diff --git a/server/src/infra/entities/shared-link.entity.ts b/server/src/infra/entities/shared-link.entity.ts index e06635d6a6..1e42b8d2c2 100644 --- a/server/src/infra/entities/shared-link.entity.ts +++ b/server/src/infra/entities/shared-link.entity.ts @@ -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; diff --git a/server/src/infra/migrations/1698290827089-AddPasswordToSharedLinks.ts b/server/src/infra/migrations/1698290827089-AddPasswordToSharedLinks.ts new file mode 100644 index 0000000000..b6906e3d05 --- /dev/null +++ b/server/src/infra/migrations/1698290827089-AddPasswordToSharedLinks.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddPasswordToSharedLinks1698290827089 implements MigrationInterface { + name = 'AddPasswordToSharedLinks1698290827089' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_links" ADD "password" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "password"`); + } + +} diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts index 80d43c7c74..03eb9da7db 100644 --- a/server/test/e2e/shared-link.e2e-spec.ts +++ b/server/test/e2e/shared-link.e2e-spec.ts @@ -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', () => { diff --git a/server/test/fixtures/error.stub.ts b/server/test/fixtures/error.stub.ts index c37aad316c..cea514e26e 100644 --- a/server/test/fixtures/error.stub.ts +++ b/server/test/fixtures/error.stub.ts @@ -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, diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index dd5771cf99..dd6eb52334 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -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({ + 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, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index e8cfda0b04..b1714e2764 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -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 => { + getMySharedLink: async (password?: string, token?: string, key?: string, options: AxiosRequestConfig = {}): Promise => { 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> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(key, options); + async getMySharedLink(password?: string, token?: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + 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 { - 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)); } /** diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index 774afc2dcd..5baefa1506 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -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 @@

LINK OPTIONS

-
+
+
+ +
+
diff --git a/web/src/routes/(user)/share/[key]/+page.server.ts b/web/src/routes/(user)/share/[key]/+page.server.ts index d1d711fda2..5ba044df96 100644 --- a/web/src/routes/(user)/share/[key]/+page.server.ts +++ b/web/src/routes/(user)/share/[key]/+page.server.ts @@ -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', }); diff --git a/web/src/routes/(user)/share/[key]/+page.svelte b/web/src/routes/(user)/share/[key]/+page.svelte index 3982221697..c8ab740236 100644 --- a/web/src/routes/(user)/share/[key]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/+page.svelte @@ -1,20 +1,79 @@ -{#if sharedLink.type == SharedLinkType.Album} + + {title} + + +{#if passwordRequired} +
+ + + + +

IMMICH

+
+
+ + + + +
+
+
+
+
Password Required
+
+ Please enter the password to view this page. +
+
+ + +
+
+
+{/if} + +{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album} {/if} -{#if sharedLink.type == SharedLinkType.Individual} +{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual}