From 8a6889529cb16229ffc4c3cd25ce9f1ccccabdaa Mon Sep 17 00:00:00 2001 From: jarvis2f <137974272+jarvis2f@users.noreply.github.com> Date: Sun, 29 Oct 2023 09:35:38 +0800 Subject: [PATCH] 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> --- cli/src/api/open-api/api.ts | 60 +++++++++++++-- mobile/assets/i18n/en-US.json | 2 + .../shared_link/models/shared_link.dart | 9 ++- .../services/shared_link.service.dart | 5 ++ .../views/shared_link_edit_page.dart | 44 ++++++++++- mobile/openapi/doc/SharedLinkApi.md | Bin 17232 -> 17431 bytes mobile/openapi/doc/SharedLinkCreateDto.md | Bin 848 -> 891 bytes mobile/openapi/doc/SharedLinkEditDto.md | Bin 859 -> 902 bytes mobile/openapi/doc/SharedLinkResponseDto.md | Bin 927 -> 999 bytes mobile/openapi/lib/api/shared_link_api.dart | Bin 13834 -> 14251 bytes .../lib/model/shared_link_create_dto.dart | Bin 5726 -> 6408 bytes .../lib/model/shared_link_edit_dto.dart | Bin 6861 -> 7543 bytes .../lib/model/shared_link_response_dto.dart | Bin 6309 -> 6958 bytes mobile/openapi/test/shared_link_api_test.dart | Bin 1727 -> 1758 bytes .../test/shared_link_create_dto_test.dart | Bin 1413 -> 1514 bytes .../test/shared_link_edit_dto_test.dart | Bin 1315 -> 1416 bytes .../test/shared_link_response_dto_test.dart | Bin 1746 -> 1942 bytes server/immich-openapi-specs.json | 32 ++++++++ server/src/domain/auth/auth.constant.ts | 1 + .../shared-link/shared-link-response.dto.ts | 4 + .../src/domain/shared-link/shared-link.dto.ts | 18 +++++ .../shared-link/shared-link.service.spec.ts | 15 +++- .../domain/shared-link/shared-link.service.ts | 31 +++++++- .../controllers/shared-link.controller.ts | 26 ++++++- .../src/infra/entities/shared-link.entity.ts | 3 + .../1698290827089-AddPasswordToSharedLinks.ts | 14 ++++ server/test/e2e/shared-link.e2e-spec.ts | 28 +++++++ server/test/fixtures/error.stub.ts | 5 ++ server/test/fixtures/shared-link.stub.ts | 23 ++++++ web/src/api/open-api/api.ts | 60 +++++++++++++-- .../create-shared-link-modal.svelte | 14 +++- .../routes/(user)/share/[key]/+page.server.ts | 17 ++++- .../routes/(user)/share/[key]/+page.svelte | 69 ++++++++++++++++-- 33 files changed, 448 insertions(+), 32 deletions(-) create mode 100644 server/src/infra/migrations/1698290827089-AddPasswordToSharedLinks.ts 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 34b8e1e71991160e437b7dda780ef19cee0c5909..873ffc5825e85099ba4094f8147a426258440c3e 100644 GIT binary patch delta 158 zcmcc6#yGu$al<`n!Ggr%;`02W6di?<{Or^`9fi&Jq_tUuV8RNv3Q*?c4`yPbU^PH7 zFdd&-k(gVMlREjMndoF|bN9&s3IdqsO-@jF!vQmHvcIANhk}+CSU-p}`JILJw1}9jqc~-=uo8_M2^k852iAVsUYKeo@NgYmCa9&6uPZ0e?aW&Hw-a delta 12 Tcmey(c7bh!8Pn!OCMiY$AglxN diff --git a/mobile/openapi/doc/SharedLinkEditDto.md b/mobile/openapi/doc/SharedLinkEditDto.md index ccd0d3b54378460bf38c72a64bc32270bf9c7e44..36af31b475483016392b78f11c9657a4c4058e41 100644 GIT binary patch delta 24 fcmcc3*2cb}n~9?!vADQAzbIw0Hk0z?Zl((Wa9{}f delta 11 ScmZo;zsY2reng z%uBad0ISp4d{0c95n(`{rnweOsk)8=jH#(<&7}YYwO}$cO+f=<3sAWjm<3b|rr`=V zN#r6Ki|MXAMiP@JOG@E%*W`O*vXkfVich{FDXR!`h85gFQ0uH8DTDR~FnXBl}JQ0Qx6qTmS$7 delta 74 zcmZ3T-<7jrrP$>AI((CN3Ghy47FXWvF0RBlnMb=|a-5pQ+tmj3&E_i%fQq dHrf1;?FHlH`O4cTR|*JB&Qz(`yjSj>1OO?-9L)d# diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index 8ce045ca1a1a2629294bdbe5de8bb85e9e7ec005..9f7b8edcd87b11e919f20e9433355b23180e70e2 100644 GIT binary patch delta 247 zcmcbo(_yqhlZm4svADQAzbIw198)XvW-*pjMugBj);>lNm;wb`TLl<?iZfS^_(HK#aLp&HE5*H=);D$dV~E>ef-+pNR0m>KSa$?^P(aL(q# H{Il5r@UK=U delta 41 xcmeA$x~H>2lWDUJQx)@O4famP%~v?87$-Myecjy2y^Lw|I=(*U&6Ps^Yyd$I4_E*I diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index 108734999d9e0b182f2bc52788e1d19419cbf6cd..4d7330f7d6f40ab8a03afe0bf0b1271d07ed8a12 100644 GIT binary patch delta 219 zcmX?W`rT^7e@2di#Ny)e{Gyc2Ul?^b1cFP7GV{{y72u+ROc{)u)wm)VMPNb-wzdfA z0`7T?^2joIr8zkY_6i0HRtQDEdAJybV2W&2V64r|d@;;$`zJdJNJ_$_)MFKF6-qKP di}j!)ll}SS;Yv3<3L%)p6ldg@`=*v8rX-dmf<<9UA-W}4Z!(I&bSc={!cCa`h|QW8WC2JJqL!C^ zH={hV+Pu=790hv?0|hIDwvFtM$So-aN!3#G9 zWb)(-+)~0|D-hbj;+#D4tYGoUKz*BIcrP-uLyeuhR#1rxW`(-uWMx5r7LZdXuM<>c ShZv=~IbD#K9bt8jge3r-B$;#o delta 88 zcmV-e0H^=1Hl;DJlLC{k0ur;)0%8IYVsCG3AaiJMcTHt-jR_yK*a;8;ll%%DvnLA<1Cz84F|$|?+5?ks6GgM@69Na5{TeaAh9E=$ diff --git a/mobile/openapi/test/shared_link_api_test.dart b/mobile/openapi/test/shared_link_api_test.dart index 05843bad7abef74abe75d4a6e0013592490a7620..edc2c55d0a200ac9615a8b90386f24eb339ad731 100644 GIT binary patch delta 36 scmdnbdyjX+H)ipI#Ny)e{Gt>ch2WB+%)E4klKkw{Je|$&m@hH`01?g(k^lez delta 12 Tcmcb|yPtQ%H|EWvEGL-&B&`Ja diff --git a/mobile/openapi/test/shared_link_create_dto_test.dart b/mobile/openapi/test/shared_link_create_dto_test.dart index e02cbe481eb67bef7d91cfca91a94d2b5efb1187..df57e089f54b561a05ea92f28c8cdd140a06f112 100644 GIT binary patch delta 48 xcmZqWe#O0E4hw&9Nl|8AxadCNmQOaZkRz(gt=L3uGWCteJ$qQMY006f_5&ZxF delta 15 WcmaFG-O9aT4$EW*Cf3PptWN+fIR!ER diff --git a/mobile/openapi/test/shared_link_edit_dto_test.dart b/mobile/openapi/test/shared_link_edit_dto_test.dart index 893d12efe0882dd93d1f2d25b357e2d266321506..f5c45190c3eaba982a7a21cfcb0e4cfd0b377254 100644 GIT binary patch delta 43 tcmZ3?)xo`CD+_;cNl|8AxadCNmQOe{v7DaX_XL3E0-sG(;c>rAs59 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}