diff --git a/server/src/controllers/download.controller.ts b/server/src/controllers/download.controller.ts index e45eeb23f3..7c5958a049 100644 --- a/server/src/controllers/download.controller.ts +++ b/server/src/controllers/download.controller.ts @@ -1,10 +1,12 @@ -import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common'; +import { Body, Controller, HttpCode, HttpStatus, Post, Res, StreamableFile, UseInterceptors } from '@nestjs/common'; +import { Response } from 'express'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; +import { FormsToJsonInterceptor } from 'src/middleware/forms-to-json.interceptor'; import { DownloadService } from 'src/services/download.service'; import { asStreamableFile } from 'src/utils/file'; @@ -29,13 +31,19 @@ export class DownloadController { @Authenticated({ permission: Permission.AssetDownload, sharedLink: true }) @FileResponse() @HttpCode(HttpStatus.OK) + @UseInterceptors(FormsToJsonInterceptor) @Endpoint({ summary: 'Download asset archive', description: 'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.', history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), }) - downloadArchive(@Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { + downloadArchive(@Res({ passthrough: true }) res: Response, @Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise { + if (dto.archiveName) { + res.set({ + 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(dto.archiveName)}.zip`, + }) + } return this.service.downloadArchive(auth, dto).then(asStreamableFile); } } diff --git a/server/src/dtos/download.dto.ts b/server/src/dtos/download.dto.ts index c37fa5c113..5f504449c4 100644 --- a/server/src/dtos/download.dto.ts +++ b/server/src/dtos/download.dto.ts @@ -27,6 +27,7 @@ const DownloadResponseSchema = z const DownloadArchiveSchema = AssetIdsSchema.extend({ edited: z.boolean().optional().describe('Download edited asset if available'), + archiveName: z.string().optional().describe('The name of the archive to download, without extension'), }).meta({ id: 'DownloadArchiveDto' }); export class DownloadInfoDto extends createZodDto(DownloadInfoSchema) {} diff --git a/server/src/middleware/forms-to-json.interceptor.ts b/server/src/middleware/forms-to-json.interceptor.ts new file mode 100644 index 0000000000..aa2b325e4e --- /dev/null +++ b/server/src/middleware/forms-to-json.interceptor.ts @@ -0,0 +1,26 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { Request } from 'express'; + +@Injectable() +export class FormsToJsonInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + + const contentType = req.headers['content-type']; + if (contentType?.startsWith('application/x-www-form-urlencoded')) { + try { + req.body = JSON.parse(req?.body?.json); + req.headers['content-type'] = 'application/json'; + } catch { + } + } + + return next.handle(); + } +} diff --git a/web/src/lib/components/album-page/AlbumViewer.svelte b/web/src/lib/components/album-page/AlbumViewer.svelte index 4e7d6bce1a..aab28d110c 100644 --- a/web/src/lib/components/album-page/AlbumViewer.svelte +++ b/web/src/lib/components/album-page/AlbumViewer.svelte @@ -99,7 +99,7 @@ {#if sharedLink.allowDownload} - + {/if} {:else} diff --git a/web/src/lib/components/share-page/IndividualSharedViewer.svelte b/web/src/lib/components/share-page/IndividualSharedViewer.svelte index a3cf76c78e..476437ebd9 100644 --- a/web/src/lib/components/share-page/IndividualSharedViewer.svelte +++ b/web/src/lib/components/share-page/IndividualSharedViewer.svelte @@ -42,7 +42,7 @@ }); const downloadAssets = async () => { - await downloadArchive(`immich-shared.zip`, { assetIds: assets.map((asset) => asset.id) }); + await downloadArchive(`immich-shared`, { assetIds: assets.map((asset) => asset.id) }); }; const handleUploadAssets = async (files: File[] = []) => { @@ -91,7 +91,7 @@ onclick={handleSelectAll} /> {#if sharedLink?.allowDownload} - + {/if} {#if isOwned} diff --git a/web/src/lib/components/timeline/actions/DownloadAction.svelte b/web/src/lib/components/timeline/actions/DownloadAction.svelte index 43916c2165..b1b683ca28 100644 --- a/web/src/lib/components/timeline/actions/DownloadAction.svelte +++ b/web/src/lib/components/timeline/actions/DownloadAction.svelte @@ -16,7 +16,7 @@ menuItem?: boolean; } - let { filename = 'immich.zip', menuItem = false }: Props = $props(); + let { filename = 'immich', menuItem = false }: Props = $props(); const handleDownloadFiles = async () => { const assets = assetMultiSelectManager.assets; diff --git a/web/src/lib/managers/download-manager.svelte.ts b/web/src/lib/managers/download-manager.svelte.ts index 107f80b8dc..d548db5b21 100644 --- a/web/src/lib/managers/download-manager.svelte.ts +++ b/web/src/lib/managers/download-manager.svelte.ts @@ -1,44 +1,39 @@ -export interface DownloadProgress { - progress: number; +export interface DownloadState { + url: string; + payload: unknown; total: number; - percentage: number; - abort: AbortController | null; + downloaded: boolean; } class DownloadManager { - assets = $state>({}); + assets = $state>({}); isDownloading = $derived(Object.keys(this.assets).length > 0); - #update(key: string, value: Partial | null) { + #update(key: string, value: Partial | null) { if (value === null) { delete this.assets[key]; return; } if (!this.assets[key]) { - this.assets[key] = { progress: 0, total: 0, percentage: 0, abort: null }; + this.assets[key] = { url: "", payload: undefined, total: 0, downloaded: false }; } const item = this.assets[key]; Object.assign(item, value); - item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100); } - add(key: string, total: number, abort?: AbortController) { - this.#update(key, { total, abort }); + add(key: string, url: string, payload: unknown, total: number) { + this.#update(key, { url, payload, total }); } clear(key: string) { this.#update(key, null); } - update(key: string, progress: number, total?: number) { - const download: Partial = { progress }; - if (total !== undefined) { - download.total = total; - } - this.#update(key, download); + markDownloaded(key: string) { + this.#update(key, { downloaded: true }); } } diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index fc09a11215..f4b34f9783 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -284,7 +284,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro }; export const handleDownloadAlbum = async (album: AlbumResponseDto) => { - await downloadArchive(`${album.albumName}.zip`, { albumId: album.id }); + await downloadArchive(album.albumName, { albumId: album.id }); }; export const handleConfirmAlbumDelete = async (album: AlbumResponseDto) => { diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 0e31782164..7aa3fe2097 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -24,7 +24,6 @@ import { init, register, t } from 'svelte-i18n'; import { derived, get } from 'svelte/store'; import { defaultLang, locales } from '$lib/constants'; import { authManager } from '$lib/managers/auth-manager.svelte'; -import { downloadManager } from '$lib/managers/download-manager.svelte'; import { alwaysLoadOriginalFile, lang } from '$lib/stores/preferences.store'; import { isWebCompatibleImage } from '$lib/utils/asset-utils'; import { handleError } from '$lib/utils/handle-error'; @@ -294,15 +293,33 @@ export const downloadUrl = (url: string, filename: string) => { URL.revokeObjectURL(url); }; +export const downloadUrlPost = (url: string, data: unknown) => { + const form = document.createElement("form"); + form.method = "post"; + form.action = url; + form.target = "_blank"; + + const inputJson = document.createElement("input"); + inputJson.type = "hidden"; + inputJson.name = "json"; + inputJson.value = JSON.stringify(data); + form.appendChild(inputJson); + + document.body.appendChild(form); + form.submit(); + form.remove(); +} + export const downloadBlob = (data: Blob, filename: string) => downloadUrl(URL.createObjectURL(data), filename); export const downloadJson = (data: unknown, filename: string) => { const blob = new Blob([JSON.stringify(data, jsonReplacer, 2)], { type: 'application/json' }); const downloadKey = filename; - downloadManager.add(downloadKey, blob.size); - downloadManager.update(downloadKey, blob.size); + // downloadManager.add(downloadKey, blob.size); + // downloadManager.update(downloadKey, blob.size); downloadBlob(blob, downloadKey); - setTimeout(() => downloadManager.clear(downloadKey), 5000); + // setTimeout(() => downloadManager.clear(downloadKey), 5000); + // TODO show notification instead? }; export const oauth = { diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index fdfc15e636..757704de77 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -27,7 +27,7 @@ import { downloadManager } from '$lib/managers/download-manager.svelte'; import { eventManager } from '$lib/managers/event-manager.svelte'; import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; -import { downloadBlob, downloadRequest, withError } from '$lib/utils'; +import { downloadUrlPost, withError } from '$lib/utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { getFormatter } from '$lib/utils/i18n'; import { navigate } from '$lib/utils/navigation'; @@ -92,7 +92,7 @@ export const downloadArchive = async (fileName: string, options: Omit 1 ? `+${index + 1}` : ''; - const archiveName = fileName.replace('.zip', `${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}.zip`); + const archiveName = `${fileName}${suffix}-${DateTime.now().toFormat('yyyyLLdd_HHmmss')}`; const queryParams = asQueryString(authManager.params); let downloadKey = `${archiveName} `; @@ -100,27 +100,20 @@ export const downloadArchive = async (fileName: string, options: Omit downloadManager.update(downloadKey, event.loaded), - }); - - downloadBlob(data, archiveName); + if (downloadInfo.archives.length > 1) { + downloadManager.add(downloadKey, url, payload, archive.size); + } else { + downloadUrlPost(url, payload); + // TODO show notification/toast + } } catch (error) { const $t = get(t); handleError(error, $t('errors.unable_to_download_files')); - downloadManager.clear(downloadKey); return; - } finally { - setTimeout(() => downloadManager.clear(downloadKey), 5000); } } }; diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 42072c122b..e2a40233d4 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -465,7 +465,7 @@ > {/if} - + {#if assetMultiSelectManager.isAllUserOwned} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 1ef13c8504..383e7141bf 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -470,7 +470,7 @@ onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))} /> - + - import { type DownloadProgress, downloadManager } from '$lib/managers/download-manager.svelte'; + import { type DownloadState, downloadManager } from '$lib/managers/download-manager.svelte'; import { locale } from '$lib/stores/preferences.store'; import { Heading, IconButton } from '@immich/ui'; - import { mdiClose } from '@mdi/js'; + import { mdiReload, mdiDownload } from '@mdi/js'; import { t } from 'svelte-i18n'; import { fly, slide } from 'svelte/transition'; import { getByteUnitString } from '$lib/utils/byte-units'; + import { downloadUrlPost } from '$lib/utils'; - const abort = (downloadKey: string, download: DownloadProgress) => { - download.abort?.abort(); - downloadManager.clear(downloadKey); + const startDownload = (downloadKey: string, download: DownloadState) => { + downloadUrlPost(download.url, download.payload); + downloadManager.markDownloaded(downloadKey); }; @@ -30,26 +31,16 @@

{getByteUnitString(download.total, $locale)}

{/if} -
-
-
-
-

- - {(download.percentage / 100).toLocaleString($locale, { style: 'percent' })} - -

-
abort(downloadKey, download)} + aria-label={$t(download.downloaded ? 'retry' : 'download')} + onclick={() => startDownload(downloadKey, download)} size="tiny" - icon={mdiClose} + icon={download.downloaded ? mdiReload : mdiDownload} />