mirror of
https://github.com/immich-app/immich
synced 2026-08-22 13:13:05 +00:00
fix: download archives via html POST forms
This commit is contained in:
parent
12fc8bac18
commit
4f4e283b57
13 changed files with 95 additions and 64 deletions
|
|
@ -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<StreamableFile> {
|
||||
downloadArchive(@Res({ passthrough: true }) res: Response, @Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise<StreamableFile> {
|
||||
if (dto.archiveName) {
|
||||
res.set({
|
||||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(dto.archiveName)}.zip`,
|
||||
})
|
||||
}
|
||||
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
26
server/src/middleware/forms-to-json.interceptor.ts
Normal file
26
server/src/middleware/forms-to-json.interceptor.ts
Normal file
|
|
@ -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<any> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
<AssetSelectControlBar>
|
||||
<SelectAllAssets {timelineManager} assetInteraction={assetMultiSelectManager} />
|
||||
{#if sharedLink.allowDownload}
|
||||
<DownloadAction filename="{album.albumName}.zip" />
|
||||
<DownloadAction filename={album.albumName} />
|
||||
{/if}
|
||||
</AssetSelectControlBar>
|
||||
{:else}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
<DownloadAction filename="immich-shared.zip" />
|
||||
<DownloadAction filename="immich-shared" />
|
||||
{/if}
|
||||
{#if isOwned}
|
||||
<RemoveFromSharedLink bind:sharedLink />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Record<string, DownloadProgress>>({});
|
||||
assets = $state<Record<string, DownloadState>>({});
|
||||
|
||||
isDownloading = $derived(Object.keys(this.assets).length > 0);
|
||||
|
||||
#update(key: string, value: Partial<DownloadProgress> | null) {
|
||||
#update(key: string, value: Partial<DownloadState> | 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<DownloadProgress> = { progress };
|
||||
if (total !== undefined) {
|
||||
download.total = total;
|
||||
}
|
||||
this.#update(key, download);
|
||||
markDownloaded(key: string) {
|
||||
this.#update(key, { downloaded: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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<DownloadIn
|
|||
for (let index = 0; index < downloadInfo.archives.length; index++) {
|
||||
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 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<DownloadIn
|
|||
downloadKey = `${archiveName} (${index + 1}/${downloadInfo.archives.length})`;
|
||||
}
|
||||
|
||||
const abort = new AbortController();
|
||||
downloadManager.add(downloadKey, archive.size, abort);
|
||||
const url = getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : '');
|
||||
const payload = { assetIds: archive.assetIds, edited: true, archiveName };
|
||||
|
||||
try {
|
||||
// TODO use sdk once it supports progress events
|
||||
const { data } = await downloadRequest({
|
||||
method: 'POST',
|
||||
url: getBaseUrl() + '/download/archive' + (queryParams ? `?${queryParams}` : ''),
|
||||
data: { assetIds: archive.assetIds, edited: true },
|
||||
signal: abort.signal,
|
||||
onDownloadProgress: (event) => 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -465,7 +465,7 @@
|
|||
></FavoriteAction>
|
||||
{/if}
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')} offset={{ x: 175, y: 25 }}>
|
||||
<DownloadAction menuItem filename="{album.albumName}.zip" />
|
||||
<DownloadAction menuItem filename={album.albumName} />
|
||||
{#if assetMultiSelectManager.isAllUserOwned}
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@
|
|||
onFavorite={(ids, isFavorite) => timelineManager.update(ids, (asset) => (asset.isFavorite = isFavorite))}
|
||||
/>
|
||||
<ButtonContextMenu icon={mdiDotsVertical} title={$t('menu')}>
|
||||
<DownloadAction menuItem filename="{person.name || 'immich'}.zip" />
|
||||
<DownloadAction menuItem filename={person.name || 'immich'} />
|
||||
<MenuOption
|
||||
icon={mdiAccountMultipleCheckOutline}
|
||||
text={$t('fix_incorrect_match')}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
<script lang="ts">
|
||||
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);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -30,26 +31,16 @@
|
|||
<p class="whitespace-nowrap">{getByteUnitString(download.total, $locale)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex place-items-center gap-2">
|
||||
<div class="h-2.5 w-full rounded-full bg-neutral-200 dark:bg-neutral-600">
|
||||
<div class="h-2.5 rounded-full bg-primary" style={`width: ${download.percentage}%`}></div>
|
||||
</div>
|
||||
<p class="min-w-16 text-right whitespace-nowrap">
|
||||
<span class="text-primary">
|
||||
{(download.percentage / 100).toLocaleString($locale, { style: 'percent' })}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute inset-e-4">
|
||||
<IconButton
|
||||
color="secondary"
|
||||
variant="outline"
|
||||
shape="round"
|
||||
aria-label={$t('close')}
|
||||
onclick={() => abort(downloadKey, download)}
|
||||
aria-label={$t(download.downloaded ? 'retry' : 'download')}
|
||||
onclick={() => startDownload(downloadKey, download)}
|
||||
size="tiny"
|
||||
icon={mdiClose}
|
||||
icon={download.downloaded ? mdiReload : mdiDownload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue