mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat(server,web) Semantic import path validation (#7076)
* add library validation api * chore: open api * show warning i UI * add flex row * fix e2e * tests * fix tests * enforce path validation * enforce validation on refresh * return 400 on bad import path * add limits to import paths * set response code to 200 * fix e2e * fix lint * fix test * restore e2e folder * fix import * use startsWith * icon color * notify user of failed validation * add parent div to validation * add docs to the import validation * improve library troubleshooting docs * fix button alignment --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
e7a875eadd
commit
b3c7bebbd4
32 changed files with 1472 additions and 75 deletions
|
|
@ -1,6 +1,16 @@
|
|||
import { PostgreSqlContainer } from '@testcontainers/postgresql';
|
||||
import path from 'node:path';
|
||||
|
||||
export default async () => {
|
||||
let IMMICH_TEST_ASSET_PATH: string = '';
|
||||
|
||||
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
|
||||
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`);
|
||||
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
|
||||
} else {
|
||||
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
|
||||
}
|
||||
|
||||
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0')
|
||||
.withDatabase('immich')
|
||||
.withUsername('postgres')
|
||||
|
|
@ -11,6 +21,9 @@ export default async () => {
|
|||
|
||||
process.env.DB_URL = pg.getConnectionUri();
|
||||
process.env.NODE_ENV = 'development';
|
||||
process.env.LOG_LEVEL = 'fatal';
|
||||
process.env.TZ = 'Z';
|
||||
|
||||
if (process.env.LOG_LEVEL === undefined) {
|
||||
process.env.LOG_LEVEL = 'fatal';
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
|
|||
import { LibraryController } from '@app/immich';
|
||||
import { LibraryType } from '@app/infra/entities';
|
||||
import { errorStub, userDto, uuidStub } from '@test/fixtures';
|
||||
import { IMMICH_TEST_ASSET_TEMP_PATH, restoreTempFolder } from 'src/test-utils/utils';
|
||||
import request from 'supertest';
|
||||
import { api } from '../../client';
|
||||
import { testApp } from '../utils';
|
||||
|
|
@ -20,6 +21,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await restoreTempFolder();
|
||||
await testApp.reset();
|
||||
await api.authApi.adminSignUp(server);
|
||||
admin = await api.authApi.adminLogin(server);
|
||||
|
|
@ -247,15 +249,16 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||
});
|
||||
|
||||
it('should change the import paths', async () => {
|
||||
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_PATH);
|
||||
const { status, body } = await request(server)
|
||||
.put(`/library/${library.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ importPaths: ['/path/to/import'] });
|
||||
.send({ importPaths: [IMMICH_TEST_ASSET_TEMP_PATH] });
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
importPaths: ['/path/to/import'],
|
||||
importPaths: [IMMICH_TEST_ASSET_TEMP_PATH],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -435,4 +438,93 @@ describe(`${LibraryController.name} (e2e)`, () => {
|
|||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /library/:id/validate', () => {
|
||||
it('should require authentication', async () => {
|
||||
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/validate`).send({});
|
||||
|
||||
expect(status).toBe(401);
|
||||
expect(body).toEqual(errorStub.unauthorized);
|
||||
});
|
||||
|
||||
describe('Validate import path', () => {
|
||||
let library: LibraryResponseDto;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create an external library with default settings
|
||||
library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
|
||||
});
|
||||
|
||||
it('should fail with no external path set', async () => {
|
||||
const { status, body } = await request(server)
|
||||
.post(`/library/${library.id}/validate`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
|
||||
.send({ importPaths: [] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorStub.badRequest('User has no external path set'));
|
||||
});
|
||||
|
||||
describe('With external path set', () => {
|
||||
beforeEach(async () => {
|
||||
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_PATH);
|
||||
});
|
||||
|
||||
it('should pass with no import paths', async () => {
|
||||
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] });
|
||||
expect(response.importPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not allow paths outside of the external path', async () => {
|
||||
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/../`;
|
||||
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Not contained in user's external path`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if path does not exist', async () => {
|
||||
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
|
||||
|
||||
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Path does not exist (ENOENT)`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if path is a file', async () => {
|
||||
const pathToTest = `${IMMICH_TEST_ASSET_TEMP_PATH}/does/not/exist`;
|
||||
|
||||
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, {
|
||||
importPaths: [pathToTest],
|
||||
});
|
||||
|
||||
expect(response.importPaths?.length).toEqual(1);
|
||||
const pathResponse = response?.importPaths?.at(0);
|
||||
|
||||
expect(pathResponse).toEqual({
|
||||
importPath: pathToTest,
|
||||
isValid: false,
|
||||
message: `Path does not exist (ENOENT)`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import {
|
|||
LibraryStatsResponseDto,
|
||||
ScanLibraryDto,
|
||||
UpdateLibraryDto,
|
||||
ValidateLibraryDto,
|
||||
ValidateLibraryResponseDto,
|
||||
} from '@app/domain';
|
||||
import request from 'supertest';
|
||||
|
||||
|
|
@ -58,4 +60,12 @@ export const libraryApi = {
|
|||
expect(status).toBe(200);
|
||||
return body as LibraryResponseDto;
|
||||
},
|
||||
validate: async (server: any, accessToken: string, id: string, data: ValidateLibraryDto) => {
|
||||
const { body, status } = await request(server)
|
||||
.post(`/library/${id}/validate`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(data);
|
||||
expect(status).toBe(200);
|
||||
return body as ValidateLibraryResponseDto;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { LibraryEntity, LibraryType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayUnique, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { ArrayMaxSize, ArrayUnique, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { ValidateUUID } from '../domain.util';
|
||||
|
||||
export class CreateLibraryDto {
|
||||
|
|
@ -21,12 +21,14 @@ export class CreateLibraryDto {
|
|||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
exclusionPatterns?: string[];
|
||||
|
||||
@IsOptional()
|
||||
|
|
@ -48,12 +50,14 @@ export class UpdateLibraryDto {
|
|||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNotEmpty({ each: true })
|
||||
@IsString({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +67,32 @@ export class CrawlOptionsDto {
|
|||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export class ValidateLibraryDto {
|
||||
@IsOptional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNotEmpty({ each: true })
|
||||
@IsString({ each: true })
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export class ValidateLibraryResponseDto {
|
||||
importPaths?: ValidateLibraryImportPathResponseDto[];
|
||||
}
|
||||
|
||||
export class ValidateLibraryImportPathResponseDto {
|
||||
importPath!: string;
|
||||
isValid?: boolean = false;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class LibrarySearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
userId?: string;
|
||||
|
|
|
|||
|
|
@ -54,12 +54,6 @@ describe(LibraryService.name, () => {
|
|||
cryptoMock = newCryptoRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
storageMock.stat.mockResolvedValue({
|
||||
size: 100,
|
||||
mtime: new Date('2023-01-01'),
|
||||
ctime: new Date('2023-01-01'),
|
||||
} as Stats);
|
||||
|
||||
// Always validate owner access for library.
|
||||
accessMock.library.checkOwnerAccess.mockImplementation(async (_, libraryIds) => libraryIds);
|
||||
|
||||
|
|
@ -270,6 +264,39 @@ describe(LibraryService.name, () => {
|
|||
|
||||
await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore import paths that do not exist', async () => {
|
||||
storageMock.stat.mockImplementation((path): Promise<Stats> => {
|
||||
if (path === libraryStub.externalLibraryWithImportPaths1.importPaths[0]) {
|
||||
const error = { code: 'ENOENT' } as any;
|
||||
throw error;
|
||||
}
|
||||
return Promise.resolve({
|
||||
isDirectory: () => true,
|
||||
} as Stats);
|
||||
});
|
||||
|
||||
storageMock.checkFileExists.mockResolvedValue(true);
|
||||
|
||||
const mockLibraryJob: ILibraryRefreshJob = {
|
||||
id: libraryStub.externalLibraryWithImportPaths1.id,
|
||||
refreshModifiedFiles: false,
|
||||
refreshAllFiles: false,
|
||||
};
|
||||
|
||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
||||
storageMock.crawl.mockResolvedValue([]);
|
||||
assetMock.getByLibraryId.mockResolvedValue([]);
|
||||
libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
|
||||
userMock.get.mockResolvedValue(userStub.externalPathRoot);
|
||||
|
||||
await sut.handleQueueAssetRefresh(mockLibraryJob);
|
||||
|
||||
expect(storageMock.crawl).toHaveBeenCalledWith({
|
||||
pathsToCrawl: [libraryStub.externalLibraryWithImportPaths1.importPaths[1]],
|
||||
exclusionPatterns: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAssetRefresh', () => {
|
||||
|
|
@ -278,6 +305,12 @@ describe(LibraryService.name, () => {
|
|||
beforeEach(() => {
|
||||
mockUser = userStub.externalPath1;
|
||||
userMock.get.mockResolvedValue(mockUser);
|
||||
|
||||
storageMock.stat.mockResolvedValue({
|
||||
size: 100,
|
||||
mtime: new Date('2023-01-01'),
|
||||
ctime: new Date('2023-01-01'),
|
||||
} as Stats);
|
||||
});
|
||||
|
||||
it('should reject an unknown file extension', async () => {
|
||||
|
|
@ -1104,13 +1137,19 @@ describe(LibraryService.name, () => {
|
|||
libraryMock.update.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
||||
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
|
||||
|
||||
await expect(sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/foo'] })).resolves.toEqual(
|
||||
mapLibrary(libraryStub.externalLibraryWithImportPaths1),
|
||||
);
|
||||
storageMock.stat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
} as Stats);
|
||||
|
||||
storageMock.checkFileExists.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.external1, authStub.external1.user.id, { importPaths: ['/data/user1/foo'] }),
|
||||
).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1));
|
||||
|
||||
expect(libraryMock.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: authStub.admin.user.id,
|
||||
id: authStub.external1.user.id,
|
||||
}),
|
||||
);
|
||||
expect(storageMock.watch).toHaveBeenCalledWith(
|
||||
|
|
@ -1142,7 +1181,7 @@ describe(LibraryService.name, () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('watchAll new', () => {
|
||||
describe('watchAll', () => {
|
||||
describe('watching disabled', () => {
|
||||
beforeEach(async () => {
|
||||
configMock.load.mockResolvedValue(systemConfigStub.libraryWatchDisabled);
|
||||
|
|
@ -1523,4 +1562,121 @@ describe(LibraryService.name, () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate', () => {
|
||||
it('should validate directory', async () => {
|
||||
storageMock.stat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
} as Stats);
|
||||
|
||||
storageMock.checkFileExists.mockResolvedValue(true);
|
||||
|
||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
||||
importPaths: ['/data/user1/'],
|
||||
});
|
||||
|
||||
expect(result.importPaths).toEqual([
|
||||
{
|
||||
importPath: '/data/user1/',
|
||||
isValid: true,
|
||||
message: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should error when no external path is set', async () => {
|
||||
await expect(
|
||||
sut.validate(authStub.admin, libraryStub.externalLibrary1.id, { importPaths: ['/photos'] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should detect when path is outside external path', async () => {
|
||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
||||
importPaths: ['/data/user2'],
|
||||
});
|
||||
|
||||
expect(result.importPaths).toEqual([
|
||||
{
|
||||
importPath: '/data/user2',
|
||||
isValid: false,
|
||||
message: "Not contained in user's external path",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect when path does not exist', async () => {
|
||||
storageMock.stat.mockImplementation(() => {
|
||||
const error = { code: 'ENOENT' } as any;
|
||||
throw error;
|
||||
});
|
||||
|
||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
||||
importPaths: ['/data/user1/'],
|
||||
});
|
||||
|
||||
expect(result.importPaths).toEqual([
|
||||
{
|
||||
importPath: '/data/user1/',
|
||||
isValid: false,
|
||||
message: 'Path does not exist (ENOENT)',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect when path is not a directory', async () => {
|
||||
storageMock.stat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
} as Stats);
|
||||
|
||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
||||
importPaths: ['/data/user1/file'],
|
||||
});
|
||||
|
||||
expect(result.importPaths).toEqual([
|
||||
{
|
||||
importPath: '/data/user1/file',
|
||||
isValid: false,
|
||||
message: 'Not a directory',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return an unknown exception from stat', async () => {
|
||||
storageMock.stat.mockImplementation(() => {
|
||||
throw new Error('Unknown error');
|
||||
});
|
||||
|
||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
||||
importPaths: ['/data/user1/'],
|
||||
});
|
||||
|
||||
expect(result.importPaths).toEqual([
|
||||
{
|
||||
importPath: '/data/user1/',
|
||||
isValid: false,
|
||||
message: 'Error: Unknown error',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should detect when access rights are missing', async () => {
|
||||
storageMock.stat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
} as Stats);
|
||||
|
||||
storageMock.checkFileExists.mockResolvedValue(false);
|
||||
|
||||
const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, {
|
||||
importPaths: ['/data/user1/'],
|
||||
});
|
||||
|
||||
expect(result.importPaths).toEqual([
|
||||
{
|
||||
importPath: '/data/user1/',
|
||||
isValid: false,
|
||||
message: 'Lacking read permission for folder',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ import {
|
|||
LibraryStatsResponseDto,
|
||||
ScanLibraryDto,
|
||||
UpdateLibraryDto,
|
||||
ValidateLibraryDto,
|
||||
ValidateLibraryImportPathResponseDto,
|
||||
ValidateLibraryResponseDto,
|
||||
mapLibrary,
|
||||
} from './library.dto';
|
||||
|
||||
|
|
@ -270,10 +273,81 @@ export class LibraryService extends EventEmitter {
|
|||
);
|
||||
}
|
||||
|
||||
private async validateImportPath(importPath: string): Promise<ValidateLibraryImportPathResponseDto> {
|
||||
const validation = new ValidateLibraryImportPathResponseDto();
|
||||
validation.importPath = importPath;
|
||||
|
||||
try {
|
||||
const stat = await this.storageRepository.stat(importPath);
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
validation.message = 'Not a directory';
|
||||
return validation;
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
validation.message = 'Path does not exist (ENOENT)';
|
||||
return validation;
|
||||
}
|
||||
validation.message = String(error);
|
||||
return validation;
|
||||
}
|
||||
|
||||
const access = await this.storageRepository.checkFileExists(importPath, R_OK);
|
||||
|
||||
if (!access) {
|
||||
validation.message = 'Lacking read permission for folder';
|
||||
return validation;
|
||||
}
|
||||
|
||||
validation.isValid = true;
|
||||
return validation;
|
||||
}
|
||||
|
||||
public async validate(auth: AuthDto, id: string, dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
|
||||
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
|
||||
|
||||
if (!auth.user.externalPath) {
|
||||
throw new BadRequestException('User has no external path set');
|
||||
}
|
||||
|
||||
const response = new ValidateLibraryResponseDto();
|
||||
|
||||
if (dto.importPaths) {
|
||||
response.importPaths = await Promise.all(
|
||||
dto.importPaths.map(async (importPath) => {
|
||||
const normalizedPath = path.normalize(importPath);
|
||||
|
||||
if (!this.isInExternalPath(normalizedPath, auth.user.externalPath)) {
|
||||
const validation = new ValidateLibraryImportPathResponseDto();
|
||||
validation.importPath = importPath;
|
||||
validation.message = `Not contained in user's external path`;
|
||||
return validation;
|
||||
}
|
||||
|
||||
return await this.validateImportPath(importPath);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
|
||||
await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id);
|
||||
const library = await this.repository.update({ id, ...dto });
|
||||
|
||||
if (dto.importPaths) {
|
||||
const validation = await this.validate(auth, id, { importPaths: dto.importPaths });
|
||||
if (validation.importPaths) {
|
||||
for (const path of validation.importPaths) {
|
||||
if (!path.isValid) {
|
||||
throw new BadRequestException(`Invalid import path: ${path.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.importPaths || dto.exclusionPatterns) {
|
||||
// Re-watch library to use new paths and/or exclusion patterns
|
||||
await this.watch(id);
|
||||
|
|
@ -509,6 +583,14 @@ export class LibraryService extends EventEmitter {
|
|||
return true;
|
||||
}
|
||||
|
||||
// Check if a given path is in a user's external path. Both arguments are assumed to be normalized
|
||||
private isInExternalPath(filePath: string, externalPath: string | null): boolean {
|
||||
if (externalPath === null) {
|
||||
return false;
|
||||
}
|
||||
return filePath.startsWith(externalPath);
|
||||
}
|
||||
|
||||
async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise<boolean> {
|
||||
const library = await this.repository.get(job.id);
|
||||
if (!library || library.type !== LibraryType.EXTERNAL) {
|
||||
|
|
@ -523,17 +605,31 @@ export class LibraryService extends EventEmitter {
|
|||
}
|
||||
|
||||
this.logger.verbose(`Refreshing library: ${job.id}`);
|
||||
|
||||
const pathValidation = await Promise.all(
|
||||
library.importPaths.map(async (importPath) => await this.validateImportPath(importPath)),
|
||||
);
|
||||
|
||||
const validImportPaths = pathValidation
|
||||
.map((validation) => {
|
||||
if (!validation.isValid) {
|
||||
this.logger.error(`Skipping invalid import path: ${validation.importPath}. Reason: ${validation.message}`);
|
||||
}
|
||||
return validation;
|
||||
})
|
||||
.filter((validation) => validation.isValid)
|
||||
.map((validation) => validation.importPath);
|
||||
|
||||
const rawPaths = await this.storageRepository.crawl({
|
||||
pathsToCrawl: library.importPaths,
|
||||
pathsToCrawl: validImportPaths,
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
});
|
||||
|
||||
const crawledAssetPaths = rawPaths
|
||||
// Normalize file paths. This is important to prevent security issues like path traversal
|
||||
.map((filePath) => path.normalize(filePath))
|
||||
.filter((assetPath) =>
|
||||
// Filter out paths that are not within the user's external path
|
||||
assetPath.match(new RegExp(`^${user.externalPath}`)),
|
||||
) as string[];
|
||||
// Filter out paths that are not within the user's external path
|
||||
.filter((assetPath) => this.isInExternalPath(assetPath, user.externalPath)) as string[];
|
||||
|
||||
this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`);
|
||||
const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import {
|
|||
LibraryResponseDto as ResponseDto,
|
||||
ScanLibraryDto,
|
||||
UpdateLibraryDto as UpdateDto,
|
||||
ValidateLibraryDto,
|
||||
ValidateLibraryResponseDto,
|
||||
} from '@app/domain';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Auth, Authenticated } from '../app.guard';
|
||||
import { UseValidation } from '../app.utils';
|
||||
|
|
@ -40,6 +42,16 @@ export class LibraryController {
|
|||
return this.service.get(auth, id);
|
||||
}
|
||||
|
||||
@Post(':id/validate')
|
||||
@HttpCode(200)
|
||||
validate(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: ValidateLibraryDto,
|
||||
): Promise<ValidateLibraryResponseDto> {
|
||||
return this.service.validate(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
deleteLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
|
|
|
|||
|
|
@ -164,7 +164,21 @@ export function searchAssetBuilder(
|
|||
builder.andWhere(_.omitBy(path, _.isUndefined));
|
||||
|
||||
const status = _.pick(options, ['isExternal', 'isFavorite', 'isOffline', 'isReadOnly', 'isVisible', 'type']);
|
||||
const { isArchived, isEncoded, isMotion, withArchived } = options;
|
||||
const {
|
||||
isArchived,
|
||||
isEncoded,
|
||||
isMotion,
|
||||
withArchived,
|
||||
isNotInAlbum,
|
||||
withFaces,
|
||||
withPeople,
|
||||
withSmartInfo,
|
||||
personIds,
|
||||
withExif,
|
||||
withStacked,
|
||||
trashedAfter,
|
||||
trashedBefore,
|
||||
} = options;
|
||||
builder.andWhere(
|
||||
_.omitBy(
|
||||
{
|
||||
|
|
@ -177,38 +191,38 @@ export function searchAssetBuilder(
|
|||
),
|
||||
);
|
||||
|
||||
if (options.isNotInAlbum) {
|
||||
if (isNotInAlbum) {
|
||||
builder
|
||||
.leftJoin(`${builder.alias}.albums`, 'albums')
|
||||
.andWhere('albums.id IS NULL')
|
||||
.andWhere(`${builder.alias}.isVisible = true`);
|
||||
}
|
||||
|
||||
if (options.withFaces || options.withPeople) {
|
||||
if (withFaces || withPeople) {
|
||||
builder.leftJoinAndSelect(`${builder.alias}.faces`, 'faces');
|
||||
}
|
||||
|
||||
if (options.withPeople) {
|
||||
if (withPeople) {
|
||||
builder.leftJoinAndSelect(`${builder.alias}.person`, 'person');
|
||||
}
|
||||
|
||||
if (options.withSmartInfo) {
|
||||
if (withSmartInfo) {
|
||||
builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo');
|
||||
}
|
||||
|
||||
if (options.personIds && options.personIds.length > 0) {
|
||||
if (personIds && personIds.length > 0) {
|
||||
builder
|
||||
.leftJoin(`${builder.alias}.faces`, 'faces')
|
||||
.andWhere('faces.personId IN (:...personIds)', { personIds: options.personIds })
|
||||
.andWhere('faces.personId IN (:...personIds)', { personIds: personIds })
|
||||
.addGroupBy(`${builder.alias}.id`)
|
||||
.having('COUNT(faces.id) = :personCount', { personCount: options.personIds.length });
|
||||
.having('COUNT(faces.id) = :personCount', { personCount: personIds.length });
|
||||
|
||||
if (options.withExif) {
|
||||
if (withExif) {
|
||||
builder.addGroupBy('exifInfo.assetId');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.withStacked) {
|
||||
if (withStacked) {
|
||||
builder
|
||||
.leftJoinAndSelect(`${builder.alias}.stack`, 'stack')
|
||||
.leftJoinAndSelect('stack.assets', 'stackedAssets')
|
||||
|
|
@ -217,8 +231,7 @@ export function searchAssetBuilder(
|
|||
);
|
||||
}
|
||||
|
||||
const withDeleted =
|
||||
options.withDeleted ?? (options.trashedAfter !== undefined || options.trashedBefore !== undefined);
|
||||
const withDeleted = options.withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined);
|
||||
if (withDeleted) {
|
||||
builder.withDeleted();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,12 +12,24 @@ import archiver from 'archiver';
|
|||
import chokidar, { WatchOptions } from 'chokidar';
|
||||
import { glob } from 'glob';
|
||||
import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs';
|
||||
import fs, { copyFile, readdir, rename, utimes, writeFile } from 'node:fs/promises';
|
||||
import fs, { copyFile, readdir, rename, stat, utimes, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
export class FilesystemProvider implements IStorageRepository {
|
||||
private logger = new ImmichLogger(FilesystemProvider.name);
|
||||
|
||||
readdir = readdir;
|
||||
|
||||
copyFile = copyFile;
|
||||
|
||||
stat = stat;
|
||||
|
||||
writeFile = writeFile;
|
||||
|
||||
rename = rename;
|
||||
|
||||
utimes = utimes;
|
||||
|
||||
createZipStream(): ImmichZipStream {
|
||||
const archive = archiver('zip', { store: true });
|
||||
|
||||
|
|
@ -50,14 +62,6 @@ export class FilesystemProvider implements IStorageRepository {
|
|||
}
|
||||
}
|
||||
|
||||
writeFile = writeFile;
|
||||
|
||||
rename = rename;
|
||||
|
||||
copyFile = copyFile;
|
||||
|
||||
utimes = utimes;
|
||||
|
||||
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filepath, mode);
|
||||
|
|
@ -79,8 +83,6 @@ export class FilesystemProvider implements IStorageRepository {
|
|||
}
|
||||
}
|
||||
|
||||
stat = fs.stat;
|
||||
|
||||
async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) {
|
||||
await fs.rm(folder, options);
|
||||
}
|
||||
|
|
@ -146,6 +148,4 @@ export class FilesystemProvider implements IStorageRepository {
|
|||
|
||||
return () => watcher.close();
|
||||
}
|
||||
|
||||
readdir = readdir;
|
||||
}
|
||||
|
|
|
|||
19
server/test/fixtures/user.stub.ts
vendored
19
server/test/fixtures/user.stub.ts
vendored
|
|
@ -140,6 +140,25 @@ export const userStub = {
|
|||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
}),
|
||||
externalPathRoot: Object.freeze<UserEntity>({
|
||||
...authStub.user1.user,
|
||||
password: 'immich_password',
|
||||
name: 'immich_name',
|
||||
storageLabel: 'label-1',
|
||||
externalPath: '/',
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
tags: [],
|
||||
assets: [],
|
||||
memoriesEnabled: true,
|
||||
avatarColor: UserAvatarColor.PRIMARY,
|
||||
quotaSizeInBytes: null,
|
||||
quotaUsageInBytes: 0,
|
||||
}),
|
||||
profilePath: Object.freeze<UserEntity>({
|
||||
...authStub.user1.user,
|
||||
password: 'immich_password',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue