1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-28 22:51:59 +00:00

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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 718 additions and 1007 deletions

View file

@ -44,7 +44,6 @@ export const userDto = {
email: signupDto.admin.email,
password: signupDto.admin.password,
storageLabel: 'admin',
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
@ -63,7 +62,6 @@ export const userDto = {
email: createUserDto.user1.email,
password: createUserDto.user1.password,
storageLabel: null,
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',

View file

@ -65,7 +65,6 @@ export const signupResponseDto = {
name: 'Immich Admin',
email: 'admin@immich.cloud',
storageLabel: 'admin',
externalPath: null,
profileImagePath: '',
// why? lol
shouldChangePassword: true,

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -3299,8 +3299,17 @@
},
"/library": {
"get": {
"operationId": "getLibraries",
"parameters": [],
"operationId": "getAllLibraries",
"parameters": [
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/LibraryType"
}
}
],
"responses": {
"200": {
"content": {
@ -3407,7 +3416,7 @@
]
},
"get": {
"operationId": "getLibraryInfo",
"operationId": "getLibrary",
"parameters": [
{
"name": "id",
@ -7592,6 +7601,10 @@
"name": {
"type": "string"
},
"ownerId": {
"format": "uuid",
"type": "string"
},
"type": {
"$ref": "#/components/schemas/LibraryType"
}
@ -7648,10 +7661,6 @@
"email": {
"type": "string"
},
"externalPath": {
"nullable": true,
"type": "string"
},
"memoriesEnabled": {
"type": "boolean"
},
@ -8549,10 +8558,6 @@
"email": {
"type": "string"
},
"externalPath": {
"nullable": true,
"type": "string"
},
"id": {
"type": "string"
},
@ -8601,7 +8606,6 @@
"createdAt",
"deletedAt",
"email",
"externalPath",
"id",
"isAdmin",
"name",
@ -10326,9 +10330,6 @@
"email": {
"type": "string"
},
"externalPath": {
"type": "string"
},
"id": {
"format": "uuid",
"type": "string"
@ -10455,10 +10456,6 @@
"email": {
"type": "string"
},
"externalPath": {
"nullable": true,
"type": "string"
},
"id": {
"type": "string"
},
@ -10504,7 +10501,6 @@
"createdAt",
"deletedAt",
"email",
"externalPath",
"id",
"isAdmin",
"name",

View file

@ -66,7 +66,6 @@ export type UserResponseDto = {
createdAt: string;
deletedAt: string | null;
email: string;
externalPath: string | null;
id: string;
isAdmin: boolean;
memoriesEnabled?: boolean;
@ -462,6 +461,7 @@ export type CreateLibraryDto = {
isVisible?: boolean;
isWatched?: boolean;
name?: string;
ownerId?: string;
"type": LibraryType;
};
export type UpdateLibraryDto = {
@ -506,7 +506,6 @@ export type PartnerResponseDto = {
createdAt: string;
deletedAt: string | null;
email: string;
externalPath: string | null;
id: string;
inTimeline?: boolean;
isAdmin: boolean;
@ -950,7 +949,6 @@ export type UpdateTagDto = {
};
export type CreateUserDto = {
email: string;
externalPath?: string | null;
memoriesEnabled?: boolean;
name: string;
password: string;
@ -960,7 +958,6 @@ export type CreateUserDto = {
export type UpdateUserDto = {
avatarColor?: UserAvatarColor;
email?: string;
externalPath?: string;
id: string;
isAdmin?: boolean;
memoriesEnabled?: boolean;
@ -1841,11 +1838,15 @@ export function sendJobCommand({ id, jobCommandDto }: {
body: jobCommandDto
})));
}
export function getLibraries(opts?: Oazapfts.RequestOpts) {
export function getAllLibraries({ $type }: {
$type?: LibraryType;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: LibraryResponseDto[];
}>("/library", {
}>(`/library${QS.query(QS.explode({
"type": $type
}))}`, {
...opts
}));
}
@ -1869,7 +1870,7 @@ export function deleteLibrary({ id }: {
method: "DELETE"
}));
}
export function getLibraryInfo({ id }: {
export function getLibrary({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{

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(),
};

View file

@ -34,14 +34,13 @@
const editUser = async () => {
try {
const { id, email, name, storageLabel, externalPath } = user;
const { id, email, name, storageLabel } = user;
await updateUser({
updateUserDto: {
id,
email,
name,
storageLabel: storageLabel || '',
externalPath: externalPath || '',
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null,
},
});
@ -126,22 +125,6 @@
</p>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="external-path">External Path</label>
<input
class="immich-form-input"
id="external-path"
name="external-path"
type="text"
bind:value={user.externalPath}
/>
<p>
Note: Absolute path of parent import directory. A user can only import files if they exist at or under this
path.
</p>
</div>
{#if error}
<p class="ml-4 text-sm text-red-400">{error}</p>
{/if}

View file

@ -243,8 +243,8 @@
addImportPath = true;
}}>Add path</Button
></td
></tr
>
>
</tr>
</tbody>
</table>
<div class="flex justify-between w-full">

View file

@ -0,0 +1,54 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import Button from '../elements/buttons/button.svelte';
import FullScreenModal from '../shared-components/full-screen-modal.svelte';
import { mdiFolderSync } from '@mdi/js';
import { onMount } from 'svelte';
import { getAllUsers } from '@immich/sdk';
import { user } from '$lib/stores/user.store';
import SettingSelect from '$lib/components/shared-components/settings/setting-select.svelte';
let ownerId: string = $user.id;
let userOptions: { value: string; text: string }[] = [];
onMount(async () => {
const users = await getAllUsers({ isAll: true });
userOptions = users.map((user) => ({ value: user.id, text: user.name }));
});
const dispatch = createEventDispatcher<{
cancel: void;
submit: { ownerId: string | null };
delete: void;
}>();
const handleCancel = () => dispatch('cancel');
const handleSubmit = () => dispatch('submit', { ownerId });
</script>
<FullScreenModal on:clickOutside={() => handleCancel()}>
<div
class="w-[500px] max-w-[95vw] rounded-3xl border bg-immich-bg p-4 py-8 shadow-sm dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-fg"
>
<div
class="flex flex-col place-content-center place-items-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
>
<Icon path={mdiFolderSync} size="4em" />
<h1 class="text-2xl font-medium text-immich-primary dark:text-immich-dark-primary">Select library owner</h1>
</div>
<form on:submit|preventDefault={() => handleSubmit()} autocomplete="off">
<p class="p-5 text-sm">NOTE: This cannot be changed later!</p>
<SettingSelect bind:value={ownerId} options={userOptions} name="user" />
<div class="mt-8 flex w-full gap-4 px-4">
<Button color="gray" fullwidth on:click={() => handleCancel()}>Cancel</Button>
<Button type="submit" fullwidth>Create</Button>
</div>
</form>
</div>
</FullScreenModal>

View file

@ -4,7 +4,7 @@
import SideBarSection from '$lib/components/shared-components/side-bar/side-bar-section.svelte';
import StatusBox from '$lib/components/shared-components/status-box.svelte';
import { AppRoute } from '$lib/constants';
import { mdiAccountMultipleOutline, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
import { mdiAccountMultipleOutline, mdiBookshelf, mdiCog, mdiServer, mdiSync, mdiTools } from '@mdi/js';
</script>
<SideBarSection>
@ -21,6 +21,13 @@
<a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_SETTINGS} draggable="false">
<SideBarButton title="Settings" icon={mdiCog} isSelected={$page.route.id === AppRoute.ADMIN_SETTINGS} />
</a>
<a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_LIBRARY_MANAGEMENT} draggable="false">
<SideBarButton
title="External Libraries"
icon={mdiBookshelf}
isSelected={$page.route.id === AppRoute.ADMIN_LIBRARY_MANAGEMENT}
/>
</a>
<a data-sveltekit-preload-data="hover" href={AppRoute.ADMIN_STATS} draggable="false">
<SideBarButton title="Server Stats" icon={mdiServer} isSelected={$page.route.id === AppRoute.ADMIN_STATS} />
</a>

View file

@ -1,416 +0,0 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error';
import {
LibraryType,
createLibrary,
deleteLibrary,
getLibraries,
getLibraryStatistics,
removeOfflineFiles,
scanLibrary,
updateLibrary,
type LibraryResponseDto,
type LibraryStatsResponseDto,
} from '@immich/sdk';
import { mdiDatabase, mdiDotsVertical, mdiUpload } from '@mdi/js';
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import Button from '../elements/buttons/button.svelte';
import LibraryImportPathsForm from '../forms/library-import-paths-form.svelte';
import LibraryRenameForm from '../forms/library-rename-form.svelte';
import LibraryScanSettingsForm from '../forms/library-scan-settings-form.svelte';
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
import ContextMenu from '../shared-components/context-menu/context-menu.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification';
import Portal from '../shared-components/portal/portal.svelte';
let libraries: LibraryResponseDto[] = [];
let stats: LibraryStatsResponseDto[] = [];
let photos: number[] = [];
let videos: number[] = [];
let totalCount: number[] = [];
let diskUsage: number[] = [];
let diskUsageUnit: string[] = [];
let confirmDeleteLibrary: LibraryResponseDto | null = null;
let deletedLibrary: LibraryResponseDto | null = null;
let editImportPaths: number | null;
let editScanSettings: number | null;
let renameLibrary: number | null;
let updateLibraryIndex: number | null;
let deleteAssetCount = 0;
let dropdownOpen: boolean[] = [];
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
let selectedLibraryIndex = 0;
let selectedLibrary: LibraryResponseDto | null = null;
onMount(async () => {
await readLibraryList();
});
const closeAll = () => {
editImportPaths = null;
editScanSettings = null;
renameLibrary = null;
updateLibraryIndex = null;
showContextMenu = false;
for (let index = 0; index < dropdownOpen.length; index++) {
dropdownOpen[index] = false;
}
};
const showMenu = (event: MouseEvent, library: LibraryResponseDto, index: number) => {
contextMenuPosition = getContextMenuPosition(event);
showContextMenu = !showContextMenu;
selectedLibraryIndex = index;
selectedLibrary = library;
};
const onMenuExit = () => {
showContextMenu = false;
};
const refreshStats = async (listIndex: number) => {
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
photos[listIndex] = stats[listIndex].photos;
videos[listIndex] = stats[listIndex].videos;
totalCount[listIndex] = stats[listIndex].total;
[diskUsage[listIndex], diskUsageUnit[listIndex]] = getBytesWithUnit(stats[listIndex].usage, 0);
};
async function readLibraryList() {
libraries = await getLibraries();
dropdownOpen.length = libraries.length;
for (let index = 0; index < libraries.length; index++) {
await refreshStats(index);
dropdownOpen[index] = false;
}
}
const handleCreate = async (libraryType: LibraryType) => {
try {
const createdLibrary = await createLibrary({
createLibraryDto: { type: libraryType },
});
notificationController.show({
message: `Created library: ${createdLibrary.name}`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to create library');
} finally {
await readLibraryList();
}
};
const handleUpdate = async (event: Partial<LibraryResponseDto>) => {
if (updateLibraryIndex === null) {
return;
}
try {
const libraryId = libraries[updateLibraryIndex].id;
await updateLibrary({ id: libraryId, updateLibraryDto: { ...event } });
closeAll();
await readLibraryList();
} catch (error) {
handleError(error, 'Unable to update library');
}
};
const handleDelete = async () => {
if (confirmDeleteLibrary) {
deletedLibrary = confirmDeleteLibrary;
}
if (!deletedLibrary) {
return;
}
try {
await deleteLibrary({ id: deletedLibrary.id });
notificationController.show({
message: `Library deleted`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to remove library');
} finally {
confirmDeleteLibrary = null;
deletedLibrary = null;
await readLibraryList();
}
};
const handleScanAll = async () => {
try {
for (const library of libraries) {
if (library.type === LibraryType.External) {
await scanLibrary({ id: library.id, scanLibraryDto: {} });
}
}
notificationController.show({
message: `Refreshing all libraries`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan libraries');
}
};
const handleScan = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: {} });
notificationController.show({
message: `Scanning library for new files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleScanChanges = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: { refreshModifiedFiles: true } });
notificationController.show({
message: `Scanning library for changed files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleForceScan = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: { refreshAllFiles: true } });
notificationController.show({
message: `Forcing refresh of all library files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleRemoveOffline = async (libraryId: string) => {
try {
await removeOfflineFiles({ id: libraryId });
notificationController.show({
message: `Removing Offline Files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to remove offline files');
}
};
const onRenameClicked = () => {
closeAll();
renameLibrary = selectedLibraryIndex;
updateLibraryIndex = selectedLibraryIndex;
};
const onEditImportPathClicked = () => {
closeAll();
editImportPaths = selectedLibraryIndex;
updateLibraryIndex = selectedLibraryIndex;
};
const onScanNewLibraryClicked = async () => {
closeAll();
if (selectedLibrary) {
await handleScan(selectedLibrary.id);
}
};
const onScanSettingClicked = () => {
closeAll();
editScanSettings = selectedLibraryIndex;
updateLibraryIndex = selectedLibraryIndex;
};
const onScanAllLibraryFilesClicked = async () => {
closeAll();
if (selectedLibrary) {
await handleScanChanges(selectedLibrary.id);
}
};
const onForceScanAllLibraryFilesClicked = async () => {
closeAll();
if (selectedLibrary) {
await handleForceScan(selectedLibrary.id);
}
};
const onRemoveOfflineFilesClicked = async () => {
closeAll();
if (selectedLibrary) {
await handleRemoveOffline(selectedLibrary.id);
}
};
const onDeleteLibraryClicked = async () => {
closeAll();
if (selectedLibrary && confirm(`Are you sure you want to delete ${selectedLibrary.name} library?`) == true) {
await refreshStats(selectedLibraryIndex);
if (totalCount[selectedLibraryIndex] > 0) {
deleteAssetCount = totalCount[selectedLibraryIndex];
confirmDeleteLibrary = selectedLibrary;
} else {
deletedLibrary = selectedLibrary;
await handleDelete();
}
}
};
</script>
{#if confirmDeleteLibrary}
<ConfirmDialogue
title="Warning!"
prompt="Are you sure you want to delete this library? This will DELETE all {deleteAssetCount} contained assets and cannot be undone."
on:confirm={handleDelete}
on:cancel={() => (confirmDeleteLibrary = null)}
/>
{/if}
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
{#if libraries.length > 0}
<table class="w-full text-left">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="flex w-full place-items-center">
<th class="w-1/6 text-center text-sm font-medium">Type</th>
<th class="w-1/3 text-center text-sm font-medium">Name</th>
<th class="w-1/5 text-center text-sm font-medium">Assets</th>
<th class="w-1/6 text-center text-sm font-medium">Size</th>
<th class="w-1/6 text-center text-sm font-medium" />
</tr>
</thead>
<tbody class="block w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each libraries as library, index (library.id)}
<tr
class={`flex h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
index % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class="w-1/6 px-10 text-sm">
{#if library.type === LibraryType.External}
<Icon path={mdiDatabase} size="40" title="External library (created on {library.createdAt})" />
{:else if library.type === LibraryType.Upload}
<Icon path={mdiUpload} size="40" title="Upload library (created on {library.createdAt})" />
{/if}</td
>
<td class="w-1/3 text-ellipsis px-4 text-sm">{library.name}</td>
{#if totalCount[index] == undefined}
<td colspan="2" class="flex w-1/3 items-center justify-center text-ellipsis px-4 text-sm">
<LoadingSpinner size="40" />
</td>
{:else}
<td class="w-1/6 text-ellipsis px-4 text-sm">
{totalCount[index].toLocaleString($locale)}
</td>
<td class="w-1/6 text-ellipsis px-4 text-sm">{diskUsage[index]} {diskUsageUnit[index]}</td>
{/if}
<td class="w-1/6 text-ellipsis px-4 text-sm">
<button
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
on:click|stopPropagation|preventDefault={(e) => showMenu(e, library, index)}
>
<Icon path={mdiDotsVertical} size="16" />
</button>
{#if showContextMenu}
<Portal target="body">
<ContextMenu {...contextMenuPosition} on:outclick={onMenuExit}>
<MenuOption on:click={onRenameClicked} text={`Rename`} />
{#if selectedLibrary && selectedLibrary.type === LibraryType.External}
<MenuOption on:click={onEditImportPathClicked} text="Edit Import Paths" />
<MenuOption on:click={onScanSettingClicked} text="Scan Settings" />
<hr />
<MenuOption on:click={onScanNewLibraryClicked} text="Scan New Library Files" />
<MenuOption
on:click={onScanAllLibraryFilesClicked}
text="Re-scan All Library Files"
subtitle={'Only refreshes modified files'}
/>
<MenuOption
on:click={onForceScanAllLibraryFilesClicked}
text="Force Re-scan All Library Files"
subtitle={'Refreshes every file'}
/>
<hr />
<MenuOption on:click={onRemoveOfflineFilesClicked} text="Remove Offline Files" />
<MenuOption on:click={onDeleteLibraryClicked}>
<p class="text-red-600">Delete library</p>
</MenuOption>
{/if}
</ContextMenu>
</Portal>
{/if}
</td>
</tr>
{#if renameLibrary === index}
<div transition:slide={{ duration: 250 }}>
<LibraryRenameForm
{library}
on:submit={({ detail }) => handleUpdate(detail)}
on:cancel={() => (renameLibrary = null)}
/>
</div>
{/if}
{#if editImportPaths === index}
<div transition:slide={{ duration: 250 }}>
<LibraryImportPathsForm
{library}
on:submit={({ detail }) => handleUpdate(detail)}
on:cancel={() => (editImportPaths = null)}
/>
</div>
{/if}
{#if editScanSettings === index}
<div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4">
<LibraryScanSettingsForm
{library}
on:submit={({ detail }) => handleUpdate(detail.library)}
on:cancel={() => (editScanSettings = null)}
/>
</div>
{/if}
{/each}
</tbody>
</table>
{/if}
<div class="my-2 flex justify-end gap-2">
<Button size="sm" on:click={() => handleScanAll()}>Scan All Libraries</Button>
<Button size="sm" on:click={() => handleCreate(LibraryType.External)}>Create External Library</Button>
</div>
</div>
</section>

View file

@ -66,14 +66,6 @@
required={false}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="EXTERNAL PATH"
disabled={true}
value={editedUser.externalPath || ''}
required={false}
/>
<div class="flex justify-end">
<Button type="submit" size="sm" on:click={() => handleSaveProfile()}>Save</Button>
</div>

View file

@ -9,7 +9,6 @@
import AppearanceSettings from './appearance-settings.svelte';
import ChangePasswordSettings from './change-password-settings.svelte';
import DeviceList from './device-list.svelte';
import LibraryList from './library-list.svelte';
import MemoriesSettings from './memories-settings.svelte';
import OAuthSettings from './oauth-settings.svelte';
import PartnerSettings from './partner-settings.svelte';
@ -43,10 +42,6 @@
<DeviceList bind:devices />
</SettingAccordion>
<SettingAccordion key="libraries" title="Libraries" subtitle="Manage your asset libraries">
<LibraryList />
</SettingAccordion>
<SettingAccordion key="memories" title="Memories" subtitle="Manage what you see in your memories.">
<MemoriesSettings user={$user} />
</SettingAccordion>

View file

@ -11,6 +11,7 @@ export enum AssetAction {
export enum AppRoute {
ADMIN_USER_MANAGEMENT = '/admin/user-management',
ADMIN_LIBRARY_MANAGEMENT = '/admin/library-management',
ADMIN_SETTINGS = '/admin/system-settings',
ADMIN_STATS = '/admin/server-status',
ADMIN_JOBS = '/admin/jobs-status',

View file

@ -0,0 +1,450 @@
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import LibraryImportPathsForm from '$lib/components/forms/library-import-paths-form.svelte';
import LibraryRenameForm from '$lib/components/forms/library-rename-form.svelte';
import LibraryScanSettingsForm from '$lib/components/forms/library-scan-settings-form.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { getBytesWithUnit } from '$lib/utils/byte-units';
import { getContextMenuPosition } from '$lib/utils/context-menu';
import { handleError } from '$lib/utils/handle-error';
import {
LibraryType,
createLibrary,
deleteLibrary,
getLibraryStatistics,
removeOfflineFiles,
scanLibrary,
updateLibrary,
type LibraryResponseDto,
type LibraryStatsResponseDto,
getAllLibraries,
type UserResponseDto,
getUserById,
type CreateLibraryDto,
} from '@immich/sdk';
import { mdiDatabase, mdiDotsVertical, mdiUpload } from '@mdi/js';
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import type { PageData } from './$types';
import LibraryUserPickerForm from '$lib/components/forms/library-user-picker-form.svelte';
export let data: PageData;
let libraries: LibraryResponseDto[] = [];
let stats: LibraryStatsResponseDto[] = [];
let owner: UserResponseDto[] = [];
let photos: number[] = [];
let videos: number[] = [];
let totalCount: number[] = [];
let diskUsage: number[] = [];
let diskUsageUnit: string[] = [];
let confirmDeleteLibrary: LibraryResponseDto | null = null;
let deletedLibrary: LibraryResponseDto | null = null;
let editImportPaths: number | null;
let editScanSettings: number | null;
let renameLibrary: number | null;
let updateLibraryIndex: number | null;
let deleteAssetCount = 0;
let dropdownOpen: boolean[] = [];
let showContextMenu = false;
let contextMenuPosition = { x: 0, y: 0 };
let selectedLibraryIndex = 0;
let selectedLibrary: LibraryResponseDto | null = null;
let toCreateLibrary = false;
onMount(async () => {
await readLibraryList();
});
const closeAll = () => {
editImportPaths = null;
editScanSettings = null;
renameLibrary = null;
updateLibraryIndex = null;
showContextMenu = false;
for (let index = 0; index < dropdownOpen.length; index++) {
dropdownOpen[index] = false;
}
};
const showMenu = (event: MouseEvent, library: LibraryResponseDto, index: number) => {
contextMenuPosition = getContextMenuPosition(event);
showContextMenu = !showContextMenu;
selectedLibraryIndex = index;
selectedLibrary = library;
};
const onMenuExit = () => {
showContextMenu = false;
};
const refreshStats = async (listIndex: number) => {
stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id });
owner[listIndex] = await getUserById({ id: libraries[listIndex].ownerId });
photos[listIndex] = stats[listIndex].photos;
videos[listIndex] = stats[listIndex].videos;
totalCount[listIndex] = stats[listIndex].total;
[diskUsage[listIndex], diskUsageUnit[listIndex]] = getBytesWithUnit(stats[listIndex].usage, 0);
};
async function readLibraryList() {
libraries = await getAllLibraries({ $type: LibraryType.External });
dropdownOpen.length = libraries.length;
for (let index = 0; index < libraries.length; index++) {
await refreshStats(index);
dropdownOpen[index] = false;
}
}
const handleCreate = async (ownerId: string | null) => {
try {
let createLibraryDto: CreateLibraryDto = { type: LibraryType.External };
if (ownerId) {
createLibraryDto = { ...createLibraryDto, ownerId };
}
const createdLibrary = await createLibrary({ createLibraryDto });
notificationController.show({
message: `Created library: ${createdLibrary.name}`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to create library');
} finally {
toCreateLibrary = false;
await readLibraryList();
}
};
const handleUpdate = async (event: Partial<LibraryResponseDto>) => {
if (updateLibraryIndex === null) {
return;
}
try {
const libraryId = libraries[updateLibraryIndex].id;
await updateLibrary({ id: libraryId, updateLibraryDto: { ...event } });
closeAll();
await readLibraryList();
} catch (error) {
handleError(error, 'Unable to update library');
}
};
const handleDelete = async () => {
if (confirmDeleteLibrary) {
deletedLibrary = confirmDeleteLibrary;
}
if (!deletedLibrary) {
return;
}
try {
await deleteLibrary({ id: deletedLibrary.id });
notificationController.show({
message: `Library deleted`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to remove library');
} finally {
confirmDeleteLibrary = null;
deletedLibrary = null;
await readLibraryList();
}
};
const handleScanAll = async () => {
try {
for (const library of libraries) {
if (library.type === LibraryType.External) {
await scanLibrary({ id: library.id, scanLibraryDto: {} });
}
}
notificationController.show({
message: `Refreshing all libraries`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan libraries');
}
};
const handleScan = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: {} });
notificationController.show({
message: `Scanning library for new files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleScanChanges = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: { refreshModifiedFiles: true } });
notificationController.show({
message: `Scanning library for changed files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleForceScan = async (libraryId: string) => {
try {
await scanLibrary({ id: libraryId, scanLibraryDto: { refreshAllFiles: true } });
notificationController.show({
message: `Forcing refresh of all library files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to scan library');
}
};
const handleRemoveOffline = async (libraryId: string) => {
try {
await removeOfflineFiles({ id: libraryId });
notificationController.show({
message: `Removing Offline Files`,
type: NotificationType.Info,
});
} catch (error) {
handleError(error, 'Unable to remove offline files');
}
};
const onRenameClicked = () => {
closeAll();
renameLibrary = selectedLibraryIndex;
updateLibraryIndex = selectedLibraryIndex;
};
const onEditImportPathClicked = () => {
closeAll();
editImportPaths = selectedLibraryIndex;
updateLibraryIndex = selectedLibraryIndex;
};
const onScanNewLibraryClicked = async () => {
closeAll();
if (selectedLibrary) {
await handleScan(selectedLibrary.id);
}
};
const onScanSettingClicked = () => {
closeAll();
editScanSettings = selectedLibraryIndex;
updateLibraryIndex = selectedLibraryIndex;
};
const onScanAllLibraryFilesClicked = async () => {
closeAll();
if (selectedLibrary) {
await handleScanChanges(selectedLibrary.id);
}
};
const onForceScanAllLibraryFilesClicked = async () => {
closeAll();
if (selectedLibrary) {
await handleForceScan(selectedLibrary.id);
}
};
const onRemoveOfflineFilesClicked = async () => {
closeAll();
if (selectedLibrary) {
await handleRemoveOffline(selectedLibrary.id);
}
};
const onDeleteLibraryClicked = async () => {
closeAll();
if (selectedLibrary && confirm(`Are you sure you want to delete ${selectedLibrary.name} library?`) == true) {
await refreshStats(selectedLibraryIndex);
if (totalCount[selectedLibraryIndex] > 0) {
deleteAssetCount = totalCount[selectedLibraryIndex];
confirmDeleteLibrary = selectedLibrary;
} else {
deletedLibrary = selectedLibrary;
await handleDelete();
}
}
};
</script>
{#if confirmDeleteLibrary}
<ConfirmDialogue
title="Warning!"
prompt="Are you sure you want to delete this library? This will delete all {deleteAssetCount} contained assets from Immich and cannot be undone. Files will remain on disk."
on:confirm={handleDelete}
on:cancel={() => (confirmDeleteLibrary = null)}
/>
{/if}
{#if toCreateLibrary}
<LibraryUserPickerForm
on:submit={({ detail }) => handleCreate(detail.ownerId)}
on:cancel={() => (toCreateLibrary = false)}
/>
{/if}
<UserPageLayout title={data.meta.title} admin>
<section class="my-4">
<div class="flex flex-col gap-2" in:fade={{ duration: 500 }}>
{#if libraries.length > 0}
<table class="w-full text-left">
<thead
class="mb-4 flex h-12 w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
>
<tr class="grid grid-cols-6 w-full place-items-center">
<th class="text-center text-sm font-medium">Type</th>
<th class="text-center text-sm font-medium">Name</th>
<th class="text-center text-sm font-medium">Owner</th>
<th class="text-center text-sm font-medium">Assets</th>
<th class="text-center text-sm font-medium">Size</th>
<th class="text-center text-sm font-medium" />
</tr>
</thead>
<tbody class="block overflow-y-auto rounded-md border dark:border-immich-dark-gray">
{#each libraries as library, index (library.id)}
<tr
class={`grid grid-cols-6 h-[80px] w-full place-items-center text-center dark:text-immich-dark-fg ${
index % 2 == 0
? 'bg-immich-gray dark:bg-immich-dark-gray/75'
: 'bg-immich-bg dark:bg-immich-dark-gray/50'
}`}
>
<td class=" px-10 text-sm">
{#if library.type === LibraryType.External}
<Icon path={mdiDatabase} size="40" title="External library (created on {library.createdAt})" />
{:else if library.type === LibraryType.Upload}
<Icon path={mdiUpload} size="40" title="Upload library (created on {library.createdAt})" />
{/if}</td
>
<td class=" text-ellipsis px-4 text-sm">{library.name}</td>
<td class=" text-ellipsis px-4 text-sm">
{#if owner[index] == undefined}
<LoadingSpinner size="40" />
{:else}{owner[index].name}{/if}
</td>
{#if totalCount[index] == undefined}
<td colspan="2" class="flex w-1/3 items-center justify-center text-ellipsis px-4 text-sm">
<LoadingSpinner size="40" />
</td>
{:else}
<td class=" text-ellipsis px-4 text-sm">
{totalCount[index]}
</td>
<td class=" text-ellipsis px-4 text-sm">{diskUsage[index]} {diskUsageUnit[index]}</td>
{/if}
<td class=" text-ellipsis px-4 text-sm">
<button
class="rounded-full bg-immich-primary p-3 text-gray-100 transition-all duration-150 hover:bg-immich-primary/75 dark:bg-immich-dark-primary dark:text-gray-700"
on:click|stopPropagation|preventDefault={(e) => showMenu(e, library, index)}
>
<Icon path={mdiDotsVertical} size="16" />
</button>
{#if showContextMenu}
<Portal target="body">
<ContextMenu {...contextMenuPosition} on:outclick={() => onMenuExit()}>
<MenuOption on:click={() => onRenameClicked()} text={`Rename`} />
{#if selectedLibrary && selectedLibrary.type === LibraryType.External}
<MenuOption on:click={() => onEditImportPathClicked()} text="Edit Import Paths" />
<MenuOption on:click={() => onScanSettingClicked()} text="Scan Settings" />
<hr />
<MenuOption on:click={() => onScanNewLibraryClicked()} text="Scan New Library Files" />
<MenuOption
on:click={() => onScanAllLibraryFilesClicked()}
text="Re-scan All Library Files"
subtitle={'Only refreshes modified files'}
/>
<MenuOption
on:click={() => onForceScanAllLibraryFilesClicked()}
text="Force Re-scan All Library Files"
subtitle={'Refreshes every file'}
/>
<hr />
<MenuOption on:click={() => onRemoveOfflineFilesClicked()} text="Remove Offline Files" />
<MenuOption on:click={() => onDeleteLibraryClicked()}>
<p class="text-red-600">Delete library</p>
</MenuOption>
{/if}
</ContextMenu>
</Portal>
{/if}
</td>
</tr>
{#if renameLibrary === index}
<div transition:slide={{ duration: 250 }}>
<LibraryRenameForm
{library}
on:submit={({ detail }) => handleUpdate(detail)}
on:cancel={() => (renameLibrary = null)}
/>
</div>
{/if}
{#if editImportPaths === index}
<div transition:slide={{ duration: 250 }}>
<LibraryImportPathsForm
{library}
on:submit={({ detail }) => handleUpdate(detail)}
on:cancel={() => (editImportPaths = null)}
/>
</div>
{/if}
{#if editScanSettings === index}
<div transition:slide={{ duration: 250 }} class="mb-4 ml-4 mr-4">
<LibraryScanSettingsForm
{library}
on:submit={({ detail }) => handleUpdate(detail.library)}
on:cancel={() => (editScanSettings = null)}
/>
</div>
{/if}
{/each}
</tbody>
</table>
{/if}
<div class="my-2 flex justify-end gap-2">
<Button size="sm" on:click={() => handleScanAll()}>Scan All Libraries</Button>
<Button size="sm" on:click={() => (toCreateLibrary = true)}>Create Library</Button>
</div>
</div>
</section>
</UserPageLayout>

View file

@ -0,0 +1,16 @@
import { authenticate, requestServerInfo } from '$lib/utils/auth';
import { getAllUsers } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate({ admin: true });
await requestServerInfo();
const allUsers = await getAllUsers({ isAll: false });
return {
allUsers,
meta: {
title: 'External Library Management',
},
};
}) satisfies PageLoad;

View file

@ -12,7 +12,7 @@
import { user } from '$lib/stores/user.store';
import { asByteUnitString } from '$lib/utils/byte-units';
import { getAllUsers, type UserResponseDto } from '@immich/sdk';
import { mdiCheck, mdiClose, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { mdiClose, mdiDeleteRestore, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { onMount } from 'svelte';
import type { PageData } from './$types';
@ -175,7 +175,6 @@
<th class="w-8/12 sm:w-5/12 lg:w-6/12 xl:w-4/12 2xl:w-5/12 text-center text-sm font-medium">Email</th>
<th class="hidden sm:block w-3/12 text-center text-sm font-medium">Name</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">Has quota</th>
<th class="hidden xl:block w-3/12 2xl:w-2/12 text-center text-sm font-medium">Can import</th>
<th class="w-4/12 lg:w-3/12 xl:w-2/12 text-center text-sm font-medium">Action</th>
</tr>
</thead>
@ -204,16 +203,6 @@
{/if}
</div>
</td>
<td class="hidden xl:block w-3/12 2xl:w-2/12 text-ellipsis break-all px-2 text-sm">
<div class="container mx-auto flex flex-wrap justify-center">
{#if immichUser.externalPath}
<Icon path={mdiCheck} size="16" />
{:else}
<Icon path={mdiClose} size="16" />
{/if}
</div>
</td>
<td class="w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm">
{#if !isDeleted(immichUser)}
<button

View file

@ -7,7 +7,6 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
email: Sync.each(() => faker.internet.email()),
name: Sync.each(() => faker.person.fullName()),
storageLabel: Sync.each(() => faker.string.alphanumeric()),
externalPath: Sync.each(() => faker.string.alphanumeric()),
profileImagePath: '',
shouldChangePassword: Sync.each(() => faker.datatype.boolean()),
isAdmin: true,