From 10789503c160a7c73ccd44a5e15684400fd89264 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 9 Jan 2023 14:16:08 -0600 Subject: [PATCH] feat(web/server) public album sharing (#1266) --- mobile/openapi/.openapi-generator/FILES | 18 + mobile/openapi/README.md | Bin 12505 -> 13465 bytes mobile/openapi/doc/AlbumApi.md | Bin 16216 -> 17790 bytes mobile/openapi/doc/AssetApi.md | Bin 27798 -> 29219 bytes mobile/openapi/doc/CreateAlbumShareLinkDto.md | Bin 0 -> 554 bytes mobile/openapi/doc/DownloadFilesDto.md | Bin 0 -> 441 bytes mobile/openapi/doc/EditSharedLinkDto.md | Bin 0 -> 566 bytes mobile/openapi/doc/ShareApi.md | Bin 0 -> 4992 bytes mobile/openapi/doc/SharedLinkResponseDto.md | Bin 0 -> 802 bytes mobile/openapi/doc/SharedLinkType.md | Bin 0 -> 380 bytes mobile/openapi/lib/api.dart | Bin 4363 -> 4595 bytes mobile/openapi/lib/api/album_api.dart | Bin 18557 -> 20343 bytes mobile/openapi/lib/api/asset_api.dart | Bin 33815 -> 35454 bytes mobile/openapi/lib/api/share_api.dart | Bin 0 -> 8156 bytes mobile/openapi/lib/api_client.dart | Bin 15655 -> 16112 bytes mobile/openapi/lib/api_helper.dart | Bin 3944 -> 4050 bytes .../model/create_album_share_link_dto.dart | Bin 0 -> 5616 bytes .../openapi/lib/model/download_files_dto.dart | Bin 0 -> 3449 bytes .../lib/model/edit_shared_link_dto.dart | Bin 0 -> 6044 bytes .../lib/model/shared_link_response_dto.dart | Bin 0 -> 6556 bytes .../openapi/lib/model/shared_link_type.dart | Bin 0 -> 2676 bytes mobile/openapi/test/album_api_test.dart | Bin 2084 -> 2270 bytes mobile/openapi/test/asset_api_test.dart | Bin 3793 -> 3934 bytes .../create_album_share_link_dto_test.dart | Bin 0 -> 906 bytes .../openapi/test/download_files_dto_test.dart | Bin 0 -> 604 bytes .../test/edit_shared_link_dto_test.dart | Bin 0 -> 904 bytes mobile/openapi/test/share_api_test.dart | Bin 0 -> 1150 bytes .../test/shared_link_response_dto_test.dart | Bin 0 -> 1516 bytes .../openapi/test/shared_link_type_test.dart | Bin 0 -> 425 bytes notes.md | 12 +- .../src/api-v1/album/album-repository.ts | 22 +- .../src/api-v1/album/album.controller.ts | 23 +- .../immich/src/api-v1/album/album.module.ts | 2 + .../src/api-v1/album/album.service.spec.ts | 35 +- .../immich/src/api-v1/album/album.service.ts | 50 +- .../album/dto/create-album-shared-link.dto.ts | 19 + .../album/response-dto/album-response.dto.ts | 4 +- .../src/api-v1/asset/asset-repository.ts | 2 +- .../src/api-v1/asset/asset.controller.ts | 38 +- .../immich/src/api-v1/asset/asset.module.ts | 2 + .../src/api-v1/asset/asset.service.spec.ts | 13 + .../immich/src/api-v1/asset/asset.service.ts | 41 +- .../api-v1/asset/dto/download-files.dto.ts | 12 + .../share/dto/create-shared-link.dto.ts | 11 + .../api-v1/share/dto/edit-shared-link.dto.ts | 15 + .../response-dto/shared-link-response.dto.ts | 40 ++ .../src/api-v1/share/share.controller.ts | 46 ++ .../immich/src/api-v1/share/share.core.ts | 99 +++ .../immich/src/api-v1/share/share.module.ts | 19 + .../immich/src/api-v1/share/share.service.ts | 54 ++ .../api-v1/share/shared-link.repository.ts | 123 ++++ server/apps/immich/src/app.module.ts | 3 + .../immich/src/config/asset-upload.config.ts | 7 + .../src/decorators/auth-user.decorator.ts | 16 +- .../src/decorators/authenticated.decorator.ts | 9 + .../route-not-shared-guard.middleware.ts | 21 + .../modules/immich-jwt/guards/auth.guard.ts | 3 +- .../modules/immich-jwt/immich-jwt.module.ts | 6 +- .../immich-jwt/strategies/api-key.strategy.ts | 14 +- .../immich-jwt/strategies/jwt.strategy.ts | 12 +- .../strategies/public-share.strategy.ts | 53 ++ server/immich-openapi-specs.json | 603 ++++++++++++---- .../database/src/entities/album.entity.ts | 4 + .../database/src/entities/asset.entity.ts | 5 + server/libs/database/src/entities/index.ts | 1 + .../src/entities/shared-link.entity.ts | 50 ++ .../1673150490490-AddSharedLinkTable.ts | 28 + server/package-lock.json | 20 + server/package.json | 1 + web/src/api/api.ts | 3 + web/src/api/open-api/api.ts | 642 ++++++++++++++++++ web/src/api/utils.ts | 3 +- web/src/app.html | 22 +- .../settings/oauth/oauth-settings.svelte | 2 +- .../settings/setting-input-field.svelte | 2 +- .../admin-page/settings/setting-switch.svelte | 4 +- .../album-page/__tests__/album-card.spec.ts | 1 + .../components/album-page/album-viewer.svelte | 140 +++- .../album-page/asset-selection.svelte | 2 +- .../album-page/user-selection-modal.svelte | 42 +- .../asset-viewer/asset-viewer-nav-bar.svelte | 17 +- .../asset-viewer/asset-viewer.svelte | 110 +-- .../asset-viewer/photo-viewer.svelte | 10 +- .../asset-viewer/video-viewer.svelte | 10 +- .../shared-components/control-app-bar.svelte | 19 +- .../create-shared-link-modal.svelte | 243 +++++++ .../shared-components/dropdown-button.svelte | 76 +++ .../shared-components/immich-thumbnail.svelte | 12 +- .../shared-components/theme-button.svelte | 2 +- .../sharedlinks-page/shared-link-card.svelte | 142 ++++ web/src/lib/utils/asset-utils.ts | 109 ++- web/src/lib/utils/file-uploader.ts | 39 +- web/src/routes/photos/+page.svelte | 14 +- web/src/routes/share/[key]/+error.svelte | 9 + web/src/routes/share/[key]/+page.server.ts | 18 + web/src/routes/share/[key]/+page.svelte | 22 + .../[key]/photos/[assetId]/+page.server.ts | 21 + .../share/[key]/photos/[assetId]/+page.svelte | 17 + web/src/routes/sharing/+page.svelte | 14 +- .../sharing/sharedlinks/+page.server.ts | 18 + .../routes/sharing/sharedlinks/+page.svelte | 109 +++ 101 files changed, 3103 insertions(+), 347 deletions(-) create mode 100644 mobile/openapi/doc/CreateAlbumShareLinkDto.md create mode 100644 mobile/openapi/doc/DownloadFilesDto.md create mode 100644 mobile/openapi/doc/EditSharedLinkDto.md create mode 100644 mobile/openapi/doc/ShareApi.md create mode 100644 mobile/openapi/doc/SharedLinkResponseDto.md create mode 100644 mobile/openapi/doc/SharedLinkType.md create mode 100644 mobile/openapi/lib/api/share_api.dart create mode 100644 mobile/openapi/lib/model/create_album_share_link_dto.dart create mode 100644 mobile/openapi/lib/model/download_files_dto.dart create mode 100644 mobile/openapi/lib/model/edit_shared_link_dto.dart create mode 100644 mobile/openapi/lib/model/shared_link_response_dto.dart create mode 100644 mobile/openapi/lib/model/shared_link_type.dart create mode 100644 mobile/openapi/test/create_album_share_link_dto_test.dart create mode 100644 mobile/openapi/test/download_files_dto_test.dart create mode 100644 mobile/openapi/test/edit_shared_link_dto_test.dart create mode 100644 mobile/openapi/test/share_api_test.dart create mode 100644 mobile/openapi/test/shared_link_response_dto_test.dart create mode 100644 mobile/openapi/test/shared_link_type_test.dart create mode 100644 server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts create mode 100644 server/apps/immich/src/api-v1/asset/dto/download-files.dto.ts create mode 100644 server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts create mode 100644 server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts create mode 100644 server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts create mode 100644 server/apps/immich/src/api-v1/share/share.controller.ts create mode 100644 server/apps/immich/src/api-v1/share/share.core.ts create mode 100644 server/apps/immich/src/api-v1/share/share.module.ts create mode 100644 server/apps/immich/src/api-v1/share/share.service.ts create mode 100644 server/apps/immich/src/api-v1/share/shared-link.repository.ts create mode 100644 server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts create mode 100644 server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts create mode 100644 server/libs/database/src/entities/shared-link.entity.ts create mode 100644 server/libs/database/src/migrations/1673150490490-AddSharedLinkTable.ts create mode 100644 web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte create mode 100644 web/src/lib/components/shared-components/dropdown-button.svelte create mode 100644 web/src/lib/components/sharedlinks-page/shared-link-card.svelte create mode 100644 web/src/routes/share/[key]/+error.svelte create mode 100644 web/src/routes/share/[key]/+page.server.ts create mode 100644 web/src/routes/share/[key]/+page.svelte create mode 100644 web/src/routes/share/[key]/photos/[assetId]/+page.server.ts create mode 100644 web/src/routes/share/[key]/photos/[assetId]/+page.svelte create mode 100644 web/src/routes/sharing/sharedlinks/+page.server.ts create mode 100644 web/src/routes/sharing/sharedlinks/+page.svelte diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index a34d0d4830..76099cd883 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -30,6 +30,7 @@ doc/CheckDuplicateAssetResponseDto.md doc/CheckExistingAssetsDto.md doc/CheckExistingAssetsResponseDto.md doc/CreateAlbumDto.md +doc/CreateAlbumShareLinkDto.md doc/CreateProfileImageResponseDto.md doc/CreateTagDto.md doc/CreateUserDto.md @@ -41,6 +42,8 @@ doc/DeleteAssetStatus.md doc/DeviceInfoApi.md doc/DeviceInfoResponseDto.md doc/DeviceTypeEnum.md +doc/DownloadFilesDto.md +doc/EditSharedLinkDto.md doc/ExifResponseDto.md doc/GetAssetByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md @@ -64,6 +67,9 @@ doc/ServerInfoResponseDto.md doc/ServerPingResponse.md doc/ServerStatsResponseDto.md doc/ServerVersionReponseDto.md +doc/ShareApi.md +doc/SharedLinkResponseDto.md +doc/SharedLinkType.md doc/SignUpDto.md doc/SmartInfoResponseDto.md doc/SystemConfigApi.md @@ -97,6 +103,7 @@ lib/api/device_info_api.dart lib/api/job_api.dart lib/api/o_auth_api.dart lib/api/server_info_api.dart +lib/api/share_api.dart lib/api/system_config_api.dart lib/api/tag_api.dart lib/api/user_api.dart @@ -131,6 +138,7 @@ lib/model/check_duplicate_asset_response_dto.dart lib/model/check_existing_assets_dto.dart lib/model/check_existing_assets_response_dto.dart lib/model/create_album_dto.dart +lib/model/create_album_share_link_dto.dart lib/model/create_profile_image_response_dto.dart lib/model/create_tag_dto.dart lib/model/create_user_dto.dart @@ -141,6 +149,8 @@ lib/model/delete_asset_response_dto.dart lib/model/delete_asset_status.dart lib/model/device_info_response_dto.dart lib/model/device_type_enum.dart +lib/model/download_files_dto.dart +lib/model/edit_shared_link_dto.dart lib/model/exif_response_dto.dart lib/model/get_asset_by_time_bucket_dto.dart lib/model/get_asset_count_by_time_bucket_dto.dart @@ -161,6 +171,8 @@ lib/model/server_info_response_dto.dart lib/model/server_ping_response.dart lib/model/server_stats_response_dto.dart lib/model/server_version_reponse_dto.dart +lib/model/shared_link_response_dto.dart +lib/model/shared_link_type.dart lib/model/sign_up_dto.dart lib/model/smart_info_response_dto.dart lib/model/system_config_dto.dart @@ -209,6 +221,7 @@ test/check_duplicate_asset_response_dto_test.dart test/check_existing_assets_dto_test.dart test/check_existing_assets_response_dto_test.dart test/create_album_dto_test.dart +test/create_album_share_link_dto_test.dart test/create_profile_image_response_dto_test.dart test/create_tag_dto_test.dart test/create_user_dto_test.dart @@ -220,6 +233,8 @@ test/delete_asset_status_test.dart test/device_info_api_test.dart test/device_info_response_dto_test.dart test/device_type_enum_test.dart +test/download_files_dto_test.dart +test/edit_shared_link_dto_test.dart test/exif_response_dto_test.dart test/get_asset_by_time_bucket_dto_test.dart test/get_asset_count_by_time_bucket_dto_test.dart @@ -243,6 +258,9 @@ test/server_info_response_dto_test.dart test/server_ping_response_test.dart test/server_stats_response_dto_test.dart test/server_version_reponse_dto_test.dart +test/share_api_test.dart +test/shared_link_response_dto_test.dart +test/shared_link_type_test.dart test/sign_up_dto_test.dart test/smart_info_response_dto_test.dart test/system_config_api_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 27387bfe0f633a7860928f7f81fde6022f3c50f9..786043e174b1147c9b2567acb6b0f6b7279d7909 100644 GIT binary patch delta 886 zcmZ{j%}&BV6onIo5EbJ>-MFffAdJ@f2Bt#&Nidqw4IwTHGw8%AB}~;cYATOlx-!0m zNuR=%8#l(6aPP*MX<=xAWzU@X?!9MjzpYpN{*m+BePp>vC#_q%er@#;tl{nr0GB1` zv^7oWdJpf}un;*d?xey=n6s=rDga!Z*G&KvO&n^GQH@M0cQMl<7_tESY61WeK`shE zb6=V45hjQRdVX_@rht?pSA))dmpB$wJIoghR@TF}{Liw_n_#`gJOr^jWdb3^6L8B! z>$+Jv;k}Ck?E%9l(e3bFOus^|PKXdiLKFZ$&S>c588Mqhh(JL;cs$OrA>tAG4EmWT z;iLFdXxo$Ivf}gP$H|(FX22!-&^v{*@DkqgFpM4|!B2+MklFWIPuZh?uc_A&ccPcL`s?szP q8zV1F=;3qecYa~?cLXmxt7BgSuvUE9V2VFfeHZ DzUdM8 diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index fbc0cfa8c1aaafb0130f7275ae574335f94f19df..7f57c201a9b64297ed95c8c47b1ee6cb86a27377 100644 GIT binary patch delta 432 zcmcan_pgg_!*Rxhq|)5rjKreU6raqzY%Q%=4X}`7L8e}AiZVn|B1lm& zSWym8k)}e8f|gc*e{hJFmV!Q5QXisF7otcPq-yg4Mh{^X9JX-TDIhGJe3xHb9>LW> z*8_6cCV_X1%3w7ho=Zu-f~|rxnh!uSlm82fa>CV34m8x$LN^ZVQLL6s{?8LUd5sV^ z7s4@<6M0)F+X!PfzO`gD^1n~pd2|=mF1^Ic!sZbqA ScE;-#O)ju|v6)3QL<;~EzMjPZ delta 18 Zcmey@#dxD`!*RyVdQ6VOoAsmvv;a{$2L%8C diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index d6139bd58b00cb439cc6c5fc235e0ef856957ac7..f8fafd51d25b0280ea3379567ecafa628527f6ff 100644 GIT binary patch delta 271 zcmbPslX3AA#tjNgtZtb(sl}5oFv?6$WEAB~$uG~#$xlqtO#=yU7G(03(E+Q_(&DmH z@K4H0O)gP@DFRDqpzt=Y7k$W}`fg+P0>v=nL-qP4WpEz{D9)j$!Od_Glaa#TY3B8 diff --git a/mobile/openapi/doc/CreateAlbumShareLinkDto.md b/mobile/openapi/doc/CreateAlbumShareLinkDto.md new file mode 100644 index 0000000000000000000000000000000000000000..dd305b4ff72ec5acf30d992764c2d6617553b2e7 GIT binary patch literal 554 zcma)(!D<3A5Qgu0iohPM3t8{&Y1{Q6s7P&3%Ytz;)nJkd*^#!;hi|goq87pC68@Pl z|72z|jvk!!y0ECg6i)#pL20%}yWYzHbL|clA!Av_=O`7YJ@6joUbY9h1Gd?0lnlhQ z^%#hchWPzpJW1vW7(FDkcUpy*45XoBOKE|B@t04Zt_r#~XipklR7<2th_e%eXG(}$ zn&r7nz06deM>fyb6tvs*BI=li53d8LlCAGH zWh$V6UI$x`WYEs%>&4@0*=$$! sXevX)X++PutgH?c)l^QJ!?%06U;opat5WDqAnW-@#Mi)I!{42AFh6@nbP29n;lQ$}(aw56=wDFouhZY%0o#?jaY`|-20K)W$$eKAk| z^n{-hAg8T#nD?Z#$t#7tJxZ_D678vE5+X@pNm2nHHH;8A*`4G;i?(f*^enJ<I#R6Sg(!zhBTX&fg_1A*2b4#T$#zJZm-(EE!sA(n}LB{Ki};t(^fM)w`|`bvYS z>&Fl;Fu^Kr1Pl(`G_aRpq@(6m@UCwk%^@*`9nhE@qg+xx3gr-Pjw}lxeFR Qq2*r|KLh^^p9>+r0r@hq82|tP literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/ShareApi.md b/mobile/openapi/doc/ShareApi.md new file mode 100644 index 0000000000000000000000000000000000000000..419f115fb261dd64c4c6c7ae3b41cea368c84604 GIT binary patch literal 4992 zcmeHKZBN@U5dQ98aZ)F>jn?)%9Y}>n1F1z7>pn3AUVXG4iJc`bqXYi?&T(m88r~!- z##p6k)jJd$nu@5_wG*o!1%wHgdG7hq-xz0m*_H!L^IO@ZeV{r9+Ah- zIVOf4`3WN1kljvROpWPpi7{Opfccp21iks(z8g^LVl9i|gv zhLqv#E)GNmR)+L_Aw>mRuE-ceeTf-l8P2U|hz_mq5B#yes&dseq7SvSya{XvvH(Yz zL|P@-Cn^*j8&Z5MnaHWh=vO0F`d2)Rf@cwtolC_7AikYSl^Cuj2-_|f;(DXmT{=iX zW+_h5h3G=317A!m)zo&~ffRGK*>8hiU>n&8(!RT9w=m4a3%F)LKZh7&G7k*F8T^>b*<}`%WFoO15JmOvg!!UJXT<+7G_0XNz|1n(y5YTumyAM?9FMK#qu|e zIhtGSPy^3f()lK7@Hp?n$eSS-XIc{(35#cannGpa~-#cl`T{v z(nKg(+FMdxRqih=x^jrOrMc{DY{MTt??6a@ E0OAQotpET3 literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/SharedLinkResponseDto.md b/mobile/openapi/doc/SharedLinkResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..b27cc6dbc219d8d121ca72373d241eeec0d05776 GIT binary patch literal 802 zcma)4-)q7!5Pr{J5$J-~BDHxkmgJ~0zE1R%CewW%&9m-Y;FYr_kaJZZ*Wj(GJxtIr7Daq+gyeVt-U7K5>`MU^Cf z)hv#Yl;fCyc^vn%1;02hhS#06k|RRWOGG7G8nMu+dfyi36WQPLCL}lQ1VR@lr>aOze4k`5t0~`+X{3i*RPGYCYaBXN%2Jds|s8T00Y%w8;;tvKc~ZdT=zVBXxzo5lhFlZFW? diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 7a0d91ae6f004db74909fa824e8eae3bc8f724e0..614aad807abc6f0e4d6bad616d984d9bfc687fa9 100644 GIT binary patch delta 472 zcmex6f${r1#tlkhasmFqAqx75IZ36t`pHG9i6yDJ#Tki3sVTZSnR(fhC)#*Uz9q!1 z6busc0gAYk>fK;*%TI{e&^yrat+iw3WDmEmm!l<;_1&Ruwuxq{E}Y-T=EaD7CmCKd(3yXtBK= zhS^{xBza)+2OiDI1;X6g2rEzm13kDvp|#nLO_xaxp#jNF>N=VViNy*Cxyk(;Zj*IQ L#W!1uxflTe8yc?q delta 14 Vcmex9kMZvW#tlkhn`LC9jQ}!A1%?0s diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index f8609a66e69260e4dc8ba4513bf30aa36895b07d..fb0e2b7190a96344f04d823edb91559b15b28c03 100644 GIT binary patch delta 292 zcmbQ7Kq4+B`LPNxMIez$ zTq=_f$a5HgWy3Q|GCWF33Ow`D@-@))p=c|f{D3JC-L{4dI{{>~LAFh1bY3>ufaS^L r1=7+4ogy_^N=8(nn2^;0=9-hwDsjkTx*5gx%?|duOq)N*siy(}ewl7G delta 18 acmex2g=u;R(*`^F$+x4pH*ZiqmjVD!&InKd diff --git a/mobile/openapi/lib/api/share_api.dart b/mobile/openapi/lib/api/share_api.dart new file mode 100644 index 0000000000000000000000000000000000000000..6695e6aa0afb613766a6144983b235adb94e672d GIT binary patch literal 8156 zcmeHM-%}em5PtVxvB^Wu?p*DZ=|hL`qXtZ1h5!X;G989-gl~vR1Q0el4WJ-ax9}fXBEFV=>kk^)SW}3KM2yK1!rA3pH3g zVN=~p#-^w6)0it#7i>hI2~+fKP-$9R>BZ`ODYRk2^ms%Y@&QNX)b+y6VlGhkVkxdk z%nX_6{r0lgA2OwDHQLUA9Dqzvu!J}1?^Uf<>&HxMI42owCme2TFdNh_Xl6I&wDC5O zxPQECz7c7R^T(05Xw#d<^sL0794NSm6w|fe~X}i07 z04;3|Yu)nbF0hs>X1Ek26@fxCRziarB3zuIPRLVu*`)2F;Y3`u0x`5K(Tf(=|xD<^e)nL-pug{Gee4#3X0xz?*W<$lbH3f z9mn;+xj7`G8ZI!4HgcgPg!NhMw>Y`_fPKP8BDL9((Udl-_$gh@!{E~x)%4VcN0aQv zAG6`s#pSDl)k9u ztq)p?fb6mGg%oQ!Zkt@($dA}QJ*=mGv_1~gXOG<=8x{_nO=Zr?#>KN{bNP|7nWEuM zt~;Z|Oko?uIF2bLOK%;acs+_Gz@0tEznm9&Hf0$?$ljjMl5fil4>6UpfhDI2<7N(# zr9`wDSOIM4oCd%LHpM-)G>M1JSntXRVQUj?CZD^R<`s+1h}uz`Mw*~Ntfb_`QeNkC z9}`Pjixvt~`)uaYx;B4yrUvy)wvifg=b%M-|89sxe+0;pGzc2i4XPOx3JF&>1t-Kb zB;*aYoWJ(+*AONoKV*azAr0}g-4M93gy|B!Z5)*vK0>Q9QQQzs(j307ihs%%sf)6};!!()*O1%*;Fi6MG1wrfz+i*+MI!gS2)8 z?F}nMz#fi(e=Lp1{)-oM^lEgTm7*i$u21+pIKFhGzMmDgW`yKV&SL%r+m>zX9z6F4 zCxl=jr~}8>zK*)`Q2@MWE$0~@)YKT8#pO(`l*HGaZdwo@UkfWW=^71jeQvfcWyANZ zGr`k~$bX|?JhePf9G9;+q(C|1+HBq z%aBNKh<`<_9uc#9S4zYWFm+Z2?E1vY51=@(hy^Rqa3pN2U$f8dMSZ;>W~9}tXSd_0 z^LJ~6-$vnYrg@H*lj(c@P#`N;9KQI9zsww-L!&_7p1q5>c{FzymC9gSG56}0yu7#M zAJjHlbj8k7aA$fLm6FD{8*JyNe0N&rrq1doyr&~GW@ zv3zlxwNmIVUtXXDZj0(wQCJm)Wh)AMouf{-^JD*=P^k;HX?KHp_LMSbRTf;=?;q&v zKebKcF|&M~ny0q$v&!K7Og{(a;rMqe!MuJ6&Z|PO>Y+cnhwc!WHO<|?-yt6AlHb+7 E0@W9D%m4rY literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f3d0604b7247446b750706d4e47db554747b5196..1e2ef461b29ca9cfffc2134950b8897985b1c8f9 100644 GIT binary patch delta 201 zcmZ2p^`UlynlhhbPEu)Za7JQLs!wKK_GCqA@yY9y*?3WeCvVVF*{q+Pqn0wCYf5Gb*uWH+iM%L6VAD>iU1j40*$Po)pre4q6V%n=h0BFh Y7NkygG#24QmU+nk}_!4Cj_Dhc@j diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index c59dc0f913d9e3ab765ecd40ab8dd7d51faf6a32..ba9f94ccdcdad6f49a6719e8f9c89979bb556bfe 100644 GIT binary patch delta 56 ycmaDMcS(MOEQfY*Mq*KFice-D9~3HFi$(-V62`t50-PR{A<{F2_C zpS*tc=QhMx^2JmrGki99`fQ6Iv+JT%ay%>5c$Vezfa;>waypi|T*$)o!*jN%N@X@` zh%cQdEdZV*EE2JLh zWpNELGZ)49=O^QIE|lKdf;&UyjLJ$DqRIyN_k3$B&4t$VQb}RtaXzUR7m${3v*Oc< zDd}d5XdiiaFHNlqO@a!!q)~_X6=gQqR1TomA(XI9qL2$R^Gpu}dwxCb+a>!({#|jZ zj!pDVjjIfA5rsE{`zBCjCW;c~uw<%mbH&K^8+> zDjE)x^T|h<0(vpf=6NrF8j*jRF9kiRo(VyoZlsO`w_dAJ)+!*)}{-DcnYc;Z>Msa8L($Z$7`@v3J-dnc-2TqF=(frMn~ zAu#=oE(?U_&gEHFh@8eB@s5VHCVq4K+%=#R8L6HRws!?_|F;v#W|TBh)3(cda6^rL zBVsKyPtL|I$s7l@3$06s>*>X9Z0tBnOSkJt$cNwL@|%WxqRsNXP~dJwF&g zBxm*R2cRrnb17y+x6Fu`0DLQtBT-Drky}lf(T>4rnyGT(O^z(70ri_fpvM$(gg(3K zyD_skwam0<3Z76OfbGZ`D5_<*s!oBFddwI)2H7=PItDMEms17<_o53Bk8CKmgMCN4 z5JR7FFF4i(P(@tfJ%O=tqx(L$B7;5wi~#es zFB)F^Nv<^0Bd>S{wnR@4tc>-BOab$HhSIrAKglWKlEJZO(+o6pB3C6+Kaot+0tLNq z?iriIzAP8SZw<~{RWJnxr*K7Kj=KQ(Zu>C6^uckHX4W~h5btjsEy#| zTNjS8E+?ZaS7n9?w#>cBQGrHTXc%M~gL}HDOf(NXsEPMbE_v8kHMr&qx<%J` z>~V8%EKtaW%L8L%L?ybc1!@u30#Q6z3R|I5>8~Czml01~2&|j$@oC zT3c4URjW|g0Y7C@hfQ&Azp=p9-oW-lRIaZS8fhCjMT>w#hhPDfLVd&UJJp6N%lTuU z%fv8flU2IQ$TBl?yy6k#Z$ce4dF-U2>`#9F0)WpM{3QuS;lfYyyQo{Z!#W4zH>;? zmM_`brvVEPOCrzxeBU8^y-u$O@Berlo&P?(A3nT)9A3lC?Z;sZ*CV(eJ;JBa_3h1{ zCn!de?+T`EdXc=j=+LKll1lSzrgSzFCGSHmYr~6-mwdscjl;KCR7%^U8mwHhmC35w zW}5$13ytcME%3Ku8lRSHgT}QvuAVDtER!}91v(Tn!L?I2N1c^Ia+8%xKB1VMGnu{p zCClebn@$JaSx_@j6_>0M3I1PpI(f;AforvtrDDYmCTXs%g69rE4-4?XZLOsN29hsf zye~Ha;lj&#f(rY)5m^DSb72zn!tHHgAO)8aj3+VfuFo(AfRDo1Uq4$d2tc=KM9Cp5c_irGMqYfEK|e>m0FTGD1~`#IhpKkb`qoP;30c0gkU+46bxd zDSd3Aju=klA|rfZKw%xgSGXy#6G?Kmf%>*Hkp}>=1p^;SQ)`aAVHSTlELcUPu?)%P z7Q!HbuY6^Qd5z+|vK-#@olH|bf?n4`KIi#YUI0!qid_`Ku3*Yn3Tr;)NIQ^9o>Lk3 z&0UZ=%qu6HYfD3u zyzf-H(XxutibARS5Z;^>H*A|WvzZhgrY+l4M>5uj$-3Sd5SjLsCmKF{|GzOnN7u7_=z{{-10R34?xv}N4z~y zl!Tvf2)j!Ip6PhGSk$;R<$yONgYgrMm_Nw2=<&Dpp!@+(@NJU^&u(CC?}hG4f7!u# F^fz~}OyU3l literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/edit_shared_link_dto.dart b/mobile/openapi/lib/model/edit_shared_link_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..458ce2f7597e0c93f30e3abd6761d36c4eb5103f GIT binary patch literal 6044 zcmeHLTW{Mo6n^)wI0=HSPA$dX1<^^7$CecvG^ znYQe-dsqhy4G`NR&;5RMbksRIq8A@7&maFXIvc%zaXC7rSFhiWB04>%v-3-OcYgZ% z)gOBRW60-;(01^&|Kw?hAJt8kYdM+bdNNh1Jf<=$jZ7vom2;WdsD4)SBG+~+hDaA; zX(mN!CtChlDh>2Z%<)+Wjc+Tp0dZ}%qsMt>tjO#{C19wgN@|C0wlYgqCe0+xvm1cf znaCzT{WytdLYqzp>@4L|$_tr^LiO?gtka28VGNBDWiJ3Glea3nJ+(RAb%>5&f%npu zIx{3FlM5QR0Ix_n0b|(*M(euAbcpOsnSLTotW{xEo<*xu`B%XbbUQkGkI>k@3K(9l@hC7D@XrU}i&CrQ^*W&p|_ zXJwj->s0R1h00vv>bG+Y3a2U}nU&v2VM-JphW>O0)lZw2_OAQg?9F*s!u{4Dt=vu_L1dl*&rVU3_ zB9mPf&g$%^mxvRDh$fLF9&C#Mkb8%Mjsfs-4U@6;*qtDZ&kL(T@dTQn4 z8N~F{(^O?5rO8K}(STauclXbnchr>D9J}WoTT>xVVXV7>X5A}L44yrudy*-JD9?R2 z9*0eZ&Fl*8G^I57Wi)&YT-M!bD+I=-C|AU#HM3#_WZVV}F{FrNKs`EK0^58jklP}hQDFC{>D2x%) zSCKC&uCy+csm94_p*qG$ET>pe+;*eO7-ii|xZqE)>{t~~uounpgsYW5XjktOCxpwB zDsQ}Sb+~gj_@NxN9}?B)gn^#Zsmdk?(vO9)t-R{_-GVy=^c-cT)I1*x>t^V=C@`sK zR$%{e2laMGw{mHCdae-9Fig{vV+Z57!A2;@T}$yy#)N3jVi&o3~D~k&eMk1c| zdJukPDF&-h*ia_usiPZaL!Ep*I)jXZnUUm=tUdw_srR|FZ8pv*d9+ajQ0iK&y`~%34z-BG^0+mhh6{*Uw z_s%$#>FP@DYEPBQZxxcQWz*parMP&7=vYf?Z@oJ=0^aC+!OJ>&7FJ^;qx(8meNJm> zco#>HY?B{fc^7kCV456~ED44?R}1>eAX7*4!UhSznc1so=L^m*My;8$_xz>HYw&Hc z?Q^s{eIR-ed8_KwYLiUWE7HdCMa4oB`L2U18ar_3G8eI;Q%UqY`=oWwEJx zEzyS@&s*J5>+f~vLKsXXZ93t6Qd=IJA2hs!8+ga;D$ZP${R=wullooS1Z4z={C3XI zx^U6Ti8$5E+KqjFQqt7wusy%posLr9`8{yX7u*U{jXk0(a06ea>e5qtv;JQ5El?S* z>f!7fu652#xnG7B%Fb}aNyM-DT7G!!;eb}Paen867g+pS5ad9-%FlN}RB$A1NhTK? ze77||BKo>?1=lcgcJcZ@#SeR@u~`s9QIn*rbdCOlz&gS^Q`y8&JtDY~1``KHANOAJ hTJivAAV24l@J)6VKyIpAn=e*w9^P>jKkFcn{|2%)sKfvO literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..3c51d90d2d2be1007224eb8d7cc7cbdd1c992332 GIT binary patch literal 6556 zcmeHLZEqVl68`RAF%FDsh1GaSx5^Fp4U9-W@j`7c+OFMqoDaPjf{>f$xMdHe1nrPr7A z;qr>!U%r0(=3mDkW6B?j%rui%qwiiF@lfAXwNdkBZRSf|sxfV=tyRTbm1?c3CT+)h zy{S#p%aN70*`1wlw$0qAe{Zz`dzG#6cas_XHp*BCH@2TWuPfVRRWsKGHq=Y4jIi6@ z%}QM?V~>uoT|@Pf>W!+hjUM6u_eV#0nORGpK%$Cwy88T4 z*$uW-ubZ0gkBClTg^#M)n#xi}mAa)_FYz^LQ8ZU0aP^WTERU$Q0yQgbN6pGD4QF-X>?un(ZVI>t*76W{#o7iG2}*NjVjKYzD4z)4V+}#MNt4YQjMhvi92X^ z9VwTUi|x8kP*#@p?Jt|M&P2C|A-u778vd)H2(a4SmVVt)gUWSt}Ou!mo^91Pz8Mt?FC#MVVAcZd9cVTh@!W z_0~XzyoTiU);6?+O=ywjp9%1S-Ygra@+yll7(RUNAAv$(-cVK)j;u!k=>5n6WdrbI z875=vdBG6>Nlbl)qXt;TE^u&)X8@yk7~m64o>9P!%)ZmM`J3BlN)A3U|EFXBK*yeQ zC`K9Vj3hY4@Qb=GF$^IolRwnJt^rJd3L)Ug%9`5HWRhGiepNYACctloG?^kcIR)og znJ}o*cICI<+!q08y@`*$`3CawPee(|gM2~z-jXhO0e^QnBB1hmJlrL8&}AC`wYv-) z^BVu`E(ApLayQr|3IJ!KZm>(#X3B{N{oY3659W6td%T@@9LeL((p5t@szDC1s~* zHlLjE-xLs^oS?+VHk_P9rbcHaMSXJOep3eRwz5feYWu_9E#qdY18=>NB!B>6%OjUnWe6>lIFi+4(-7K`TfK9 zF7zGA%nbPagKN<8$Nr)&WQm&q&KG(~Nn{i{cKB=erXVyT5&_ZJNqQhvqKmlSQzoJl zKE{P-*rRlA5F+>K$T}6^8d=5f{!;Lg5QLE<<1+?_J$}MKA~M3rE8Bm%KqWknpUr_o zh}>tVnTwc5?JmoXkrDNzHXZE~nsgZSQUfNPY$L6>^E=Nxa7)SU(%1FqlvM?}ON%9q z&Q`iTOG{I)y}+4Fp{-wnV8G4)4DoQCx(q0l>{eUfRr!n37@eV7;_4x7vf1XSq;}3V zc8*I7O6MH+omE|M5_S{)1@lZ4kvQ{l=M-##&zN!jDM~K1YUc^PEr+9zMj;vHS?I*N zFiu=E&Orv_T~u~!xK+#=X(!j&hCBTx!<|MZ!mR@6w%ka1#t-N#>ASJecoTA^88noy zRQ_2NgsUU#t_zKZeWC7Z7=EF+p>3P>eQ~c?9L^v06uD~v~cQUNI&Fr@b3vAYbi0=gIz!1PbPOInwewN#3^muc)?j=4`LmpXa)&=8>1X)^Wqeqdu*VYe zaDf{lcLI=n1q%*jZ=8`>JthO1m~0qliD@$2VH@w=`dO4D^D&+8T{sRP;gf#kov6(? zI5A&ArU4ldWn?iA?FUiOgSRaxQVo1!br;aScb(}Ol{oX87QJnXb%?x|d(e%kZ}}Ag zeG2aER;3SZ=5QGn4Cs&JlZ<YWGTTtD6<3II#GUwbvgn9W7XW!^MwOcyZ&0S%}02K6c+n zD0w1|1uMMXib)mTJw$PvBD|78o2PWyocRIq(-P3!?Y3@?$ItXq^{Fm<#{D=#`|LiKcpXf@n SpYV0K9Yxm~p2DRVq4Zy}kSW3d literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/shared_link_type.dart b/mobile/openapi/lib/model/shared_link_type.dart new file mode 100644 index 0000000000000000000000000000000000000000..117eb7ca011491410b9d7decec0e96bea3e1a26b GIT binary patch literal 2676 zcmai0ZI9YU5dO}un3JlUPok#Hr;3yiD!7)2a8V8_RR~#&y@u`CyYBA#5Q^@6jZ?_D@2IzcfDRO&X_Uqh%!7`2>G-+6&$|y00x-<+&dS{a$q3&5x6XKI0}0J zd*D!L^^Acfuz6-#$+scuxrkc^!zi!^VL~T$hi<+36`Ckz!gY2Mau4p7LL^!5Dd+i) zYciV($ufAfx{-8dKB#A|HO7D<)umPbA-IFldvDZFcRz=hp+kLO6}ymi4;rmj`TkZP5?VkIYpESAUjkIH&2N}yS3 zCJjzX&UJ7XCOlRN4=x&B`JkNJF^(a_U+>%A{>47^;p_%WoLO|Pqjs~sWb#0ap;$NL89mT+N($G zE1)!WLuCgQ3c!cpHEu>A%PhMz2;fr)S&}qm^LhX;E`|ErpES3nmbI2ulvGfcY1kCZ z`G4uX5T6a8X%eCxYvG3>icfmPM8^_^8FkpX87rcL&J7M!dog6Yo$rVhi&tpFvm_HRjA_ z@5=TMBJjQmeQSw()NfXv%(Pb8CwagzlGml6&Cv=7<2(VbyyOH&$_6b?KFWYv&?s|<9nj_Z>XOFutK*H zJT(%_R66J7aQiLAv&dOH;VDD<9b)$Ni!!)r^1BR=BrNq5CVF4e|0vh%;C3yaBzD?w ze%~OR2@l&L{6zKeo45Ay9a-RmM6nt(`5}|x=7r47EC6M$GZFv* delta 12 Tcmca7xI|#XLgvlUEX^zcA|V8k diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 924e8e288c89fe55e17b0990c140ce74fe0dc4a4..c7f356dac3fd576b0f52f4c4c1594886e519b60e 100644 GIT binary patch delta 70 zcmca8drxkIBdfAoW=?9chD&~VUQT{u3Yh0olCO}0Brtgav-IQ~Rt{FM?BtJZMw4f= L2yAv_oxlkIqD2`_ delta 12 Tcmca7cTskOBkSgKtP?l^BtZoL diff --git a/mobile/openapi/test/create_album_share_link_dto_test.dart b/mobile/openapi/test/create_album_share_link_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..bb9bf91663e51636813573e9ad592b514d9be2d5 GIT binary patch literal 906 zcmb7BPfP1S5WnYB%saJ0HR|c12o_VNpa!%aJgs9gO&2%2>+VEf5x@JJO{)l2)I&Bq z@cTQP=UJY^_-0mapG__&SL4~FfYa&uWCTSCm*osD%3^wY`$u32d0b=Qqy3$|{Va{T zu`ZCByP)RU&;fL|i&QHz)RN`V+pJsf0&irXdB$Z_zGD^WvC~257F#(z2Kh6EDB>Y* zrdQ5JM$1aqVyNeuLQjty&5X7bm2tKa7%$MOzq_hhV2D|^$_N9G)dMzk;5=FMI+t%J zwH_RPf-ptS)A0sKrfH5KD}j?h$TT+hU3)Dabgu0~!7h7px`r0D9d5x(1|X!lwrD{2 zT`Gd3ueigl!%P@WaGf6xlGRtp!3fs!y}>mHZ5zN8W?G8`^g%_#^%vj;fe%tT@DlFU zxVx1K#-*+LsEH?WXJt|L8n3H7A1&ygPmeg}Z%y8jzp}Y^&PWgAo^QNy_>(U0*k2i~ ZS5{N3g7&PP{kG8u?9Z%3v0ve9_8*yTA3Oj6 literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/download_files_dto_test.dart b/mobile/openapi/test/download_files_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..fcc46a6c323b32e2e51d5a3152254eb755446588 GIT binary patch literal 604 zcmZuuOG^VW5WerP7*B1Xy6Q=MVPRV-R2Q@!L?jNov)f>rtjVlW#Q*Lj+EaVTJm7oG zO!7R-bC^6W%fs90{q$k7oE9*fFQ#KCO1LkVa90-d+4F(GBJ!q2&sV2MC#P9#b!(j` z)i|#jZRi|&+Xt!@8CsL&@wV6N&UxO;K=Xy0pt_!wr;lEHp<7%_?9fYQ@D+Z>vD3M{qqz0p zVh6$$IFDfqh^Da*ASZ#seOcqMp2=xK!7lrBx`s7sI~>7x1|aB0TQs245*5MK4rw^L z$OPJY*LTA~6yJdi#_%tgBo{jHqD&$`aa5^N{>gp;9va6! literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/edit_shared_link_dto_test.dart b/mobile/openapi/test/edit_shared_link_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..b7815e0ed068e34a6c68537f6b2dd70b4d1ed1a4 GIT binary patch literal 904 zcmb7>Piq@75XJBNDW)eo;AY#LNmgc6m1diolJDM;+!RwA z+#K|0;P>8}k;<|tOIY5w&D&4)ZT)50))ib`-_#4J8n|s*_}o<2S6|-rxt3JF_-CBoO6x$wQ=5_a|2RwHk zt@c#PI!#m0F7^<{$Yt7IfE1eQ2=XLQ_r!N%j{ObU$BJF?!%+{TCObcYUj=~RE3(=E z7%? ceSHwc^mlchsihH*ru{4UzeusoPx)N@24CJDpa1{> literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/share_api_test.dart b/mobile/openapi/test/share_api_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..fcc988cdffc713148f678e6ec636f1b37eb9052d GIT binary patch literal 1150 zcmb`FTTkOK5QX3KD~6}GQX#uMt+-SaB}J7Ak)S;AWLe2Hj7V%_JFKb||DACPgkE4T z+J`u@{hc{yCW+%Hj$wQ|&-O3UtMqz2PZO9-XXy}<46d>{TxQ91a<|7=AkPYE{psPs z_roY`sZ`pcSZFI2RN)acs&QBdtguGqhi`kTjkbQLLRLTJ(h1XeVezpc%W@@aUX8T; z_Gld!w{EvQ*UEXRyr6;&X+daPx!Y-0kwPabtxCrHy;S1IH<8~<>!N6t5qd`E53)!De;?emyEN?&g&{lRg>NNIIr`n~O9y&=3-33VooZ!Dg)PQ{GW!!lIeG!u_HdQ} literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/shared_link_response_dto_test.dart b/mobile/openapi/test/shared_link_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..46778bfa713acc806909ad12b998135aefd7b59f GIT binary patch literal 1516 zcmbW1QES^U6oudYEAF0>Kvr*k8tpn3a5@564Xyhy#&A{p+7Xc@O_JN0(f__HdF=u- zxbcH!%Y65obB(U!IErIf-Dc_AFYBxI^(tE@aJkv8myo1zm1giYO*WU`-Vo-=&m}YN z;`HSGY2>%OmdapWDU(-R;2E^CwOHmTutDjT&wJizW!zu|6OXL3xo%x<@Mp^nl}pyp zs+pm?Lt{zY*x~X*N$Z$&IWNhOR~$_!w}WPaOSHLAvL?*!n9M)C%ZnW|Hj4U;Fw?Bq zvl`EoM#(gf={oUU&(8lt99kFqdYX-h?Cd37EA= z12r$PVy$rSz(h-bnlSkA{l{Xo)n4GJ04s6dHU~os%)c0S^3+jA_eu#87wYj=3&qBd P-2wYLBMKb`^(p!TtuxKK literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/shared_link_type_test.dart b/mobile/openapi/test/shared_link_type_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..6a2c8cdf51a2df8983fea0297db17ddec35d6553 GIT binary patch literal 425 zcmZvY(M!WH5XRs0SDa7Xpf0*6*$^DGlfhg;>w{0>Xs=yZo5UnlhU|YgQTn2NxH~@h zefM3$IpZ8QuVr!lm>=@zO_^t~-R<)fvH}i82~S0~+rC`|ERqj3T7SP>+%8#^vQ^rW zXtWiL>}Ump8XVPvI_gRJbkwpp+WJWccAt25!VF$mdLN_>c7=WD25p$0tP9Jno3>Y4 zIgiQcmAbt=z>*oT%i^eEMA4quD`v{nyhmPErJHc4TGF7q{G G7xoS4?~oG! literal 0 HcmV?d00001 diff --git a/notes.md b/notes.md index 74e97d5d98..043dc05993 100644 --- a/notes.md +++ b/notes.md @@ -1,10 +1,6 @@ -# User defined storage structure +## Public sharing -# Folder structure -* Year is the top level - * Different parsing sequence will be the second level +### Albums -# Filename -* Filename will always be appended by a unique ID. Maybe use https://github.com/ai/nanoid - * Example: `notes.md` -> `notes-1234567890.md` -* Filename will be unique in the same folder \ No newline at end of file +- [ ] Add asset to shared link when new asset is added to shared album +- [ ] Prevent public user to delete asset from shared album diff --git a/server/apps/immich/src/api-v1/album/album-repository.ts b/server/apps/immich/src/api-v1/album/album-repository.ts index 988f68b5f7..f504a5bd35 100644 --- a/server/apps/immich/src/api-v1/album/album-repository.ts +++ b/server/apps/immich/src/api-v1/album/album-repository.ts @@ -1,7 +1,7 @@ import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/database'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository, SelectQueryBuilder, DataSource, Brackets } from 'typeorm'; +import { In, Repository, SelectQueryBuilder, DataSource, Brackets, Not, IsNull } from 'typeorm'; import { AddAssetsDto } from './dto/add-assets.dto'; import { AddUsersDto } from './dto/add-users.dto'; import { CreateAlbumDto } from './dto/create-album.dto'; @@ -14,6 +14,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; export interface IAlbumRepository { create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise; getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise; + getPublicSharingList(ownerId: string): Promise; get(albumId: string): Promise; delete(album: AlbumEntity): Promise; addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise; @@ -43,6 +44,21 @@ export class AlbumRepository implements IAlbumRepository { private dataSource: DataSource, ) {} + async getPublicSharingList(ownerId: string): Promise { + return this.albumRepository.find({ + relations: { + sharedLinks: true, + assets: true, + }, + where: { + ownerId, + sharedLinks: { + id: Not(IsNull()), + }, + }, + }); + } + async getCountByUserId(userId: string): Promise { const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] }); @@ -161,6 +177,9 @@ export class AlbumRepository implements IAlbumRepository { .leftJoinAndSelect('assets.assetInfo', 'assetInfo') .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC'); + // Get information of shared links in albums + query = query.leftJoinAndSelect('album.sharedLinks', 'sharedLink'); + const albums = await query.getMany(); albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf()); @@ -203,6 +222,7 @@ export class AlbumRepository implements IAlbumRepository { .leftJoinAndSelect('album.assets', 'assets') .leftJoinAndSelect('assets.assetInfo', 'assetInfo') .leftJoinAndSelect('assetInfo.exifInfo', 'exifInfo') + .leftJoinAndSelect('album.sharedLinks', 'sharedLinks') .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC') .getOne(); diff --git a/server/apps/immich/src/api-v1/album/album.controller.ts b/server/apps/immich/src/api-v1/album/album.controller.ts index 678c94bbf3..a10a992979 100644 --- a/server/apps/immich/src/api-v1/album/album.controller.ts +++ b/server/apps/immich/src/api-v1/album/album.controller.ts @@ -33,25 +33,29 @@ import { IMMICH_CONTENT_LENGTH_HINT, } from '../../constants/download.constant'; import { DownloadDto } from '../asset/dto/download-library.dto'; +import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto'; // TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe. -@Authenticated() + @ApiBearerAuth() @ApiTags('Album') @Controller('album') export class AlbumController { constructor(private readonly albumService: AlbumService) {} + @Authenticated() @Get('count-by-user-id') async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise { return this.albumService.getAlbumCountByUserId(authUser); } + @Authenticated() @Post() async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) { return this.albumService.create(authUser, createAlbumDto); } + @Authenticated() @Put('/:albumId/users') async addUsersToAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -61,6 +65,7 @@ export class AlbumController { return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId); } + @Authenticated({ isShared: true }) @Put('/:albumId/assets') async addAssetsToAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -70,6 +75,7 @@ export class AlbumController { return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId); } + @Authenticated() @Get() async getAllAlbums( @GetAuthUser() authUser: AuthUserDto, @@ -78,6 +84,7 @@ export class AlbumController { return this.albumService.getAllAlbums(authUser, query); } + @Authenticated({ isShared: true }) @Get('/:albumId') async getAlbumInfo( @GetAuthUser() authUser: AuthUserDto, @@ -86,6 +93,7 @@ export class AlbumController { return this.albumService.getAlbumInfo(authUser, albumId); } + @Authenticated() @Delete('/:albumId/assets') async removeAssetFromAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -95,6 +103,7 @@ export class AlbumController { return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId); } + @Authenticated() @Delete('/:albumId') async deleteAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -103,6 +112,7 @@ export class AlbumController { return this.albumService.deleteAlbum(authUser, albumId); } + @Authenticated() @Delete('/:albumId/user/:userId') async removeUserFromAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -112,6 +122,7 @@ export class AlbumController { return this.albumService.removeUserFromAlbum(authUser, albumId, userId); } + @Authenticated() @Patch('/:albumId') async updateAlbumInfo( @GetAuthUser() authUser: AuthUserDto, @@ -121,6 +132,7 @@ export class AlbumController { return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId); } + @Authenticated({ isShared: true }) @Get('/:albumId/download') async downloadArchive( @GetAuthUser() authUser: AuthUserDto, @@ -139,4 +151,13 @@ export class AlbumController { res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`); return stream; } + + @Authenticated() + @Post('/create-shared-link') + async createAlbumSharedLink( + @GetAuthUser() authUser: AuthUserDto, + @Body(ValidationPipe) createAlbumShareLinkDto: CreateAlbumSharedLinkDto, + ) { + return this.albumService.createAlbumSharedLink(authUser, createAlbumShareLinkDto); + } } diff --git a/server/apps/immich/src/api-v1/album/album.module.ts b/server/apps/immich/src/api-v1/album/album.module.ts index 45b56b86cc..aa1077d3fe 100644 --- a/server/apps/immich/src/api-v1/album/album.module.ts +++ b/server/apps/immich/src/api-v1/album/album.module.ts @@ -7,6 +7,7 @@ import { AlbumRepository, IAlbumRepository } from './album-repository'; import { DownloadModule } from '../../modules/download/download.module'; import { AssetModule } from '../asset/asset.module'; import { UserModule } from '../user/user.module'; +import { ShareModule } from '../share/share.module'; const ALBUM_REPOSITORY_PROVIDER = { provide: IAlbumRepository, @@ -19,6 +20,7 @@ const ALBUM_REPOSITORY_PROVIDER = { DownloadModule, UserModule, forwardRef(() => AssetModule), + ShareModule, ], controllers: [AlbumController], providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER], diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index 2239284cc2..61633d7c90 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -3,15 +3,15 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { AlbumEntity } from '@app/database'; import { AlbumResponseDto } from './response-dto/album-response.dto'; -import { IAssetRepository } from '../asset/asset-repository'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { IAlbumRepository } from './album-repository'; import { DownloadService } from '../../modules/download/download.service'; +import { ISharedLinkRepository } from '../share/shared-link.repository'; describe('Album service', () => { let sut: AlbumService; let albumRepositoryMock: jest.Mocked; - let assetRepositoryMock: jest.Mocked; + let sharedLinkRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; const authUser: AuthUserDto = Object.freeze({ @@ -33,7 +33,7 @@ describe('Album service', () => { albumEntity.sharedUsers = []; albumEntity.assets = []; albumEntity.albumThumbnailAssetId = null; - + albumEntity.sharedLinks = []; return albumEntity; }; @@ -94,6 +94,7 @@ describe('Album service', () => { }, }, ]; + albumEntity.sharedLinks = []; return albumEntity; }; @@ -113,6 +114,7 @@ describe('Album service', () => { beforeAll(() => { albumRepositoryMock = { + getPublicSharingList: jest.fn(), addAssets: jest.fn(), addSharedUsers: jest.fn(), create: jest.fn(), @@ -127,31 +129,20 @@ describe('Album service', () => { getSharedWithUserAlbumCount: jest.fn(), }; - assetRepositoryMock = { + sharedLinkRepositoryMock = { create: jest.fn(), - update: jest.fn(), - getAllByUserId: jest.fn(), - getAllByDeviceId: jest.fn(), - getAssetCountByTimeBucket: jest.fn(), + remove: jest.fn(), + get: jest.fn(), getById: jest.fn(), - getDetectedObjectsByUserId: jest.fn(), - getLocationsByUserId: jest.fn(), - getSearchPropertiesByUserId: jest.fn(), - getAssetByTimeBucket: jest.fn(), - getAssetByChecksum: jest.fn(), - getAssetCountByUserId: jest.fn(), - getAssetWithNoEXIF: jest.fn(), - getAssetWithNoThumbnail: jest.fn(), - getAssetWithNoSmartInfo: jest.fn(), - getExistingAssets: jest.fn(), - countByIdAndUser: jest.fn(), + getByKey: jest.fn(), + save: jest.fn(), }; downloadServiceMock = { downloadArchive: jest.fn(), }; - sut = new AlbumService(albumRepositoryMock, assetRepositoryMock, downloadServiceMock as DownloadService); + sut = new AlbumService(albumRepositoryMock, sharedLinkRepositoryMock, downloadServiceMock as DownloadService); }); it('creates album', async () => { @@ -175,10 +166,8 @@ describe('Album service', () => { albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums)); const result = await sut.getAllAlbums(authUser, {}); - expect(result).toHaveLength(3); + expect(result).toHaveLength(1); expect(result[0].id).toEqual(ownedAlbum.id); - expect(result[1].id).toEqual(ownedSharedAlbum.id); - expect(result[2].id).toEqual(sharedWithMeAlbum.id); }); it('gets an owned album', async () => { diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts index 941df5df02..ffb364637d 100644 --- a/server/apps/immich/src/api-v1/album/album.service.ts +++ b/server/apps/immich/src/api-v1/album/album.service.ts @@ -1,7 +1,7 @@ -import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { CreateAlbumDto } from './dto/create-album.dto'; -import { AlbumEntity } from '@app/database'; +import { AlbumEntity, SharedLinkType } from '@app/database'; import { AddUsersDto } from './dto/add-users.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { UpdateAlbumDto } from './dto/update-album.dto'; @@ -9,19 +9,28 @@ import { GetAlbumsDto } from './dto/get-albums.dto'; import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto'; import { IAlbumRepository } from './album-repository'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; -import { IAssetRepository } from '../asset/asset-repository'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsDto } from './dto/add-assets.dto'; import { DownloadService } from '../../modules/download/download.service'; import { DownloadDto } from '../asset/dto/download-library.dto'; +import { ShareCore } from '../share/share.core'; +import { ISharedLinkRepository } from '../share/shared-link.repository'; +import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto'; +import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto'; +import _ from 'lodash'; @Injectable() export class AlbumService { + readonly logger = new Logger(AlbumService.name); + private shareCore: ShareCore; + constructor( @Inject(IAlbumRepository) private _albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private _assetRepository: IAssetRepository, + @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, private downloadService: DownloadService, - ) {} + ) { + this.shareCore = new ShareCore(sharedLinkRepository); + } private async _getAlbum({ authUser, @@ -63,8 +72,14 @@ export class AlbumService { albums = await this._albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId); } else { albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); + if (getAlbumsDto.shared) { + const publicSharingAlbums = await this._albumRepository.getPublicSharingList(authUser.id); + albums = [...albums, ...publicSharingAlbums]; + } } + albums = _.uniqBy(albums, (album) => album.id); + for (const album of albums) { await this._checkValidThumbnail(album); } @@ -85,6 +100,11 @@ export class AlbumService { async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise { const album = await this._getAlbum({ authUser, albumId }); + + for (const sharedLink of album.sharedLinks) { + await this.shareCore.removeSharedLink(sharedLink.id, authUser.id); + } + await this._albumRepository.delete(album); } @@ -125,6 +145,11 @@ export class AlbumService { addAssetsDto: AddAssetsDto, albumId: string, ): Promise { + if (authUser.isPublicUser && !authUser.isAllowUpload) { + this.logger.warn('Deny public user attempt to add asset to album'); + throw new ForbiddenException('Public user is not allowed to upload'); + } + const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); const result = await this._albumRepository.addAssets(album, addAssetsDto); const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); @@ -174,4 +199,19 @@ export class AlbumService { album.albumThumbnailAssetId = dto.albumThumbnailAssetId || null; } } + + async createAlbumSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise { + const album = await this._getAlbum({ authUser, albumId: dto.albumId }); + + const sharedLink = await this.shareCore.createSharedLink(authUser.id, { + sharedType: SharedLinkType.ALBUM, + expiredAt: dto.expiredAt, + allowUpload: dto.allowUpload, + album: album, + assets: [], + description: dto.description, + }); + + return mapSharedLinkToResponseDto(sharedLink); + } } diff --git a/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts b/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts new file mode 100644 index 0000000000..a0ab83c1c3 --- /dev/null +++ b/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts @@ -0,0 +1,19 @@ +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateAlbumShareLinkDto { + @IsString() + @IsNotEmpty() + albumId!: string; + + @IsString() + @IsOptional() + expiredAt?: string; + + @IsBoolean() + @IsOptional() + allowUpload?: boolean; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts b/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts index 41279252d5..cb71543a0e 100644 --- a/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts +++ b/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts @@ -33,7 +33,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { id: entity.id, ownerId: entity.ownerId, sharedUsers, - shared: sharedUsers.length > 0, + shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [], assetCount: entity.assets?.length || 0, }; @@ -55,7 +55,7 @@ export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto id: entity.id, ownerId: entity.ownerId, sharedUsers, - shared: sharedUsers.length > 0, + shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, assets: [], assetCount: entity.assets?.length || 0, }; diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index ccc9076073..4efe35bb16 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -226,7 +226,7 @@ export class AssetRepository implements IAssetRepository { where: { id: assetId, }, - relations: ['exifInfo', 'tags'], + relations: ['exifInfo', 'tags', 'sharedLinks'], }); } diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index f9a0c109ab..a2f9a7f7f1 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -49,14 +49,15 @@ import { IMMICH_ARCHIVE_FILE_COUNT, IMMICH_CONTENT_LENGTH_HINT, } from '../../constants/download.constant'; +import { DownloadFilesDto } from './dto/download-files.dto'; -@Authenticated() @ApiBearerAuth() @ApiTags('Asset') @Controller('asset') export class AssetController { constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {} + @Authenticated({ isShared: true }) @Post('upload') @UseInterceptors( FileFieldsInterceptor( @@ -84,6 +85,7 @@ export class AssetController { return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData); } + @Authenticated({ isShared: true }) @Get('/download/:assetId') async downloadFile( @GetAuthUser() authUser: AuthUserDto, @@ -95,6 +97,23 @@ export class AssetController { return this.assetService.downloadFile(query, assetId, res); } + @Authenticated({ isShared: true }) + @Post('/download-files') + async downloadFiles( + @GetAuthUser() authUser: AuthUserDto, + @Response({ passthrough: true }) res: Res, + @Body(new ValidationPipe()) dto: DownloadFilesDto, + ): Promise { + await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]); + const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto); + res.attachment(fileName); + res.setHeader(IMMICH_CONTENT_LENGTH_HINT, fileSize); + res.setHeader(IMMICH_ARCHIVE_FILE_COUNT, fileCount); + res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`); + return stream; + } + + @Authenticated({ isShared: true }) @Get('/download-library') async downloadLibrary( @GetAuthUser() authUser: AuthUserDto, @@ -109,6 +128,7 @@ export class AssetController { return stream; } + @Authenticated({ isShared: true }) @Get('/file/:assetId') @Header('Cache-Control', 'max-age=31536000') async serveFile( @@ -122,6 +142,7 @@ export class AssetController { return this.assetService.serveFile(assetId, query, res, headers); } + @Authenticated({ isShared: true }) @Get('/thumbnail/:assetId') @Header('Cache-Control', 'max-age=31536000') async getAssetThumbnail( @@ -135,21 +156,25 @@ export class AssetController { return this.assetService.getAssetThumbnail(assetId, query, res, headers); } + @Authenticated() @Get('/curated-objects') async getCuratedObjects(@GetAuthUser() authUser: AuthUserDto): Promise { return this.assetService.getCuratedObject(authUser); } + @Authenticated() @Get('/curated-locations') async getCuratedLocations(@GetAuthUser() authUser: AuthUserDto): Promise { return this.assetService.getCuratedLocation(authUser); } + @Authenticated() @Get('/search-terms') async getAssetSearchTerms(@GetAuthUser() authUser: AuthUserDto): Promise { return this.assetService.getAssetSearchTerm(authUser); } + @Authenticated() @Post('/search') async searchAsset( @GetAuthUser() authUser: AuthUserDto, @@ -158,6 +183,7 @@ export class AssetController { return this.assetService.searchAsset(authUser, searchAssetDto); } + @Authenticated() @Post('/count-by-time-bucket') async getAssetCountByTimeBucket( @GetAuthUser() authUser: AuthUserDto, @@ -166,6 +192,7 @@ export class AssetController { return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto); } + @Authenticated() @Get('/count-by-user-id') async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise { return this.assetService.getAssetCountByUserId(authUser); @@ -174,6 +201,7 @@ export class AssetController { /** * Get all AssetEntity belong to the user */ + @Authenticated() @Get('/') @ApiHeader({ name: 'if-none-match', @@ -186,6 +214,7 @@ export class AssetController { return assets; } + @Authenticated() @Post('/time-bucket') async getAssetByTimeBucket( @GetAuthUser() authUser: AuthUserDto, @@ -193,9 +222,11 @@ export class AssetController { ): Promise { return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto); } + /** * Get all asset of a device that are in the database, ID only. */ + @Authenticated() @Get('/:deviceId') async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) { return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId); @@ -204,6 +235,7 @@ export class AssetController { /** * Get a single asset's information */ + @Authenticated({ isShared: true }) @Get('/assetById/:assetId') async getAssetById( @GetAuthUser() authUser: AuthUserDto, @@ -216,6 +248,7 @@ export class AssetController { /** * Update an asset */ + @Authenticated() @Put('/:assetId') async updateAsset( @GetAuthUser() authUser: AuthUserDto, @@ -226,6 +259,7 @@ export class AssetController { return await this.assetService.updateAsset(authUser, assetId, dto); } + @Authenticated() @Delete('/') async deleteAsset( @GetAuthUser() authUser: AuthUserDto, @@ -265,6 +299,7 @@ export class AssetController { /** * Check duplicated asset before uploading - for Web upload used */ + @Authenticated({ isShared: true }) @Post('/check') @HttpCode(200) async checkDuplicateAsset( @@ -277,6 +312,7 @@ export class AssetController { /** * Checks if multiple assets exist on the server and returns all existing - used by background backup */ + @Authenticated() @Post('/exist') @HttpCode(200) async checkExistingAssets( diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index 6833d3e2e1..f987d8fc2d 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -14,6 +14,7 @@ import { AlbumModule } from '../album/album.module'; import { UserModule } from '../user/user.module'; import { StorageModule } from '@app/storage'; import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant'; +import { ShareModule } from '../share/share.module'; const ASSET_REPOSITORY_PROVIDER = { provide: IAssetRepository, @@ -32,6 +33,7 @@ const ASSET_REPOSITORY_PROVIDER = { StorageModule, forwardRef(() => AlbumModule), BullModule.registerQueue(...immichSharedQueues), + ShareModule, ], controllers: [AssetController], providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER], diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index c709f53a94..ce38fc84e5 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -13,6 +13,7 @@ import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job'; import { Queue } from 'bull'; import { IAlbumRepository } from '../album/album-repository'; import { StorageService } from '@app/storage'; +import { ISharedLinkRepository } from '../share/shared-link.repository'; describe('AssetService', () => { let sui: AssetService; @@ -24,6 +25,7 @@ describe('AssetService', () => { let assetUploadedQueueMock: jest.Mocked>; let videoConversionQueueMock: jest.Mocked>; let storageSeriveMock: jest.Mocked; + let sharedLinkRepositoryMock: jest.Mocked; const authUser: AuthUserDto = Object.freeze({ id: 'user_id_1', email: 'auth@test.com', @@ -128,12 +130,22 @@ describe('AssetService', () => { getAssetWithNoSmartInfo: jest.fn(), getExistingAssets: jest.fn(), countByIdAndUser: jest.fn(), + getSharePermission: jest.fn(), }; downloadServiceMock = { downloadArchive: jest.fn(), }; + sharedLinkRepositoryMock = { + create: jest.fn(), + get: jest.fn(), + getById: jest.fn(), + getByKey: jest.fn(), + remove: jest.fn(), + save: jest.fn(), + }; + sui = new AssetService( assetRepositoryMock, albumRepositoryMock, @@ -143,6 +155,7 @@ describe('AssetService', () => { videoConversionQueueMock, downloadServiceMock as DownloadService, storageSeriveMock, + sharedLinkRepositoryMock, ); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 33334bd2d1..ad07db7b1c 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -56,11 +56,17 @@ import { DownloadService } from '../../modules/download/download.service'; import { DownloadDto } from './dto/download-library.dto'; import { IAlbumRepository } from '../album/album-repository'; import { StorageService } from '@app/storage'; +import { ShareCore } from '../share/share.core'; +import { ISharedLinkRepository } from '../share/shared-link.repository'; +import { DownloadFilesDto } from './dto/download-files.dto'; const fileInfo = promisify(stat); @Injectable() export class AssetService { + readonly logger = new Logger(AssetService.name); + private shareCore: ShareCore; + constructor( @Inject(IAssetRepository) private _assetRepository: IAssetRepository, @@ -80,7 +86,10 @@ export class AssetService { private downloadService: DownloadService, private storageService: StorageService, - ) {} + @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, + ) { + this.shareCore = new ShareCore(sharedLinkRepository); + } public async handleUploadedAsset( authUser: AuthUserDto, @@ -253,6 +262,24 @@ export class AssetService { return this.downloadService.downloadArchive(dto.name || `library`, assets); } + public async downloadFiles(dto: DownloadFilesDto) { + const assetToDownload = []; + + for (const assetId of dto.assetIds) { + const asset = await this._assetRepository.getById(assetId); + assetToDownload.push(asset); + + // Get live photo asset + if (asset.livePhotoVideoId) { + const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId); + assetToDownload.push(livePhotoAsset); + } + } + + const now = new Date().toISOString(); + return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload); + } + public async downloadFile(query: ServeFileDto, assetId: string, res: Res) { try { let fileReadStream = null; @@ -649,7 +676,15 @@ export class AssetService { async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) { for (const assetId of assetIds) { - // Step 1: Check if user owns asset + // Step 1: Check if asset is part of a public shared + if (authUser.sharedLinkId) { + const canAccess = await this.shareCore.hasAssetAccess(authUser.sharedLinkId, assetId); + if (!canAccess) { + throw new ForbiddenException(); + } + } + + // Step 2: Check if user owns asset if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) { continue; } @@ -660,8 +695,6 @@ export class AssetService { if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) { continue; } - - //TODO: Step 3: Check if asset is part of a public album } throw new ForbiddenException(); } diff --git a/server/apps/immich/src/api-v1/asset/dto/download-files.dto.ts b/server/apps/immich/src/api-v1/asset/dto/download-files.dto.ts new file mode 100644 index 0000000000..557db73d57 --- /dev/null +++ b/server/apps/immich/src/api-v1/asset/dto/download-files.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class DownloadFilesDto { + @IsNotEmpty() + @ApiProperty({ + isArray: true, + type: String, + title: 'Array of asset ids to be downloaded', + }) + assetIds!: string[]; +} diff --git a/server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts b/server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts new file mode 100644 index 0000000000..388fa67e7d --- /dev/null +++ b/server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts @@ -0,0 +1,11 @@ +import { AlbumEntity, AssetEntity } from '@app/database'; +import { SharedLinkType } from '@app/database/entities/shared-link.entity'; + +export class CreateSharedLinkDto { + description?: string; + expiredAt?: string; + sharedType!: SharedLinkType; + assets!: AssetEntity[]; + album?: AlbumEntity; + allowUpload?: boolean; +} diff --git a/server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts b/server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts new file mode 100644 index 0000000000..fb9a794958 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsOptional } from 'class-validator'; + +export class EditSharedLinkDto { + @IsOptional() + description?: string; + + @IsOptional() + expiredAt?: string; + + @IsOptional() + allowUpload?: boolean; + + @IsNotEmpty() + isEditExpireTime?: boolean; +} diff --git a/server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts b/server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts new file mode 100644 index 0000000000..a0490698e0 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts @@ -0,0 +1,40 @@ +import { SharedLinkEntity, SharedLinkType } from '@app/database'; +import { ApiProperty } from '@nestjs/swagger'; +import _ from 'lodash'; +import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto'; +import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto'; + +export class SharedLinkResponseDto { + id!: string; + description?: string; + userId!: string; + key!: string; + + @ApiProperty({ enumName: 'SharedLinkType', enum: SharedLinkType }) + type!: SharedLinkType; + createdAt!: string; + expiresAt!: string | null; + assets!: AssetResponseDto[]; + album?: AlbumResponseDto; + allowUpload!: boolean; +} + +export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): SharedLinkResponseDto { + const linkAssets = sharedLink.assets || []; + const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo); + + const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id); + + return { + id: sharedLink.id, + description: sharedLink.description, + userId: sharedLink.userId, + key: sharedLink.key.toString('hex'), + type: sharedLink.type, + createdAt: sharedLink.createdAt, + expiresAt: sharedLink.expiresAt, + assets: assets.map(mapAsset), + album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined, + allowUpload: sharedLink.allowUpload, + }; +} diff --git a/server/apps/immich/src/api-v1/share/share.controller.ts b/server/apps/immich/src/api-v1/share/share.controller.ts new file mode 100644 index 0000000000..705116cd13 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/share.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Delete, Get, Param, Patch, ValidationPipe } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; +import { Authenticated } from '../../decorators/authenticated.decorator'; +import { EditSharedLinkDto } from './dto/edit-shared-link.dto'; +import { SharedLinkResponseDto } from './response-dto/shared-link-response.dto'; +import { ShareService } from './share.service'; + +@ApiTags('share') +@Controller('share') +export class ShareController { + constructor(private readonly shareService: ShareService) {} + @Authenticated() + @Get() + getAllSharedLinks(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.shareService.getAll(authUser); + } + + @Authenticated({ isShared: true }) + @Get('me') + getMySharedLink(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.shareService.getMine(authUser); + } + + @Authenticated() + @Get(':id') + getSharedLinkById(@Param('id') id: string): Promise { + return this.shareService.getById(id); + } + + @Authenticated() + @Delete(':id') + removeSharedLink(@Param('id') id: string, @GetAuthUser() authUser: AuthUserDto): Promise { + return this.shareService.remove(id, authUser.id); + } + + @Authenticated() + @Patch(':id') + editSharedLink( + @Param('id') id: string, + @GetAuthUser() authUser: AuthUserDto, + @Body(new ValidationPipe()) dto: EditSharedLinkDto, + ): Promise { + return this.shareService.edit(id, authUser, dto); + } +} diff --git a/server/apps/immich/src/api-v1/share/share.core.ts b/server/apps/immich/src/api-v1/share/share.core.ts new file mode 100644 index 0000000000..c65008eb40 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/share.core.ts @@ -0,0 +1,99 @@ +import { SharedLinkEntity } from '@app/database/entities/shared-link.entity'; +import { CreateSharedLinkDto } from './dto/create-shared-link.dto'; +import { ISharedLinkRepository } from './shared-link.repository'; +import crypto from 'node:crypto'; +import { BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common'; +import { AssetEntity } from '@app/database'; +import { EditSharedLinkDto } from './dto/edit-shared-link.dto'; + +export class ShareCore { + readonly logger = new Logger(ShareCore.name); + + constructor(private sharedLinkRepository: ISharedLinkRepository) {} + + async createSharedLink(userId: string, dto: CreateSharedLinkDto): Promise { + try { + const sharedLink = new SharedLinkEntity(); + + sharedLink.key = Buffer.from(crypto.randomBytes(50)); + sharedLink.description = dto.description; + sharedLink.userId = userId; + sharedLink.createdAt = new Date().toISOString(); + sharedLink.expiresAt = dto.expiredAt ?? null; + sharedLink.type = dto.sharedType; + sharedLink.assets = dto.assets; + sharedLink.album = dto.album; + sharedLink.allowUpload = dto.allowUpload ?? false; + + return this.sharedLinkRepository.create(sharedLink); + } catch (error: any) { + this.logger.error(error, error.stack); + throw new InternalServerErrorException('failed to create shared link'); + } + } + + async getSharedLinks(userId: string): Promise { + return this.sharedLinkRepository.get(userId); + } + + async removeSharedLink(id: string, userId: string): Promise { + const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId); + + if (!link) { + throw new BadRequestException('Shared link not found'); + } + + return await this.sharedLinkRepository.remove(link); + } + + async getSharedLinkById(id: string): Promise { + const link = await this.sharedLinkRepository.getById(id); + + if (!link) { + throw new BadRequestException('Shared link not found'); + } + + return link; + } + + async getSharedLinkByKey(key: string): Promise { + const link = await this.sharedLinkRepository.getByKey(key); + + if (!link) { + throw new BadRequestException(); + } + + return link; + } + + async updateAssetsInSharedLink(sharedLinkId: string, assets: AssetEntity[]) { + const link = await this.getSharedLinkById(sharedLinkId); + + link.assets = assets; + + return await this.sharedLinkRepository.save(link); + } + + async updateSharedLink(id: string, userId: string, dto: EditSharedLinkDto): Promise { + const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId); + + if (!link) { + throw new BadRequestException('Shared link not found'); + } + + link.description = dto.description ?? link.description; + link.allowUpload = dto.allowUpload ?? link.allowUpload; + + if (dto.isEditExpireTime && dto.expiredAt) { + link.expiresAt = dto.expiredAt; + } else if (dto.isEditExpireTime && !dto.expiredAt) { + link.expiresAt = null; + } + + return await this.sharedLinkRepository.save(link); + } + + async hasAssetAccess(id: string, assetId: string): Promise { + return this.sharedLinkRepository.hasAssetAccess(id, assetId); + } +} diff --git a/server/apps/immich/src/api-v1/share/share.module.ts b/server/apps/immich/src/api-v1/share/share.module.ts new file mode 100644 index 0000000000..4b164de51b --- /dev/null +++ b/server/apps/immich/src/api-v1/share/share.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ShareService } from './share.service'; +import { ShareController } from './share.controller'; +import { SharedLinkEntity } from '@app/database/entities/shared-link.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SharedLinkRepository, ISharedLinkRepository } from './shared-link.repository'; + +const SHARED_LINK_REPOSITORY_PROVIDER = { + provide: ISharedLinkRepository, + useClass: SharedLinkRepository, +}; + +@Module({ + imports: [TypeOrmModule.forFeature([SharedLinkEntity])], + controllers: [ShareController], + providers: [ShareService, SHARED_LINK_REPOSITORY_PROVIDER], + exports: [SHARED_LINK_REPOSITORY_PROVIDER, ShareService], +}) +export class ShareModule {} diff --git a/server/apps/immich/src/api-v1/share/share.service.ts b/server/apps/immich/src/api-v1/share/share.service.ts new file mode 100644 index 0000000000..c3c9e63b80 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/share.service.ts @@ -0,0 +1,54 @@ +import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common'; +import { AuthUserDto } from '../../decorators/auth-user.decorator'; +import { EditSharedLinkDto } from './dto/edit-shared-link.dto'; +import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from './response-dto/shared-link-response.dto'; +import { ShareCore } from './share.core'; +import { ISharedLinkRepository } from './shared-link.repository'; + +@Injectable() +export class ShareService { + readonly logger = new Logger(ShareService.name); + private shareCore: ShareCore; + + constructor( + @Inject(ISharedLinkRepository) + sharedLinkRepository: ISharedLinkRepository, + ) { + this.shareCore = new ShareCore(sharedLinkRepository); + } + async getAll(authUser: AuthUserDto): Promise { + const links = await this.shareCore.getSharedLinks(authUser.id); + return links.map(mapSharedLinkToResponseDto); + } + + async getMine(authUser: AuthUserDto): Promise { + if (!authUser.isPublicUser || !authUser.sharedLinkId) { + throw new ForbiddenException(); + } + + const link = await this.shareCore.getSharedLinkById(authUser.sharedLinkId); + + return mapSharedLinkToResponseDto(link); + } + + async getById(id: string): Promise { + const link = await this.shareCore.getSharedLinkById(id); + return mapSharedLinkToResponseDto(link); + } + + async remove(id: string, userId: string): Promise { + await this.shareCore.removeSharedLink(id, userId); + return id; + } + + async getByKey(key: string): Promise { + const link = await this.shareCore.getSharedLinkByKey(key); + return mapSharedLinkToResponseDto(link); + } + + async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) { + const link = await this.shareCore.updateSharedLink(id, authUser.id, dto); + + return mapSharedLinkToResponseDto(link); + } +} diff --git a/server/apps/immich/src/api-v1/share/shared-link.repository.ts b/server/apps/immich/src/api-v1/share/shared-link.repository.ts new file mode 100644 index 0000000000..c7335124cb --- /dev/null +++ b/server/apps/immich/src/api-v1/share/shared-link.repository.ts @@ -0,0 +1,123 @@ +import { SharedLinkEntity } from '@app/database/entities/shared-link.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Logger } from '@nestjs/common'; + +export interface ISharedLinkRepository { + get(userId: string): Promise; + getById(id: string): Promise; + getByIdAndUserId(id: string, userId: string): Promise; + getByKey(key: string): Promise; + create(payload: SharedLinkEntity): Promise; + remove(entity: SharedLinkEntity): Promise; + save(entity: SharedLinkEntity): Promise; + hasAssetAccess(id: string, assetId: string): Promise; +} + +export const ISharedLinkRepository = 'ISharedLinkRepository'; + +export class SharedLinkRepository implements ISharedLinkRepository { + readonly logger = new Logger(SharedLinkRepository.name); + constructor( + @InjectRepository(SharedLinkEntity) + private readonly sharedLinkRepository: Repository, + ) {} + async getByIdAndUserId(id: string, userId: string): Promise { + return await this.sharedLinkRepository.findOne({ + where: { + userId: userId, + id: id, + }, + order: { + createdAt: 'DESC', + }, + }); + } + + async get(userId: string): Promise { + return await this.sharedLinkRepository.find({ + where: { + userId: userId, + }, + relations: ['assets', 'album'], + order: { + createdAt: 'DESC', + }, + }); + } + + async create(payload: SharedLinkEntity): Promise { + return await this.sharedLinkRepository.save(payload); + } + + async getById(id: string): Promise { + return await this.sharedLinkRepository.findOne({ + where: { + id: id, + }, + relations: { + assets: true, + album: { + assets: { + assetInfo: true, + }, + }, + }, + order: { + createdAt: 'DESC', + }, + }); + } + + async getByKey(key: string): Promise { + return await this.sharedLinkRepository.findOne({ + where: { + key: Buffer.from(key, 'hex'), + }, + relations: { + assets: true, + album: { + assets: { + assetInfo: true, + }, + }, + }, + order: { + createdAt: 'DESC', + }, + }); + } + + async remove(entity: SharedLinkEntity): Promise { + return await this.sharedLinkRepository.remove(entity); + } + + async save(entity: SharedLinkEntity): Promise { + return await this.sharedLinkRepository.save(entity); + } + + async hasAssetAccess(id: string, assetId: string): Promise { + const count1 = await this.sharedLinkRepository.count({ + where: { + id, + assets: { + id: assetId, + }, + }, + }); + + const count2 = await this.sharedLinkRepository.count({ + where: { + id, + album: { + assets: { + assetId, + }, + }, + }, + }); + + return Boolean(count1 + count2); + } +} diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 0e3af7e119..ced895fec5 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -19,6 +19,7 @@ import { JobModule } from './api-v1/job/job.module'; import { SystemConfigModule } from './api-v1/system-config/system-config.module'; import { OAuthModule } from './api-v1/oauth/oauth.module'; import { TagModule } from './api-v1/tag/tag.module'; +import { ShareModule } from './api-v1/share/share.module'; import { APIKeyModule } from './api-v1/api-key/api-key.module'; @Module({ @@ -58,6 +59,8 @@ import { APIKeyModule } from './api-v1/api-key/api-key.module'; SystemConfigModule, TagModule, + + ShareModule, ], controllers: [AppController], providers: [], diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index 8d01d4fb0f..4aca54bb87 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/server/apps/immich/src/config/asset-upload.config.ts @@ -7,6 +7,7 @@ import { existsSync, mkdirSync } from 'fs'; import { diskStorage } from 'multer'; import { extname, join } from 'path'; import sanitize from 'sanitize-filename'; +import { AuthUserDto } from '../decorators/auth-user.decorator'; import { patchFormData } from '../utils/path-form-data.util'; const logger = new Logger('AssetUploadConfig'); @@ -42,6 +43,12 @@ function destination(req: Request, file: Express.Multer.File, cb: any) { return cb(new UnauthorizedException()); } + const user = req.user as AuthUserDto; + + if (user.isPublicUser && !user.isAllowUpload) { + return cb(new UnauthorizedException()); + } + const basePath = APP_UPLOAD_LOCATION; const sanitizedDeviceId = sanitize(String(req.body['deviceId'])); const originalUploadFolder = join(basePath, req.user.id, 'original', sanitizedDeviceId); diff --git a/server/apps/immich/src/decorators/auth-user.decorator.ts b/server/apps/immich/src/decorators/auth-user.decorator.ts index 023cab7ea1..3b5ab6d199 100644 --- a/server/apps/immich/src/decorators/auth-user.decorator.ts +++ b/server/apps/immich/src/decorators/auth-user.decorator.ts @@ -1,23 +1,15 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { UserEntity } from '@app/database'; // import { AuthUserDto } from './dto/auth-user.dto'; export class AuthUserDto { id!: string; email!: string; isAdmin!: boolean; + isPublicUser?: boolean; + sharedLinkId?: string; + isAllowUpload?: boolean; } export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => { - const req = ctx.switchToHttp().getRequest<{ user: UserEntity }>(); - - const { id, email, isAdmin } = req.user; - - const authUser: AuthUserDto = { - id: id.toString(), - email, - isAdmin, - }; - - return authUser; + return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user; }); diff --git a/server/apps/immich/src/decorators/authenticated.decorator.ts b/server/apps/immich/src/decorators/authenticated.decorator.ts index 6e3690e5fb..4939ec5f20 100644 --- a/server/apps/immich/src/decorators/authenticated.decorator.ts +++ b/server/apps/immich/src/decorators/authenticated.decorator.ts @@ -1,16 +1,25 @@ import { UseGuards } from '@nestjs/common'; import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware'; +import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware'; import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard'; interface AuthenticatedOptions { admin?: boolean; + isShared?: boolean; } export const Authenticated = (options?: AuthenticatedOptions) => { const guards: Parameters = [AuthGuard]; + options = options || {}; + if (options.admin) { guards.push(AdminRolesGuard); } + + if (!options.isShared) { + guards.push(RouteNotSharedGuard); + } + return UseGuards(...guards); }; diff --git a/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts b/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts new file mode 100644 index 0000000000..bb90c607d8 --- /dev/null +++ b/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts @@ -0,0 +1,21 @@ +import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { Request } from 'express'; +import { AuthUserDto } from '../decorators/auth-user.decorator'; + +@Injectable() +export class RouteNotSharedGuard implements CanActivate { + logger = new Logger(RouteNotSharedGuard.name); + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user as AuthUserDto; + + // Inverse logic - I know it is weird + if (user.isPublicUser) { + this.logger.warn(`Denied attempt to access non-shared route: ${request.path}`); + return false; + } + + return true; + } +} diff --git a/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts b/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts index bea032615f..6bb237725d 100644 --- a/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts +++ b/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; import { API_KEY_STRATEGY } from '../strategies/api-key.strategy'; import { JWT_STRATEGY } from '../strategies/jwt.strategy'; +import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy'; @Injectable() -export class AuthGuard extends PassportAuthGuard([JWT_STRATEGY, API_KEY_STRATEGY]) {} +export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, JWT_STRATEGY, API_KEY_STRATEGY]) {} diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts index a7faf1def6..c1d04f4c86 100644 --- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts +++ b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts @@ -7,10 +7,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserEntity } from '@app/database'; import { APIKeyModule } from '../../api-v1/api-key/api-key.module'; import { APIKeyStrategy } from './strategies/api-key.strategy'; +import { ShareModule } from '../../api-v1/share/share.module'; +import { PublicShareStrategy } from './strategies/public-share.strategy'; @Module({ - imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule], - providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy], + imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule, ShareModule], + providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy], exports: [ImmichJwtService], }) export class ImmichJwtModule {} diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts index fb6a222327..bf4aa8f9e7 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; +import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; import { APIKeyService } from '../../../api-v1/api-key/api-key.service'; @@ -15,7 +16,16 @@ export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY) super(options); } - async validate(token: string) { - return this.apiKeyService.validate(token); + async validate(token: string): Promise { + const user = await this.apiKeyService.validate(token); + + const authUser = new AuthUserDto(); + authUser.id = user.id; + authUser.email = user.email; + authUser.isAdmin = user.isAdmin; + authUser.isPublicUser = false; + authUser.isAllowUpload = true; + + return authUser; } } diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts index 58720174a6..916e718e2c 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts @@ -7,6 +7,7 @@ import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto'; import { UserEntity } from '@app/database'; import { jwtSecret } from '../../../constants/jwt.constant'; import { ImmichJwtService } from '../immich-jwt.service'; +import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; export const JWT_STRATEGY = 'jwt'; @@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { } as StrategyOptions); } - async validate(payload: JwtPayloadDto) { + async validate(payload: JwtPayloadDto): Promise { const { userId } = payload; const user = await this.usersRepository.findOne({ where: { id: userId } }); @@ -35,6 +36,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { throw new UnauthorizedException('Failure to validate JWT payload'); } - return user; + const authUser = new AuthUserDto(); + authUser.id = user.id; + authUser.email = user.email; + authUser.isAdmin = user.isAdmin; + authUser.isPublicUser = false; + authUser.isAllowUpload = true; + + return authUser; } } diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts new file mode 100644 index 0000000000..41393e294d --- /dev/null +++ b/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts @@ -0,0 +1,53 @@ +import { UserEntity } from '@app/database'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ShareService } from '../../../api-v1/share/share.service'; +import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; +import { Repository } from 'typeorm'; +import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; + +export const PUBLIC_SHARE_STRATEGY = 'public-share'; + +const options: IStrategyOptions = { + header: 'x-immich-share-key', + param: 'key', +}; + +@Injectable() +export class PublicShareStrategy extends PassportStrategy(Strategy, PUBLIC_SHARE_STRATEGY) { + constructor( + private shareService: ShareService, + @InjectRepository(UserEntity) + private usersRepository: Repository, + ) { + super(options); + } + + async validate(key: string): Promise { + const validatedLink = await this.shareService.getByKey(key); + + if (validatedLink.expiresAt) { + const now = new Date().getTime(); + const expiresAt = new Date(validatedLink.expiresAt).getTime(); + + if (now > expiresAt) { + throw new UnauthorizedException('Expired link'); + } + } + + const user = await this.usersRepository.findOne({ where: { id: validatedLink.userId } }); + + if (!user) { + throw new UnauthorizedException('Failure to validate public share payload'); + } + + let publicUser = new AuthUserDto(); + publicUser = user; + publicUser.isPublicUser = true; + publicUser.sharedLinkId = validatedLink.id; + publicUser.isAllowUpload = validatedLink.allowUpload; + + return publicUser; + } +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 203929f3af..92fad332a3 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -473,6 +473,147 @@ ] } }, + "/share": { + "get": { + "operationId": "getAllSharedLinks", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, + "/share/me": { + "get": { + "operationId": "getMySharedLink", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, + "/share/{id}": { + "get": { + "operationId": "getSharedLinkById", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + }, + "delete": { + "operationId": "removeSharedLink", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + }, + "tags": [ + "share" + ] + }, + "patch": { + "operationId": "editSharedLink", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditSharedLinkDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, "/asset/upload": { "post": { "operationId": "uploadFile", @@ -563,6 +704,42 @@ ] } }, + "/asset/download-files": { + "post": { + "operationId": "downloadFiles", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadFilesDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Asset" + ], + "security": [ + { + "bearer": [] + } + ] + } + }, "/asset/download-library": { "get": { "operationId": "downloadLibrary", @@ -1616,6 +1793,42 @@ ] } }, + "/album/create-shared-link": { + "post": { + "operationId": "createAlbumSharedLink", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlbumShareLinkDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "Album" + ], + "security": [ + { + "bearer": [] + } + ] + } + }, "/tag": { "post": { "operationId": "create", @@ -2666,99 +2879,11 @@ "name" ] }, - "AssetFileUploadDto": { - "type": "object", - "properties": { - "assetData": { - "type": "string", - "format": "binary" - } - }, - "required": [ - "assetData" - ] - }, - "AssetFileUploadResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": [ - "id" - ] - }, - "ThumbnailFormat": { + "SharedLinkType": { "type": "string", "enum": [ - "JPEG", - "WEBP" - ] - }, - "CuratedObjectsResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "object": { - "type": "string" - }, - "resizePath": { - "type": "string" - }, - "deviceAssetId": { - "type": "string" - }, - "deviceId": { - "type": "string" - } - }, - "required": [ - "id", - "object", - "resizePath", - "deviceAssetId", - "deviceId" - ] - }, - "CuratedLocationsResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "city": { - "type": "string" - }, - "resizePath": { - "type": "string" - }, - "deviceAssetId": { - "type": "string" - }, - "deviceId": { - "type": "string" - } - }, - "required": [ - "id", - "city", - "resizePath", - "deviceAssetId", - "deviceId" - ] - }, - "SearchAssetDto": { - "type": "object", - "properties": { - "searchTerm": { - "type": "string" - } - }, - "required": [ - "searchTerm" + "ALBUM", + "INDIVIDUAL" ] }, "AssetTypeEnum": { @@ -3019,6 +3144,232 @@ "tags" ] }, + "AlbumResponseDto": { + "type": "object", + "properties": { + "assetCount": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "albumName": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "albumThumbnailAssetId": { + "type": "string", + "nullable": true + }, + "shared": { + "type": "boolean" + }, + "sharedUsers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserResponseDto" + } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + } + }, + "required": [ + "assetCount", + "id", + "ownerId", + "albumName", + "createdAt", + "albumThumbnailAssetId", + "shared", + "sharedUsers", + "assets" + ] + }, + "SharedLinkResponseDto": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/SharedLinkType" + }, + "id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + }, + "album": { + "$ref": "#/components/schemas/AlbumResponseDto" + }, + "allowUpload": { + "type": "boolean" + } + }, + "required": [ + "type", + "id", + "userId", + "key", + "createdAt", + "expiresAt", + "assets", + "allowUpload" + ] + }, + "EditSharedLinkDto": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "expiredAt": { + "type": "string" + }, + "allowUpload": { + "type": "boolean" + }, + "isEditExpireTime": { + "type": "boolean" + } + } + }, + "AssetFileUploadDto": { + "type": "object", + "properties": { + "assetData": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "assetData" + ] + }, + "AssetFileUploadResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "DownloadFilesDto": { + "type": "object", + "properties": { + "assetIds": { + "title": "Array of asset ids to be downloaded", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "assetIds" + ] + }, + "ThumbnailFormat": { + "type": "string", + "enum": [ + "JPEG", + "WEBP" + ] + }, + "CuratedObjectsResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string" + }, + "resizePath": { + "type": "string" + }, + "deviceAssetId": { + "type": "string" + }, + "deviceId": { + "type": "string" + } + }, + "required": [ + "id", + "object", + "resizePath", + "deviceAssetId", + "deviceId" + ] + }, + "CuratedLocationsResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "city": { + "type": "string" + }, + "resizePath": { + "type": "string" + }, + "deviceAssetId": { + "type": "string" + }, + "deviceId": { + "type": "string" + } + }, + "required": [ + "id", + "city", + "resizePath", + "deviceAssetId", + "deviceId" + ] + }, + "SearchAssetDto": { + "type": "object", + "properties": { + "searchTerm": { + "type": "string" + } + }, + "required": [ + "searchTerm" + ] + }, "TimeGroupEnum": { "type": "string", "enum": [ @@ -3287,56 +3638,6 @@ "albumName" ] }, - "AlbumResponseDto": { - "type": "object", - "properties": { - "assetCount": { - "type": "integer" - }, - "id": { - "type": "string" - }, - "ownerId": { - "type": "string" - }, - "albumName": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "albumThumbnailAssetId": { - "type": "string", - "nullable": true - }, - "shared": { - "type": "boolean" - }, - "sharedUsers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UserResponseDto" - } - }, - "assets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - } - } - }, - "required": [ - "assetCount", - "id", - "ownerId", - "albumName", - "createdAt", - "albumThumbnailAssetId", - "shared", - "sharedUsers", - "assets" - ] - }, "AddUsersDto": { "type": "object", "properties": { @@ -3411,6 +3712,26 @@ } } }, + "CreateAlbumShareLinkDto": { + "type": "object", + "properties": { + "albumId": { + "type": "string" + }, + "expiredAt": { + "type": "string" + }, + "allowUpload": { + "type": "boolean" + }, + "description": { + "type": "string" + } + }, + "required": [ + "albumId" + ] + }, "CreateTagDto": { "type": "object", "properties": { diff --git a/server/libs/database/src/entities/album.entity.ts b/server/libs/database/src/entities/album.entity.ts index e113f269a0..53c06a53fc 100644 --- a/server/libs/database/src/entities/album.entity.ts +++ b/server/libs/database/src/entities/album.entity.ts @@ -1,5 +1,6 @@ import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { AssetAlbumEntity } from './asset-album.entity'; +import { SharedLinkEntity } from './shared-link.entity'; import { UserAlbumEntity } from './user-album.entity'; @Entity('albums') @@ -24,4 +25,7 @@ export class AlbumEntity { @OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo) assets?: AssetAlbumEntity[]; + + @OneToMany(() => SharedLinkEntity, (link) => link.album) + sharedLinks!: SharedLinkEntity[]; } diff --git a/server/libs/database/src/entities/asset.entity.ts b/server/libs/database/src/entities/asset.entity.ts index 6ff49747ef..9fcb8be5ed 100644 --- a/server/libs/database/src/entities/asset.entity.ts +++ b/server/libs/database/src/entities/asset.entity.ts @@ -1,5 +1,6 @@ import { Column, Entity, Index, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { ExifEntity } from './exif.entity'; +import { SharedLinkEntity } from './shared-link.entity'; import { SmartInfoEntity } from './smart-info.entity'; import { TagEntity } from './tag.entity'; @@ -68,6 +69,10 @@ export class AssetEntity { @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true }) @JoinTable({ name: 'tag_asset' }) tags!: TagEntity[]; + + @ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true }) + @JoinTable({ name: 'shared_link__asset' }) + sharedLinks!: SharedLinkEntity[]; } export enum AssetType { diff --git a/server/libs/database/src/entities/index.ts b/server/libs/database/src/entities/index.ts index f5edae663b..81073d4ce1 100644 --- a/server/libs/database/src/entities/index.ts +++ b/server/libs/database/src/entities/index.ts @@ -9,3 +9,4 @@ export * from './system-config.entity'; export * from './tag.entity'; export * from './user-album.entity'; export * from './user.entity'; +export * from './shared-link.entity'; diff --git a/server/libs/database/src/entities/shared-link.entity.ts b/server/libs/database/src/entities/shared-link.entity.ts new file mode 100644 index 0000000000..f096e361ea --- /dev/null +++ b/server/libs/database/src/entities/shared-link.entity.ts @@ -0,0 +1,50 @@ +import { Column, Entity, Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { AlbumEntity } from './album.entity'; +import { AssetEntity } from './asset.entity'; + +@Entity('shared_links') +@Unique('UQ_sharedlink_key', ['key']) +export class SharedLinkEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ nullable: true }) + description?: string; + + @Column() + userId!: string; + + @Index('IDX_sharedlink_key') + @Column({ type: 'bytea' }) + key!: Buffer; // use to access the inidividual asset + + @Column() + type!: SharedLinkType; + + @Column({ type: 'timestamptz' }) + createdAt!: string; + + @Column({ type: 'timestamptz', nullable: true }) + expiresAt!: string | null; + + @Column({ type: 'boolean', default: false }) + allowUpload!: boolean; + + @ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks) + assets!: AssetEntity[]; + + @ManyToOne(() => AlbumEntity, (album) => album.sharedLinks) + album?: AlbumEntity; +} + +export enum SharedLinkType { + ALBUM = 'ALBUM', + + /** + * Individual asset + * or group of assets that are not in an album + */ + INDIVIDUAL = 'INDIVIDUAL', +} + +// npm run typeorm -- migration:generate ./libs/database/src/AddSharedLinkTable -d libs/database/src/config/database.config.ts diff --git a/server/libs/database/src/migrations/1673150490490-AddSharedLinkTable.ts b/server/libs/database/src/migrations/1673150490490-AddSharedLinkTable.ts new file mode 100644 index 0000000000..a7508722d2 --- /dev/null +++ b/server/libs/database/src/migrations/1673150490490-AddSharedLinkTable.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSharedLinkTable1673150490490 implements MigrationInterface { + name = 'AddSharedLinkTable1673150490490' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "shared_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "description" character varying, "userId" character varying NOT NULL, "key" bytea NOT NULL, "type" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, "allowUpload" boolean NOT NULL DEFAULT false, "albumId" uuid, CONSTRAINT "UQ_sharedlink_key" UNIQUE ("key"), CONSTRAINT "PK_642e2b0f619e4876e5f90a43465" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_sharedlink_key" ON "shared_links" ("key") `); + await queryRunner.query(`CREATE TABLE "shared_link__asset" ("assetsId" uuid NOT NULL, "sharedLinksId" uuid NOT NULL, CONSTRAINT "PK_9b4f3687f9b31d1e311336b05e3" PRIMARY KEY ("assetsId", "sharedLinksId"))`); + await queryRunner.query(`CREATE INDEX "IDX_5b7decce6c8d3db9593d6111a6" ON "shared_link__asset" ("assetsId") `); + await queryRunner.query(`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId") `); + await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab" FOREIGN KEY ("sharedLinksId") REFERENCES "shared_links"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab"`); + await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66"`); + await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c9fab4aa97ffd1b034f3d6581a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5b7decce6c8d3db9593d6111a6"`); + await queryRunner.query(`DROP TABLE "shared_link__asset"`); + await queryRunner.query(`DROP INDEX "public"."IDX_sharedlink_key"`); + await queryRunner.query(`DROP TABLE "shared_links"`); + } + +} diff --git a/server/package-lock.json b/server/package-lock.json index 5dba867bb1..492b4a1f75 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -47,6 +47,7 @@ "nest-commander": "^3.3.0", "openid-client": "^5.2.1", "passport": "^0.6.0", + "passport-custom": "^1.1.1", "passport-http-header-strategy": "^1.1.0", "passport-jwt": "^4.0.0", "pg": "^8.7.1", @@ -8619,6 +8620,17 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-custom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", + "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/passport-http-header-strategy": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz", @@ -17927,6 +17939,14 @@ "utils-merge": "^1.0.1" } }, + "passport-custom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", + "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "requires": { + "passport-strategy": "1.x.x" + } + }, "passport-http-header-strategy": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz", diff --git a/server/package.json b/server/package.json index 1cf4f4f7b2..1229f23c5a 100644 --- a/server/package.json +++ b/server/package.json @@ -70,6 +70,7 @@ "nest-commander": "^3.3.0", "openid-client": "^5.2.1", "passport": "^0.6.0", + "passport-custom": "^1.1.1", "passport-http-header-strategy": "^1.1.0", "passport-jwt": "^4.0.0", "pg": "^8.7.1", diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 061bc3ffdf..ed21ab7570 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -9,6 +9,7 @@ import { JobApi, OAuthApi, ServerInfoApi, + ShareApi, SystemConfigApi, UserApi } from './open-api'; @@ -24,6 +25,7 @@ class ImmichApi { public jobApi: JobApi; public keyApi: APIKeyApi; public systemConfigApi: SystemConfigApi; + public shareApi: ShareApi; private config = new Configuration({ basePath: '/api' }); @@ -38,6 +40,7 @@ class ImmichApi { this.jobApi = new JobApi(this.config); this.keyApi = new APIKeyApi(this.config); this.systemConfigApi = new SystemConfigApi(this.config); + this.shareApi = new ShareApi(this.config); } public setAccessToken(accessToken: string) { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 7bfcdfe8b3..72e819edf0 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -671,6 +671,37 @@ export interface CreateAlbumDto { */ 'assetIds'?: Array; } +/** + * + * @export + * @interface CreateAlbumShareLinkDto + */ +export interface CreateAlbumShareLinkDto { + /** + * + * @type {string} + * @memberof CreateAlbumShareLinkDto + */ + 'albumId': string; + /** + * + * @type {string} + * @memberof CreateAlbumShareLinkDto + */ + 'expiredAt'?: string; + /** + * + * @type {boolean} + * @memberof CreateAlbumShareLinkDto + */ + 'allowUpload'?: boolean; + /** + * + * @type {string} + * @memberof CreateAlbumShareLinkDto + */ + 'description'?: string; +} /** * * @export @@ -918,6 +949,50 @@ export const DeviceTypeEnum = { export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum]; +/** + * + * @export + * @interface DownloadFilesDto + */ +export interface DownloadFilesDto { + /** + * + * @type {Array} + * @memberof DownloadFilesDto + */ + 'assetIds': Array; +} +/** + * + * @export + * @interface EditSharedLinkDto + */ +export interface EditSharedLinkDto { + /** + * + * @type {string} + * @memberof EditSharedLinkDto + */ + 'description'?: string; + /** + * + * @type {string} + * @memberof EditSharedLinkDto + */ + 'expiredAt'?: string; + /** + * + * @type {boolean} + * @memberof EditSharedLinkDto + */ + 'allowUpload'?: boolean; + /** + * + * @type {boolean} + * @memberof EditSharedLinkDto + */ + 'isEditExpireTime'?: boolean; +} /** * * @export @@ -1477,6 +1552,87 @@ export interface ServerVersionReponseDto { */ 'build': number; } +/** + * + * @export + * @interface SharedLinkResponseDto + */ +export interface SharedLinkResponseDto { + /** + * + * @type {SharedLinkType} + * @memberof SharedLinkResponseDto + */ + 'type': SharedLinkType; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'description'?: string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'userId': string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'key': string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'createdAt': string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'expiresAt': string | null; + /** + * + * @type {Array} + * @memberof SharedLinkResponseDto + */ + 'assets': Array; + /** + * + * @type {AlbumResponseDto} + * @memberof SharedLinkResponseDto + */ + 'album'?: AlbumResponseDto; + /** + * + * @type {boolean} + * @memberof SharedLinkResponseDto + */ + 'allowUpload': boolean; +} +/** + * + * @export + * @enum {string} + */ + +export const SharedLinkType = { + Album: 'ALBUM', + Individual: 'INDIVIDUAL' +} as const; + +export type SharedLinkType = typeof SharedLinkType[keyof typeof SharedLinkType]; + + /** * * @export @@ -2554,6 +2710,45 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createAlbumSharedLink: async (createAlbumShareLinkDto: CreateAlbumShareLinkDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'createAlbumShareLinkDto' is not null or undefined + assertParamExists('createAlbumSharedLink', 'createAlbumShareLinkDto', createAlbumShareLinkDto) + const localVarPath = `/album/create-shared-link`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(createAlbumShareLinkDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} albumId @@ -2915,6 +3110,16 @@ export const AlbumApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.createAlbum(createAlbumDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createAlbumSharedLink(createAlbumShareLinkDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} albumId @@ -3038,6 +3243,15 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath createAlbum(createAlbumDto: CreateAlbumDto, options?: any): AxiosPromise { return localVarFp.createAlbum(createAlbumDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: any): AxiosPromise { + return localVarFp.createAlbumSharedLink(createAlbumShareLinkDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} albumId @@ -3159,6 +3373,17 @@ export class AlbumApi extends BaseAPI { return AlbumApiFp(this.configuration).createAlbum(createAlbumDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AlbumApi + */ + public createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: AxiosRequestConfig) { + return AlbumApiFp(this.configuration).createAlbumSharedLink(createAlbumShareLinkDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} albumId @@ -3423,6 +3648,45 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {DownloadFilesDto} downloadFilesDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadFiles: async (downloadFilesDto: DownloadFilesDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'downloadFilesDto' is not null or undefined + assertParamExists('downloadFiles', 'downloadFilesDto', downloadFilesDto) + const localVarPath = `/asset/download-files`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(downloadFilesDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {number} [skip] @@ -4050,6 +4314,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, isThumb, isWeb, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {DownloadFilesDto} downloadFilesDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadFiles(downloadFilesDto: DownloadFilesDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFiles(downloadFilesDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {number} [skip] @@ -4248,6 +4522,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: any): AxiosPromise { return localVarFp.downloadFile(assetId, isThumb, isWeb, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {DownloadFilesDto} downloadFilesDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadFiles(downloadFilesDto: DownloadFilesDto, options?: any): AxiosPromise { + return localVarFp.downloadFiles(downloadFilesDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {number} [skip] @@ -4439,6 +4722,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).downloadFile(assetId, isThumb, isWeb, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {DownloadFilesDto} downloadFilesDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public downloadFiles(downloadFilesDto: DownloadFilesDto, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).downloadFiles(downloadFilesDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {number} [skip] @@ -6052,6 +6346,354 @@ export class ServerInfoApi extends BaseAPI { } +/** + * ShareApi - axios parameter creator + * @export + */ +export const ShareApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} id + * @param {EditSharedLinkDto} editSharedLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editSharedLink: async (id: string, editSharedLinkDto: EditSharedLinkDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('editSharedLink', 'id', id) + // verify required parameter 'editSharedLinkDto' is not null or undefined + assertParamExists('editSharedLink', 'editSharedLinkDto', editSharedLinkDto) + const localVarPath = `/share/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(editSharedLinkDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllSharedLinks: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/share`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getMySharedLink: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/share/me`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSharedLinkById: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getSharedLinkById', 'id', id) + const localVarPath = `/share/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeSharedLink: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('removeSharedLink', 'id', id) + const localVarPath = `/share/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ShareApi - functional programming interface + * @export + */ +export const ShareApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ShareApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} id + * @param {EditSharedLinkDto} editSharedLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.editSharedLink(id, editSharedLinkDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAllSharedLinks(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllSharedLinks(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getMySharedLink(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSharedLinkById(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSharedLinkById(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async removeSharedLink(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeSharedLink(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * ShareApi - factory interface + * @export + */ +export const ShareApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ShareApiFp(configuration) + return { + /** + * + * @param {string} id + * @param {EditSharedLinkDto} editSharedLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: any): AxiosPromise { + return localVarFp.editSharedLink(id, editSharedLinkDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllSharedLinks(options?: any): AxiosPromise> { + return localVarFp.getAllSharedLinks(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getMySharedLink(options?: any): AxiosPromise { + return localVarFp.getMySharedLink(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSharedLinkById(id: string, options?: any): AxiosPromise { + return localVarFp.getSharedLinkById(id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeSharedLink(id: string, options?: any): AxiosPromise { + return localVarFp.removeSharedLink(id, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * ShareApi - object-oriented interface + * @export + * @class ShareApi + * @extends {BaseAPI} + */ +export class ShareApi extends BaseAPI { + /** + * + * @param {string} id + * @param {EditSharedLinkDto} editSharedLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).editSharedLink(id, editSharedLinkDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public getAllSharedLinks(options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).getAllSharedLinks(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public getMySharedLink(options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).getMySharedLink(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public getSharedLinkById(id: string, options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).getSharedLinkById(id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public removeSharedLink(id: string, options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).removeSharedLink(id, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * SystemConfigApi - axios parameter creator * @export diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts index d2381c9e92..cddada7c86 100644 --- a/web/src/api/utils.ts +++ b/web/src/api/utils.ts @@ -4,13 +4,14 @@ import { UserResponseDto } from './open-api'; const _basePath = '/api'; -export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) { +export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean, key?: string) { const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file/${assetId}`); if (isThumb !== undefined && isThumb !== null) urlObj.searchParams.append('isThumb', `${isThumb}`); if (isWeb !== undefined && isWeb !== null) urlObj.searchParams.append('isWeb', `${isWeb}`); + if (key !== undefined && key !== null) urlObj.searchParams.append('key', key); return urlObj.href; } diff --git a/web/src/app.html b/web/src/app.html index 58e88d2ff5..b5f19dfba5 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -1,15 +1,13 @@ + + + + + %sveltekit.head% + - - - - - %sveltekit.head% - - - -
%sveltekit.body%
- - - \ No newline at end of file + +
%sveltekit.body%
+ + diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte index 29ded0c599..e2cb1cf9a5 100644 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte @@ -93,7 +93,7 @@ >.

- +
- + {#if required}
*
{/if} diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte index 83163bd28b..e7c591b934 100644 --- a/web/src/lib/components/admin-page/settings/setting-switch.svelte +++ b/web/src/lib/components/admin-page/settings/setting-switch.svelte @@ -8,13 +8,13 @@

- {title.toUpperCase()} + {title}

{subtitle}

-
{/if}
+ +
+
+ + + {#if sharedLinks.length} + + {/if} +
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 6240545ed7..c4efa91bf9 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -23,7 +23,7 @@ export let showMotionPlayButton: boolean; export let isMotionPhotoPlaying = false; - const isOwner = asset.ownerId === $page.data.user.id; + const isOwner = asset.ownerId === $page.data.user?.id; const dispatch = createEventDispatcher(); @@ -94,12 +94,15 @@ title="Favorite" /> {/if} - dispatch('delete')} title="Delete" /> - showOptionsMenu(event)} - title="More" - /> + + {#if isOwner} + dispatch('delete')} title="Delete" /> + showOptionsMenu(event)} + title="More" + /> + {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 38b09dea10..0df252ceae 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,12 +10,7 @@ import { downloadAssets } from '$lib/stores/download'; import VideoViewer from './video-viewer.svelte'; import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte'; - import { - api, - AssetResponseDto, - AssetTypeEnum, - AlbumResponseDto - } from '@api'; + import { api, AssetResponseDto, AssetTypeEnum, AlbumResponseDto } from '@api'; import { notificationController, NotificationType @@ -25,6 +20,9 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils'; export let asset: AssetResponseDto; + export let publicSharedKey = ''; + export let showNavigation = true; + $: { appearsInAlbums = []; @@ -91,12 +89,12 @@ const handleDownload = () => { if (asset.livePhotoVideoId) { - downloadFile(asset.livePhotoVideoId, true); - downloadFile(asset.id, false); + downloadFile(asset.livePhotoVideoId, true, publicSharedKey); + downloadFile(asset.id, false, publicSharedKey); return; } - downloadFile(asset.id, false); + downloadFile(asset.id, false, publicSharedKey); }; /** @@ -111,7 +109,7 @@ }; }; - const downloadFile = async (assetId: string, isLivePhoto: boolean) => { + const downloadFile = async (assetId: string, isLivePhoto: boolean, key: string) => { try { const { filenameWithoutExtension } = getTemplateFilename(); @@ -126,6 +124,9 @@ $downloadAssets[imageFileName] = 0; const { data, status } = await api.assetApi.downloadFile(assetId, false, false, { + params: { + key + }, responseType: 'blob', onDownloadProgress: (progressEvent) => { if (progressEvent.lengthComputable) { @@ -251,69 +252,74 @@ /> -
{ - halfLeftHover = true; - halfRightHover = false; - }} - on:mouseleave={() => { - halfLeftHover = false; - }} - on:click={navigateAssetBackward} - on:keydown={navigateAssetBackward} - > - -
+ + + {/if}
{#key asset.id} {#if asset.type === AssetTypeEnum.Image} {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} (shouldPlayMotionPhoto = false)} /> {:else} - + {/if} {:else} - + {/if} {/key}
-
{ - halfLeftHover = false; - halfRightHover = true; - }} - on:mouseleave={() => { - halfRightHover = false; - }} - > - -
+ + + {/if} {#if isShowDetail}
Promise; onMount(async () => { - const { data } = await api.assetApi.getAssetById(assetId); + const { data } = await api.assetApi.getAssetById(assetId, { + params: { + key: publicSharedKey + } + }); assetInfo = data; //Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 @@ -29,6 +34,9 @@ const loadAssetData = async () => { try { const { data } = await api.assetApi.serveFile(assetInfo.id, false, true, { + params: { + key: publicSharedKey + }, responseType: 'blob' }); diff --git a/web/src/lib/components/asset-viewer/video-viewer.svelte b/web/src/lib/components/asset-viewer/video-viewer.svelte index 1c365aa8e2..73ffc0f06b 100644 --- a/web/src/lib/components/asset-viewer/video-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-viewer.svelte @@ -6,7 +6,7 @@ import { api, AssetResponseDto, getFileUrl } from '@api'; export let assetId: string; - + export let publicSharedKey = ''; let asset: AssetResponseDto; let videoPlayerNode: HTMLVideoElement; @@ -15,7 +15,11 @@ const dispatch = createEventDispatcher(); onMount(async () => { - const { data: assetInfo } = await api.assetApi.getAssetById(assetId); + const { data: assetInfo } = await api.assetApi.getAssetById(assetId, { + params: { + key: publicSharedKey + } + }); await loadVideoData(assetInfo); @@ -25,7 +29,7 @@ const loadVideoData = async (assetInfo: AssetResponseDto) => { isVideoLoading = true; - videoUrl = getFileUrl(assetInfo.id, false, true); + videoUrl = getFileUrl(assetInfo.id, false, true, publicSharedKey); return assetInfo; }; diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index 909ff92eb4..4a86dd115b 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -5,6 +5,8 @@ import Close from 'svelte-material-icons/Close.svelte'; import CircleIconButton from '../shared-components/circle-icon-button.svelte'; import { fly } from 'svelte/transition'; + + export let showBackButton = true; export let backIcon = Close; export let tailwindClasses = ''; @@ -42,14 +44,15 @@ class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray`} >
- dispatch('close-button-click')} - logo={backIcon} - backgroundColor={'transparent'} - hoverColor={'#e2e7e9'} - size={'24'} - /> - + {#if showBackButton} + dispatch('close-button-click')} + logo={backIcon} + backgroundColor={'transparent'} + hoverColor={'#e2e7e9'} + size={'24'} + /> + {/if}
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 new file mode 100644 index 0000000000..330cb14f14 --- /dev/null +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -0,0 +1,243 @@ + + + dispatch('close')}> + + + + {#if editingLink} +

Edit link

+ {:else} +

Create link to share

+ {/if} +
+
+ +
+ {#if shareType == SharedLinkType.Album} + {#if !editingLink} +
Let anyone with the link see photos and people in this album.
+ {:else} +
+ Public album | {editingLink.album?.albumName} +
+ {/if} + {/if} + +
+

LINK OPTIONS

+
+
+
+
+ +
+ + + +
+ {#if editingLink} +

+ +

+ {:else} +

Expire after

+ {/if} + + +
+
+
+
+ +
+ +
+ {#if !isShowSharedLink} + {#if editingLink} +
+ +
+ {:else} +
+ +
+ {/if} + {/if} + + {#if isShowSharedLink} +
+ + + +
+ {/if} +
+
diff --git a/web/src/lib/components/shared-components/dropdown-button.svelte b/web/src/lib/components/shared-components/dropdown-button.svelte new file mode 100644 index 0000000000..d5d530546d --- /dev/null +++ b/web/src/lib/components/shared-components/dropdown-button.svelte @@ -0,0 +1,76 @@ + + + + +
+ + + {#if isOpen} +
+ {#each options.options as option} + + {/each} +
+ {/if} +
+ + diff --git a/web/src/lib/components/shared-components/immich-thumbnail.svelte b/web/src/lib/components/shared-components/immich-thumbnail.svelte index 5952af6aa9..bf8f32f259 100644 --- a/web/src/lib/components/shared-components/immich-thumbnail.svelte +++ b/web/src/lib/components/shared-components/immich-thumbnail.svelte @@ -18,6 +18,9 @@ export let format: ThumbnailFormat = ThumbnailFormat.Webp; export let selected = false; export let disabled = false; + export let publicSharedKey = ''; + export let isRoundedCorner = false; + let imageData: string; let mouseOver = false; @@ -35,10 +38,9 @@ isThumbnailVideoPlaying = false; if (isLivePhoto && asset.livePhotoVideoId) { - console.log('get file url'); - videoUrl = getFileUrl(asset.livePhotoVideoId, false, true); + videoUrl = getFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey); } else { - videoUrl = getFileUrl(asset.id, false, true); + videoUrl = getFileUrl(asset.id, false, true, publicSharedKey); } }; @@ -118,6 +120,8 @@ return 'border-[20px] border-immich-primary/20'; } else if (disabled) { return 'border-[20px] border-gray-300'; + } else if (isRoundedCorner) { + return 'rounded-[20px]'; } else { return ''; } @@ -244,7 +248,7 @@ style:width={`${thumbnailSize}px`} style:height={`${thumbnailSize}px`} in:fade={{ duration: 150 }} - src={`/api/asset/thumbnail/${asset.id}?format=${format}`} + src={`/api/asset/thumbnail/${asset.id}?format=${format}&key=${publicSharedKey}`} alt={asset.id} class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`} loading="lazy" diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte index babb3b768c..7f6c90dc41 100644 --- a/web/src/lib/components/shared-components/theme-button.svelte +++ b/web/src/lib/components/shared-components/theme-button.svelte @@ -49,7 +49,7 @@ on:click={toggleTheme} id="theme-toggle" type="button" - class="text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none rounded-lg text-sm p-2.5" + class="text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none rounded-full text-sm p-2.5" > + import { api, AssetResponseDto, SharedLinkResponseDto, SharedLinkType } from '@api'; + import LoadingSpinner from '../shared-components/loading-spinner.svelte'; + import OpenInNew from 'svelte-material-icons/OpenInNew.svelte'; + import Delete from 'svelte-material-icons/TrashCanOutline.svelte'; + import ContentCopy from 'svelte-material-icons/ContentCopy.svelte'; + import CircleEditOutline from 'svelte-material-icons/CircleEditOutline.svelte'; + import * as luxon from 'luxon'; + import CircleIconButton from '../shared-components/circle-icon-button.svelte'; + import { createEventDispatcher } from 'svelte'; + import { goto } from '$app/navigation'; + + export let link: SharedLinkResponseDto; + + let expirationCountdown: luxon.DurationObjectUnits; + const dispatch = createEventDispatcher(); + + const getAssetInfo = async (): Promise => { + let assetId = ''; + + if (link.album?.albumThumbnailAssetId) { + assetId = link.album.albumThumbnailAssetId; + } else if (link.assets.length > 0) { + assetId = link.assets[0]; + } + + const { data } = await api.assetApi.getAssetById(assetId); + + return data; + }; + + const getCountDownExpirationDate = () => { + if (!link.expiresAt) { + return; + } + + const expiresAtDate = luxon.DateTime.fromISO(new Date(link.expiresAt).toISOString()); + const now = luxon.DateTime.now(); + + expirationCountdown = expiresAtDate + .diff(now, ['days', 'hours', 'minutes', 'seconds']) + .toObject(); + + if (expirationCountdown.days && expirationCountdown.days > 0) { + return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'days' }); + } else if (expirationCountdown.hours && expirationCountdown.hours > 0) { + return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'hours' }); + } else if (expirationCountdown.minutes && expirationCountdown.minutes > 0) { + return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'minutes' }); + } else if (expirationCountdown.seconds && expirationCountdown.seconds > 0) { + return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'seconds' }); + } + }; + + const isExpired = (expiresAt: string) => { + const now = new Date().getTime(); + const expiration = new Date(expiresAt).getTime(); + + return now > expiration; + }; + + +
+
+ {#await getAssetInfo()} + + {:then asset} + {asset.id} + {/await} +
+ +
+
+
+ {#if link.expiresAt} + {#if isExpired(link.expiresAt)} +

Expired

+ {:else} +

+ Expires {getCountDownExpirationDate()} +

+ {/if} + {:else} +

Expires ∞

+ {/if} +
+ +
+
+ {#if link.type === SharedLinkType.Album} +

+ {link.album?.albumName.toUpperCase()} +

+ {:else if link.type === SharedLinkType.Individual} +

INDIVIDUAL SHARE

+ {/if} + + {#if !link.expiresAt || !isExpired(link.expiresAt)} +
goto(`/share/${link.key}`)} + on:keydown={() => goto(`/share/${link.key}`)} + > + +
+ {/if} +
+ +

{link.description ?? ''}

+
+
+ +
+ {#if link.allowUpload} +
+ Allow upload +
+ {/if} +
+
+ +
+
+ dispatch('delete')} /> + dispatch('edit')} /> + dispatch('copy')} /> +
+
+
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index f1b6596a54..c4c9b16b03 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,21 +1,106 @@ -import { api, AddAssetsResponseDto } from '@api'; +import { api, AddAssetsResponseDto, AssetResponseDto } from '@api'; import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; +import { downloadAssets } from '$lib/stores/download'; +import { get } from 'svelte/store'; export const addAssetsToAlbum = async ( albumId: string, - assetIds: Array + assetIds: Array, + key: string | undefined = undefined ): Promise => - api.albumApi.addAssetsToAlbum(albumId, { assetIds }).then(({ data: dto }) => { - if (dto.successfullyAdded > 0) { - // This might be 0 if the user tries to add an asset that is already in the album - notificationController.show({ - message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`, - type: NotificationType.Info - }); - } + api.albumApi + .addAssetsToAlbum(albumId, { assetIds }, { params: { key } }) + .then(({ data: dto }) => { + if (dto.successfullyAdded > 0) { + // This might be 0 if the user tries to add an asset that is already in the album + notificationController.show({ + message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`, + type: NotificationType.Info + }); + } - return dto; - }); + return dto; + }); + +export async function bulkDownload( + fileName: string, + assets: AssetResponseDto[], + onDone: () => void, + key?: string +) { + const assetIds = assets.map((asset) => asset.id); + + try { + let skip = 0; + let count = 0; + let done = false; + + while (!done) { + count++; + + const downloadFileName = fileName + `${count === 1 ? '' : count}.zip`; + downloadAssets.set({ [downloadFileName]: 0 }); + + let total = 0; + + const { data, status, headers } = await api.assetApi.downloadFiles( + { assetIds }, + { + params: { key }, + responseType: 'blob', + onDownloadProgress: function (progressEvent) { + const request = this as XMLHttpRequest; + if (!total) { + total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0; + } + + if (total) { + const current = progressEvent.loaded; + downloadAssets.set({ [downloadFileName]: Math.floor((current / total) * 100) }); + } + } + } + ); + + const isNotComplete = headers['x-immich-archive-complete'] === 'false'; + const fileCount = Number(headers['x-immich-archive-file-count']) || 0; + if (isNotComplete && fileCount > 0) { + skip += fileCount; + } else { + onDone(); + done = true; + } + + if (!(data instanceof Blob)) { + return; + } + + if (status === 201) { + const fileUrl = URL.createObjectURL(data); + const anchor = document.createElement('a'); + anchor.href = fileUrl; + anchor.download = downloadFileName; + + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + URL.revokeObjectURL(fileUrl); + + // Remove item from download list + setTimeout(() => { + downloadAssets.set({}); + }, 2000); + } + } + } catch (e) { + console.error('Error downloading file ', e); + notificationController.show({ + type: NotificationType.Error, + message: 'Error downloading file, check console for more details.' + }); + } +} diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 2b352b89ec..671534f257 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -11,6 +11,7 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils'; export const openFileUploadDialog = ( albumId: string | undefined = undefined, + sharedKey: string | undefined = undefined, callback?: () => void ) => { try { @@ -27,7 +28,7 @@ export const openFileUploadDialog = ( } const files = Array.from(target.files); - await fileUploadHandler(files, albumId); + await fileUploadHandler(files, albumId, sharedKey); callback && callback(); }; @@ -37,7 +38,11 @@ export const openFileUploadDialog = ( } }; -export const fileUploadHandler = async (files: File[], albumId: string | undefined = undefined) => { +export const fileUploadHandler = async ( + files: File[], + albumId: string | undefined = undefined, + sharedKey: string | undefined = undefined +) => { if (files.length > 50) { notificationController.show({ type: NotificationType.Error, @@ -49,18 +54,22 @@ export const fileUploadHandler = async (files: File[], albumId: string | undefin return; } - + console.log('fileUploadHandler'); const acceptedFile = files.filter( (e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image' ); for (const asset of acceptedFile) { - await fileUploader(asset, albumId); + await fileUploader(asset, albumId, sharedKey); } }; //TODO: should probably use the @api SDK -async function fileUploader(asset: File, albumId: string | undefined = undefined) { +async function fileUploader( + asset: File, + albumId: string | undefined = undefined, + sharedKey: string | undefined = undefined +) { const assetType = asset.type.split('/')[0].toUpperCase(); const temp = asset.name.split('.'); const fileExtension = temp[temp.length - 1]; @@ -108,10 +117,17 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined formData.append('assetData', asset); // Check if asset upload on server before performing upload - const { data, status } = await api.assetApi.checkDuplicateAsset({ - deviceAssetId: String(deviceAssetId), - deviceId: 'WEB' - }); + const { data, status } = await api.assetApi.checkDuplicateAsset( + { + deviceAssetId: String(deviceAssetId), + deviceId: 'WEB' + }, + { + params: { + key: sharedKey + } + } + ); if (status === 200) { if (data.isExist) { @@ -124,7 +140,6 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined } const request = new XMLHttpRequest(); - request.upload.onloadstart = () => { const newUploadAsset: UploadAsset = { id: deviceAssetId, @@ -144,7 +159,7 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined try { const res: AssetFileUploadResponseDto = JSON.parse(request.response || '{}'); if (res.id) { - addAssetsToAlbum(albumId, [res.id]); + addAssetsToAlbum(albumId, [res.id], sharedKey); } } catch (e) { console.error('ERROR parsing data JSON in upload onload'); @@ -171,7 +186,7 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined uploadAssetsStore.updateProgress(deviceAssetId, percentComplete); }; - request.open('POST', `/api/asset/upload`); + request.open('POST', `/api/asset/upload?key=${sharedKey ?? ''}`); request.send(formData); } catch (e) { diff --git a/web/src/routes/photos/+page.svelte b/web/src/routes/photos/+page.svelte index 4bb7eb2815..5d0b6e270f 100644 --- a/web/src/routes/photos/+page.svelte +++ b/web/src/routes/photos/+page.svelte @@ -17,6 +17,7 @@ } from '$lib/stores/asset-interaction.store'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import Close from 'svelte-material-icons/Close.svelte'; + import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte'; import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte'; import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; import Plus from 'svelte-material-icons/Plus.svelte'; @@ -26,7 +27,7 @@ NotificationType } from '$lib/components/shared-components/notification/notification'; import { assetStore } from '$lib/stores/assets.store'; - import { addAssetsToAlbum } from '$lib/utils/asset-utils'; + import { addAssetsToAlbum, bulkDownload } from '$lib/utils/asset-utils'; export let data: PageData; @@ -106,6 +107,12 @@ assetInteractionStore.clearMultiselect(); }); }; + + const handleDownloadFiles = async () => { + await bulkDownload('immich', Array.from($selectedAssets), () => { + assetInteractionStore.clearMultiselect(); + }); + }; @@ -125,6 +132,11 @@

+ + Opps! Error - Immich +
+ +
+
+ Page not found :/ +
+
diff --git a/web/src/routes/share/[key]/+page.server.ts b/web/src/routes/share/[key]/+page.server.ts new file mode 100644 index 0000000000..45a8fe6b06 --- /dev/null +++ b/web/src/routes/share/[key]/+page.server.ts @@ -0,0 +1,18 @@ +export const prerender = false; +import { error } from '@sveltejs/kit'; + +import { serverApi } from '@api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const { key } = params; + + try { + const { data: sharedLink } = await serverApi.shareApi.getMySharedLink({ params: { key } }); + return { sharedLink }; + } catch (e) { + throw error(404, { + message: 'Invalid shared link' + }); + } +}; diff --git a/web/src/routes/share/[key]/+page.svelte b/web/src/routes/share/[key]/+page.svelte new file mode 100644 index 0000000000..50e1030b78 --- /dev/null +++ b/web/src/routes/share/[key]/+page.svelte @@ -0,0 +1,22 @@ + + + + {data.sharedLink.album?.albumName || 'Public Shared'} - Immich + + +{#if album} +
+ +
+{/if} diff --git a/web/src/routes/share/[key]/photos/[assetId]/+page.server.ts b/web/src/routes/share/[key]/photos/[assetId]/+page.server.ts new file mode 100644 index 0000000000..4fa079a150 --- /dev/null +++ b/web/src/routes/share/[key]/photos/[assetId]/+page.server.ts @@ -0,0 +1,21 @@ +export const prerender = false; +import { error } from '@sveltejs/kit'; + +import { serverApi } from '@api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + try { + const { key, assetId } = params; + const { data: asset } = await serverApi.assetApi.getAssetById(assetId, { + params: { key } + }); + + if (!asset) { + return error(404, 'Asset not found'); + } + return { asset, key }; + } catch (e) { + console.log('Error', e); + } +}; diff --git a/web/src/routes/share/[key]/photos/[assetId]/+page.svelte b/web/src/routes/share/[key]/photos/[assetId]/+page.svelte new file mode 100644 index 0000000000..41f5012c9d --- /dev/null +++ b/web/src/routes/share/[key]/photos/[assetId]/+page.svelte @@ -0,0 +1,17 @@ + + +{#if data.asset && data.key} + null} + on:navigate-next={() => null} + showNavigation={false} + on:close={() => goto(`/share/${data.key}`)} + /> +{/if} diff --git a/web/src/routes/sharing/+page.svelte b/web/src/routes/sharing/+page.svelte index df27095f41..70a6fb8280 100644 --- a/web/src/routes/sharing/+page.svelte +++ b/web/src/routes/sharing/+page.svelte @@ -2,6 +2,8 @@ import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; + import Link from 'svelte-material-icons/Link.svelte'; + import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte'; import { goto } from '$app/navigation'; import { api } from '@api'; @@ -55,7 +57,7 @@

Sharing

-
+
+ +
diff --git a/web/src/routes/sharing/sharedlinks/+page.server.ts b/web/src/routes/sharing/sharedlinks/+page.server.ts new file mode 100644 index 0000000000..52746063c5 --- /dev/null +++ b/web/src/routes/sharing/sharedlinks/+page.server.ts @@ -0,0 +1,18 @@ +import { redirect } from '@sveltejs/kit'; +export const prerender = false; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + try { + const { user } = await parent(); + if (!user) { + throw redirect(302, '/auth/login'); + } + + return { + user + }; + } catch (e) { + throw redirect(302, '/auth/login'); + } +}; diff --git a/web/src/routes/sharing/sharedlinks/+page.svelte b/web/src/routes/sharing/sharedlinks/+page.svelte new file mode 100644 index 0000000000..9ae23ace60 --- /dev/null +++ b/web/src/routes/sharing/sharedlinks/+page.svelte @@ -0,0 +1,109 @@ + + + + Shared links - Immich + + + goto('/sharing')}> + Shared links + + +
+
+

Manage shared links

+
+ {#if sharedLinks.length === 0} +
+

You don't have any shared links

+
+ {:else} +
+ {#each sharedLinks as link (link.id)} + handleDeleteLink(link.id)} + on:edit={() => handleEditLink(link.id)} + on:copy={() => handleCopy(link.key)} + /> + {/each} +
+ {/if} +
+ +{#if showEditForm} + +{/if}