mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +00: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:
parent
81c813a882
commit
df45ef0e35
5 changed files with 45 additions and 4 deletions
|
@ -36,6 +36,7 @@ export interface IStorageRepository {
|
|||
createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
|
||||
readFile(filepath: string, options?: FileReadOptions<Buffer>): Promise<Buffer>;
|
||||
writeFile(filepath: string, buffer: Buffer): Promise<void>;
|
||||
realpath(filepath: string): Promise<string>;
|
||||
unlink(filepath: string): Promise<void>;
|
||||
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
|
||||
removeEmptyDirs(folder: string, self?: boolean): Promise<void>;
|
||||
|
|
|
@ -24,6 +24,10 @@ export class StorageRepository implements IStorageRepository {
|
|||
this.logger.setContext(StorageRepository.name);
|
||||
}
|
||||
|
||||
realpath(filepath: string) {
|
||||
return fs.realpath(filepath);
|
||||
}
|
||||
|
||||
readdir(folder: string): Promise<string[]> {
|
||||
return fs.readdir(folder);
|
||||
}
|
||||
|
@ -52,7 +56,7 @@ export class StorageRepository implements IStorageRepository {
|
|||
const archive = archiver('zip', { store: true });
|
||||
|
||||
const addFile = (input: string, filename: string) => {
|
||||
archive.file(input, { name: filename });
|
||||
archive.file(input, { name: filename, mode: 0o644 });
|
||||
};
|
||||
|
||||
const finalize = () => archive.finalize();
|
||||
|
|
|
@ -2,12 +2,14 @@ import { BadRequestException } from '@nestjs/common';
|
|||
import { DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.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 { Readable } from 'typeorm/platform/PlatformTools.js';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
@ -26,6 +28,7 @@ describe(DownloadService.name, () => {
|
|||
let sut: DownloadService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
|
@ -35,9 +38,10 @@ describe(DownloadService.name, () => {
|
|||
beforeEach(() => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
sut = new DownloadService(accessMock, assetMock, storageMock);
|
||||
sut = new DownloadService(accessMock, assetMock, loggerMock, storageMock);
|
||||
});
|
||||
|
||||
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(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', () => {
|
||||
|
|
|
@ -7,7 +7,8 @@ import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/d
|
|||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { IAccessRepository } from 'src/interfaces/access.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 { usePagination } from 'src/utils/pagination';
|
||||
|
||||
|
@ -18,9 +19,11 @@ export class DownloadService {
|
|||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
this.logger.setContext(DownloadService.name);
|
||||
}
|
||||
|
||||
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
|
||||
|
@ -83,7 +86,14 @@ export class DownloadService {
|
|||
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();
|
||||
|
|
|
@ -56,6 +56,7 @@ export const newStorageRepositoryMock = (reset = true): Mocked<IStorageRepositor
|
|||
mkdirSync: vitest.fn(),
|
||||
checkDiskUsage: vitest.fn(),
|
||||
readdir: vitest.fn(),
|
||||
realpath: vitest.fn().mockImplementation((filepath: string) => Promise.resolve(filepath)),
|
||||
stat: vitest.fn(),
|
||||
crawl: vitest.fn(),
|
||||
walk: vitest.fn().mockImplementation(async function* () {}),
|
||||
|
|
Loading…
Reference in a new issue