From 9b3718120b055813cf2ffc7ca65eced276ed08b7 Mon Sep 17 00:00:00 2001 From: Jed-Giblin Date: Mon, 28 Jul 2025 14:16:55 -0400 Subject: [PATCH] feat: shared links custom URL (#19999) * feat: custom url for shared links * feat: use a separate route and query param --------- Co-authored-by: Jason Rasmussen --- i18n/en.json | 4 +- mobile/openapi/lib/api/albums_api.dart | Bin 18860 -> 19232 bytes mobile/openapi/lib/api/assets_api.dart | Bin 31971 -> 33087 bytes mobile/openapi/lib/api/download_api.dart | Bin 4081 -> 4453 bytes mobile/openapi/lib/api/shared_links_api.dart | Bin 14619 -> 15177 bytes mobile/openapi/lib/api/timeline_api.dart | Bin 11545 -> 11917 bytes .../lib/model/shared_link_create_dto.dart | Bin 6950 -> 6528 bytes .../lib/model/shared_link_edit_dto.dart | Bin 7581 -> 7159 bytes .../lib/model/shared_link_response_dto.dart | Bin 7014 -> 7311 bytes open-api/immich-openapi-specs.json | 137 ++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 93 ++++++++---- server/src/database.ts | 1 + server/src/dtos/shared-link.dto.ts | 32 ++-- server/src/enum.ts | 2 + server/src/middleware/auth.guard.ts | 5 +- server/src/queries/shared.link.repository.sql | 44 +++++- .../repositories/shared-link.repository.ts | 15 +- .../1753471866748-AddSharedLinkSlug.ts | 11 ++ server/src/schema/tables/shared-link.table.ts | 3 + server/src/services/api.service.ts | 2 +- server/src/services/auth.service.spec.ts | 46 +++++- server/src/services/auth.service.ts | 38 +++-- .../src/services/shared-link.service.spec.ts | 5 + server/src/services/shared-link.service.ts | 68 +++++---- server/test/fixtures/shared-link.stub.ts | 8 + .../components/album-page/albums-list.svelte | 2 +- .../actions/download-action.svelte | 2 +- .../asset-viewer/actions/share-action.svelte | 2 +- .../asset-viewer/asset-viewer.svelte | 4 +- .../detail-panel-star-rating.svelte | 2 +- .../asset-viewer/detail-panel-tags.svelte | 2 +- .../asset-viewer/detail-panel.svelte | 4 +- .../asset-viewer/image-panorama-viewer.svelte | 2 +- .../assets/thumbnail/thumbnail.svelte | 4 +- .../memory-page/memory-viewer.svelte | 2 +- .../pages/SharedLinkErrorPage.svelte | 14 ++ .../components/pages/SharedLinkPage.svelte | 108 ++++++++++++++ .../actions/create-shared-link.svelte | 2 +- .../actions/download-action.svelte | 4 +- .../actions/link-live-photo-action.svelte | 6 +- .../actions/remove-from-shared-link.svelte | 2 +- .../components/photos-page/asset-grid.svelte | 8 +- .../individual-shared-viewer.svelte | 8 +- .../drag-and-drop-upload-overlay.svelte | 2 +- .../actions/shared-link-copy.svelte | 2 +- .../sharedlinks-page/shared-link-card.svelte | 13 +- web/src/lib/managers/auth-manager.svelte.ts | 3 +- .../internal/load-support.svelte.ts | 5 +- .../timeline-manager.svelte.ts | 4 +- web/src/lib/modals/AlbumShareModal.svelte | 2 +- .../lib/modals/SharedLinkCreateModal.svelte | 117 +++++++-------- web/src/lib/stores/asset-viewing.store.ts | 2 +- web/src/lib/utils.ts | 11 +- web/src/lib/utils/asset-utils.ts | 17 ++- web/src/lib/utils/file-uploader.ts | 10 +- web/src/lib/utils/navigation.ts | 3 +- web/src/lib/utils/shared-links.ts | 58 ++++++++ .../[[assetId=id]]/+page.svelte | 3 +- web/src/routes/(user)/s/[slug]/+error.svelte | 5 + .../[[assetId=id]]/+page.svelte | 12 ++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 4 + .../routes/(user)/share/[key]/+error.svelte | 13 +- .../[[assetId=id]]/+page.svelte | 93 +----------- .../[[photos=photos]]/[[assetId=id]]/+page.ts | 44 +----- .../factories/shared-link-factory.ts | 1 + 65 files changed, 758 insertions(+), 358 deletions(-) create mode 100644 server/src/schema/migrations/1753471866748-AddSharedLinkSlug.ts create mode 100644 web/src/lib/components/pages/SharedLinkErrorPage.svelte create mode 100644 web/src/lib/components/pages/SharedLinkPage.svelte create mode 100644 web/src/lib/utils/shared-links.ts create mode 100644 web/src/routes/(user)/s/[slug]/+error.svelte create mode 100644 web/src/routes/(user)/s/[slug]/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/s/[slug]/[[photos=photos]]/[[assetId=id]]/+page.ts diff --git a/i18n/en.json b/i18n/en.json index cfc8ffcce..39baff338 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -725,6 +725,7 @@ "current_server_address": "Current server address", "custom_locale": "Custom Locale", "custom_locale_description": "Format dates and numbers based on the language and the region", + "custom_url": "Custom URL", "daily_title_text_date": "E, MMM dd", "daily_title_text_date_year": "E, MMM dd, yyyy", "dark": "Dark", @@ -1172,7 +1173,6 @@ "light": "Light", "like_deleted": "Like deleted", "link_motion_video": "Link motion video", - "link_options": "Link options", "link_to_oauth": "Link to OAuth", "linked_oauth_account": "Linked OAuth account", "list": "List", @@ -1745,6 +1745,7 @@ "shared_link_clipboard_copied_massage": "Copied to clipboard", "shared_link_clipboard_text": "Link: {link}\nPassword: {password}", "shared_link_create_error": "Error while creating shared link", + "shared_link_custom_url_description": "Access this shared link with a custom URL", "shared_link_edit_description_hint": "Enter the share description", "shared_link_edit_expire_after_option_day": "1 day", "shared_link_edit_expire_after_option_days": "{count} days", @@ -1770,6 +1771,7 @@ "shared_link_info_chip_metadata": "EXIF", "shared_link_manage_links": "Manage Shared links", "shared_link_options": "Shared link options", + "shared_link_password_description": "Require a password to access this shared link", "shared_links": "Shared links", "shared_links_description": "Share photos and videos with a link", "shared_photos_and_videos_count": "{assetCount, plural, other {# shared photos & videos.}}", diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index a8c518ace26775bce82894c702ae975541f7d0fd..fa7a562adb9702b1c4bcc25e582a37a833dfb776 100644 GIT binary patch delta 273 zcmZ28nQ_51#tqF(lLO>=C(HA4uoUN%rcd4|EjoD$lOIoTNl|8Ay1fEOaPtGEd5k;? zK#-ZHpaE9KuWi8vlu_3KE13LWR(J9OA-~P?I&5Jcrovg*?gks+0EVcw5kTF(Z z-8u@J103%$Zhmbh#5j2mqu}P#oRPyQ?uHu(%A$L4HiJI2Xw^2(E=g#9L~@!p)g!Bll}0h`R^ ztEP&RPq8^oHd0iZ?7(iZxzX$c(`0{F(aoJ)=LIL{>#!p%3Y&Wrr)UBI@B}Y8 diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index db6a2e78a3a977f788b34b7f9a521c3bacea3675..3cb62785becbcff287a9fe4241808520a5f2ffb4 100644 GIT binary patch delta 778 zcmaF-lW~6&(}qm3$-hf^CpRc?uoUN%rcZVV6rEfpW-{5|7b0uW@T zDQJL|P1H5#1~Jri6hPGGc(p)ARD(F}q$kgn)Ib;{Xs$8&wWKB&NWB$A*Fghqrpfba zoHsYf$a0`+2f1UNiZNXK=KCsLB)egax+~m($%5wElM@VBC(COnpt`|;X>+btjR2E z-N`P2LX%ycS3?B<7pQIKahZtU4U^;5bT=PxUCoAS&HtYC36ozZu%$vl-1mQg^*gW}ziqm9+SnJ6K<&T&_=>JMLaB%uR4iBOb5ldVVN6Ga7j^SUb;Ovg-iyezO!Xh@cR~) Z`cx5yovaK+H3$^yE>(ue)96=CjV-1p1eVYeY2yi7zfY@7pQiY%`$36nA(peHPuF3tbYbIN# z%S`qU;GbNl>x}FN<;kL1;hRr*gs=fszk#ZnxcN;0r^x1#q6kK?{*tr>liNyG0`)p= z=FJdc++3M?j{_*p!nC=qM2&Itj#4fbuv5kz?&MKroGj1eJlTfLe{v(A+T{IgCY%4W=`c_B e;ELV6nX3+@q=0d<7+)+ru)@hJrF1v%XUSqjm}RY}KG}!Ood?;x$rIU9xIo&hz`8c`>wjn3 z{EE|z4`TgCY0b$768;cQqKv}i`&=HI?@BZ?LPXH)-0UZ{2P*SXx(cSL2-*CLGFNyJ pE?TISO}_6Y?`QGdEU&A8;=@98-)T%{(G-|`j7=Ze75w@Xd(vhH(Fmr^>|B@mCV!WR-<%@(ka6=|=~awCp1@>j3yH}+vX+xmB*i8Nu!~QU yv)=43cZnBl!+dpt$>BO~7`ALash10~As1-6+T;o?kSgKH?d&oTo2DCYVg~@uI62_} diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 042bc704019d463c13ab8b3d052cb845670a5ca6..2d3ced610b9103acda7b338ea8dd5343f9c554c7 100644 GIT binary patch delta 236 zcmbOk)f>CvFAHmNPHDQ;~cwq_QP)MQ23{hReTc$p!* zMlN%1kg&Rr0$BErLgwTU9`(&Txi_(*8|5Ip4rWBjWNl3qpt)^aVC%qoH;Ep^&@xSC jDp<>AMcK31ZQOiEp$Ju5F%z;a^HFT@VcIOB!^#K%QiN2! delta 115 zcmeB;of);^FUw|bp{tCO-?J^Btf`f?nU5oadGkJQ12&*=GZRp#Z?cwX+h#j$FQ(0G z5~a*A)#8)4Yw=BK2QJv delta 60 zcmZoLUS_tzmuYhhlM(Y~MwVE{$$hK_5XL@Moz1^lPcv?Qz`l!d^AS#V#?9BcrZI26 N!>7%%*+$rf4FGHj6nX#v diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index a394ba9b3b8fd66d1ee56bf4597dbf532c7d56c7..f13bc6977bc86e2802bc3c81e60dbe07f287040a 100644 GIT binary patch delta 207 zcmbPh{oQP8JYv@(`/albums/${encodeURIComponent(id)}${QS.query(QS.explode({ key, + slug, withoutAssets }))}`, { ...opts @@ -1862,16 +1867,18 @@ export function removeAssetFromAlbum({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function addAssetsToAlbum({ id, key, bulkIdsDto }: { +export function addAssetsToAlbum({ id, key, slug, bulkIdsDto }: { id: string; key?: string; + slug?: string; bulkIdsDto: BulkIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: BulkIdResponseDto[]; }>(`/albums/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ - key + key, + slug }))}`, oazapfts.json({ ...opts, method: "PUT", @@ -1971,8 +1978,9 @@ export function deleteAssets({ assetBulkDeleteDto }: { body: assetBulkDeleteDto }))); } -export function uploadAsset({ key, xImmichChecksum, assetMediaCreateDto }: { +export function uploadAsset({ key, slug, xImmichChecksum, assetMediaCreateDto }: { key?: string; + slug?: string; xImmichChecksum?: string; assetMediaCreateDto: AssetMediaCreateDto; }, opts?: Oazapfts.RequestOpts) { @@ -1980,7 +1988,8 @@ export function uploadAsset({ key, xImmichChecksum, assetMediaCreateDto }: { status: 201; data: AssetMediaResponseDto; }>(`/assets${QS.query(QS.explode({ - key + key, + slug }))}`, oazapfts.multipart({ ...opts, method: "POST", @@ -2082,15 +2091,17 @@ export function getAssetStatistics({ isFavorite, isTrashed, visibility }: { ...opts })); } -export function getAssetInfo({ id, key }: { +export function getAssetInfo({ id, key, slug }: { id: string; key?: string; + slug?: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetResponseDto; }>(`/assets/${encodeURIComponent(id)}${QS.query(QS.explode({ - key + key, + slug }))}`, { ...opts })); @@ -2108,15 +2119,17 @@ export function updateAsset({ id, updateAssetDto }: { body: updateAssetDto }))); } -export function downloadAsset({ id, key }: { +export function downloadAsset({ id, key, slug }: { id: string; key?: string; + slug?: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; }>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({ - key + key, + slug }))}`, { ...opts })); @@ -2124,46 +2137,52 @@ export function downloadAsset({ id, key }: { /** * replaceAsset */ -export function replaceAsset({ id, key, assetMediaReplaceDto }: { +export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: { id: string; key?: string; + slug?: string; assetMediaReplaceDto: AssetMediaReplaceDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetMediaResponseDto; }>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({ - key + key, + slug }))}`, oazapfts.multipart({ ...opts, method: "PUT", body: assetMediaReplaceDto }))); } -export function viewAsset({ id, key, size }: { +export function viewAsset({ id, key, size, slug }: { id: string; key?: string; size?: AssetMediaSize; + slug?: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; }>(`/assets/${encodeURIComponent(id)}/thumbnail${QS.query(QS.explode({ key, - size + size, + slug }))}`, { ...opts })); } -export function playAssetVideo({ id, key }: { +export function playAssetVideo({ id, key, slug }: { id: string; key?: string; + slug?: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; }>(`/assets/${encodeURIComponent(id)}/video/playback${QS.query(QS.explode({ - key + key, + slug }))}`, { ...opts })); @@ -2272,30 +2291,34 @@ export function validateAccessToken(opts?: Oazapfts.RequestOpts) { method: "POST" })); } -export function downloadArchive({ key, assetIdsDto }: { +export function downloadArchive({ key, slug, assetIdsDto }: { key?: string; + slug?: string; assetIdsDto: AssetIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; }>(`/download/archive${QS.query(QS.explode({ - key + key, + slug }))}`, oazapfts.json({ ...opts, method: "POST", body: assetIdsDto }))); } -export function getDownloadInfo({ key, downloadInfoDto }: { +export function getDownloadInfo({ key, slug, downloadInfoDto }: { key?: string; + slug?: string; downloadInfoDto: DownloadInfoDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: DownloadResponseDto; }>(`/download/info${QS.query(QS.explode({ - key + key, + slug }))}`, oazapfts.json({ ...opts, method: "POST", @@ -3230,9 +3253,10 @@ export function createSharedLink({ sharedLinkCreateDto }: { body: sharedLinkCreateDto }))); } -export function getMySharedLink({ key, password, token }: { +export function getMySharedLink({ key, password, slug, token }: { key?: string; password?: string; + slug?: string; token?: string; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -3241,6 +3265,7 @@ export function getMySharedLink({ key, password, token }: { }>(`/shared-links/me${QS.query(QS.explode({ key, password, + slug, token }))}`, { ...opts @@ -3277,32 +3302,36 @@ export function updateSharedLink({ id, sharedLinkEditDto }: { body: sharedLinkEditDto }))); } -export function removeSharedLinkAssets({ id, key, assetIdsDto }: { +export function removeSharedLinkAssets({ id, key, slug, assetIdsDto }: { id: string; key?: string; + slug?: string; assetIdsDto: AssetIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ - key + key, + slug }))}`, oazapfts.json({ ...opts, method: "DELETE", body: assetIdsDto }))); } -export function addSharedLinkAssets({ id, key, assetIdsDto }: { +export function addSharedLinkAssets({ id, key, slug, assetIdsDto }: { id: string; key?: string; + slug?: string; assetIdsDto: AssetIdsDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: AssetIdsResponseDto[]; }>(`/shared-links/${encodeURIComponent(id)}/assets${QS.query(QS.explode({ - key + key, + slug }))}`, oazapfts.json({ ...opts, method: "PUT", @@ -3611,13 +3640,14 @@ export function tagAssets({ id, bulkIdsDto }: { body: bulkIdsDto }))); } -export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { +export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; personId?: string; + slug?: string; tagId?: string; timeBucket: string; userId?: string; @@ -3635,6 +3665,7 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers key, order, personId, + slug, tagId, timeBucket, userId, @@ -3645,13 +3676,14 @@ export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, pers ...opts })); } -export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, tagId, userId, visibility, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withPartners, withStacked }: { albumId?: string; isFavorite?: boolean; isTrashed?: boolean; key?: string; order?: AssetOrder; personId?: string; + slug?: string; tagId?: string; userId?: string; visibility?: AssetVisibility; @@ -3668,6 +3700,7 @@ export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, per key, order, personId, + slug, tagId, userId, visibility, diff --git a/server/src/database.ts b/server/src/database.ts index 53c39b738..44bdefa08 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -192,6 +192,7 @@ export type SharedLink = { showExif: boolean; type: SharedLinkType; userId: string; + slug: string | null; }; export type Album = Selectable & { diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index 299590c0e..011707f1f 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -22,13 +22,17 @@ export class SharedLinkCreateDto { @ValidateUUID({ optional: true }) albumId?: string; + @Optional({ nullable: true, emptyToNull: true }) @IsString() - @Optional() - description?: string; + description?: string | null; + @Optional({ nullable: true, emptyToNull: true }) @IsString() - @Optional() - password?: string; + password?: string | null; + + @Optional({ nullable: true, emptyToNull: true }) + @IsString() + slug?: string | null; @ValidateDate({ optional: true, nullable: true }) expiresAt?: Date | null = null; @@ -44,16 +48,22 @@ export class SharedLinkCreateDto { } export class SharedLinkEditDto { - @Optional() - description?: string; + @Optional({ nullable: true, emptyToNull: true }) + @IsString() + description?: string | null; - @Optional() - password?: string; + @Optional({ nullable: true, emptyToNull: true }) + @IsString() + password?: string | null; + + @Optional({ nullable: true, emptyToNull: true }) + @IsString() + slug?: string | null; @Optional({ nullable: true }) expiresAt?: Date | null; - @Optional() + @ValidateBoolean({ optional: true }) allowUpload?: boolean; @ValidateBoolean({ optional: true }) @@ -99,6 +109,8 @@ export class SharedLinkResponseDto { allowDownload!: boolean; showMetadata!: boolean; + + slug!: string | null; } export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto { @@ -118,6 +130,7 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto { allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, showMetadata: sharedLink.showExif, + slug: sharedLink.slug, }; } @@ -141,5 +154,6 @@ export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLink allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, showMetadata: sharedLink.showExif, + slug: sharedLink.slug, }; } diff --git a/server/src/enum.ts b/server/src/enum.ts index 70e94ed43..d17b5dc90 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -17,12 +17,14 @@ export enum ImmichHeader { UserToken = 'x-immich-user-token', SessionToken = 'x-immich-session-token', SharedLinkKey = 'x-immich-share-key', + SharedLinkSlug = 'x-immich-share-slug', Checksum = 'x-immich-checksum', Cid = 'x-immich-cid', } export enum ImmichQuery { SharedLinkKey = 'key', + SharedLinkSlug = 'slug', ApiKey = 'apiKey', SessionKey = 'sessionKey', } diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index 238f99257..69b3cb5ec 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -28,7 +28,10 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator = ]; if ((options as SharedLinkRoute)?.sharedLink) { - decorators.push(ApiQuery({ name: ImmichQuery.SharedLinkKey, type: String, required: false })); + decorators.push( + ApiQuery({ name: ImmichQuery.SharedLinkKey, type: String, required: false }), + ApiQuery({ name: ImmichQuery.SharedLinkSlug, type: String, required: false }), + ); } return applyDecorators(...decorators); diff --git a/server/src/queries/shared.link.repository.sql b/server/src/queries/shared.link.repository.sql index ed3507fa2..0e13b98b5 100644 --- a/server/src/queries/shared.link.repository.sql +++ b/server/src/queries/shared.link.repository.sql @@ -188,9 +188,47 @@ from "shared_link" left join "album" on "album"."id" = "shared_link"."albumId" where - "shared_link"."key" = $1 - and "album"."deletedAt" is null + "album"."deletedAt" is null and ( - "shared_link"."type" = $2 + "shared_link"."type" = $1 or "album"."id" is not null ) + and "shared_link"."key" = $2 + +-- SharedLinkRepository.getBySlug +select + "shared_link"."id", + "shared_link"."userId", + "shared_link"."expiresAt", + "shared_link"."showExif", + "shared_link"."allowUpload", + "shared_link"."allowDownload", + "shared_link"."password", + ( + select + to_json(obj) + from + ( + select + "user"."id", + "user"."name", + "user"."email", + "user"."isAdmin", + "user"."quotaUsageInBytes", + "user"."quotaSizeInBytes" + from + "user" + where + "user"."id" = "shared_link"."userId" + ) as obj + ) as "user" +from + "shared_link" + left join "album" on "album"."id" = "shared_link"."albumId" +where + "album"."deletedAt" is null + and ( + "shared_link"."type" = $1 + or "album"."id" is not null + ) + and "shared_link"."slug" = $2 diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index d5fb3be47..54eab7c86 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -173,10 +173,18 @@ export class SharedLinkRepository { } @GenerateSql({ params: [DummyValue.BUFFER] }) - async getByKey(key: Buffer) { + getByKey(key: Buffer) { + return this.authBuilder().where('shared_link.key', '=', key).executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.BUFFER] }) + getBySlug(slug: string) { + return this.authBuilder().where('shared_link.slug', '=', slug).executeTakeFirst(); + } + + private authBuilder() { return this.db .selectFrom('shared_link') - .where('shared_link.key', '=', key) .leftJoin('album', 'album.id', 'shared_link.albumId') .where('album.deletedAt', 'is', null) .select((eb) => [ @@ -185,8 +193,7 @@ export class SharedLinkRepository { eb.selectFrom('user').select(columns.authUser).whereRef('user.id', '=', 'shared_link.userId'), ).as('user'), ]) - .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])) - .executeTakeFirst(); + .where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)])); } async create(entity: Insertable & { assetIds?: string[] }) { diff --git a/server/src/schema/migrations/1753471866748-AddSharedLinkSlug.ts b/server/src/schema/migrations/1753471866748-AddSharedLinkSlug.ts new file mode 100644 index 000000000..1d77eddd0 --- /dev/null +++ b/server/src/schema/migrations/1753471866748-AddSharedLinkSlug.ts @@ -0,0 +1,11 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "shared_link" ADD "slug" character varying;`.execute(db); + await sql`ALTER TABLE "shared_link" ADD CONSTRAINT "shared_link_slug_uq" UNIQUE ("slug");`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "shared_link" DROP CONSTRAINT "shared_link_slug_uq";`.execute(db); + await sql`ALTER TABLE "shared_link" DROP COLUMN "slug";`.execute(db); +} diff --git a/server/src/schema/tables/shared-link.table.ts b/server/src/schema/tables/shared-link.table.ts index 40fd2bf6a..80e2d7cdf 100644 --- a/server/src/schema/tables/shared-link.table.ts +++ b/server/src/schema/tables/shared-link.table.ts @@ -48,4 +48,7 @@ export class SharedLinkTable { @Column({ type: 'character varying', nullable: true }) password!: string | null; + + @Column({ type: 'character varying', nullable: true, unique: true }) + slug!: string | null; } diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index 27a776e86..ced74482c 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -80,7 +80,7 @@ export class ApiService { if (shareMatches) { try { const key = shareMatches[1]; - const auth = await this.authService.validateSharedLink(key); + const auth = await this.authService.validateSharedLinkKey(key); const meta = await this.sharedLinkService.getMetadataTags( auth, request.host ? `${request.protocol}://${request.host}` : undefined, diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index 9f678162c..58c254231 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -322,15 +322,18 @@ describe(AuthService.name, () => { mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); mocks.user.get.mockResolvedValue(user); + const buffer = sharedLink.key; + const key = buffer.toString('base64url'); + await expect( sut.authenticate({ - headers: { 'x-immich-share-key': sharedLink.key.toString('base64url') }, + headers: { 'x-immich-share-key': key }, queryParams: {}, metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, }), ).resolves.toEqual({ user, sharedLink }); - expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key); + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(buffer); }); it('should accept a hex key', async () => { @@ -340,15 +343,50 @@ describe(AuthService.name, () => { mocks.sharedLink.getByKey.mockResolvedValue(sharedLink); mocks.user.get.mockResolvedValue(user); + const buffer = sharedLink.key; + const key = buffer.toString('hex'); + await expect( sut.authenticate({ - headers: { 'x-immich-share-key': sharedLink.key.toString('hex') }, + headers: { 'x-immich-share-key': key }, queryParams: {}, metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, }), ).resolves.toEqual({ user, sharedLink }); - expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(sharedLink.key); + expect(mocks.sharedLink.getByKey).toHaveBeenCalledWith(buffer); + }); + }); + + describe('validate - shared link slug', () => { + it('should not accept a non-existent slug', async () => { + mocks.sharedLink.getBySlug.mockResolvedValue(void 0); + + await expect( + sut.authenticate({ + headers: { 'x-immich-share-slug': 'slug' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('should accept a valid slug', async () => { + const user = factory.userAdmin(); + const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any; + + mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink); + mocks.user.get.mockResolvedValue(user); + + await expect( + sut.authenticate({ + headers: { 'x-immich-share-slug': 'slug-123' }, + queryParams: {}, + metadata: { adminRoute: false, sharedLinkRoute: true, uri: 'test' }, + }), + ).resolves.toEqual({ user, sharedLink }); + + expect(mocks.sharedLink.getBySlug).toHaveBeenCalledWith('slug-123'); }); }); diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index f757bf5a3..fcaeb06af 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -6,7 +6,7 @@ import { IncomingHttpHeaders } from 'node:http'; import { join } from 'node:path'; import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; -import { UserAdmin } from 'src/database'; +import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database'; import { AuthDto, AuthStatusResponseDto, @@ -196,6 +196,7 @@ export class AuthService extends BaseService { private async validate({ headers, queryParams }: Omit): Promise { const shareKey = (headers[ImmichHeader.SharedLinkKey] || queryParams[ImmichQuery.SharedLinkKey]) as string; + const shareSlug = (headers[ImmichHeader.SharedLinkSlug] || queryParams[ImmichQuery.SharedLinkSlug]) as string; const session = (headers[ImmichHeader.UserToken] || headers[ImmichHeader.SessionToken] || queryParams[ImmichQuery.SessionKey] || @@ -204,7 +205,11 @@ export class AuthService extends BaseService { const apiKey = (headers[ImmichHeader.ApiKey] || queryParams[ImmichQuery.ApiKey]) as string; if (shareKey) { - return this.validateSharedLink(shareKey); + return this.validateSharedLinkKey(shareKey); + } + + if (shareSlug) { + return this.validateSharedLinkSlug(shareSlug); } if (session) { @@ -403,18 +408,33 @@ export class AuthService extends BaseService { return cookies[ImmichCookie.OAuthCodeVerifier] || null; } - async validateSharedLink(key: string | string[]): Promise { + async validateSharedLinkKey(key: string | string[]): Promise { key = Array.isArray(key) ? key[0] : key; const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url'); const sharedLink = await this.sharedLinkRepository.getByKey(bytes); - if (sharedLink?.user && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date())) { - return { - user: sharedLink.user, - sharedLink, - }; + if (!this.isValidSharedLink(sharedLink)) { + throw new UnauthorizedException('Invalid share key'); } - throw new UnauthorizedException('Invalid share key'); + + return { user: sharedLink.user, sharedLink }; + } + + async validateSharedLinkSlug(slug: string | string[]): Promise { + slug = Array.isArray(slug) ? slug[0] : slug; + + const sharedLink = await this.sharedLinkRepository.getBySlug(slug); + if (!this.isValidSharedLink(sharedLink)) { + throw new UnauthorizedException('Invalid share slug'); + } + + return { user: sharedLink.user, sharedLink }; + } + + private isValidSharedLink( + sharedLink?: AuthSharedLink & { user: AuthUser | null }, + ): sharedLink is AuthSharedLink & { user: AuthUser } { + return !!sharedLink?.user && (!sharedLink.expiresAt || new Date(sharedLink.expiresAt) > new Date()); } private async validateApiKey(key: string): Promise { diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 8e09580d5..9483cdddf 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -136,6 +136,7 @@ describe(SharedLinkService.name, () => { allowUpload: true, description: null, expiresAt: null, + slug: null, showExif: true, key: Buffer.from('random-bytes', 'utf8'), }); @@ -163,6 +164,7 @@ describe(SharedLinkService.name, () => { userId: authStub.admin.user.id, albumId: null, allowDownload: true, + slug: null, allowUpload: true, assetIds: [assetStub.image.id], description: null, @@ -199,6 +201,7 @@ describe(SharedLinkService.name, () => { description: null, expiresAt: null, showExif: false, + slug: null, key: Buffer.from('random-bytes', 'utf8'), }); }); @@ -223,6 +226,7 @@ describe(SharedLinkService.name, () => { expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ id: sharedLinkStub.valid.id, + slug: null, userId: authStub.user1.user.id, allowDownload: false, }); @@ -277,6 +281,7 @@ describe(SharedLinkService.name, () => { expect(mocks.sharedLink.update).toHaveBeenCalled(); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, + slug: null, assetIds: ['asset-3'], }); }); diff --git a/server/src/services/shared-link.service.ts b/server/src/services/shared-link.service.ts index 9f8e238c4..096739d05 100644 --- a/server/src/services/shared-link.service.ts +++ b/server/src/services/shared-link.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { PostgresError } from 'postgres'; import { SharedLink } from 'src/database'; import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto'; import { AssetIdsDto } from 'src/dtos/asset.dto'; @@ -64,36 +65,53 @@ export class SharedLinkService extends BaseService { } } - const sharedLink = await this.sharedLinkRepository.create({ - key: this.cryptoRepository.randomBytes(50), - userId: auth.user.id, - type: dto.type, - albumId: dto.albumId || null, - assetIds: dto.assetIds, - description: dto.description || null, - password: dto.password, - expiresAt: dto.expiresAt || null, - allowUpload: dto.allowUpload ?? true, - allowDownload: dto.showMetadata === false ? false : (dto.allowDownload ?? true), - showExif: dto.showMetadata ?? true, - }); + try { + const sharedLink = await this.sharedLinkRepository.create({ + key: this.cryptoRepository.randomBytes(50), + userId: auth.user.id, + type: dto.type, + albumId: dto.albumId || null, + assetIds: dto.assetIds, + description: dto.description || null, + password: dto.password, + expiresAt: dto.expiresAt || null, + allowUpload: dto.allowUpload ?? true, + allowDownload: dto.showMetadata === false ? false : (dto.allowDownload ?? true), + showExif: dto.showMetadata ?? true, + slug: dto.slug || null, + }); - return this.mapToSharedLink(sharedLink, { withExif: true }); + return this.mapToSharedLink(sharedLink, { withExif: true }); + } catch (error) { + this.handleError(error); + } + } + + private handleError(error: unknown): never { + if ((error as PostgresError).constraint_name === 'shared_link_slug_uq') { + throw new BadRequestException('Shared link with this slug already exists'); + } + throw error; } async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) { await this.findOrFail(auth.user.id, id); - const sharedLink = await this.sharedLinkRepository.update({ - id, - userId: auth.user.id, - description: dto.description, - password: dto.password, - expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt, - allowUpload: dto.allowUpload, - allowDownload: dto.allowDownload, - showExif: dto.showMetadata, - }); - return this.mapToSharedLink(sharedLink, { withExif: true }); + try { + const sharedLink = await this.sharedLinkRepository.update({ + id, + userId: auth.user.id, + description: dto.description, + password: dto.password, + expiresAt: dto.changeExpiryTime && !dto.expiresAt ? null : dto.expiresAt, + allowUpload: dto.allowUpload, + allowDownload: dto.allowDownload, + showExif: dto.showMetadata, + slug: dto.slug || null, + }); + return this.mapToSharedLink(sharedLink, { withExif: true }); + } catch (error) { + this.handleError(error); + } } async remove(auth: AuthDto, id: string): Promise { diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 47201a5b3..1cd36f1f2 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -118,6 +118,7 @@ export const sharedLinkStub = { description: null, assets: [assetStub.image], password: 'password', + slug: null, }), valid: Object.freeze({ id: '123', @@ -135,6 +136,7 @@ export const sharedLinkStub = { password: null, assets: [] as MapAsset[], album: null, + slug: null, }), expired: Object.freeze({ id: '123', @@ -152,6 +154,7 @@ export const sharedLinkStub = { albumId: null, assets: [] as MapAsset[], album: null, + slug: null, }), readonlyNoExif: Object.freeze({ id: '123', @@ -166,6 +169,7 @@ export const sharedLinkStub = { description: null, password: null, assets: [], + slug: null, albumId: 'album-123', album: { id: 'album-123', @@ -266,6 +270,7 @@ export const sharedLinkStub = { allowUpload: true, allowDownload: true, showExif: true, + slug: null, description: null, password: 'password', assets: [], @@ -288,6 +293,7 @@ export const sharedLinkResponseStub = { showMetadata: true, type: SharedLinkType.Album, userId: 'admin_id', + slug: null, }), expired: Object.freeze({ album: undefined, @@ -303,6 +309,7 @@ export const sharedLinkResponseStub = { showMetadata: true, type: SharedLinkType.Album, userId: 'admin_id', + slug: null, }), readonlyNoMetadata: Object.freeze({ id: '123', @@ -316,6 +323,7 @@ export const sharedLinkResponseStub = { allowUpload: false, allowDownload: false, showMetadata: false, + slug: null, album: { ...albumResponse, startDate: assetResponse.localDateTime, endDate: assetResponse.localDateTime }, assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }], }), diff --git a/web/src/lib/components/album-page/albums-list.svelte b/web/src/lib/components/album-page/albums-list.svelte index 29031bee2..668f624af 100644 --- a/web/src/lib/components/album-page/albums-list.svelte +++ b/web/src/lib/components/album-page/albums-list.svelte @@ -369,7 +369,7 @@ if (sharedLink) { handleSharedLinkCreated(albumToShare); - await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) }); + await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); } return; } diff --git a/web/src/lib/components/asset-viewer/actions/download-action.svelte b/web/src/lib/components/asset-viewer/actions/download-action.svelte index e6c96da01..677550e2d 100644 --- a/web/src/lib/components/asset-viewer/actions/download-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/download-action.svelte @@ -16,7 +16,7 @@ let { asset, menuItem = false }: Props = $props(); - const onDownloadFile = async () => downloadFile(await getAssetInfo({ id: asset.id, key: authManager.key })); + const onDownloadFile = async () => downloadFile(await getAssetInfo({ ...authManager.params, id: asset.id })); diff --git a/web/src/lib/components/asset-viewer/actions/share-action.svelte b/web/src/lib/components/asset-viewer/actions/share-action.svelte index 25b3520d0..24a67848a 100644 --- a/web/src/lib/components/asset-viewer/actions/share-action.svelte +++ b/web/src/lib/components/asset-viewer/actions/share-action.svelte @@ -17,7 +17,7 @@ const sharedLink = await modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }); if (sharedLink) { - await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) }); + await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); } }; diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index d82b2e653..452510f50 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -111,7 +111,7 @@ let zoomToggle = $state(() => void 0); const refreshStack = async () => { - if (authManager.key) { + if (authManager.isSharedLink) { return; } @@ -191,7 +191,7 @@ }); const handleGetAllAlbums = async () => { - if (authManager.key) { + if (authManager.isSharedLink) { return; } diff --git a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte index 0ec869218..d333d73be 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-star-rating.svelte @@ -25,7 +25,7 @@ }; -{#if !authManager.key && $preferences?.ratings.enabled} +{#if !authManager.isSharedLink && $preferences?.ratings.enabled}
handlePromiseError(handleChangeRating(rating))} />
diff --git a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte index 007e20b7c..4dd05f520 100644 --- a/web/src/lib/components/asset-viewer/detail-panel-tags.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel-tags.svelte @@ -37,7 +37,7 @@ -{#if isOwner && !authManager.key} +{#if isOwner && !authManager.isSharedLink}

{$t('tags').toUpperCase()}

diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index ef4ddc13c..e3c29e5c1 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -85,7 +85,7 @@ const handleNewAsset = async (newAsset: AssetResponseDto) => { // TODO: check if reloading asset data is necessary - if (newAsset.id && !authManager.key) { + if (newAsset.id && !authManager.isSharedLink) { const data = await getAssetInfo({ id: asset.id }); people = data?.people || []; unassignedFaces = data?.unassignedFaces || []; @@ -195,7 +195,7 @@ - {#if !authManager.key && isOwner} + {#if !authManager.isSharedLink && isOwner}

{$t('people').toUpperCase()}

diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index d678b00dd..996efa7f3 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -14,7 +14,7 @@ const { asset }: Props = $props(); const loadAssetData = async (id: string) => { - const data = await viewAsset({ id, size: AssetMediaSize.Preview, key: authManager.key }); + const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview }); return URL.createObjectURL(data); }; diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index e07e5e99c..53dce2cdf 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -270,13 +270,13 @@ {/if} - {#if !authManager.key && asset.isFavorite} + {#if !authManager.isSharedLink && asset.isFavorite}
{/if} - {#if !authManager.key && showArchiveIcon && asset.visibility === AssetVisibility.Archive} + {#if !authManager.isSharedLink && showArchiveIcon && asset.visibility === AssetVisibility.Archive}
diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index f1a15f442..f724c4e81 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -69,7 +69,7 @@ let paused = $state(false); let current = $state(undefined); let currentMemoryAssetFull = $derived.by(async () => - current?.asset ? await getAssetInfo({ id: current.asset.id, key: authManager.key }) : undefined, + current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined, ); let currentTimelineAssets = $derived(current?.memory.assets.map((asset) => toTimelineAsset(asset)) || []); diff --git a/web/src/lib/components/pages/SharedLinkErrorPage.svelte b/web/src/lib/components/pages/SharedLinkErrorPage.svelte new file mode 100644 index 000000000..9103a7710 --- /dev/null +++ b/web/src/lib/components/pages/SharedLinkErrorPage.svelte @@ -0,0 +1,14 @@ + + + + Oops! Error - Immich + + +
+

Page not found :/

+ {#if page.error?.message} +

{page.error.message}

+ {/if} +
diff --git a/web/src/lib/components/pages/SharedLinkPage.svelte b/web/src/lib/components/pages/SharedLinkPage.svelte new file mode 100644 index 000000000..a3f559907 --- /dev/null +++ b/web/src/lib/components/pages/SharedLinkPage.svelte @@ -0,0 +1,108 @@ + + + + {title} + + +{#if passwordRequired} +
+
+
{$t('password_required')}
+
+ {$t('sharing_enter_password')} +
+
+
+ + + +
+
+
+
+ + {#snippet leading()} + + {/snippet} + + {#snippet trailing()} + + {/snippet} + +
+{/if} + +{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album} + +{/if} +{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual} +
+ +
+{/if} diff --git a/web/src/lib/components/photos-page/actions/create-shared-link.svelte b/web/src/lib/components/photos-page/actions/create-shared-link.svelte index e49cce24e..40fea4acb 100644 --- a/web/src/lib/components/photos-page/actions/create-shared-link.svelte +++ b/web/src/lib/components/photos-page/actions/create-shared-link.svelte @@ -15,7 +15,7 @@ }); if (sharedLink) { - await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink.key) }); + await modalManager.show(QrCodeModal, { title: $t('view_link'), value: makeSharedLinkUrl(sharedLink) }); } }; diff --git a/web/src/lib/components/photos-page/actions/download-action.svelte b/web/src/lib/components/photos-page/actions/download-action.svelte index 73f1a7774..0a1376374 100644 --- a/web/src/lib/components/photos-page/actions/download-action.svelte +++ b/web/src/lib/components/photos-page/actions/download-action.svelte @@ -4,11 +4,11 @@ import { authManager } from '$lib/managers/auth-manager.svelte'; import { downloadArchive, downloadFile } from '$lib/utils/asset-utils'; import { getAssetInfo } from '@immich/sdk'; + import { IconButton } from '@immich/ui'; import { mdiCloudDownloadOutline, mdiFileDownloadOutline, mdiFolderDownloadOutline } from '@mdi/js'; import { t } from 'svelte-i18n'; import MenuOption from '../../shared-components/context-menu/menu-option.svelte'; import { getAssetControlContext } from '../asset-select-control-bar.svelte'; - import { IconButton } from '@immich/ui'; interface Props { filename?: string; @@ -23,7 +23,7 @@ const assets = [...getAssets()]; if (assets.length === 1) { clearSelect(); - let asset = await getAssetInfo({ id: assets[0].id, key: authManager.key }); + let asset = await getAssetInfo({ ...authManager.params, id: assets[0].id }); await downloadFile(asset); return; } diff --git a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte index 09ca94cb2..6e3a7c789 100644 --- a/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte +++ b/web/src/lib/components/photos-page/actions/link-live-photo-action.svelte @@ -1,15 +1,15 @@ diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index 3e827281e..bd1c2924b 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -1,16 +1,16 @@ + + diff --git a/web/src/routes/(user)/s/[slug]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/s/[slug]/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 000000000..25c24ab02 --- /dev/null +++ b/web/src/routes/(user)/s/[slug]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,12 @@ + + + diff --git a/web/src/routes/(user)/s/[slug]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/s/[slug]/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 000000000..92d4dd042 --- /dev/null +++ b/web/src/routes/(user)/s/[slug]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,4 @@ +import { loadSharedLink } from '$lib/utils/shared-links'; +import type { PageLoad } from './$types'; + +export const load = (async ({ params, url }) => loadSharedLink({ params, url })) satisfies PageLoad; diff --git a/web/src/routes/(user)/share/[key]/+error.svelte b/web/src/routes/(user)/share/[key]/+error.svelte index 9103a7710..2c4d2a4d0 100644 --- a/web/src/routes/(user)/share/[key]/+error.svelte +++ b/web/src/routes/(user)/share/[key]/+error.svelte @@ -1,14 +1,5 @@ - - Oops! Error - Immich - - -
-

Page not found :/

- {#if page.error?.message} -

{page.error.message}

- {/if} -
+ diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte index d16ba622e..25c24ab02 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -1,97 +1,12 @@ - - {title} - - -{#if passwordRequired} -
-
-
{$t('password_required')}
-
- {$t('sharing_enter_password')} -
-
-
- - - -
-
-
-
- - {#snippet leading()} - - {/snippet} - - {#snippet trailing()} - - {/snippet} - -
-{/if} - -{#if !passwordRequired && sharedLink?.type == SharedLinkType.Album} - -{/if} -{#if !passwordRequired && sharedLink?.type == SharedLinkType.Individual} -
- -
-{/if} + diff --git a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts index c0edb5e66..92d4dd042 100644 --- a/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts +++ b/web/src/routes/(user)/share/[key]/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -1,44 +1,4 @@ -import { getAssetThumbnailUrl, setSharedLink } from '$lib/utils'; -import { authenticate } from '$lib/utils/auth'; -import { getFormatter } from '$lib/utils/i18n'; -import { getAssetInfoFromParam } from '$lib/utils/navigation'; -import { getMySharedLink, isHttpError } from '@immich/sdk'; +import { loadSharedLink } from '$lib/utils/shared-links'; import type { PageLoad } from './$types'; -export const load = (async ({ params, url }) => { - const { key } = params; - await authenticate(url, { public: true }); - - const $t = await getFormatter(); - - try { - const [sharedLink, asset] = await Promise.all([getMySharedLink({ key }), getAssetInfoFromParam(params)]); - setSharedLink(sharedLink); - const assetCount = sharedLink.assets.length; - const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id; - const assetPath = assetId ? getAssetThumbnailUrl(assetId) : '/feature-panel.png'; - - return { - sharedLink, - sharedLinkKey: key, - asset, - meta: { - title: sharedLink.album ? sharedLink.album.albumName : $t('public_share'), - description: sharedLink.description || $t('shared_photos_and_videos_count', { values: { assetCount } }), - imageUrl: assetPath, - }, - }; - } catch (error) { - if (isHttpError(error) && error.data.message === 'Invalid password') { - return { - passwordRequired: true, - sharedLinkKey: key, - meta: { - title: $t('password_required'), - }, - }; - } - - throw error; - } -}) satisfies PageLoad; +export const load = (async ({ params, url }) => loadSharedLink({ params, url })) satisfies PageLoad; diff --git a/web/src/test-data/factories/shared-link-factory.ts b/web/src/test-data/factories/shared-link-factory.ts index a057bc936..5768a5f9f 100644 --- a/web/src/test-data/factories/shared-link-factory.ts +++ b/web/src/test-data/factories/shared-link-factory.ts @@ -16,4 +16,5 @@ export const sharedLinkFactory = Sync.makeFactory({ allowUpload: Sync.each(() => faker.datatype.boolean()), allowDownload: Sync.each(() => faker.datatype.boolean()), showMetadata: Sync.each(() => faker.datatype.boolean()), + slug: null, });