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 <jason@rasm.me>
This commit is contained in:
Jed-Giblin
2025-07-28 14:16:55 -04:00
committed by GitHub
parent 16b14b390f
commit 9b3718120b
65 changed files with 947 additions and 432 deletions

View File

@@ -13,6 +13,7 @@ import { downloadRequest, withError } from '$lib/utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
import { asQueryString } from '$lib/utils/shared-links';
import {
addAssetsToAlbum as addAssets,
AssetVisibility,
@@ -42,11 +43,11 @@ import { handleError } from './handle-error';
export const addAssetsToAlbum = async (albumId: string, assetIds: string[], showNotification = true) => {
const result = await addAssets({
...authManager.params,
id: albumId,
bulkIdsDto: {
ids: assetIds,
},
key: authManager.key,
});
const count = result.filter(({ success }) => success).length;
const duplicateErrorCount = result.filter(({ error }) => error === 'duplicate').length;
@@ -155,7 +156,7 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
const $preferences = get<UserPreferencesResponseDto | undefined>(preferences);
const dto = { ...options, archiveSize: $preferences?.download.archiveSize };
const [error, downloadInfo] = await withError(() => getDownloadInfo({ downloadInfoDto: dto, key: authManager.key }));
const [error, downloadInfo] = await withError(() => getDownloadInfo({ ...authManager.params, downloadInfoDto: dto }));
if (error) {
const $t = get(t);
handleError(error, $t('errors.unable_to_download_files'));
@@ -170,7 +171,7 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
const archive = downloadInfo.archives[index];
const suffix = downloadInfo.archives.length > 1 ? `+${index + 1}` : '';
const archiveName = fileName.replace('.zip', `${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}.zip`);
const key = authManager.key;
const queryParams = asQueryString(authManager.params);
let downloadKey = `${archiveName} `;
if (downloadInfo.archives.length > 1) {
@@ -184,7 +185,7 @@ export const downloadArchive = async (fileName: string, options: Omit<DownloadIn
// TODO use sdk once it supports progress events
const { data } = await downloadRequest({
method: 'POST',
url: getBaseUrl() + '/download/archive' + (key ? `?key=${key}` : ''),
url: getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : ''),
data: { assetIds: archive.assetIds },
signal: abort.signal,
onDownloadProgress: (event) => downloadManager.update(downloadKey, event.loaded),
@@ -217,7 +218,7 @@ export const downloadFile = async (asset: AssetResponseDto) => {
};
if (asset.livePhotoVideoId) {
const motionAsset = await getAssetInfo({ id: asset.livePhotoVideoId, key: authManager.key });
const motionAsset = await getAssetInfo({ ...authManager.params, id: asset.livePhotoVideoId });
if (!isAndroidMotionVideo(motionAsset) || get(preferences)?.download.includeEmbeddedVideos) {
assets.push({
filename: motionAsset.originalFileName,
@@ -227,16 +228,16 @@ export const downloadFile = async (asset: AssetResponseDto) => {
}
}
const queryParams = asQueryString(authManager.params);
for (const { filename, id } of assets) {
try {
const key = authManager.key;
notificationController.show({
type: NotificationType.Info,
message: $t('downloading_asset_filename', { values: { filename: asset.originalFileName } }),
});
downloadUrl(getBaseUrl() + `/assets/${id}/original` + (key ? `?key=${key}` : ''), filename);
downloadUrl(getBaseUrl() + `/assets/${id}/original` + (queryParams ? `?${queryParams}` : ''), filename);
} catch (error) {
handleError(error, $t('errors.error_downloading', { values: { filename } }));
}

View File

@@ -5,6 +5,7 @@ import { uploadAssetsStore } from '$lib/stores/upload';
import { uploadRequest } from '$lib/utils';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { ExecutorQueue } from '$lib/utils/executor-queue';
import { asQueryString } from '$lib/utils/shared-links';
import {
Action,
AssetMediaStatus,
@@ -152,8 +153,7 @@ async function fileUploader({
}
let responseData: { id: string; status: AssetMediaStatus; isTrashed?: boolean } | undefined;
const key = authManager.key;
if (crypto?.subtle?.digest && !key) {
if (crypto?.subtle?.digest && !authManager.isSharedLink) {
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_hashing') });
await tick();
try {
@@ -179,10 +179,12 @@ async function fileUploader({
}
if (!responseData) {
const queryParams = asQueryString(authManager.params);
uploadAssetsStore.updateItem(deviceAssetId, { message: $t('asset_uploading') });
if (replaceAssetId) {
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (key ? `?key=${key}` : ''),
url: getBaseUrl() + getAssetOriginalPath(replaceAssetId) + (queryParams ? `?${queryParams}` : ''),
method: 'PUT',
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
@@ -190,7 +192,7 @@ async function fileUploader({
responseData = response.data;
} else {
const response = await uploadRequest<AssetMediaResponseDto>({
url: getBaseUrl() + '/assets' + (key ? `?key=${key}` : ''),
url: getBaseUrl() + '/assets' + (queryParams ? `?${queryParams}` : ''),
data: formData,
onUploadProgress: (event) => uploadAssetsStore.updateProgress(deviceAssetId, event.loaded, event.total),
});

View File

@@ -13,7 +13,8 @@ export const isExternalUrl = (url: string): boolean => {
};
export const isPhotosRoute = (route?: string | null) => !!route?.startsWith('/(user)/photos/[[assetId=id]]');
export const isSharedLinkRoute = (route?: string | null) => !!route?.startsWith('/(user)/share/[key]');
export const isSharedLinkRoute = (route?: string | null) =>
!!route?.startsWith('/(user)/share/[key]') || !!route?.startsWith('/(user)/s/[slug]');
export const isSearchRoute = (route?: string | null) => !!route?.startsWith('/(user)/search');
export const isAlbumsRoute = (route?: string | null) => !!route?.startsWith('/(user)/albums/[albumId=id]');
export const isPeopleRoute = (route?: string | null) => !!route?.startsWith('/(user)/people/[personId]');

View File

@@ -0,0 +1,58 @@
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';
export const asQueryString = ({ slug, key }: { slug?: string; key?: string }) => {
const params = new URLSearchParams();
if (slug) {
params.set('slug', slug);
}
if (key) {
params.set('key', key);
}
return params.toString();
};
export const loadSharedLink = async ({ url, params }: { url: URL; params: { key?: string; slug?: string } }) => {
const { key, slug } = params;
await authenticate(url, { public: true });
const common = { key, slug };
const $t = await getFormatter();
try {
const [sharedLink, asset] = await Promise.all([getMySharedLink({ key, slug }), 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 {
...common,
sharedLink,
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 {
...common,
passwordRequired: true,
meta: {
title: $t('password_required'),
},
};
}
throw error;
}
};