feat(server,web): remove external path nonsense and make libraries admin-only (#7237)

* remove external path

* open-api

* make sql

* move library settings to admin panel

* Add documentation

* show external libraries only

* fix library list

* make user library settings look good

* fix test

* fix tests

* fix tests

* can pick user for library

* fix tests

* fix e2e

* chore: make sql

* Use unauth exception

* delete user library list

* cleanup

* fix e2e

* fix await lint

* chore: remove unused code

* chore: cleanup

* revert docs

* fix: is admin stuff

* table alignment

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jonathan Jogenfors
2024-02-29 19:35:37 +01:00
committed by GitHub
parent 369acc7bea
commit efa6efd200
63 changed files with 783 additions and 1111 deletions

View File

@@ -41,6 +41,7 @@ describe(`${AssetController.name} (e2e)`, () => {
let app: INestApplication;
let server: any;
let assetRepository: IAssetRepository;
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let userWithQuota: LoginResponseDto;
@@ -72,7 +73,7 @@ describe(`${AssetController.name} (e2e)`, () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
const admin = await api.authApi.adminLogin(server);
admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
@@ -86,12 +87,7 @@ describe(`${AssetController.name} (e2e)`, () => {
api.authApi.login(server, userDto.userWithQuota),
]);
const [user1Libraries, user2Libraries] = await Promise.all([
api.libraryApi.getAll(server, user1.accessToken),
api.libraryApi.getAll(server, user2.accessToken),
]);
libraries = [...user1Libraries, ...user2Libraries];
libraries = await api.libraryApi.getAll(server, admin.accessToken);
});
beforeEach(async () => {
@@ -615,7 +611,7 @@ describe(`${AssetController.name} (e2e)`, () => {
it("should not upload to another user's library", async () => {
const content = randomBytes(32);
const [library] = await api.libraryApi.getAll(server, user2.accessToken);
const [library] = await api.libraryApi.getAll(server, admin.accessToken);
await api.assetApi.upload(server, user1.accessToken, 'example-image', { content });
const { body, status } = await request(server)

View File

@@ -10,6 +10,7 @@ import { testApp } from '../utils';
describe(`${LibraryController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let user: LoginResponseDto;
beforeAll(async () => {
const app = await testApp.create();
@@ -25,6 +26,9 @@ describe(`${LibraryController.name} (e2e)`, () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
user = await api.authApi.login(server, userDto.user1);
});
describe('GET /library', () => {
@@ -39,18 +43,19 @@ describe(`${LibraryController.name} (e2e)`, () => {
.get('/library')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual([
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.UPLOAD,
name: 'Default Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
]);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.UPLOAD,
name: 'Default Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
]),
);
});
});
@@ -61,6 +66,16 @@ describe(`${LibraryController.name} (e2e)`, () => {
expect(body).toEqual(errorStub.unauthorized);
});
it('should require admin authentication', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${user.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(403);
expect(body).toEqual(errorStub.forbidden);
});
it('should create an external library with defaults', async () => {
const { status, body } = await request(server)
.post('/library')
@@ -184,29 +199,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have exclusion patterns'));
});
it('should allow a non-admin to create a library', async () => {
await api.userApi.create(server, admin.accessToken, userDto.user1);
const user1 = await api.authApi.login(server, userDto.user1);
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: user1.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
});
describe('PUT /library/:id', () => {
@@ -249,7 +241,6 @@ 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}`)
@@ -327,6 +318,14 @@ describe(`${LibraryController.name} (e2e)`, () => {
expect(body).toEqual(errorStub.unauthorized);
});
it('should require admin access', async () => {
const { status, body } = await request(server)
.get(`/library/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${user.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorStub.forbidden);
});
it('should get library by id', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
@@ -347,27 +346,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
}),
);
});
it("should not allow getting another user's library", async () => {
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]);
const [user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
]);
const library = await api.libraryApi.create(server, user1.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.get(`/library/${library.id}`)
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no library.read access'));
});
});
describe('DELETE /library/:id', () => {
@@ -390,7 +368,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
expect(body).toEqual(errorStub.noDeleteUploadLibrary);
});
it('should delete an empty library', async () => {
it('should delete an external library', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
@@ -401,7 +379,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
expect(body).toEqual({});
const libraries = await api.libraryApi.getAll(server, admin.accessToken);
expect(libraries).toHaveLength(1);
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -455,74 +432,42 @@ describe(`${LibraryController.name} (e2e)`, () => {
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'));
it('should pass with no import paths', async () => {
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] });
expect(response.importPaths).toEqual([]);
});
describe('With external path set', () => {
beforeEach(async () => {
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, IMMICH_TEST_ASSET_TEMP_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],
});
it('should pass with no import paths', async () => {
const response = await api.libraryApi.validate(server, admin.accessToken, library.id, { importPaths: [] });
expect(response.importPaths).toEqual([]);
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],
});
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(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)`,
});
expect(pathResponse).toEqual({
importPath: pathToTest,
isValid: false,
message: `Path does not exist (ENOENT)`,
});
});
});

View File

@@ -26,7 +26,12 @@ export const userApi = {
return body as UserResponseDto;
},
setExternalPath: async (server: any, accessToken: string, id: string, externalPath: string) => {
return await userApi.update(server, accessToken, { id, externalPath });
delete: async (server: any, accessToken: string, id: string) => {
const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
return body as UserResponseDto;
},
};

View File

@@ -30,8 +30,6 @@ describe(`Library watcher (e2e)`, () => {
await restoreTempFolder();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
});
afterEach(async () => {
@@ -205,8 +203,6 @@ describe(`Library watcher (e2e)`, () => {
],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, { recursive: true });
await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir2`, { recursive: true });
await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true });

View File

@@ -40,8 +40,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
@@ -79,8 +77,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
@@ -118,16 +114,12 @@ describe(`${LibraryController.name} (e2e)`, () => {
});
it('should scan external library with exclusion pattern', async () => {
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/not/a/real/path');
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
exclusionPatterns: ['**/el_corcal*'],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
@@ -163,7 +155,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
@@ -190,39 +181,11 @@ describe(`${LibraryController.name} (e2e)`, () => {
);
});
it('should offline files outside of changed external path', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/some/other/path');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
isOffline: true,
originalFileName: 'el_torcal_rocks',
}),
expect.objectContaining({
isOffline: true,
originalFileName: 'tanners_ridge',
}),
]),
);
});
it('should scan new files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await fs.promises.cp(
`${IMMICH_TEST_ASSET_PATH}/albums/nature/silver_fir.jpg`,
@@ -258,7 +221,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await fs.promises.cp(
`${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
@@ -305,7 +267,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await fs.promises.cp(
`${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
@@ -345,7 +306,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await fs.promises.cp(
`${IMMICH_TEST_ASSET_PATH}/albums/nature/el_torcal_rocks.jpg`,
@@ -387,72 +347,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
});
});
describe('External path', () => {
let library: LibraryResponseDto;
beforeEach(async () => {
library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
});
});
it('should not scan assets for user without external path', async () => {
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual([]);
});
it("should not import assets outside of user's external path", async () => {
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/not/a/real/path');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual([]);
});
it.each([`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_PATH}/albums/nature/`])(
'should scan external library with external path %s',
async (externalPath: string) => {
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, externalPath);
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: AssetType.IMAGE,
originalFileName: 'el_torcal_rocks',
libraryId: library.id,
resized: true,
exifInfo: expect.objectContaining({
exifImageWidth: 512,
exifImageHeight: 341,
latitude: null,
longitude: null,
}),
}),
expect.objectContaining({
type: AssetType.IMAGE,
originalFileName: 'silver_fir',
libraryId: library.id,
resized: true,
exifInfo: expect.objectContaining({
exifImageWidth: 511,
exifImageHeight: 323,
latitude: null,
longitude: null,
}),
}),
]),
);
},
);
});
it('should not scan an upload library', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.UPLOAD,
@@ -484,7 +378,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
@@ -506,12 +399,11 @@ describe(`${LibraryController.name} (e2e)`, () => {
expect(assets).toEqual([]);
});
it('should not remvove online files', async () => {
it('should not remove online files', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
});
await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);

View File

@@ -1,59 +1,62 @@
import { LibraryEntity, LibraryType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, ArrayUnique, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ValidateUUID } from '../domain.util';
import { ArrayMaxSize, ArrayUnique, IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { Optional, ValidateUUID } from '../domain.util';
export class CreateLibraryDto {
@IsEnum(LibraryType)
@ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
type!: LibraryType;
@ValidateUUID({ optional: true })
ownerId?: string;
@IsString()
@IsOptional()
@Optional()
@IsNotEmpty()
name?: string;
@IsOptional()
@Optional()
@IsBoolean()
isVisible?: boolean;
@IsOptional()
@Optional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
importPaths?: string[];
@IsOptional()
@Optional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
exclusionPatterns?: string[];
@IsOptional()
@Optional()
@IsBoolean()
isWatched?: boolean;
}
export class UpdateLibraryDto {
@IsOptional()
@Optional()
@IsString()
@IsNotEmpty()
name?: string;
@IsOptional()
@Optional()
@IsBoolean()
isVisible?: boolean;
@IsOptional()
@Optional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
importPaths?: string[];
@IsOptional()
@Optional()
@IsNotEmpty({ each: true })
@IsString({ each: true })
@ArrayUnique()
@@ -68,14 +71,14 @@ export class CrawlOptionsDto {
}
export class ValidateLibraryDto {
@IsOptional()
@Optional()
@IsString({ each: true })
@IsNotEmpty({ each: true })
@ArrayUnique()
@ArrayMaxSize(128)
importPaths?: string[];
@IsOptional()
@Optional()
@IsNotEmpty({ each: true })
@IsString({ each: true })
@ArrayUnique()
@@ -100,14 +103,21 @@ export class LibrarySearchDto {
export class ScanLibraryDto {
@IsBoolean()
@IsOptional()
@Optional()
refreshModifiedFiles?: boolean;
@IsBoolean()
@IsOptional()
@Optional()
refreshAllFiles?: boolean = false;
}
export class SearchLibraryDto {
@IsEnum(LibraryType)
@ApiProperty({ enumName: 'LibraryType', enum: LibraryType })
@Optional()
type?: LibraryType;
}
export class LibraryResponseDto {
id!: string;
ownerId!: string;

View File

@@ -140,24 +140,6 @@ describe(LibraryService.name, () => {
});
describe('handleQueueAssetRefresh', () => {
it("should not queue assets outside of user's external path", async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user2/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([]);
libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
userMock.get.mockResolvedValue(userStub.externalPath1);
await sut.handleQueueAssetRefresh(mockLibraryJob);
expect(jobMock.queue.mock.calls).toEqual([]);
});
it('should queue new assets', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
@@ -168,8 +150,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([]);
libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
userMock.get.mockResolvedValue(userStub.externalPath1);
userMock.get.mockResolvedValue(userStub.admin);
await sut.handleQueueAssetRefresh(mockLibraryJob);
@@ -196,8 +177,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([]);
libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
userMock.get.mockResolvedValue(userStub.externalPath1);
userMock.get.mockResolvedValue(userStub.admin);
await sut.handleQueueAssetRefresh(mockLibraryJob);
@@ -214,45 +194,6 @@ describe(LibraryService.name, () => {
]);
});
it("should mark assets outside of the user's external path as offline", async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([assetStub.external]);
libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
userMock.get.mockResolvedValue(userStub.externalPath2);
await sut.handleQueueAssetRefresh(mockLibraryJob);
expect(assetMock.updateAll.mock.calls).toEqual([
[
[assetStub.external.id],
{
isOffline: true,
},
],
]);
});
it('should not scan libraries owned by user without external path', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
userMock.get.mockResolvedValue(userStub.user1);
await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false);
});
it('should not scan upload libraries', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
@@ -287,7 +228,6 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
storageMock.crawl.mockResolvedValue([]);
assetMock.getByLibraryId.mockResolvedValue([]);
libraryMock.getOnlineAssetPaths.mockResolvedValue([]);
userMock.get.mockResolvedValue(userStub.externalPathRoot);
await sut.handleQueueAssetRefresh(mockLibraryJob);
@@ -303,7 +243,7 @@ describe(LibraryService.name, () => {
let mockUser: UserEntity;
beforeEach(() => {
mockUser = userStub.externalPath1;
mockUser = userStub.admin;
userMock.get.mockResolvedValue(mockUser);
storageMock.stat.mockResolvedValue({
@@ -780,26 +720,6 @@ describe(LibraryService.name, () => {
});
});
describe('getAllForUser', () => {
it('should return all libraries for user', async () => {
libraryMock.getAllByUserId.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]);
await expect(sut.getAllForUser(authStub.admin)).resolves.toEqual([
expect.objectContaining({
id: libraryStub.uploadLibrary1.id,
name: libraryStub.uploadLibrary1.name,
ownerId: libraryStub.uploadLibrary1.ownerId,
}),
expect.objectContaining({
id: libraryStub.externalLibrary1.id,
name: libraryStub.externalLibrary1.name,
ownerId: libraryStub.externalLibrary1.ownerId,
}),
]);
expect(libraryMock.getAllByUserId).toHaveBeenCalledWith(authStub.admin.user.id);
});
});
describe('getStatistics', () => {
it('should return library statistics', async () => {
libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 });
@@ -1144,12 +1064,12 @@ describe(LibraryService.name, () => {
storageMock.checkFileExists.mockResolvedValue(true);
await expect(
sut.update(authStub.external1, authStub.external1.user.id, { importPaths: ['/data/user1/foo'] }),
sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/data/user1/foo'] }),
).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1));
expect(libraryMock.update).toHaveBeenCalledWith(
expect.objectContaining({
id: authStub.external1.user.id,
id: authStub.admin.user.id,
}),
);
expect(storageMock.watch).toHaveBeenCalledWith(
@@ -1584,26 +1504,6 @@ describe(LibraryService.name, () => {
]);
});
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;

View File

@@ -29,6 +29,7 @@ import {
LibraryResponseDto,
LibraryStatsResponseDto,
ScanLibraryDto,
SearchLibraryDto,
UpdateLibraryDto,
ValidateLibraryDto,
ValidateLibraryImportPathResponseDto,
@@ -182,6 +183,7 @@ export class LibraryService extends EventEmitter {
async getStatistics(auth: AuthDto, id: string): Promise<LibraryStatsResponseDto> {
await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
return this.repository.getStatistics(id);
}
@@ -189,17 +191,18 @@ export class LibraryService extends EventEmitter {
return this.repository.getCountForUser(auth.user.id);
}
async getAllForUser(auth: AuthDto): Promise<LibraryResponseDto[]> {
const libraries = await this.repository.getAllByUserId(auth.user.id);
return libraries.map((library) => mapLibrary(library));
}
async get(auth: AuthDto, id: string): Promise<LibraryResponseDto> {
await this.access.requirePermission(auth, Permission.LIBRARY_READ, id);
const library = await this.findOrFail(id);
return mapLibrary(library);
}
async getAll(auth: AuthDto, dto: SearchLibraryDto): Promise<LibraryResponseDto[]> {
const libraries = await this.repository.getAll(false, dto.type);
return libraries.map((library) => mapLibrary(library));
}
async handleQueueCleanup(): Promise<boolean> {
this.logger.debug('Cleaning up any pending library deletions');
const pendingDeletion = await this.repository.getAllDeleted();
@@ -234,8 +237,14 @@ export class LibraryService extends EventEmitter {
}
}
let ownerId = auth.user.id;
if (dto.ownerId) {
ownerId = dto.ownerId;
}
const library = await this.repository.create({
ownerId: auth.user.id,
ownerId,
name: dto.name,
type: dto.type,
importPaths: dto.importPaths ?? [],
@@ -300,24 +309,11 @@ export class LibraryService extends EventEmitter {
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);
}),
);
@@ -328,6 +324,7 @@ export class LibraryService extends EventEmitter {
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) {
@@ -404,7 +401,7 @@ export class LibraryService extends EventEmitter {
return true;
} else {
// File can't be accessed and does not already exist in db
throw new BadRequestException("Can't access file", { cause: error });
throw new BadRequestException('Cannot access file', { cause: error });
}
}
@@ -591,12 +588,6 @@ export class LibraryService extends EventEmitter {
return false;
}
const user = await this.userRepository.get(library.ownerId, {});
if (!user?.externalPath) {
this.logger.warn('User has no external path set, cannot refresh library');
return false;
}
this.logger.verbose(`Refreshing library: ${job.id}`);
const pathValidation = await Promise.all(
@@ -618,11 +609,7 @@ export class LibraryService extends EventEmitter {
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 out paths that are not within the user's external path
.filter((assetPath) => this.isInExternalPath(assetPath, user.externalPath)) as string[];
const crawledAssetPaths = rawPaths.map((filePath) => path.normalize(filePath));
this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`);
const assetsInLibrary = await this.assetRepository.getByLibraryId([job.id]);

View File

@@ -18,7 +18,6 @@ const responseDto = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
@@ -37,7 +36,6 @@ const responseDto = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
externalPath: null,
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
inTimeline: true,

View File

@@ -5,7 +5,6 @@ export const ILibraryRepository = 'ILibraryRepository';
export interface ILibraryRepository {
getCountForUser(ownerId: string): Promise<number>;
getAllByUserId(userId: string, type?: LibraryType): Promise<LibraryEntity[]>;
getAll(withDeleted?: boolean, type?: LibraryType): Promise<LibraryEntity[]>;
getAllDeleted(): Promise<LibraryEntity[]>;
get(id: string, withDeleted?: boolean): Promise<LibraryEntity | null>;
@@ -16,7 +15,5 @@ export interface ILibraryRepository {
getUploadLibraryCount(ownerId: string): Promise<number>;
update(library: Partial<LibraryEntity>): Promise<LibraryEntity>;
getStatistics(id: string): Promise<LibraryStatsResponseDto>;
getOnlineAssetPaths(id: string): Promise<string[]>;
getAssetIds(id: string, withDeleted?: boolean): Promise<string[]>;
existsByName(name: string, withDeleted?: boolean): Promise<boolean>;
}

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { Optional } from '../../domain.util';
export enum SearchSuggestionType {
COUNTRY = 'country',
@@ -16,18 +17,18 @@ export class SearchSuggestionRequestDto {
type!: SearchSuggestionType;
@IsString()
@IsOptional()
@Optional()
country?: string;
@IsString()
@IsOptional()
@Optional()
state?: string;
@IsString()
@IsOptional()
@Optional()
make?: string;
@IsString()
@IsOptional()
@Optional()
model?: string;
}

View File

@@ -21,10 +21,6 @@ export class CreateUserDto {
@Transform(toSanitized)
storageLabel?: string | null;
@Optional({ nullable: true })
@IsString()
externalPath?: string | null;
@Optional()
@IsBoolean()
memoriesEnabled?: boolean;

View File

@@ -25,10 +25,6 @@ export class UpdateUserDto {
@Transform(toSanitized)
storageLabel?: string;
@Optional()
@IsString()
externalPath?: string;
@IsNotEmpty()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })

View File

@@ -22,7 +22,6 @@ export class UserDto {
export class UserResponseDto extends UserDto {
storageLabel!: string | null;
externalPath!: string | null;
shouldChangePassword!: boolean;
isAdmin!: boolean;
createdAt!: Date;
@@ -50,7 +49,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
return {
...mapSimpleUser(entity),
storageLabel: entity.storageLabel,
externalPath: entity.externalPath,
shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin,
createdAt: entity.createdAt,

View File

@@ -1,6 +1,5 @@
import { LibraryType, UserEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import path from 'node:path';
import sanitize from 'sanitize-filename';
import { ICryptoRepository, ILibraryRepository, IUserRepository } from '../repositories';
import { UserResponseDto } from './response-dto';
@@ -42,7 +41,6 @@ export class UserCore {
// Users can never update the isAdmin property.
delete dto.isAdmin;
delete dto.storageLabel;
delete dto.externalPath;
} else if (dto.isAdmin && user.id !== id) {
// Admin cannot create another admin.
throw new BadRequestException('The server already has an admin');
@@ -70,12 +68,6 @@ export class UserCore {
dto.storageLabel = null;
}
if (dto.externalPath === '') {
dto.externalPath = null;
} else if (dto.externalPath) {
dto.externalPath = path.normalize(dto.externalPath);
}
return this.userRepository.update(id, dto);
}

View File

@@ -5,13 +5,14 @@ import {
LibraryStatsResponseDto,
LibraryResponseDto as ResponseDto,
ScanLibraryDto,
SearchLibraryDto,
UpdateLibraryDto as UpdateDto,
ValidateLibraryDto,
ValidateLibraryResponseDto,
} from '@app/domain';
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard';
import { AdminRoute, Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto';
@@ -19,12 +20,13 @@ import { UUIDParamDto } from './dto/uuid-param.dto';
@Controller('library')
@Authenticated()
@UseValidation()
@AdminRoute()
export class LibraryController {
constructor(private service: LibraryService) {}
@Get()
getLibraries(@Auth() auth: AuthDto): Promise<ResponseDto[]> {
return this.service.getAllForUser(auth);
getAllLibraries(@Auth() auth: AuthDto, @Query() dto: SearchLibraryDto): Promise<ResponseDto[]> {
return this.service.getAll(auth, dto);
}
@Post()
@@ -38,7 +40,7 @@ export class LibraryController {
}
@Get(':id')
getLibraryInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> {
getLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ResponseDto> {
return this.service.get(auth, id);
}

View File

@@ -43,9 +43,6 @@ export class UserEntity {
@Column({ type: 'varchar', unique: true, default: null })
storageLabel!: string | null;
@Column({ type: 'varchar', default: null })
externalPath!: string | null;
@Column({ default: '', select: false })
password?: string;

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveExternalPath1708425975121 implements MigrationInterface {
name = 'RemoveExternalPath1708425975121';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "externalPath"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "externalPath" character varying`);
}
}

View File

@@ -21,7 +21,6 @@ FROM
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
@@ -37,7 +36,6 @@ FROM
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
@@ -97,7 +95,6 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
@@ -113,7 +110,6 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
@@ -155,7 +151,6 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
@@ -171,7 +166,6 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
@@ -285,7 +279,6 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
@@ -313,7 +306,6 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
@@ -358,7 +350,6 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
@@ -386,7 +377,6 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
@@ -468,7 +458,6 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."isAdmin" AS "AlbumEntity__AlbumEntity_sharedUsers_isAdmin",
"AlbumEntity__AlbumEntity_sharedUsers"."email" AS "AlbumEntity__AlbumEntity_sharedUsers_email",
"AlbumEntity__AlbumEntity_sharedUsers"."storageLabel" AS "AlbumEntity__AlbumEntity_sharedUsers_storageLabel",
"AlbumEntity__AlbumEntity_sharedUsers"."externalPath" AS "AlbumEntity__AlbumEntity_sharedUsers_externalPath",
"AlbumEntity__AlbumEntity_sharedUsers"."oauthId" AS "AlbumEntity__AlbumEntity_sharedUsers_oauthId",
"AlbumEntity__AlbumEntity_sharedUsers"."profileImagePath" AS "AlbumEntity__AlbumEntity_sharedUsers_profileImagePath",
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
@@ -496,7 +485,6 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
@@ -559,7 +547,6 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."externalPath" AS "AlbumEntity__AlbumEntity_owner_externalPath",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",

View File

@@ -15,7 +15,6 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."isAdmin" AS "APIKeyEntity__APIKeyEntity_user_isAdmin",
"APIKeyEntity__APIKeyEntity_user"."email" AS "APIKeyEntity__APIKeyEntity_user_email",
"APIKeyEntity__APIKeyEntity_user"."storageLabel" AS "APIKeyEntity__APIKeyEntity_user_storageLabel",
"APIKeyEntity__APIKeyEntity_user"."externalPath" AS "APIKeyEntity__APIKeyEntity_user_externalPath",
"APIKeyEntity__APIKeyEntity_user"."oauthId" AS "APIKeyEntity__APIKeyEntity_user_oauthId",
"APIKeyEntity__APIKeyEntity_user"."profileImagePath" AS "APIKeyEntity__APIKeyEntity_user_profileImagePath",
"APIKeyEntity__APIKeyEntity_user"."shouldChangePassword" AS "APIKeyEntity__APIKeyEntity_user_shouldChangePassword",

View File

@@ -23,7 +23,6 @@ FROM
"LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
"LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
"LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
"LibraryEntity__LibraryEntity_owner"."externalPath" AS "LibraryEntity__LibraryEntity_owner_externalPath",
"LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
"LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
@@ -139,7 +138,6 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
"LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
"LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
"LibraryEntity__LibraryEntity_owner"."externalPath" AS "LibraryEntity__LibraryEntity_owner_externalPath",
"LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
"LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
@@ -185,7 +183,6 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
"LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
"LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
"LibraryEntity__LibraryEntity_owner"."externalPath" AS "LibraryEntity__LibraryEntity_owner_externalPath",
"LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
"LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
@@ -225,7 +222,6 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
"LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
"LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
"LibraryEntity__LibraryEntity_owner"."externalPath" AS "LibraryEntity__LibraryEntity_owner_externalPath",
"LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
"LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",

View File

@@ -150,7 +150,6 @@ FROM
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."isAdmin" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_isAdmin",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."email" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_email",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."storageLabel" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_storageLabel",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."externalPath" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_externalPath",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."oauthId" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_oauthId",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileImagePath" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileImagePath",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword",
@@ -254,7 +253,6 @@ SELECT
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."isAdmin" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_isAdmin",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."email" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_email",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."storageLabel" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_storageLabel",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."externalPath" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_externalPath",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."oauthId" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_oauthId",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileImagePath" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileImagePath",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword",
@@ -308,7 +306,6 @@ FROM
"SharedLinkEntity__SharedLinkEntity_user"."isAdmin" AS "SharedLinkEntity__SharedLinkEntity_user_isAdmin",
"SharedLinkEntity__SharedLinkEntity_user"."email" AS "SharedLinkEntity__SharedLinkEntity_user_email",
"SharedLinkEntity__SharedLinkEntity_user"."storageLabel" AS "SharedLinkEntity__SharedLinkEntity_user_storageLabel",
"SharedLinkEntity__SharedLinkEntity_user"."externalPath" AS "SharedLinkEntity__SharedLinkEntity_user_externalPath",
"SharedLinkEntity__SharedLinkEntity_user"."oauthId" AS "SharedLinkEntity__SharedLinkEntity_user_oauthId",
"SharedLinkEntity__SharedLinkEntity_user"."profileImagePath" AS "SharedLinkEntity__SharedLinkEntity_user_profileImagePath",
"SharedLinkEntity__SharedLinkEntity_user"."shouldChangePassword" AS "SharedLinkEntity__SharedLinkEntity_user_shouldChangePassword",

View File

@@ -8,7 +8,6 @@ SELECT
"UserEntity"."isAdmin" AS "UserEntity_isAdmin",
"UserEntity"."email" AS "UserEntity_email",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel",
"UserEntity"."externalPath" AS "UserEntity_externalPath",
"UserEntity"."oauthId" AS "UserEntity_oauthId",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
@@ -55,7 +54,6 @@ SELECT
"user"."isAdmin" AS "user_isAdmin",
"user"."email" AS "user_email",
"user"."storageLabel" AS "user_storageLabel",
"user"."externalPath" AS "user_externalPath",
"user"."oauthId" AS "user_oauthId",
"user"."profileImagePath" AS "user_profileImagePath",
"user"."shouldChangePassword" AS "user_shouldChangePassword",
@@ -79,7 +77,6 @@ SELECT
"UserEntity"."isAdmin" AS "UserEntity_isAdmin",
"UserEntity"."email" AS "UserEntity_email",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel",
"UserEntity"."externalPath" AS "UserEntity_externalPath",
"UserEntity"."oauthId" AS "UserEntity_oauthId",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",
@@ -105,7 +102,6 @@ SELECT
"UserEntity"."isAdmin" AS "UserEntity_isAdmin",
"UserEntity"."email" AS "UserEntity_email",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel",
"UserEntity"."externalPath" AS "UserEntity_externalPath",
"UserEntity"."oauthId" AS "UserEntity_oauthId",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword",

View File

@@ -18,7 +18,6 @@ FROM
"UserTokenEntity__UserTokenEntity_user"."isAdmin" AS "UserTokenEntity__UserTokenEntity_user_isAdmin",
"UserTokenEntity__UserTokenEntity_user"."email" AS "UserTokenEntity__UserTokenEntity_user_email",
"UserTokenEntity__UserTokenEntity_user"."storageLabel" AS "UserTokenEntity__UserTokenEntity_user_storageLabel",
"UserTokenEntity__UserTokenEntity_user"."externalPath" AS "UserTokenEntity__UserTokenEntity_user_externalPath",
"UserTokenEntity__UserTokenEntity_user"."oauthId" AS "UserTokenEntity__UserTokenEntity_user_oauthId",
"UserTokenEntity__UserTokenEntity_user"."profileImagePath" AS "UserTokenEntity__UserTokenEntity_user_profileImagePath",
"UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword",

View File

@@ -52,7 +52,6 @@ export const authStub = {
id: 'user-id',
email: 'immich@test.com',
isAdmin: false,
externalPath: '/data/user1',
} as UserEntity,
userToken: {
id: 'token-id',

View File

@@ -20,8 +20,8 @@ export const libraryStub = {
id: 'library-id',
name: 'test_library',
assets: [],
owner: userStub.externalPath1,
ownerId: 'user-id',
owner: userStub.admin,
ownerId: 'admin_id',
type: LibraryType.EXTERNAL,
importPaths: [],
createdAt: new Date('2023-01-01'),
@@ -34,8 +34,8 @@ export const libraryStub = {
id: 'library-id2',
name: 'test_library2',
assets: [],
owner: userStub.externalPath1,
ownerId: 'user-id',
owner: userStub.admin,
ownerId: 'admin_id',
type: LibraryType.EXTERNAL,
importPaths: [],
createdAt: new Date('2021-01-01'),
@@ -48,8 +48,8 @@ export const libraryStub = {
id: 'library-id-with-paths1',
name: 'library-with-import-paths1',
assets: [],
owner: userStub.externalPath1,
ownerId: 'user-id',
owner: userStub.admin,
ownerId: 'admin_id',
type: LibraryType.EXTERNAL,
importPaths: ['/foo', '/bar'],
createdAt: new Date('2023-01-01'),
@@ -62,8 +62,8 @@ export const libraryStub = {
id: 'library-id-with-paths2',
name: 'library-with-import-paths2',
assets: [],
owner: userStub.externalPath1,
ownerId: 'user-id',
owner: userStub.admin,
ownerId: 'admin_id',
type: LibraryType.EXTERNAL,
importPaths: ['/xyz', '/asdf'],
createdAt: new Date('2023-01-01'),
@@ -76,7 +76,7 @@ export const libraryStub = {
id: 'library-id',
name: 'test_library',
assets: [],
owner: userStub.externalPath1,
owner: userStub.admin,
ownerId: 'user-id',
type: LibraryType.EXTERNAL,
importPaths: [],
@@ -90,7 +90,7 @@ export const libraryStub = {
id: 'library-id1337',
name: 'importpath-exclusion-library1',
assets: [],
owner: userStub.externalPath1,
owner: userStub.admin,
ownerId: 'user-id',
type: LibraryType.EXTERNAL,
importPaths: ['/xyz', '/asdf'],

View File

@@ -31,7 +31,6 @@ export const userStub = {
password: 'admin_password',
name: 'admin_name',
storageLabel: 'admin',
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -50,7 +49,6 @@ export const userStub = {
password: 'immich_password',
name: 'immich_name',
storageLabel: null,
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -69,7 +67,6 @@ export const userStub = {
password: 'immich_password',
name: 'immich_name',
storageLabel: null,
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -88,45 +85,6 @@ export const userStub = {
password: 'immich_password',
name: 'immich_name',
storageLabel: 'label-1',
externalPath: null,
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,
}),
externalPath1: Object.freeze<UserEntity>({
...authStub.user1.user,
password: 'immich_password',
name: 'immich_name',
storageLabel: 'label-1',
externalPath: '/data/user1',
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,
}),
externalPath2: Object.freeze<UserEntity>({
...authStub.user1.user,
password: 'immich_password',
name: 'immich_name',
storageLabel: 'label-1',
externalPath: '/data/user2',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -145,7 +103,6 @@ export const userStub = {
password: 'immich_password',
name: 'immich_name',
storageLabel: 'label-1',
externalPath: '/',
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@@ -164,7 +121,6 @@ export const userStub = {
password: 'immich_password',
name: 'immich_name',
storageLabel: 'label-1',
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '/path/to/profile.jpg',

View File

@@ -4,7 +4,6 @@ export const newLibraryRepositoryMock = (): jest.Mocked<ILibraryRepository> => {
return {
get: jest.fn(),
getCountForUser: jest.fn(),
getAllByUserId: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
softDelete: jest.fn(),
@@ -12,9 +11,7 @@ export const newLibraryRepositoryMock = (): jest.Mocked<ILibraryRepository> => {
getStatistics: jest.fn(),
getDefaultUploadLibrary: jest.fn(),
getUploadLibraryCount: jest.fn(),
getOnlineAssetPaths: jest.fn(),
getAssetIds: jest.fn(),
existsByName: jest.fn(),
getAllDeleted: jest.fn(),
getAll: jest.fn(),
};