fix(server): only serve edited assets to shared links in archive downloads (#32562)

(cherry picked from commit cd47ee30676e8575a5048d12cde2ee86fc02b965)
This commit is contained in:
bo0tzz
2026-10-22 05:21:00 +03:00
committed by Codex
parent aeefb962d4
commit 59be6583b8
4 changed files with 54 additions and 19 deletions
+2
View File
@@ -21,6 +21,8 @@ export type AuthDto = {
export const isMetadataHidden = (auth?: AuthDto) => !!auth?.sharedLink && !auth.sharedLink.showExif;
export const isEditedOnly = (auth: AuthDto, edited = false) => !!auth.sharedLink || edited;
const LoginCredentialSchema = z
.object({
email: toEmail.describe('User email').meta({ example: 'testuser@email.com' }),
+5 -16
View File
@@ -18,7 +18,7 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto.js';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto.js';
import { AuthDto, isMetadataHidden } from 'src/dtos/auth.dto.js';
import { AuthDto, isEditedOnly, isMetadataHidden } from 'src/dtos/auth.dto.js';
import {
AssetFileType,
AssetVisibility,
@@ -231,13 +231,9 @@ export class AssetMediaService extends BaseService {
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
if (auth.sharedLink) {
dto.edited = true;
}
const { originalPath, originalFileName, editedPath } = await this.assetRepository.getForOriginal(
id,
dto.edited ?? false,
isEditedOnly(auth, dto.edited),
);
const path = editedPath ?? originalPath!;
@@ -261,18 +257,11 @@ export class AssetMediaService extends BaseService {
throw new BadRequestException('May not request original file');
}
if (auth.sharedLink) {
dto.edited = true;
}
const edited = isEditedOnly(auth, dto.edited);
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
id,
size,
dto.edited ?? false,
);
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(id, size, edited);
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) {
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !edited) {
// use original file for web supported images
return { targetSize: 'original' };
}
@@ -50,6 +50,49 @@ describe(DownloadService.name, () => {
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset.originalPath, asset.originalFileName);
});
it('should return the original file when the unedited version is requested', async () => {
const archiveMock = {
addFile: vitest.fn(),
finalize: vitest.fn(),
stream: new Readable(),
};
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForOriginals.mockResolvedValue([asset]);
mocks.storage.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id], edited: false })).resolves.toEqual({
stream: archiveMock.stream,
});
expect(mocks.asset.getForOriginals).toHaveBeenCalledWith([asset.id], false);
expect(archiveMock.addFile).toHaveBeenCalledWith(asset.originalPath, asset.originalFileName);
});
it('should not return the unedited version when requested using a shared link', async () => {
const archiveMock = {
addFile: vitest.fn(),
finalize: vitest.fn(),
stream: new Readable(),
};
const asset = AssetFactory.create();
const editedPath = '/data/edited.jpg';
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForOriginals.mockResolvedValue([{ ...asset, editedPath }]);
mocks.storage.createZipStream.mockReturnValue(archiveMock);
await expect(
sut.downloadArchive(authStub.adminSharedLink, { assetIds: [asset.id], edited: false }),
).resolves.toEqual({
stream: archiveMock.stream,
});
expect(mocks.asset.getForOriginals).toHaveBeenCalledWith([asset.id], true);
expect(archiveMock.addFile).toHaveBeenCalledWith(editedPath, asset.originalFileName);
});
it('should log a warning if the original path could not be resolved', async () => {
const archiveMock = {
addFile: vitest.fn(),
+4 -3
View File
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { parse } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core.js';
import { AuthDto } from 'src/dtos/auth.dto.js';
import { AuthDto, isEditedOnly } from 'src/dtos/auth.dto.js';
import {
DownloadArchiveDto,
DownloadArchiveInfo,
@@ -90,8 +90,9 @@ export class DownloadService extends BaseService {
async downloadArchive(auth: AuthDto, dto: DownloadArchiveDto): Promise<ImmichReadStream> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds });
const edited = isEditedOnly(auth, dto.edited);
const zip = this.storageRepository.createZipStream();
const assets = await this.assetRepository.getForOriginals(dto.assetIds, dto.edited ?? false);
const assets = await this.assetRepository.getForOriginals(dto.assetIds, edited);
const assetMap = new Map(assets.map((asset) => [asset.id, asset]));
const paths: Record<string, number> = {};
@@ -111,7 +112,7 @@ export class DownloadService extends BaseService {
filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`;
}
let realpath = dto.edited && editedPath ? editedPath : originalPath;
let realpath = edited && editedPath ? editedPath : originalPath;
try {
realpath = await this.storageRepository.realpath(realpath);