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:
parent
81c813a882
commit
df45ef0e35
@ -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>;
|
||||||
|
@ -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();
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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();
|
||||||
|
@ -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* () {}),
|
||||||
|
Loading…
Reference in New Issue
Block a user