1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-30 09:47:31 +02:00

fix(server): follow symlinks when zipping assets (#11685)

* follow symlinks when zipping assets

fixes #9335

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Carsten Otto 2024-08-13 17:39:24 +02:00 committed by GitHub
parent 81c813a882
commit df45ef0e35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 45 additions and 4 deletions

View File

@ -36,6 +36,7 @@ export interface IStorageRepository {
createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>; createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
readFile(filepath: string, options?: FileReadOptions<Buffer>): Promise<Buffer>; readFile(filepath: string, options?: FileReadOptions<Buffer>): Promise<Buffer>;
writeFile(filepath: string, buffer: Buffer): Promise<void>; writeFile(filepath: string, buffer: Buffer): Promise<void>;
realpath(filepath: string): Promise<string>;
unlink(filepath: string): Promise<void>; unlink(filepath: string): Promise<void>;
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
removeEmptyDirs(folder: string, self?: boolean): Promise<void>; removeEmptyDirs(folder: string, self?: boolean): Promise<void>;

View File

@ -24,6 +24,10 @@ export class StorageRepository implements IStorageRepository {
this.logger.setContext(StorageRepository.name); this.logger.setContext(StorageRepository.name);
} }
realpath(filepath: string) {
return fs.realpath(filepath);
}
readdir(folder: string): Promise<string[]> { readdir(folder: string): Promise<string[]> {
return fs.readdir(folder); return fs.readdir(folder);
} }
@ -52,7 +56,7 @@ export class StorageRepository implements IStorageRepository {
const archive = archiver('zip', { store: true }); const archive = archiver('zip', { store: true });
const addFile = (input: string, filename: string) => { const addFile = (input: string, filename: string) => {
archive.file(input, { name: filename }); archive.file(input, { name: filename, mode: 0o644 });
}; };
const finalize = () => archive.finalize(); const finalize = () => archive.finalize();

View File

@ -2,12 +2,14 @@ import { BadRequestException } from '@nestjs/common';
import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadResponseDto } from 'src/dtos/download.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { DownloadService } from 'src/services/download.service'; import { DownloadService } from 'src/services/download.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { Readable } from 'typeorm/platform/PlatformTools.js'; import { Readable } from 'typeorm/platform/PlatformTools.js';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
@ -26,6 +28,7 @@ describe(DownloadService.name, () => {
let sut: DownloadService; let sut: DownloadService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
it('should work', () => { it('should work', () => {
@ -35,9 +38,10 @@ describe(DownloadService.name, () => {
beforeEach(() => { beforeEach(() => {
accessMock = newAccessRepositoryMock(); accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
loggerMock = newLoggerRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
sut = new DownloadService(accessMock, assetMock, storageMock); sut = new DownloadService(accessMock, assetMock, loggerMock, storageMock);
}); });
describe('downloadArchive', () => { describe('downloadArchive', () => {
@ -109,6 +113,27 @@ describe(DownloadService.name, () => {
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg');
}); });
it('should resolve symlinks', async () => {
const archiveMock = {
addFile: vitest.fn(),
finalize: vitest.fn(),
stream: new Readable(),
};
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
assetMock.getByIds.mockResolvedValue([
{ ...assetStub.noResizePath, id: 'asset-1', originalPath: '/path/to/symlink.jpg' },
]);
storageMock.realpath.mockResolvedValue('/path/to/realpath.jpg');
storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1'] })).resolves.toEqual({
stream: archiveMock.stream,
});
expect(archiveMock.addFile).toHaveBeenCalledWith('/path/to/realpath.jpg', 'IMG_123.jpg');
});
}); });
describe('getDownloadInfo', () => { describe('getDownloadInfo', () => {

View File

@ -7,7 +7,8 @@ import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/d
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IStorageRepository, ImmichReadStream } from 'src/interfaces/storage.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface';
import { HumanReadableSize } from 'src/utils/bytes'; import { HumanReadableSize } from 'src/utils/bytes';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@ -18,9 +19,11 @@ export class DownloadService {
constructor( constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
this.logger.setContext(DownloadService.name);
} }
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> { async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
@ -83,7 +86,14 @@ export class DownloadService {
filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`; filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`;
} }
zip.addFile(originalPath, filename); let realpath = originalPath;
try {
realpath = await this.storageRepository.realpath(originalPath);
} catch {
this.logger.warn('Unable to resolve realpath', { originalPath });
}
zip.addFile(realpath, filename);
} }
void zip.finalize(); void zip.finalize();

View File

@ -56,6 +56,7 @@ export const newStorageRepositoryMock = (reset = true): Mocked<IStorageRepositor
mkdirSync: vitest.fn(), mkdirSync: vitest.fn(),
checkDiskUsage: vitest.fn(), checkDiskUsage: vitest.fn(),
readdir: vitest.fn(), readdir: vitest.fn(),
realpath: vitest.fn().mockImplementation((filepath: string) => Promise.resolve(filepath)),
stat: vitest.fn(), stat: vitest.fn(),
crawl: vitest.fn(), crawl: vitest.fn(),
walk: vitest.fn().mockImplementation(async function* () {}), walk: vitest.fn().mockImplementation(async function* () {}),