mirror of
https://github.com/immich-app/immich.git
synced 2024-11-15 09:59:00 -07:00
feat(web,server): user storage label (#2418)
* feat: user storage label * chore: open api * fix: checks * fix: api update validation and tests * feat: default admin storage label * fix: linting * fix: user create/update dto * fix: delete library with custom label
This commit is contained in:
parent
0ccb73cf2b
commit
74353193f8
1
mobile/openapi/doc/CreateUserDto.md
generated
1
mobile/openapi/doc/CreateUserDto.md
generated
@ -12,6 +12,7 @@ Name | Type | Description | Notes
|
||||
**password** | **String** | |
|
||||
**firstName** | **String** | |
|
||||
**lastName** | **String** | |
|
||||
**storageLabel** | **String** | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
3
mobile/openapi/doc/UpdateUserDto.md
generated
3
mobile/openapi/doc/UpdateUserDto.md
generated
@ -8,11 +8,12 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**id** | **String** | |
|
||||
**email** | **String** | | [optional]
|
||||
**password** | **String** | | [optional]
|
||||
**firstName** | **String** | | [optional]
|
||||
**lastName** | **String** | | [optional]
|
||||
**id** | **String** | |
|
||||
**storageLabel** | **String** | | [optional]
|
||||
**isAdmin** | **bool** | | [optional]
|
||||
**shouldChangePassword** | **bool** | | [optional]
|
||||
|
||||
|
1
mobile/openapi/doc/UserResponseDto.md
generated
1
mobile/openapi/doc/UserResponseDto.md
generated
@ -12,6 +12,7 @@ Name | Type | Description | Notes
|
||||
**email** | **String** | |
|
||||
**firstName** | **String** | |
|
||||
**lastName** | **String** | |
|
||||
**storageLabel** | **String** | |
|
||||
**createdAt** | **String** | |
|
||||
**profileImagePath** | **String** | |
|
||||
**shouldChangePassword** | **bool** | |
|
||||
|
17
mobile/openapi/lib/model/create_user_dto.dart
generated
17
mobile/openapi/lib/model/create_user_dto.dart
generated
@ -17,6 +17,7 @@ class CreateUserDto {
|
||||
required this.password,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
this.storageLabel,
|
||||
});
|
||||
|
||||
String email;
|
||||
@ -27,12 +28,15 @@ class CreateUserDto {
|
||||
|
||||
String lastName;
|
||||
|
||||
String? storageLabel;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is CreateUserDto &&
|
||||
other.email == email &&
|
||||
other.password == password &&
|
||||
other.firstName == firstName &&
|
||||
other.lastName == lastName;
|
||||
other.lastName == lastName &&
|
||||
other.storageLabel == storageLabel;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@ -40,10 +44,11 @@ class CreateUserDto {
|
||||
(email.hashCode) +
|
||||
(password.hashCode) +
|
||||
(firstName.hashCode) +
|
||||
(lastName.hashCode);
|
||||
(lastName.hashCode) +
|
||||
(storageLabel == null ? 0 : storageLabel!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName]';
|
||||
String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -51,6 +56,11 @@ class CreateUserDto {
|
||||
json[r'password'] = this.password;
|
||||
json[r'firstName'] = this.firstName;
|
||||
json[r'lastName'] = this.lastName;
|
||||
if (this.storageLabel != null) {
|
||||
json[r'storageLabel'] = this.storageLabel;
|
||||
} else {
|
||||
// json[r'storageLabel'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
@ -77,6 +87,7 @@ class CreateUserDto {
|
||||
password: mapValueOfType<String>(json, r'password')!,
|
||||
firstName: mapValueOfType<String>(json, r'firstName')!,
|
||||
lastName: mapValueOfType<String>(json, r'lastName')!,
|
||||
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
31
mobile/openapi/lib/model/update_user_dto.dart
generated
31
mobile/openapi/lib/model/update_user_dto.dart
generated
@ -13,15 +13,18 @@ part of openapi.api;
|
||||
class UpdateUserDto {
|
||||
/// Returns a new [UpdateUserDto] instance.
|
||||
UpdateUserDto({
|
||||
required this.id,
|
||||
this.email,
|
||||
this.password,
|
||||
this.firstName,
|
||||
this.lastName,
|
||||
required this.id,
|
||||
this.storageLabel,
|
||||
this.isAdmin,
|
||||
this.shouldChangePassword,
|
||||
});
|
||||
|
||||
String id;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
@ -54,7 +57,13 @@ class UpdateUserDto {
|
||||
///
|
||||
String? lastName;
|
||||
|
||||
String id;
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
String? storageLabel;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@ -74,30 +83,33 @@ class UpdateUserDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is UpdateUserDto &&
|
||||
other.id == id &&
|
||||
other.email == email &&
|
||||
other.password == password &&
|
||||
other.firstName == firstName &&
|
||||
other.lastName == lastName &&
|
||||
other.id == id &&
|
||||
other.storageLabel == storageLabel &&
|
||||
other.isAdmin == isAdmin &&
|
||||
other.shouldChangePassword == shouldChangePassword;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(id.hashCode) +
|
||||
(email == null ? 0 : email!.hashCode) +
|
||||
(password == null ? 0 : password!.hashCode) +
|
||||
(firstName == null ? 0 : firstName!.hashCode) +
|
||||
(lastName == null ? 0 : lastName!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(storageLabel == null ? 0 : storageLabel!.hashCode) +
|
||||
(isAdmin == null ? 0 : isAdmin!.hashCode) +
|
||||
(shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UpdateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, id=$id, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]';
|
||||
String toString() => 'UpdateUserDto[id=$id, email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'id'] = this.id;
|
||||
if (this.email != null) {
|
||||
json[r'email'] = this.email;
|
||||
} else {
|
||||
@ -118,7 +130,11 @@ class UpdateUserDto {
|
||||
} else {
|
||||
// json[r'lastName'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
if (this.storageLabel != null) {
|
||||
json[r'storageLabel'] = this.storageLabel;
|
||||
} else {
|
||||
// json[r'storageLabel'] = null;
|
||||
}
|
||||
if (this.isAdmin != null) {
|
||||
json[r'isAdmin'] = this.isAdmin;
|
||||
} else {
|
||||
@ -151,11 +167,12 @@ class UpdateUserDto {
|
||||
}());
|
||||
|
||||
return UpdateUserDto(
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
email: mapValueOfType<String>(json, r'email'),
|
||||
password: mapValueOfType<String>(json, r'password'),
|
||||
firstName: mapValueOfType<String>(json, r'firstName'),
|
||||
lastName: mapValueOfType<String>(json, r'lastName'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
|
||||
isAdmin: mapValueOfType<bool>(json, r'isAdmin'),
|
||||
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
|
||||
);
|
||||
|
14
mobile/openapi/lib/model/user_response_dto.dart
generated
14
mobile/openapi/lib/model/user_response_dto.dart
generated
@ -17,6 +17,7 @@ class UserResponseDto {
|
||||
required this.email,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.storageLabel,
|
||||
required this.createdAt,
|
||||
required this.profileImagePath,
|
||||
required this.shouldChangePassword,
|
||||
@ -34,6 +35,8 @@ class UserResponseDto {
|
||||
|
||||
String lastName;
|
||||
|
||||
String? storageLabel;
|
||||
|
||||
String createdAt;
|
||||
|
||||
String profileImagePath;
|
||||
@ -66,6 +69,7 @@ class UserResponseDto {
|
||||
other.email == email &&
|
||||
other.firstName == firstName &&
|
||||
other.lastName == lastName &&
|
||||
other.storageLabel == storageLabel &&
|
||||
other.createdAt == createdAt &&
|
||||
other.profileImagePath == profileImagePath &&
|
||||
other.shouldChangePassword == shouldChangePassword &&
|
||||
@ -81,6 +85,7 @@ class UserResponseDto {
|
||||
(email.hashCode) +
|
||||
(firstName.hashCode) +
|
||||
(lastName.hashCode) +
|
||||
(storageLabel == null ? 0 : storageLabel!.hashCode) +
|
||||
(createdAt.hashCode) +
|
||||
(profileImagePath.hashCode) +
|
||||
(shouldChangePassword.hashCode) +
|
||||
@ -90,7 +95,7 @@ class UserResponseDto {
|
||||
(oauthId.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
|
||||
String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@ -98,6 +103,11 @@ class UserResponseDto {
|
||||
json[r'email'] = this.email;
|
||||
json[r'firstName'] = this.firstName;
|
||||
json[r'lastName'] = this.lastName;
|
||||
if (this.storageLabel != null) {
|
||||
json[r'storageLabel'] = this.storageLabel;
|
||||
} else {
|
||||
// json[r'storageLabel'] = null;
|
||||
}
|
||||
json[r'createdAt'] = this.createdAt;
|
||||
json[r'profileImagePath'] = this.profileImagePath;
|
||||
json[r'shouldChangePassword'] = this.shouldChangePassword;
|
||||
@ -139,6 +149,7 @@ class UserResponseDto {
|
||||
email: mapValueOfType<String>(json, r'email')!,
|
||||
firstName: mapValueOfType<String>(json, r'firstName')!,
|
||||
lastName: mapValueOfType<String>(json, r'lastName')!,
|
||||
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
|
||||
createdAt: mapValueOfType<String>(json, r'createdAt')!,
|
||||
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
|
||||
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
|
||||
@ -197,6 +208,7 @@ class UserResponseDto {
|
||||
'email',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'storageLabel',
|
||||
'createdAt',
|
||||
'profileImagePath',
|
||||
'shouldChangePassword',
|
||||
|
5
mobile/openapi/test/create_user_dto_test.dart
generated
5
mobile/openapi/test/create_user_dto_test.dart
generated
@ -36,6 +36,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String storageLabel
|
||||
test('to test the property `storageLabel`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
9
mobile/openapi/test/update_user_dto_test.dart
generated
9
mobile/openapi/test/update_user_dto_test.dart
generated
@ -16,6 +16,11 @@ void main() {
|
||||
// final instance = UpdateUserDto();
|
||||
|
||||
group('test UpdateUserDto', () {
|
||||
// String id
|
||||
test('to test the property `id`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String email
|
||||
test('to test the property `email`', () async {
|
||||
// TODO
|
||||
@ -36,8 +41,8 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String id
|
||||
test('to test the property `id`', () async {
|
||||
// String storageLabel
|
||||
test('to test the property `storageLabel`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
5
mobile/openapi/test/user_response_dto_test.dart
generated
5
mobile/openapi/test/user_response_dto_test.dart
generated
@ -36,6 +36,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String storageLabel
|
||||
test('to test the property `storageLabel`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String createdAt
|
||||
test('to test the property `createdAt`', () async {
|
||||
// TODO
|
||||
|
@ -39,6 +39,7 @@ describe('Album service', () => {
|
||||
oauthId: '',
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: null,
|
||||
});
|
||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||
const sharedAlbumOwnerId = '2222';
|
||||
|
@ -27,6 +27,7 @@ describe('TagService', () => {
|
||||
tags: [],
|
||||
assets: [],
|
||||
oauthId: 'oauth-id-1',
|
||||
storageLabel: null,
|
||||
});
|
||||
|
||||
// const user2: UserEntity = Object.freeze({
|
||||
|
@ -1,4 +1,10 @@
|
||||
export const toBoolean = ({ value }: { value: string }) => {
|
||||
import sanitize from 'sanitize-filename';
|
||||
|
||||
interface IValue {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export const toBoolean = ({ value }: IValue) => {
|
||||
if (value == 'true') {
|
||||
return true;
|
||||
} else if (value == 'false') {
|
||||
@ -6,3 +12,7 @@ export const toBoolean = ({ value }: { value: string }) => {
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const toEmail = ({ value }: IValue) => value?.toLowerCase();
|
||||
|
||||
export const toSanitized = ({ value }: IValue) => sanitize((value || '').replace(/\./g, ''));
|
||||
|
@ -87,10 +87,10 @@ describe('User', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('fetches the user collection excluding the auth user', async () => {
|
||||
it('fetches the user collection including the auth user', async () => {
|
||||
const { status, body } = await request(app.getHttpServer()).get('/user?isAll=false');
|
||||
expect(status).toEqual(200);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body).toHaveLength(3);
|
||||
expect(body).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
@ -105,6 +105,7 @@ describe('User', () => {
|
||||
deletedAt: null,
|
||||
updatedAt: expect.anything(),
|
||||
oauthId: '',
|
||||
storageLabel: null,
|
||||
},
|
||||
{
|
||||
email: userTwoEmail,
|
||||
@ -118,10 +119,24 @@ describe('User', () => {
|
||||
deletedAt: null,
|
||||
updatedAt: expect.anything(),
|
||||
oauthId: '',
|
||||
storageLabel: null,
|
||||
},
|
||||
{
|
||||
email: authUserEmail,
|
||||
firstName: 'auth-user',
|
||||
lastName: 'test',
|
||||
id: expect.anything(),
|
||||
createdAt: expect.anything(),
|
||||
isAdmin: true,
|
||||
shouldChangePassword: true,
|
||||
profileImagePath: '',
|
||||
deletedAt: null,
|
||||
updatedAt: expect.anything(),
|
||||
oauthId: '',
|
||||
storageLabel: 'admin',
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(body).toEqual(expect.not.arrayContaining([expect.objectContaining({ email: authUserEmail })]));
|
||||
});
|
||||
|
||||
it('disallows admin user from creating a second admin account', async () => {
|
||||
|
@ -4122,6 +4122,10 @@
|
||||
"lastName": {
|
||||
"type": "string"
|
||||
},
|
||||
"storageLabel": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
@ -4150,6 +4154,7 @@
|
||||
"email",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"storageLabel",
|
||||
"createdAt",
|
||||
"profileImagePath",
|
||||
"shouldChangePassword",
|
||||
@ -5529,20 +5534,20 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "testuser@email.com"
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"example": "password"
|
||||
"type": "string"
|
||||
},
|
||||
"firstName": {
|
||||
"type": "string",
|
||||
"example": "John"
|
||||
"type": "string"
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string"
|
||||
},
|
||||
"storageLabel": {
|
||||
"type": "string",
|
||||
"example": "Doe"
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@ -5566,26 +5571,25 @@
|
||||
"UpdateUserDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "testuser@email.com"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"example": "password"
|
||||
},
|
||||
"firstName": {
|
||||
"type": "string",
|
||||
"example": "John"
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string",
|
||||
"example": "Doe"
|
||||
},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"firstName": {
|
||||
"type": "string"
|
||||
},
|
||||
"lastName": {
|
||||
"type": "string"
|
||||
},
|
||||
"storageLabel": {
|
||||
"type": "string"
|
||||
},
|
||||
"isAdmin": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
@ -306,7 +306,7 @@ describe('AuthService', () => {
|
||||
expect(userTokenMock.save.mock.calls[0][0]).toMatchObject({
|
||||
id: 'not_active',
|
||||
token: 'auth_token',
|
||||
userId: 'immich_id',
|
||||
userId: 'user-id',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: expect.any(Date),
|
||||
deviceOS: 'Android',
|
||||
|
@ -122,6 +122,7 @@ export class AuthService {
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName,
|
||||
password: dto.password,
|
||||
storageLabel: 'admin',
|
||||
});
|
||||
|
||||
return mapAdminSignupResponse(admin);
|
||||
|
@ -17,19 +17,21 @@ const responseDto = {
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: false,
|
||||
updatedAt: '2021-01-01',
|
||||
storageLabel: 'admin',
|
||||
},
|
||||
user1: {
|
||||
createdAt: '2021-01-01',
|
||||
deletedAt: undefined,
|
||||
email: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
id: 'immich_id',
|
||||
id: 'user-id',
|
||||
isAdmin: false,
|
||||
lastName: 'immich_last_name',
|
||||
oauthId: '',
|
||||
profileImagePath: '',
|
||||
shouldChangePassword: false,
|
||||
updatedAt: '2021-01-01',
|
||||
storageLabel: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -4,7 +4,7 @@ import handlebar from 'handlebars';
|
||||
import * as luxon from 'luxon';
|
||||
import path from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||
import { IStorageRepository, StorageCore } from '../storage';
|
||||
import {
|
||||
ISystemConfigRepository,
|
||||
supportedDayTokens,
|
||||
@ -15,6 +15,7 @@ import {
|
||||
supportedYearTokens,
|
||||
} from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { MoveAssetMetadata } from './storage-template.service';
|
||||
|
||||
export class StorageTemplateCore {
|
||||
private logger = new Logger(StorageTemplateCore.name);
|
||||
@ -33,12 +34,14 @@ export class StorageTemplateCore {
|
||||
this.configCore.config$.subscribe((config) => this.onConfig(config));
|
||||
}
|
||||
|
||||
public async getTemplatePath(asset: AssetEntity, filename: string): Promise<string> {
|
||||
public async getTemplatePath(asset: AssetEntity, metadata: MoveAssetMetadata): Promise<string> {
|
||||
const { storageLabel, filename } = metadata;
|
||||
|
||||
try {
|
||||
const source = asset.originalPath;
|
||||
const ext = path.extname(source).split('.').pop() as string;
|
||||
const sanitized = sanitize(path.basename(filename, `.${ext}`));
|
||||
const rootPath = this.storageCore.getFolderLocation(StorageFolder.LIBRARY, asset.ownerId);
|
||||
const rootPath = this.storageCore.getLibraryFolder({ id: asset.ownerId, storageLabel });
|
||||
const storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
|
||||
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
||||
let destination = `${fullPath}.${ext}`;
|
||||
|
@ -4,18 +4,22 @@ import {
|
||||
newAssetRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
systemConfigStub,
|
||||
userEntityStub,
|
||||
} from '../../test';
|
||||
import { IAssetRepository } from '../asset';
|
||||
import { StorageTemplateService } from '../storage-template';
|
||||
import { IStorageRepository } from '../storage/storage.repository';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user';
|
||||
|
||||
describe(StorageTemplateService.name, () => {
|
||||
let sut: StorageTemplateService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
@ -25,12 +29,15 @@ describe(StorageTemplateService.name, () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock);
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock);
|
||||
});
|
||||
|
||||
describe('handle template migration', () => {
|
||||
it('should handle no assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue([]);
|
||||
userMock.getList.mockResolvedValue([]);
|
||||
|
||||
await sut.handleTemplateMigration();
|
||||
|
||||
@ -40,6 +47,7 @@ describe(StorageTemplateService.name, () => {
|
||||
it('should handle an asset with a duplicate destination', async () => {
|
||||
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
|
||||
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
||||
userMock.getList.mockResolvedValue([userEntityStub.user1]);
|
||||
|
||||
when(storageMock.checkFileExists)
|
||||
.calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext')
|
||||
@ -57,6 +65,7 @@ describe(StorageTemplateService.name, () => {
|
||||
id: assetEntityStub.image.id,
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
|
||||
});
|
||||
expect(userMock.getList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when an asset already matches the template', async () => {
|
||||
@ -66,6 +75,7 @@ describe(StorageTemplateService.name, () => {
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext',
|
||||
},
|
||||
]);
|
||||
userMock.getList.mockResolvedValue([userEntityStub.user1]);
|
||||
|
||||
await sut.handleTemplateMigration();
|
||||
|
||||
@ -82,6 +92,7 @@ describe(StorageTemplateService.name, () => {
|
||||
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
|
||||
},
|
||||
]);
|
||||
userMock.getList.mockResolvedValue([userEntityStub.user1]);
|
||||
|
||||
await sut.handleTemplateMigration();
|
||||
|
||||
@ -94,6 +105,7 @@ describe(StorageTemplateService.name, () => {
|
||||
it('should move an asset', async () => {
|
||||
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
|
||||
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
||||
userMock.getList.mockResolvedValue([userEntityStub.user1]);
|
||||
|
||||
await sut.handleTemplateMigration();
|
||||
|
||||
@ -108,9 +120,28 @@ describe(StorageTemplateService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should use the user storage label', async () => {
|
||||
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
|
||||
assetMock.save.mockResolvedValue(assetEntityStub.image);
|
||||
userMock.getList.mockResolvedValue([userEntityStub.storageLabel]);
|
||||
|
||||
await sut.handleTemplateMigration();
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(storageMock.moveFile).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/library/label-1/2023/2023-02-23/asset-id.ext',
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: assetEntityStub.image.id,
|
||||
originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.ext',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not update the database if the move fails', async () => {
|
||||
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
|
||||
storageMock.moveFile.mockRejectedValue(new Error('Read only system'));
|
||||
userMock.getList.mockResolvedValue([userEntityStub.user1]);
|
||||
|
||||
await sut.handleTemplateMigration();
|
||||
|
||||
@ -125,6 +156,7 @@ describe(StorageTemplateService.name, () => {
|
||||
it('should move the asset back if the database fails', async () => {
|
||||
assetMock.getAll.mockResolvedValue([assetEntityStub.image]);
|
||||
assetMock.save.mockRejectedValue('Connection Error!');
|
||||
userMock.getList.mockResolvedValue([userEntityStub.user1]);
|
||||
|
||||
await sut.handleTemplateMigration();
|
||||
|
||||
@ -143,6 +175,7 @@ describe(StorageTemplateService.name, () => {
|
||||
it('should handle an error', async () => {
|
||||
assetMock.getAll.mockResolvedValue([]);
|
||||
storageMock.removeEmptyDirs.mockRejectedValue(new Error('Read only filesystem'));
|
||||
userMock.getList.mockResolvedValue([]);
|
||||
|
||||
await sut.handleTemplateMigration();
|
||||
});
|
||||
|
@ -6,8 +6,14 @@ import { getLivePhotoMotionFilename } from '../domain.util';
|
||||
import { IAssetJob } from '../job';
|
||||
import { IStorageRepository } from '../storage/storage.repository';
|
||||
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
|
||||
import { IUserRepository } from '../user/user.repository';
|
||||
import { StorageTemplateCore } from './storage-template.core';
|
||||
|
||||
export interface MoveAssetMetadata {
|
||||
storageLabel: string | null;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StorageTemplateService {
|
||||
private logger = new Logger(StorageTemplateService.name);
|
||||
@ -18,6 +24,7 @@ export class StorageTemplateService {
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
) {
|
||||
this.core = new StorageTemplateCore(configRepository, config, storageRepository);
|
||||
}
|
||||
@ -26,14 +33,16 @@ export class StorageTemplateService {
|
||||
const { asset } = data;
|
||||
|
||||
try {
|
||||
const user = await this.userRepository.get(asset.ownerId);
|
||||
const storageLabel = user?.storageLabel || null;
|
||||
const filename = asset.originalFileName || asset.id;
|
||||
await this.moveAsset(asset, filename);
|
||||
await this.moveAsset(asset, { storageLabel, filename });
|
||||
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, motionFilename);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error running single template migration', error);
|
||||
@ -44,6 +53,7 @@ export class StorageTemplateService {
|
||||
try {
|
||||
console.time('migrating-time');
|
||||
const assets = await this.assetRepository.getAll();
|
||||
const users = await this.userRepository.getList();
|
||||
|
||||
const livePhotoMap: Record<string, AssetEntity> = {};
|
||||
|
||||
@ -56,8 +66,10 @@ export class StorageTemplateService {
|
||||
for (const asset of assets) {
|
||||
const livePhotoParentAsset = livePhotoMap[asset.id];
|
||||
// TODO: remove livePhoto specific stuff once upload is fixed
|
||||
const user = users.find((user) => user.id === asset.ownerId);
|
||||
const storageLabel = user?.storageLabel || null;
|
||||
const filename = asset.originalFileName || livePhotoParentAsset?.originalFileName || asset.id;
|
||||
await this.moveAsset(asset, filename);
|
||||
await this.moveAsset(asset, { storageLabel, filename });
|
||||
}
|
||||
|
||||
this.logger.debug('Cleaning up empty directories...');
|
||||
@ -70,8 +82,8 @@ export class StorageTemplateService {
|
||||
}
|
||||
|
||||
// TODO: use asset core (once in domain)
|
||||
async moveAsset(asset: AssetEntity, originalName: string) {
|
||||
const destination = await this.core.getTemplatePath(asset, originalName);
|
||||
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
|
||||
const destination = await this.core.getTemplatePath(asset, metadata);
|
||||
if (asset.originalPath !== destination) {
|
||||
const source = asset.originalPath;
|
||||
|
||||
|
@ -10,7 +10,14 @@ export enum StorageFolder {
|
||||
}
|
||||
|
||||
export class StorageCore {
|
||||
getFolderLocation(folder: StorageFolder, userId: string) {
|
||||
getFolderLocation(
|
||||
folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
|
||||
userId: string,
|
||||
) {
|
||||
return join(APP_MEDIA_LOCATION, folder, userId);
|
||||
}
|
||||
|
||||
getLibraryFolder(user: { storageLabel: string | null; id: string }) {
|
||||
return join(APP_MEDIA_LOCATION, StorageFolder.LIBRARY, user.storageLabel || user.id);
|
||||
}
|
||||
}
|
||||
|
@ -1,24 +1,28 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsEmail } from 'class-validator';
|
||||
import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { toEmail, toSanitized } from '../../../../../apps/immich/src/utils/transform.util';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
@Transform(({ value }) => value?.toLowerCase())
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
@Transform(toEmail)
|
||||
email!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
@IsString()
|
||||
password!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'John' })
|
||||
@IsString()
|
||||
firstName!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'Doe' })
|
||||
@IsString()
|
||||
lastName!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(toSanitized)
|
||||
storageLabel?: string | null;
|
||||
}
|
||||
|
||||
export class CreateAdminDto {
|
||||
|
@ -1,8 +1,34 @@
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsUUID } from 'class-validator';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
import { ApiProperty, PartialType } from '@nestjs/swagger';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { toEmail, toSanitized } from '../../../../../apps/immich/src/utils/transform.util';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@Transform(toEmail)
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Transform(toSanitized)
|
||||
storageLabel?: string;
|
||||
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {
|
||||
@IsNotEmpty()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
|
@ -5,6 +5,7 @@ export class UserResponseDto {
|
||||
email!: string;
|
||||
firstName!: string;
|
||||
lastName!: string;
|
||||
storageLabel!: string | null;
|
||||
createdAt!: string;
|
||||
profileImagePath!: string;
|
||||
shouldChangePassword!: boolean;
|
||||
@ -20,6 +21,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
email: entity.email,
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
storageLabel: entity.storageLabel,
|
||||
createdAt: entity.createdAt,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { hash } from 'bcrypt';
|
||||
import { constants, createReadStream, ReadStream } from 'fs';
|
||||
@ -28,6 +27,7 @@ export class UserCore {
|
||||
if (!authUser.isAdmin) {
|
||||
// Users can never update the isAdmin property.
|
||||
delete dto.isAdmin;
|
||||
delete dto.storageLabel;
|
||||
} else if (dto.isAdmin && authUser.id !== id) {
|
||||
// Admin cannot create another admin.
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
@ -36,7 +36,14 @@ export class UserCore {
|
||||
if (dto.email) {
|
||||
const duplicate = await this.userRepository.getByEmail(dto.email);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Email already in user by another account');
|
||||
throw new BadRequestException('Email already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.storageLabel) {
|
||||
const duplicate = await this.userRepository.getByStorageLabel(dto.storageLabel);
|
||||
if (duplicate && duplicate.id !== id) {
|
||||
throw new BadRequestException('Storage label already in use by another account');
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +52,10 @@ export class UserCore {
|
||||
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
if (dto.storageLabel === '') {
|
||||
dto.storageLabel = null;
|
||||
}
|
||||
|
||||
return this.userRepository.update(id, dto);
|
||||
} catch (e) {
|
||||
Logger.error(e, 'Failed to update user info');
|
||||
@ -106,14 +117,8 @@ export class UserCore {
|
||||
}
|
||||
|
||||
async createProfileImage(authUser: AuthUserDto, filePath: string): Promise<UserEntity> {
|
||||
// TODO: do we need to do this? Maybe we can trust the authUser
|
||||
const user = await this.userRepository.get(authUser.id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
try {
|
||||
return this.userRepository.update(user.id, { profileImagePath: filePath });
|
||||
return this.userRepository.update(authUser.id, { profileImagePath: filePath });
|
||||
} catch (e) {
|
||||
Logger.error(e, 'Create User Profile Image');
|
||||
throw new InternalServerErrorException('Failed to create new user profile image');
|
||||
@ -121,12 +126,7 @@ export class UserCore {
|
||||
}
|
||||
|
||||
async restoreUser(authUser: AuthUserDto, userToRestore: UserEntity): Promise<UserEntity> {
|
||||
// TODO: do we need to do this? Maybe we can trust the authUser
|
||||
const requestor = await this.userRepository.get(authUser.id);
|
||||
if (!requestor) {
|
||||
throw new UnauthorizedException('Requestor not found');
|
||||
}
|
||||
if (!requestor.isAdmin) {
|
||||
if (!authUser.isAdmin) {
|
||||
throw new ForbiddenException('Unauthorized');
|
||||
}
|
||||
try {
|
||||
@ -138,12 +138,7 @@ export class UserCore {
|
||||
}
|
||||
|
||||
async deleteUser(authUser: AuthUserDto, userToDelete: UserEntity): Promise<UserEntity> {
|
||||
// TODO: do we need to do this? Maybe we can trust the authUser
|
||||
const requestor = await this.userRepository.get(authUser.id);
|
||||
if (!requestor) {
|
||||
throw new UnauthorizedException('Requestor not found');
|
||||
}
|
||||
if (!requestor.isAdmin) {
|
||||
if (!authUser.isAdmin) {
|
||||
throw new ForbiddenException('Unauthorized');
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { UserEntity } from '@app/infra/entities';
|
||||
|
||||
export interface UserListFilter {
|
||||
excludeId?: string;
|
||||
withDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface UserStatsQueryResponse {
|
||||
@ -19,6 +19,7 @@ export interface IUserRepository {
|
||||
get(id: string, withDeleted?: boolean): Promise<UserEntity | null>;
|
||||
getAdmin(): Promise<UserEntity | null>;
|
||||
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>;
|
||||
getByStorageLabel(storageLabel: string): Promise<UserEntity | null>;
|
||||
getByOAuthId(oauthId: string): Promise<UserEntity | null>;
|
||||
getDeletedUsers(): Promise<UserEntity[]>;
|
||||
getList(filter?: UserListFilter): Promise<UserEntity[]>;
|
||||
|
@ -36,7 +36,7 @@ const adminUserAuth: AuthUserDto = Object.freeze({
|
||||
});
|
||||
|
||||
const immichUserAuth: AuthUserDto = Object.freeze({
|
||||
id: 'immich_id',
|
||||
id: 'user-id',
|
||||
email: 'immich@test.com',
|
||||
isAdmin: false,
|
||||
});
|
||||
@ -55,6 +55,7 @@ const adminUser: UserEntity = Object.freeze({
|
||||
updatedAt: '2021-01-01',
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: 'admin',
|
||||
});
|
||||
|
||||
const immichUser: UserEntity = Object.freeze({
|
||||
@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({
|
||||
updatedAt: '2021-01-01',
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: null,
|
||||
});
|
||||
|
||||
const updatedImmichUser: UserEntity = Object.freeze({
|
||||
@ -87,6 +89,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
|
||||
updatedAt: '2021-01-01',
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: null,
|
||||
});
|
||||
|
||||
const adminUserResponse = Object.freeze({
|
||||
@ -101,6 +104,7 @@ const adminUserResponse = Object.freeze({
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
updatedAt: '2021-01-01',
|
||||
storageLabel: 'admin',
|
||||
});
|
||||
|
||||
describe(UserService.name, () => {
|
||||
@ -150,7 +154,7 @@ describe(UserService.name, () => {
|
||||
|
||||
const response = await sut.getAllUsers(adminUserAuth, false);
|
||||
|
||||
expect(userRepositoryMock.getList).toHaveBeenCalledWith({ excludeId: adminUser.id });
|
||||
expect(userRepositoryMock.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||
expect(response).toEqual([
|
||||
{
|
||||
id: adminUserAuth.id,
|
||||
@ -164,6 +168,7 @@ describe(UserService.name, () => {
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
updatedAt: '2021-01-01',
|
||||
storageLabel: 'admin',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -231,6 +236,22 @@ describe(UserService.name, () => {
|
||||
expect(updatedUser.shouldChangePassword).toEqual(true);
|
||||
});
|
||||
|
||||
it('should not set an empty string for storage label', async () => {
|
||||
userRepositoryMock.update.mockResolvedValue(updatedImmichUser);
|
||||
|
||||
await sut.updateUser(adminUserAuth, { id: immichUser.id, storageLabel: '' });
|
||||
|
||||
expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { id: immichUser.id, storageLabel: null });
|
||||
});
|
||||
|
||||
it('should omit a storage label set by non-admin users', async () => {
|
||||
userRepositoryMock.update.mockResolvedValue(updatedImmichUser);
|
||||
|
||||
await sut.updateUser(immichUserAuth, { id: immichUser.id, storageLabel: 'admin' });
|
||||
|
||||
expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, { id: immichUser.id });
|
||||
});
|
||||
|
||||
it('user can only update its information', async () => {
|
||||
when(userRepositoryMock.get)
|
||||
.calledWith('not_immich_auth_user_id', undefined)
|
||||
@ -255,7 +276,7 @@ describe(UserService.name, () => {
|
||||
await sut.updateUser(immichUser, dto);
|
||||
|
||||
expect(userRepositoryMock.update).toHaveBeenCalledWith(immichUser.id, {
|
||||
id: 'immich_id',
|
||||
id: 'user-id',
|
||||
email: 'updated@test.com',
|
||||
});
|
||||
});
|
||||
@ -271,6 +292,17 @@ describe(UserService.name, () => {
|
||||
expect(userRepositoryMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not let the admin change the storage label to one already in use', async () => {
|
||||
const dto = { id: immichUser.id, storageLabel: 'admin' };
|
||||
|
||||
userRepositoryMock.get.mockResolvedValue(immichUser);
|
||||
userRepositoryMock.getByStorageLabel.mockResolvedValue(adminUser);
|
||||
|
||||
await expect(sut.updateUser(adminUser, dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(userRepositoryMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('admin can update any user information', async () => {
|
||||
const update: UpdateUserDto = {
|
||||
id: immichUser.id,
|
||||
@ -481,6 +513,16 @@ describe(UserService.name, () => {
|
||||
expect(userRepositoryMock.delete).toHaveBeenCalledWith(user, true);
|
||||
});
|
||||
|
||||
it('should delete the library path for a storage label', async () => {
|
||||
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10), storageLabel: 'admin' } as UserEntity;
|
||||
|
||||
await sut.handleUserDelete({ user });
|
||||
|
||||
const options = { force: true, recursive: true };
|
||||
|
||||
expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
|
||||
});
|
||||
|
||||
it('should handle an error', async () => {
|
||||
const user = { id: 'deleted-user', deletedAt: makeDeletedAt(10) } as UserEntity;
|
||||
|
||||
|
@ -44,13 +44,8 @@ export class UserService {
|
||||
}
|
||||
|
||||
async getAllUsers(authUser: AuthUserDto, isAll: boolean): Promise<UserResponseDto[]> {
|
||||
if (isAll) {
|
||||
const allUsers = await this.userCore.getList();
|
||||
return allUsers.map(mapUser);
|
||||
}
|
||||
|
||||
const allUserExceptRequestedUser = await this.userCore.getList({ excludeId: authUser.id });
|
||||
return allUserExceptRequestedUser.map(mapUser);
|
||||
const users = await this.userCore.getList({ withDeleted: !isAll });
|
||||
return users.map(mapUser);
|
||||
}
|
||||
|
||||
async getUserById(userId: string, withDeleted = false): Promise<UserResponseDto> {
|
||||
@ -165,7 +160,7 @@ export class UserService {
|
||||
|
||||
try {
|
||||
const folders = [
|
||||
this.storageCore.getFolderLocation(StorageFolder.LIBRARY, user.id),
|
||||
this.storageCore.getLibraryFolder(user),
|
||||
this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
|
||||
this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
|
||||
this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
|
||||
|
@ -43,7 +43,7 @@ export const authStub = {
|
||||
isAllowUpload: true,
|
||||
}),
|
||||
user1: Object.freeze<AuthUserDto>({
|
||||
id: 'immich_id',
|
||||
id: 'user-id',
|
||||
email: 'immich@test.com',
|
||||
isAdmin: false,
|
||||
isPublicUser: false,
|
||||
@ -81,6 +81,7 @@ export const userEntityStub = {
|
||||
password: 'admin_password',
|
||||
firstName: 'admin_first_name',
|
||||
lastName: 'admin_last_name',
|
||||
storageLabel: 'admin',
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
@ -94,6 +95,21 @@ export const userEntityStub = {
|
||||
password: 'immich_password',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
storageLabel: null,
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
createdAt: '2021-01-01',
|
||||
updatedAt: '2021-01-01',
|
||||
tags: [],
|
||||
assets: [],
|
||||
}),
|
||||
storageLabel: Object.freeze<UserEntity>({
|
||||
...authStub.user1,
|
||||
password: 'immich_password',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
storageLabel: 'label-1',
|
||||
oauthId: '',
|
||||
shouldChangePassword: false,
|
||||
profileImagePath: '',
|
||||
@ -536,7 +552,7 @@ export const loginResponseStub = {
|
||||
user1oauth: {
|
||||
response: {
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: 'immich_id',
|
||||
userId: 'user-id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
@ -552,7 +568,7 @@ export const loginResponseStub = {
|
||||
user1password: {
|
||||
response: {
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: 'immich_id',
|
||||
userId: 'user-id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
@ -568,7 +584,7 @@ export const loginResponseStub = {
|
||||
user1insecure: {
|
||||
response: {
|
||||
accessToken: 'cmFuZG9tLWJ5dGVz',
|
||||
userId: 'immich_id',
|
||||
userId: 'user-id',
|
||||
userEmail: 'immich@test.com',
|
||||
firstName: 'immich_first_name',
|
||||
lastName: 'immich_last_name',
|
||||
|
@ -5,6 +5,7 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
|
||||
get: jest.fn(),
|
||||
getAdmin: jest.fn(),
|
||||
getByEmail: jest.fn(),
|
||||
getByStorageLabel: jest.fn(),
|
||||
getByOAuthId: jest.fn(),
|
||||
getUserStats: jest.fn(),
|
||||
getList: jest.fn(),
|
||||
|
@ -27,6 +27,9 @@ export class UserEntity {
|
||||
@Column({ unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'varchar', unique: true, default: null })
|
||||
storageLabel!: string | null;
|
||||
|
||||
@Column({ default: '', select: false })
|
||||
password?: string;
|
||||
|
||||
|
@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddStorageLabel1684410565398 implements MigrationInterface {
|
||||
name = 'AddStorageLabel1684410565398'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD "storageLabel" character varying`);
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_b309cf34fa58137c416b32cea3a" UNIQUE ("storageLabel")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_b309cf34fa58137c416b32cea3a"`);
|
||||
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "storageLabel"`);
|
||||
}
|
||||
|
||||
}
|
@ -6,10 +6,7 @@ import { UserEntity } from '../entities';
|
||||
|
||||
@Injectable()
|
||||
export class UserRepository implements IUserRepository {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private userRepository: Repository<UserEntity>,
|
||||
) {}
|
||||
constructor(@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>) {}
|
||||
|
||||
async get(userId: string, withDeleted?: boolean): Promise<UserEntity | null> {
|
||||
return this.userRepository.findOne({ where: { id: userId }, withDeleted: withDeleted });
|
||||
@ -29,6 +26,10 @@ export class UserRepository implements IUserRepository {
|
||||
return builder.getOne();
|
||||
}
|
||||
|
||||
async getByStorageLabel(storageLabel: string): Promise<UserEntity | null> {
|
||||
return this.userRepository.findOne({ where: { storageLabel } });
|
||||
}
|
||||
|
||||
async getByOAuthId(oauthId: string): Promise<UserEntity | null> {
|
||||
return this.userRepository.findOne({ where: { oauthId } });
|
||||
}
|
||||
@ -37,13 +38,9 @@ export class UserRepository implements IUserRepository {
|
||||
return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } });
|
||||
}
|
||||
|
||||
async getList({ excludeId }: UserListFilter = {}): Promise<UserEntity[]> {
|
||||
if (!excludeId) {
|
||||
return this.userRepository.find(); // TODO: this should also be ordered the same as below
|
||||
}
|
||||
async getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
|
||||
return this.userRepository.find({
|
||||
where: { id: Not(excludeId) },
|
||||
withDeleted: true,
|
||||
withDeleted,
|
||||
order: {
|
||||
createdAt: 'DESC',
|
||||
},
|
||||
|
20
web/src/api/open-api/api.ts
generated
20
web/src/api/open-api/api.ts
generated
@ -910,6 +910,12 @@ export interface CreateUserDto {
|
||||
* @memberof CreateUserDto
|
||||
*/
|
||||
'lastName': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof CreateUserDto
|
||||
*/
|
||||
'storageLabel'?: string | null;
|
||||
}
|
||||
/**
|
||||
*
|
||||
@ -2450,6 +2456,12 @@ export interface UpdateTagDto {
|
||||
* @interface UpdateUserDto
|
||||
*/
|
||||
export interface UpdateUserDto {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof UpdateUserDto
|
||||
*/
|
||||
'id': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
@ -2479,7 +2491,7 @@ export interface UpdateUserDto {
|
||||
* @type {string}
|
||||
* @memberof UpdateUserDto
|
||||
*/
|
||||
'id': string;
|
||||
'storageLabel'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@ -2579,6 +2591,12 @@ export interface UserResponseDto {
|
||||
* @memberof UserResponseDto
|
||||
*/
|
||||
'lastName': string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof UserResponseDto
|
||||
*/
|
||||
'storageLabel': string | null;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
|
@ -2,15 +2,24 @@
|
||||
import { api, UserResponseDto } from '@api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const deleteUser = async () => {
|
||||
const deletedUser = await api.userApi.deleteUser(user.id);
|
||||
if (deletedUser.data.deletedAt != null) dispatch('user-delete-success');
|
||||
else dispatch('user-delete-fail');
|
||||
try {
|
||||
const deletedUser = await api.userApi.deleteUser(user.id);
|
||||
if (deletedUser.data.deletedAt != null) {
|
||||
dispatch('user-delete-success');
|
||||
} else {
|
||||
dispatch('user-delete-fail');
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to delete user');
|
||||
dispatch('user-delete-fail');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -171,14 +171,14 @@
|
||||
</p>
|
||||
|
||||
<p class="text-xs">
|
||||
{user.id} is the user's ID
|
||||
<code>{user.storageLabel || user.id}</code> is the user's Storage Label
|
||||
</p>
|
||||
|
||||
<p
|
||||
class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2"
|
||||
>
|
||||
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
|
||||
>UPLOAD_LOCATION/{user.id}</span
|
||||
>UPLOAD_LOCATION/{user.storageLabel || user.id}</span
|
||||
>/{parsedTemplate()}.jpg
|
||||
</p>
|
||||
|
||||
|
@ -21,8 +21,8 @@
|
||||
await getSharedLinks();
|
||||
const { data } = await api.userApi.getAllUsers(false);
|
||||
|
||||
// remove soft deleted users
|
||||
users = data.filter((user) => !user.deletedAt);
|
||||
// remove invalid users
|
||||
users = data.filter((user) => !(user.deletedAt || user.id === album.ownerId));
|
||||
|
||||
// Remove the existed shared users from the album
|
||||
sharedUsersInAlbum.forEach((sharedUser) => {
|
||||
|
@ -7,8 +7,10 @@
|
||||
NotificationType
|
||||
} from '../shared-components/notification/notification';
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import { handleError } from '../../utils/handle-error';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
export let canResetPassword = true;
|
||||
|
||||
let error: string;
|
||||
let success: string;
|
||||
@ -17,18 +19,20 @@
|
||||
|
||||
const editUser = async () => {
|
||||
try {
|
||||
const { id, email, firstName, lastName } = user;
|
||||
const { status } = await api.userApi.updateUser({ id, email, firstName, lastName });
|
||||
const { id, email, firstName, lastName, storageLabel } = user;
|
||||
const { status } = await api.userApi.updateUser({
|
||||
id,
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
storageLabel: storageLabel || ''
|
||||
});
|
||||
|
||||
if (status === 200) {
|
||||
dispatch('edit-success');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error updating user ', e);
|
||||
notificationController.show({
|
||||
message: 'Error updating user, check console for more details',
|
||||
type: NotificationType.Error
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, 'Unable to update user');
|
||||
}
|
||||
};
|
||||
|
||||
@ -105,6 +109,24 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="m-4 flex flex-col gap-2">
|
||||
<label class="immich-form-label" for="storage-label">Storage Label</label>
|
||||
<input
|
||||
class="immich-form-input"
|
||||
id="storage-label"
|
||||
name="storage-label"
|
||||
type="text"
|
||||
bind:value={user.storageLabel}
|
||||
/>
|
||||
|
||||
<p>
|
||||
Note: To apply the Storage Label to previously uploaded assets, run the <a
|
||||
href="/admin/jobs-status"
|
||||
class="text-immich-primary dark:text-immich-dark-primary">Storage Migration Job</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-red-400 ml-4 text-sm">{error}</p>
|
||||
{/if}
|
||||
@ -113,7 +135,9 @@
|
||||
<p class="text-immich-primary ml-4 text-sm">{success}</p>
|
||||
{/if}
|
||||
<div class="flex w-full px-4 gap-4 mt-8">
|
||||
<Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button>
|
||||
{#if canResetPassword}
|
||||
<Button color="light-red" fullwidth on:click={resetPassword}>Reset password</Button>
|
||||
{/if}
|
||||
<Button type="submit" fullwidth>Confirm</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -6,6 +6,8 @@
|
||||
import Button from '../elements/buttons/button.svelte';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
|
||||
let availableUsers: UserResponseDto[] = [];
|
||||
let selectedUsers: UserResponseDto[] = [];
|
||||
|
||||
@ -15,8 +17,8 @@
|
||||
// TODO: update endpoint to have a query param for deleted users
|
||||
let { data: users } = await api.userApi.getAllUsers(false);
|
||||
|
||||
// remove soft deleted users
|
||||
users = users.filter((user) => !user.deletedAt);
|
||||
// remove invalid users
|
||||
users = users.filter((_user) => !(_user.deletedAt || _user.id === user.id));
|
||||
|
||||
// exclude partners from the list of users available for selection
|
||||
const { data: partners } = await api.partnerApi.getPartners('shared-by');
|
||||
|
@ -9,6 +9,8 @@
|
||||
import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
|
||||
export let user: UserResponseDto;
|
||||
|
||||
let partners: UserResponseDto[] = [];
|
||||
let createPartner = false;
|
||||
let removePartner: UserResponseDto | null = null;
|
||||
@ -83,6 +85,7 @@
|
||||
|
||||
{#if createPartner}
|
||||
<PartnerSelectionModal
|
||||
{user}
|
||||
on:close={() => (createPartner = false)}
|
||||
on:add-users={(event) => handleCreatePartners(event.detail)}
|
||||
/>
|
||||
|
@ -65,6 +65,14 @@
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label="STORAGE LABEL"
|
||||
disabled={true}
|
||||
value={user.storageLabel || ''}
|
||||
required={false}
|
||||
/>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" size="sm" on:click={() => handleSaveProfile()}>Save</Button>
|
||||
</div>
|
||||
|
@ -54,5 +54,5 @@
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion title="Sharing" subtitle="Manage sharing with partners">
|
||||
<PartnerSettings />
|
||||
<PartnerSettings {user} />
|
||||
</SettingAccordion>
|
||||
|
@ -13,6 +13,9 @@
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import Button from '$lib/components/elements/buttons/button.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let allUsers: UserResponseDto[] = [];
|
||||
let shouldShowEditUserForm = false;
|
||||
@ -113,6 +116,7 @@
|
||||
<FullScreenModal on:clickOutside={() => (shouldShowEditUserForm = false)}>
|
||||
<EditUserForm
|
||||
user={selectedUser}
|
||||
canResetPassword={selectedUser?.id !== data.user.id}
|
||||
on:edit-success={onEditUserSuccess}
|
||||
on:reset-password-success={onEditPasswordSuccess}
|
||||
/>
|
||||
@ -195,12 +199,14 @@
|
||||
>
|
||||
<PencilOutline size="16" />
|
||||
</button>
|
||||
<button
|
||||
on:click={() => deleteUserHandler(user)}
|
||||
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
|
||||
>
|
||||
<TrashCanOutline size="16" />
|
||||
</button>
|
||||
{#if user.id !== data.user.id}
|
||||
<button
|
||||
on:click={() => deleteUserHandler(user)}
|
||||
class="bg-immich-primary dark:bg-immich-dark-primary text-gray-100 dark:text-gray-700 rounded-full p-3 transition-all duration-150 hover:bg-immich-primary/75"
|
||||
>
|
||||
<TrashCanOutline size="16" />
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if isDeleted(user)}
|
||||
<button
|
||||
|
Loading…
Reference in New Issue
Block a user