1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-12 15:32:36 +02:00

feat(server): support for read-only assets and importing existing items in the filesystem (#2715)

* Added read-only flag for assets, endpoint to trigger file import vs upload

* updated fixtures with new property

* if upload is 'read-only', ensure there is no existing asset at the designated originalPath

* added test for file import as well as detecting existing image at read-only destination location

* Added storage service test for a case where it should not move read-only assets

* upload doesn't need the read-only flag available, just importing

* default isReadOnly on import endpoint to true

* formatting fixes

* create-asset dto needs isReadOnly, so set it to false by default on create, updated api generation

* updated code to reflect changes in MR

* fixed read stream promise return type

* new index for originalPath, check for existing path on import, reglardless of user, to prevent duplicates

* refactor: import asset

* chore: open api

* chore: tests

* Added externalPath support for individual users, updated UI to allow this to be set by admin

* added missing var for externalPath in ui

* chore: open api

* fix: compilation issues

* fix: server test

* built api, fixed user-response dto to include externalPath

* reverted accidental commit

* bad commit of duplicate externalPath in user response  dto

* fixed tests to include externalPath on expected result

* fix: unit tests

* centralized supported filetypes, perform file type checking of asset and sidecar during file import process

* centralized supported filetype check method to keep regex DRY

* fixed typo

* combined migrations into one

* update api

* Removed externalPath from shared-link code, added column to admin user page whether external paths / import is enabled or not

* update mimetype

* Fixed detect correct mimetype

* revert asset-upload config

* reverted domain.constant

* refactor

* fix mime-type issue

* fix format

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Alex Phillips 2023-06-21 22:33:20 -04:00 committed by GitHub
parent 7f44d508dc
commit e171fec5aa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 779 additions and 107 deletions

View File

@ -49,6 +49,7 @@ doc/DownloadFilesDto.md
doc/ExifResponseDto.md doc/ExifResponseDto.md
doc/GetAssetByTimeBucketDto.md doc/GetAssetByTimeBucketDto.md
doc/GetAssetCountByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md
doc/ImportAssetDto.md
doc/JobApi.md doc/JobApi.md
doc/JobCommand.md doc/JobCommand.md
doc/JobCommandDto.md doc/JobCommandDto.md
@ -181,6 +182,7 @@ lib/model/download_files_dto.dart
lib/model/exif_response_dto.dart lib/model/exif_response_dto.dart
lib/model/get_asset_by_time_bucket_dto.dart lib/model/get_asset_by_time_bucket_dto.dart
lib/model/get_asset_count_by_time_bucket_dto.dart lib/model/get_asset_count_by_time_bucket_dto.dart
lib/model/import_asset_dto.dart
lib/model/job_command.dart lib/model/job_command.dart
lib/model/job_command_dto.dart lib/model/job_command_dto.dart
lib/model/job_counts_dto.dart lib/model/job_counts_dto.dart
@ -284,6 +286,7 @@ test/download_files_dto_test.dart
test/exif_response_dto_test.dart test/exif_response_dto_test.dart
test/get_asset_by_time_bucket_dto_test.dart test/get_asset_by_time_bucket_dto_test.dart
test/get_asset_count_by_time_bucket_dto_test.dart test/get_asset_count_by_time_bucket_dto_test.dart
test/import_asset_dto_test.dart
test/job_api_test.dart test/job_api_test.dart
test/job_command_dto_test.dart test/job_command_dto_test.dart
test/job_command_test.dart test/job_command_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/ImportAssetDto.md generated Normal file

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

@ -105,6 +105,7 @@ describe('User', () => {
updatedAt: expect.anything(), updatedAt: expect.anything(),
oauthId: '', oauthId: '',
storageLabel: null, storageLabel: null,
externalPath: null,
}, },
{ {
email: userTwoEmail, email: userTwoEmail,
@ -119,6 +120,7 @@ describe('User', () => {
updatedAt: expect.anything(), updatedAt: expect.anything(),
oauthId: '', oauthId: '',
storageLabel: null, storageLabel: null,
externalPath: null,
}, },
{ {
email: authUserEmail, email: authUserEmail,
@ -133,6 +135,7 @@ describe('User', () => {
updatedAt: expect.anything(), updatedAt: expect.anything(),
oauthId: '', oauthId: '',
storageLabel: 'admin', storageLabel: 'admin',
externalPath: null,
}, },
]), ]),
); );

View File

@ -1430,6 +1430,48 @@
] ]
} }
}, },
"/asset/import": {
"post": {
"operationId": "importFile",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ImportAssetDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetFileUploadResponseDto"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/map-marker": { "/asset/map-marker": {
"get": { "get": {
"operationId": "getMapMarkers", "operationId": "getMapMarkers",
@ -5085,6 +5127,13 @@
"type": "string", "type": "string",
"format": "binary" "format": "binary"
}, },
"isReadOnly": {
"type": "boolean",
"default": false
},
"fileExtension": {
"type": "string"
},
"deviceAssetId": { "deviceAssetId": {
"type": "string" "type": "string"
}, },
@ -5108,9 +5157,6 @@
"isVisible": { "isVisible": {
"type": "boolean" "type": "boolean"
}, },
"fileExtension": {
"type": "string"
},
"duration": { "duration": {
"type": "string" "type": "string"
} }
@ -5118,12 +5164,12 @@
"required": [ "required": [
"assetType", "assetType",
"assetData", "assetData",
"fileExtension",
"deviceAssetId", "deviceAssetId",
"deviceId", "deviceId",
"fileCreatedAt", "fileCreatedAt",
"fileModifiedAt", "fileModifiedAt",
"isFavorite", "isFavorite"
"fileExtension"
] ]
}, },
"CreateProfileImageDto": { "CreateProfileImageDto": {
@ -5186,6 +5232,10 @@
"storageLabel": { "storageLabel": {
"type": "string", "type": "string",
"nullable": true "nullable": true
},
"externalPath": {
"type": "string",
"nullable": true
} }
}, },
"required": [ "required": [
@ -5461,6 +5511,59 @@
"timeGroup" "timeGroup"
] ]
}, },
"ImportAssetDto": {
"type": "object",
"properties": {
"assetType": {
"$ref": "#/components/schemas/AssetTypeEnum"
},
"isReadOnly": {
"type": "boolean",
"default": true
},
"assetPath": {
"type": "string"
},
"sidecarPath": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
"deviceId": {
"type": "string"
},
"fileCreatedAt": {
"format": "date-time",
"type": "string"
},
"fileModifiedAt": {
"format": "date-time",
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isArchived": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"duration": {
"type": "string"
}
},
"required": [
"assetType",
"assetPath",
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt",
"isFavorite"
]
},
"JobCommand": { "JobCommand": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -6592,6 +6695,9 @@
"storageLabel": { "storageLabel": {
"type": "string" "type": "string"
}, },
"externalPath": {
"type": "string"
},
"isAdmin": { "isAdmin": {
"type": "boolean" "type": "boolean"
}, },
@ -6665,6 +6771,10 @@
"type": "string", "type": "string",
"nullable": true "nullable": true
}, },
"externalPath": {
"type": "string",
"nullable": true
},
"profileImagePath": { "profileImagePath": {
"type": "string" "type": "string"
}, },
@ -6697,6 +6807,7 @@
"firstName", "firstName",
"lastName", "lastName",
"storageLabel", "storageLabel",
"externalPath",
"profileImagePath", "profileImagePath",
"shouldChangePassword", "shouldChangePassword",
"isAdmin", "isAdmin",

View File

@ -21,6 +21,7 @@
"@nestjs/typeorm": "^9.0.1", "@nestjs/typeorm": "^9.0.1",
"@nestjs/websockets": "^9.2.1", "@nestjs/websockets": "^9.2.1",
"@socket.io/redis-adapter": "^8.0.1", "@socket.io/redis-adapter": "^8.0.1",
"@types/mime-types": "^2.1.1",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^0.26.0", "axios": "^0.26.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
@ -38,6 +39,7 @@
"local-reverse-geocoder": "0.12.5", "local-reverse-geocoder": "0.12.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.0.3", "luxon": "^3.0.3",
"mime-types": "^2.1.35",
"mv": "^2.1.1", "mv": "^2.1.1",
"nest-commander": "^3.3.0", "nest-commander": "^3.3.0",
"openid-client": "^5.2.1", "openid-client": "^5.2.1",
@ -3018,6 +3020,11 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true "dev": true
}, },
"node_modules/@types/mime-types": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
},
"node_modules/@types/multer": { "node_modules/@types/multer": {
"version": "1.4.7", "version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
@ -14296,6 +14303,11 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true "dev": true
}, },
"@types/mime-types": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
"integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
},
"@types/multer": { "@types/multer": {
"version": "1.4.7", "version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",

View File

@ -50,6 +50,7 @@
"@nestjs/typeorm": "^9.0.1", "@nestjs/typeorm": "^9.0.1",
"@nestjs/websockets": "^9.2.1", "@nestjs/websockets": "^9.2.1",
"@socket.io/redis-adapter": "^8.0.1", "@socket.io/redis-adapter": "^8.0.1",
"@types/mime-types": "^2.1.1",
"archiver": "^5.3.1", "archiver": "^5.3.1",
"axios": "^0.26.0", "axios": "^0.26.0",
"bcrypt": "^5.0.1", "bcrypt": "^5.0.1",
@ -67,6 +68,7 @@
"local-reverse-geocoder": "0.12.5", "local-reverse-geocoder": "0.12.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.0.3", "luxon": "^3.0.3",
"mime-types": "^2.1.35",
"mv": "^2.1.1", "mv": "^2.1.1",
"nest-commander": "^3.3.0", "nest-commander": "^3.3.0",
"openid-client": "^5.2.1", "openid-client": "^5.2.1",

View File

@ -169,6 +169,7 @@ describe(AlbumService.name, () => {
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
deletedAt: null, deletedAt: null,
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
externalPath: null,
}, },
ownerId: 'admin_id', ownerId: 'admin_id',
shared: false, shared: false,

View File

@ -19,6 +19,7 @@ export class APIKeyCore {
isAdmin: user.isAdmin, isAdmin: user.isAdmin,
isPublicUser: false, isPublicUser: false,
isAllowUpload: true, isAllowUpload: true,
externalPath: user.externalPath,
}; };
} }

View File

@ -8,4 +8,5 @@ export class AuthUserDto {
isAllowDownload?: boolean; isAllowDownload?: boolean;
isShowExif?: boolean; isShowExif?: boolean;
accessTokenId?: string; accessTokenId?: string;
externalPath?: string | null;
} }

View File

@ -2,6 +2,7 @@ export const ICryptoRepository = 'ICryptoRepository';
export interface ICryptoRepository { export interface ICryptoRepository {
randomBytes(size: number): Buffer; randomBytes(size: number): Buffer;
hashFile(filePath: string): Promise<Buffer>;
hashSha256(data: string): string; hashSha256(data: string): string;
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>; hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
compareBcrypt(data: string | Buffer, encrypted: string): boolean; compareBcrypt(data: string | Buffer, encrypted: string): boolean;

View File

@ -27,3 +27,60 @@ export function assertMachineLearningEnabled() {
throw new BadRequestException('Machine learning is not enabled.'); throw new BadRequestException('Machine learning is not enabled.');
} }
} }
const validMimeTypes = [
'image/avif',
'image/gif',
'image/heic',
'image/heif',
'image/jpeg',
'image/jxl',
'image/png',
'image/tiff',
'image/webp',
'image/x-adobe-dng',
'image/x-arriflex-ari',
'image/x-canon-cr2',
'image/x-canon-cr3',
'image/x-canon-crw',
'image/x-epson-erf',
'image/x-fuji-raf',
'image/x-hasselblad-3fr',
'image/x-hasselblad-fff',
'image/x-kodak-dcr',
'image/x-kodak-k25',
'image/x-kodak-kdc',
'image/x-leica-rwl',
'image/x-minolta-mrw',
'image/x-nikon-nef',
'image/x-olympus-orf',
'image/x-olympus-ori',
'image/x-panasonic-raw',
'image/x-pentax-pef',
'image/x-phantom-cin',
'image/x-phaseone-cap',
'image/x-phaseone-iiq',
'image/x-samsung-srw',
'image/x-sigma-x3f',
'image/x-sony-arw',
'image/x-sony-sr2',
'image/x-sony-srf',
'video/3gpp',
'video/mp2t',
'video/mp4',
'video/mpeg',
'video/quicktime',
'video/webm',
'video/x-flv',
'video/x-matroska',
'video/x-ms-wmv',
'video/x-msvideo',
];
export function isSupportedFileType(mimetype: string): boolean {
return validMimeTypes.includes(mimetype);
}
export function isSidecarFileType(mimeType: string): boolean {
return ['application/xml', 'text/xml'].includes(mimeType);
}

View File

@ -17,6 +17,7 @@ const responseDto = {
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
deletedAt: null, deletedAt: null,
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
externalPath: null,
}, },
user1: { user1: {
email: 'immich@test.com', email: 'immich@test.com',
@ -31,6 +32,7 @@ const responseDto = {
createdAt: new Date('2021-01-01'), createdAt: new Date('2021-01-01'),
deletedAt: null, deletedAt: null,
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
externalPath: null,
}, },
}; };

View File

@ -194,5 +194,26 @@ describe(StorageTemplateService.name, () => {
['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'], ['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'],
]); ]);
}); });
it('should not move read-only asset', async () => {
assetMock.getAll.mockResolvedValue({
items: [
{
...assetEntityStub.image,
originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
isReadOnly: true,
},
],
hasNextPage: false,
});
assetMock.save.mockResolvedValue(assetEntityStub.image);
userMock.getList.mockResolvedValue([userEntityStub.user1]);
await sut.handleMigration();
expect(assetMock.getAll).toHaveBeenCalled();
expect(storageMock.moveFile).not.toHaveBeenCalled();
expect(assetMock.save).not.toHaveBeenCalled();
});
}); });
}); });

View File

@ -76,6 +76,11 @@ export class StorageTemplateService {
// TODO: use asset core (once in domain) // TODO: use asset core (once in domain)
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
if (asset.isReadOnly) {
this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`);
return;
}
const destination = await this.core.getTemplatePath(asset, metadata); const destination = await this.core.getTemplatePath(asset, metadata);
if (asset.originalPath !== destination) { if (asset.originalPath !== destination) {
const source = asset.originalPath; const source = asset.originalPath;

View File

@ -23,6 +23,10 @@ export class CreateUserDto {
@IsString() @IsString()
@Transform(toSanitized) @Transform(toSanitized)
storageLabel?: string | null; storageLabel?: string | null;
@IsOptional()
@IsString()
externalPath?: string | null;
} }
export class CreateAdminDto { export class CreateAdminDto {

View File

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

View File

@ -6,6 +6,7 @@ export class UserResponseDto {
firstName!: string; firstName!: string;
lastName!: string; lastName!: string;
storageLabel!: string | null; storageLabel!: string | null;
externalPath!: string | null;
profileImagePath!: string; profileImagePath!: string;
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
isAdmin!: boolean; isAdmin!: boolean;
@ -22,6 +23,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
firstName: entity.firstName, firstName: entity.firstName,
lastName: entity.lastName, lastName: entity.lastName,
storageLabel: entity.storageLabel, storageLabel: entity.storageLabel,
externalPath: entity.externalPath,
profileImagePath: entity.profileImagePath, profileImagePath: entity.profileImagePath,
shouldChangePassword: entity.shouldChangePassword, shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin, isAdmin: entity.isAdmin,

View File

@ -6,7 +6,6 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { hash } from 'bcrypt';
import { constants, createReadStream, ReadStream } from 'fs'; import { constants, createReadStream, ReadStream } from 'fs';
import fs from 'fs/promises'; import fs from 'fs/promises';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
@ -28,6 +27,7 @@ export class UserCore {
// Users can never update the isAdmin property. // Users can never update the isAdmin property.
delete dto.isAdmin; delete dto.isAdmin;
delete dto.storageLabel; delete dto.storageLabel;
delete dto.externalPath;
} else if (dto.isAdmin && authUser.id !== id) { } else if (dto.isAdmin && authUser.id !== id) {
// Admin cannot create another admin. // Admin cannot create another admin.
throw new BadRequestException('The server already has an admin'); throw new BadRequestException('The server already has an admin');
@ -56,6 +56,10 @@ export class UserCore {
dto.storageLabel = null; dto.storageLabel = null;
} }
if (dto.externalPath === '') {
dto.externalPath = null;
}
return this.userRepository.update(id, dto); return this.userRepository.update(id, dto);
} catch (e) { } catch (e) {
Logger.error(e, 'Failed to update user info'); Logger.error(e, 'Failed to update user info');
@ -79,7 +83,7 @@ export class UserCore {
try { try {
const payload: Partial<UserEntity> = { ...createUserDto }; const payload: Partial<UserEntity> = { ...createUserDto };
if (payload.password) { if (payload.password) {
payload.password = await hash(payload.password, SALT_ROUNDS); payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
} }
return this.userRepository.create(payload); return this.userRepository.create(payload);
} catch (e) { } catch (e) {

View File

@ -53,6 +53,7 @@ const adminUser: UserEntity = Object.freeze({
tags: [], tags: [],
assets: [], assets: [],
storageLabel: 'admin', storageLabel: 'admin',
externalPath: null,
}); });
const immichUser: UserEntity = Object.freeze({ const immichUser: UserEntity = Object.freeze({
@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({
tags: [], tags: [],
assets: [], assets: [],
storageLabel: null, storageLabel: null,
externalPath: null,
}); });
const updatedImmichUser: UserEntity = Object.freeze({ const updatedImmichUser: UserEntity = Object.freeze({
@ -89,6 +91,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
tags: [], tags: [],
assets: [], assets: [],
storageLabel: null, storageLabel: null,
externalPath: null,
}); });
const adminUserResponse = Object.freeze({ const adminUserResponse = Object.freeze({
@ -104,6 +107,7 @@ const adminUserResponse = Object.freeze({
deletedAt: null, deletedAt: null,
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
storageLabel: 'admin', storageLabel: 'admin',
externalPath: null,
}); });
describe(UserService.name, () => { describe(UserService.name, () => {
@ -153,6 +157,7 @@ describe(UserService.name, () => {
deletedAt: null, deletedAt: null,
updatedAt: new Date('2021-01-01'), updatedAt: new Date('2021-01-01'),
storageLabel: 'admin', storageLabel: 'admin',
externalPath: null,
}, },
]); ]);
}); });

View File

@ -32,6 +32,7 @@ describe('Album service', () => {
tags: [], tags: [],
assets: [], assets: [],
storageLabel: null, storageLabel: null,
externalPath: null,
}); });
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
const sharedAlbumOwnerId = '2222'; const sharedAlbumOwnerId = '2222';

View File

@ -20,6 +20,10 @@ export interface AssetCheck {
checksum: Buffer; checksum: Buffer;
} }
export interface AssetOwnerCheck extends AssetCheck {
ownerId: string;
}
export interface IAssetRepository { export interface IAssetRepository {
get(id: string): Promise<AssetEntity | null>; get(id: string): Promise<AssetEntity | null>;
create( create(
@ -39,6 +43,7 @@ export interface IAssetRepository {
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>; getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>; getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>; getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
} }
export const IAssetRepository = 'IAssetRepository'; export const IAssetRepository = 'IAssetRepository';
@ -350,4 +355,17 @@ export class AssetRepository implements IAssetRepository {
return assetCountByUserId; return assetCountByUserId;
} }
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null> {
return this.assetRepository.findOne({
select: {
id: true,
ownerId: true,
checksum: true,
},
where: {
originalPath,
},
});
}
} }

View File

@ -33,7 +33,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto'; import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DeviceIdDto } from './dto/device-id.dto'; import { DeviceIdDto } from './dto/device-id.dto';
import { DownloadFilesDto } from './dto/download-files.dto'; import { DownloadFilesDto } from './dto/download-files.dto';
@ -114,6 +114,20 @@ export class AssetController {
return responseDto; return responseDto;
} }
@Post('import')
async importFile(
@AuthUser() authUser: AuthUserDto,
@Body(new ValidationPipe()) dto: ImportAssetDto,
@Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> {
const responseDto = await this.assetService.importFile(authUser, dto);
if (responseDto.duplicate) {
res.status(200);
}
return responseDto;
}
@SharedLinkRoute() @SharedLinkRoute()
@Get('/download/:id') @Get('/download/:id')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })

View File

@ -2,17 +2,17 @@ import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities'; import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
import { parse } from 'node:path'; import { parse } from 'node:path';
import { IAssetRepository } from './asset-repository'; import { IAssetRepository } from './asset-repository';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
export class AssetCore { export class AssetCore {
constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {} constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
async create( async create(
authUser: AuthUserDto, authUser: AuthUserDto,
dto: CreateAssetDto, dto: CreateAssetDto | ImportAssetDto,
file: UploadFile, file: UploadFile,
livePhotoAssetId?: string, livePhotoAssetId?: string,
sidecarFile?: UploadFile, sidecarPath?: string,
): Promise<AssetEntity> { ): Promise<AssetEntity> {
const asset = await this.repository.create({ const asset = await this.repository.create({
owner: { id: authUser.id } as UserEntity, owner: { id: authUser.id } as UserEntity,
@ -41,7 +41,8 @@ export class AssetCore {
sharedLinks: [], sharedLinks: [],
originalFileName: parse(file.originalName).name, originalFileName: parse(file.originalName).name,
faces: [], faces: [],
sidecarPath: sidecarFile?.originalPath || null, sidecarPath: sidecarPath || null,
isReadOnly: dto.isReadOnly ?? false,
}); });
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });

View File

@ -1,4 +1,4 @@
import { IAccessRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain'; import { IAccessRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { ForbiddenException } from '@nestjs/common'; import { ForbiddenException } from '@nestjs/common';
import { import {
@ -6,6 +6,7 @@ import {
authStub, authStub,
fileStub, fileStub,
newAccessRepositoryMock, newAccessRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
} from '@test'; } from '@test';
@ -121,6 +122,7 @@ describe('AssetService', () => {
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: jest.Mocked<IAccessRepository>; let accessMock: jest.Mocked<IAccessRepository>;
let assetRepositoryMock: jest.Mocked<IAssetRepository>; let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>; let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
@ -144,13 +146,17 @@ describe('AssetService', () => {
getAssetCountByUserId: jest.fn(), getAssetCountByUserId: jest.fn(),
getArchivedAssetCountByUserId: jest.fn(), getArchivedAssetCountByUserId: jest.fn(),
getExistingAssets: jest.fn(), getExistingAssets: jest.fn(),
getByOriginalPath: jest.fn(),
}; };
cryptoMock = newCryptoRepositoryMock();
downloadServiceMock = { downloadServiceMock = {
downloadArchive: jest.fn(), downloadArchive: jest.fn(),
}; };
accessMock = newAccessRepositoryMock(); accessMock = newAccessRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
@ -158,6 +164,7 @@ describe('AssetService', () => {
accessMock, accessMock,
assetRepositoryMock, assetRepositoryMock,
a, a,
cryptoMock,
downloadServiceMock as DownloadService, downloadServiceMock as DownloadService,
jobMock, jobMock,
storageMock, storageMock,
@ -439,6 +446,43 @@ describe('AssetService', () => {
}); });
}); });
describe('importFile', () => {
it('should handle a file import', async () => {
assetRepositoryMock.create.mockResolvedValue(assetEntityStub.image);
storageMock.checkFileExists.mockResolvedValue(true);
await expect(
sut.importFile(authStub.external1, {
..._getCreateAssetDto(),
assetPath: '/data/user1/fake_path/asset_1.jpeg',
isReadOnly: true,
}),
).resolves.toEqual({ duplicate: false, id: 'asset-id' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
it('should handle a duplicate if originalPath already exists', async () => {
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetEntityStub.image]);
storageMock.checkFileExists.mockResolvedValue(true);
cryptoMock.hashFile.mockResolvedValue(Buffer.from('file hash', 'utf8'));
await expect(
sut.importFile(authStub.external1, {
..._getCreateAssetDto(),
assetPath: '/data/user1/fake_path/asset_1.jpeg',
isReadOnly: true,
}),
).resolves.toEqual({ duplicate: true, id: 'asset-id' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
});
describe('getAssetById', () => { describe('getAssetById', () => {
it('should allow owner access', async () => { it('should allow owner access', async () => {
accessMock.hasOwnerAssetAccess.mockResolvedValue(true); accessMock.hasOwnerAssetAccess.mockResolvedValue(true);

View File

@ -1,9 +1,13 @@
import { import {
AssetResponseDto, AssetResponseDto,
AuthUserDto,
getLivePhotoMotionFilename, getLivePhotoMotionFilename,
IAccessRepository, IAccessRepository,
ICryptoRepository,
IJobRepository, IJobRepository,
ImmichReadStream, ImmichReadStream,
isSidecarFileType,
isSupportedFileType,
IStorageRepository, IStorageRepository,
JobName, JobName,
mapAsset, mapAsset,
@ -21,12 +25,14 @@ import {
StreamableFile, StreamableFile,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { R_OK, W_OK } from 'constants';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { constants, createReadStream, stat } from 'fs'; import { createReadStream, stat } from 'fs';
import fs from 'fs/promises'; import fs from 'fs/promises';
import mime from 'mime-types';
import path from 'path';
import { QueryFailedError, Repository } from 'typeorm'; import { QueryFailedError, Repository } from 'typeorm';
import { promisify } from 'util'; import { promisify } from 'util';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service'; import { DownloadService } from '../../modules/download/download.service';
import { IAssetRepository } from './asset-repository'; import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core'; import { AssetCore } from './asset.core';
@ -34,7 +40,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
import { AssetSearchDto } from './dto/asset-search.dto'; import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DownloadFilesDto } from './dto/download-files.dto'; import { DownloadFilesDto } from './dto/download-files.dto';
import { DownloadDto } from './dto/download-library.dto'; import { DownloadDto } from './dto/download-library.dto';
@ -78,6 +84,7 @@ export class AssetService {
@Inject(IAccessRepository) private accessRepository: IAccessRepository, @Inject(IAccessRepository) private accessRepository: IAccessRepository,
@Inject(IAssetRepository) private _assetRepository: IAssetRepository, @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
private downloadService: DownloadService, private downloadService: DownloadService,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@ -107,7 +114,7 @@ export class AssetService {
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile); livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
} }
const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile); const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath);
return { id: asset.id, duplicate: false }; return { id: asset.id, duplicate: false };
} catch (error: any) { } catch (error: any) {
@ -129,6 +136,73 @@ export class AssetService {
} }
} }
public async importFile(authUser: AuthUserDto, dto: ImportAssetDto): Promise<AssetFileUploadResponseDto> {
dto = {
...dto,
assetPath: path.resolve(dto.assetPath),
sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined,
};
const assetPathType = mime.lookup(dto.assetPath) as string;
if (!isSupportedFileType(assetPathType)) {
throw new BadRequestException(`Unsupported file type ${assetPathType}`);
}
if (dto.sidecarPath) {
const sidecarType = mime.lookup(dto.sidecarPath) as string;
if (!isSidecarFileType(sidecarType)) {
throw new BadRequestException(`Unsupported sidecar file type ${assetPathType}`);
}
}
for (const filepath of [dto.assetPath, dto.sidecarPath]) {
if (!filepath) {
continue;
}
const exists = await this.storageRepository.checkFileExists(filepath, R_OK);
if (!exists) {
throw new BadRequestException('File does not exist');
}
}
if (!authUser.externalPath || !dto.assetPath.match(new RegExp(`^${authUser.externalPath}`))) {
throw new BadRequestException("File does not exist within user's external path");
}
const assetFile: UploadFile = {
checksum: await this.cryptoRepository.hashFile(dto.assetPath),
mimeType: assetPathType,
originalPath: dto.assetPath,
originalName: path.parse(dto.assetPath).name,
};
try {
const asset = await this.assetCore.create(authUser, dto, assetFile, undefined, dto.sidecarPath);
return { id: asset.id, duplicate: false };
} catch (error: QueryFailedError | Error | any) {
// handle duplicates with a success response
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, [assetFile.checksum]);
return { id: duplicate.id, duplicate: true };
}
if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_4ed4f8052685ff5b1e7ca1058ba') {
const duplicate = await this._assetRepository.getByOriginalPath(dto.assetPath);
if (duplicate) {
if (duplicate.ownerId === authUser.id) {
return { id: duplicate.id, duplicate: true };
}
throw new BadRequestException('Path in use by another user');
}
}
this.logger.error(`Error importing file ${error}`, error?.stack);
throw new BadRequestException(`Error importing file`, `${error}`);
}
}
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) { public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
return this._assetRepository.getAllByDeviceId(authUser.id, deviceId); return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
} }
@ -291,7 +365,7 @@ export class AssetService {
let videoPath = asset.originalPath; let videoPath = asset.originalPath;
let mimeType = asset.mimeType; let mimeType = asset.mimeType;
await fs.access(videoPath, constants.R_OK | constants.W_OK); await fs.access(videoPath, R_OK | W_OK);
if (asset.encodedVideoPath) { if (asset.encodedVideoPath) {
videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath); videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
@ -373,13 +447,16 @@ export class AssetService {
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } }); await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
deleteQueue.push(
asset.originalPath, if (!asset.isReadOnly) {
asset.webpPath, deleteQueue.push(
asset.resizePath, asset.originalPath,
asset.encodedVideoPath, asset.webpPath,
asset.sidecarPath, asset.resizePath,
); asset.encodedVideoPath,
asset.sidecarPath,
);
}
// TODO refactor this to use cascades // TODO refactor this to use cascades
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) { if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
@ -665,7 +742,7 @@ export class AssetService {
return; return;
} }
await fs.access(filepath, constants.R_OK); await fs.access(filepath, R_OK);
return new StreamableFile(createReadStream(filepath)); return new StreamableFile(createReadStream(filepath));
} }

View File

@ -1,9 +1,11 @@
import { AssetType } from '@app/infra/entities'; import { AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; import { Transform } from 'class-transformer';
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ImmichFile } from '../../../config/asset-upload.config'; import { ImmichFile } from '../../../config/asset-upload.config';
import { toSanitized } from '../../../utils/transform.util';
export class CreateAssetDto { export class CreateAssetBase {
@IsNotEmpty() @IsNotEmpty()
deviceAssetId!: string; deviceAssetId!: string;
@ -32,11 +34,17 @@ export class CreateAssetDto {
@IsBoolean() @IsBoolean()
isVisible?: boolean; isVisible?: boolean;
@IsNotEmpty()
fileExtension!: string;
@IsOptional() @IsOptional()
duration?: string; duration?: string;
}
export class CreateAssetDto extends CreateAssetBase {
@IsOptional()
@IsBoolean()
isReadOnly?: boolean = false;
@IsNotEmpty()
fileExtension!: string;
// The properties below are added to correctly generate the API docs // The properties below are added to correctly generate the API docs
// and client SDKs. Validation should be handled in the controller. // and client SDKs. Validation should be handled in the controller.
@ -50,6 +58,23 @@ export class CreateAssetDto {
sidecarData?: any; sidecarData?: any;
} }
export class ImportAssetDto extends CreateAssetBase {
@IsOptional()
@IsBoolean()
isReadOnly?: boolean = true;
@IsString()
@IsNotEmpty()
@Transform(toSanitized)
assetPath!: string;
@IsString()
@IsOptional()
@IsNotEmpty()
@Transform(toSanitized)
sidecarPath?: string;
}
export interface UploadFile { export interface UploadFile {
mimeType: string; mimeType: string;
checksum: Buffer; checksum: Buffer;

View File

@ -1,3 +1,4 @@
import { isSidecarFileType, isSupportedFileType } from '@app/domain';
import { StorageCore, StorageFolder } from '@app/domain/storage'; import { StorageCore, StorageFolder } from '@app/domain/storage';
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
@ -49,67 +50,18 @@ export const multerUtils = { fileFilter, filename, destination };
const logger = new Logger('AssetUploadConfig'); const logger = new Logger('AssetUploadConfig');
const validMimeTypes = [
'image/avif',
'image/gif',
'image/heic',
'image/heif',
'image/jpeg',
'image/jxl',
'image/png',
'image/tiff',
'image/webp',
'image/x-adobe-dng',
'image/x-arriflex-ari',
'image/x-canon-cr2',
'image/x-canon-cr3',
'image/x-canon-crw',
'image/x-epson-erf',
'image/x-fuji-raf',
'image/x-hasselblad-3fr',
'image/x-hasselblad-fff',
'image/x-kodak-dcr',
'image/x-kodak-k25',
'image/x-kodak-kdc',
'image/x-leica-rwl',
'image/x-minolta-mrw',
'image/x-nikon-nef',
'image/x-olympus-orf',
'image/x-olympus-ori',
'image/x-panasonic-raw',
'image/x-pentax-pef',
'image/x-phantom-cin',
'image/x-phaseone-cap',
'image/x-phaseone-iiq',
'image/x-samsung-srw',
'image/x-sigma-x3f',
'image/x-sony-arw',
'image/x-sony-sr2',
'image/x-sony-srf',
'video/3gpp',
'video/mp2t',
'video/mp4',
'video/mpeg',
'video/quicktime',
'video/webm',
'video/x-flv',
'video/x-matroska',
'video/x-ms-wmv',
'video/x-msvideo',
];
function fileFilter(req: AuthRequest, file: any, cb: any) { function fileFilter(req: AuthRequest, file: any, cb: any) {
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) { if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
return cb(new UnauthorizedException()); return cb(new UnauthorizedException());
} }
if (validMimeTypes.includes(file.mimetype)) { if (isSupportedFileType(file.mimetype)) {
cb(null, true); cb(null, true);
return; return;
} }
// Additionally support XML but only for sidecar files. // Additionally support XML but only for sidecar files.
if (file.fieldname === 'sidecarData' && ['application/xml', 'text/xml'].includes(file.mimetype)) { if (file.fieldname === 'sidecarData' && isSidecarFileType(file.mimetype)) {
return cb(null, true); return cb(null, true);
} }

View File

@ -42,7 +42,7 @@ export class AssetEntity {
@Column() @Column()
type!: AssetType; type!: AssetType;
@Column() @Column({ unique: true })
originalPath!: string; originalPath!: string;
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
@ -75,6 +75,9 @@ export class AssetEntity {
@Column({ type: 'boolean', default: false }) @Column({ type: 'boolean', default: false })
isArchived!: boolean; isArchived!: boolean;
@Column({ type: 'boolean', default: false })
isReadOnly!: boolean;
@Column({ type: 'varchar', nullable: true }) @Column({ type: 'varchar', nullable: true })
mimeType!: string | null; mimeType!: string | null;

View File

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

View File

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class ImportAsset1686584273471 implements MigrationInterface {
name = 'ImportAsset1686584273471'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "isReadOnly" boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba" UNIQUE ("originalPath")`);
await queryRunner.query(`ALTER TABLE "users" ADD "externalPath" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba"`);
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isReadOnly"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "externalPath"`);
}
}

View File

@ -2,6 +2,7 @@ import { ICryptoRepository } from '@app/domain';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt'; import { compareSync, hash } from 'bcrypt';
import { createHash, randomBytes } from 'crypto'; import { createHash, randomBytes } from 'crypto';
import { createReadStream } from 'fs';
@Injectable() @Injectable()
export class CryptoRepository implements ICryptoRepository { export class CryptoRepository implements ICryptoRepository {
@ -13,4 +14,14 @@ export class CryptoRepository implements ICryptoRepository {
hashSha256(value: string) { hashSha256(value: string) {
return createHash('sha256').update(value).digest('base64'); return createHash('sha256').update(value).digest('base64');
} }
hashFile(filepath: string): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
const hash = createHash('sha1');
const stream = createReadStream(filepath);
stream.on('error', (err) => reject(err));
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest()));
});
}
} }

View File

@ -50,6 +50,7 @@ export const authStub = {
isAdmin: true, isAdmin: true,
isPublicUser: false, isPublicUser: false,
isAllowUpload: true, isAllowUpload: true,
externalPath: null,
}), }),
user1: Object.freeze<AuthUserDto>({ user1: Object.freeze<AuthUserDto>({
id: 'user-id', id: 'user-id',
@ -60,6 +61,7 @@ export const authStub = {
isAllowDownload: true, isAllowDownload: true,
isShowExif: true, isShowExif: true,
accessTokenId: 'token-id', accessTokenId: 'token-id',
externalPath: null,
}), }),
user2: Object.freeze<AuthUserDto>({ user2: Object.freeze<AuthUserDto>({
id: 'user-2', id: 'user-2',
@ -70,6 +72,18 @@ export const authStub = {
isAllowDownload: true, isAllowDownload: true,
isShowExif: true, isShowExif: true,
accessTokenId: 'token-id', accessTokenId: 'token-id',
externalPath: null,
}),
external1: Object.freeze<AuthUserDto>({
id: 'user-id',
email: 'immich@test.com',
isAdmin: false,
isPublicUser: false,
isAllowUpload: true,
isAllowDownload: true,
isShowExif: true,
accessTokenId: 'token-id',
externalPath: '/data/user1',
}), }),
adminSharedLink: Object.freeze<AuthUserDto>({ adminSharedLink: Object.freeze<AuthUserDto>({
id: 'admin_id', id: 'admin_id',
@ -111,6 +125,7 @@ export const userEntityStub = {
firstName: 'admin_first_name', firstName: 'admin_first_name',
lastName: 'admin_last_name', lastName: 'admin_last_name',
storageLabel: 'admin', storageLabel: 'admin',
externalPath: null,
oauthId: '', oauthId: '',
shouldChangePassword: false, shouldChangePassword: false,
profileImagePath: '', profileImagePath: '',
@ -126,6 +141,7 @@ export const userEntityStub = {
firstName: 'immich_first_name', firstName: 'immich_first_name',
lastName: 'immich_last_name', lastName: 'immich_last_name',
storageLabel: null, storageLabel: null,
externalPath: null,
oauthId: '', oauthId: '',
shouldChangePassword: false, shouldChangePassword: false,
profileImagePath: '', profileImagePath: '',
@ -141,6 +157,7 @@ export const userEntityStub = {
firstName: 'immich_first_name', firstName: 'immich_first_name',
lastName: 'immich_last_name', lastName: 'immich_last_name',
storageLabel: null, storageLabel: null,
externalPath: null,
oauthId: '', oauthId: '',
shouldChangePassword: false, shouldChangePassword: false,
profileImagePath: '', profileImagePath: '',
@ -156,6 +173,7 @@ export const userEntityStub = {
firstName: 'immich_first_name', firstName: 'immich_first_name',
lastName: 'immich_last_name', lastName: 'immich_last_name',
storageLabel: 'label-1', storageLabel: 'label-1',
externalPath: null,
oauthId: '', oauthId: '',
shouldChangePassword: false, shouldChangePassword: false,
profileImagePath: '', profileImagePath: '',
@ -212,6 +230,7 @@ export const assetEntityStub = {
sharedLinks: [], sharedLinks: [],
faces: [], faces: [],
sidecarPath: null, sidecarPath: null,
isReadOnly: false,
}), }),
noWebpPath: Object.freeze<AssetEntity>({ noWebpPath: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
@ -242,6 +261,7 @@ export const assetEntityStub = {
originalFileName: 'asset-id.ext', originalFileName: 'asset-id.ext',
faces: [], faces: [],
sidecarPath: null, sidecarPath: null,
isReadOnly: false,
}), }),
noThumbhash: Object.freeze<AssetEntity>({ noThumbhash: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
@ -263,6 +283,7 @@ export const assetEntityStub = {
mimeType: null, mimeType: null,
isFavorite: true, isFavorite: true,
isArchived: false, isArchived: false,
isReadOnly: false,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -293,6 +314,7 @@ export const assetEntityStub = {
mimeType: null, mimeType: null,
isFavorite: true, isFavorite: true,
isArchived: false, isArchived: false,
isReadOnly: false,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -324,6 +346,7 @@ export const assetEntityStub = {
mimeType: null, mimeType: null,
isFavorite: true, isFavorite: true,
isArchived: false, isArchived: false,
isReadOnly: false,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -375,6 +398,7 @@ export const assetEntityStub = {
mimeType: null, mimeType: null,
isFavorite: false, isFavorite: false,
isArchived: false, isArchived: false,
isReadOnly: false,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -408,6 +432,7 @@ export const assetEntityStub = {
mimeType: null, mimeType: null,
isFavorite: true, isFavorite: true,
isArchived: false, isArchived: false,
isReadOnly: false,
duration: null, duration: null,
isVisible: true, isVisible: true,
livePhotoVideo: null, livePhotoVideo: null,
@ -865,6 +890,7 @@ export const sharedLinkStub = {
updatedAt: today, updatedAt: today,
isFavorite: false, isFavorite: false,
isArchived: false, isArchived: false,
isReadOnly: false,
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
smartInfo: { smartInfo: {
assetId: 'id_1', assetId: 'id_1',

View File

@ -6,5 +6,6 @@ export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => {
compareBcrypt: jest.fn().mockReturnValue(true), compareBcrypt: jest.fn().mockReturnValue(true),
hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
hashSha256: jest.fn().mockImplementation((input) => `${input} (hashed)`), hashSha256: jest.fn().mockImplementation((input) => `${input} (hashed)`),
hashFile: jest.fn().mockImplementation((input) => `${input} (file-hashed)`),
}; };
}; };

View File

@ -979,6 +979,12 @@ export interface CreateUserDto {
* @memberof CreateUserDto * @memberof CreateUserDto
*/ */
'storageLabel'?: string | null; 'storageLabel'?: string | null;
/**
*
* @type {string}
* @memberof CreateUserDto
*/
'externalPath'?: string | null;
} }
/** /**
* *
@ -1294,6 +1300,87 @@ export interface GetAssetCountByTimeBucketDto {
} }
/**
*
* @export
* @interface ImportAssetDto
*/
export interface ImportAssetDto {
/**
*
* @type {AssetTypeEnum}
* @memberof ImportAssetDto
*/
'assetType': AssetTypeEnum;
/**
*
* @type {boolean}
* @memberof ImportAssetDto
*/
'isReadOnly'?: boolean;
/**
*
* @type {string}
* @memberof ImportAssetDto
*/
'assetPath': string;
/**
*
* @type {string}
* @memberof ImportAssetDto
*/
'sidecarPath'?: string;
/**
*
* @type {string}
* @memberof ImportAssetDto
*/
'deviceAssetId': string;
/**
*
* @type {string}
* @memberof ImportAssetDto
*/
'deviceId': string;
/**
*
* @type {string}
* @memberof ImportAssetDto
*/
'fileCreatedAt': string;
/**
*
* @type {string}
* @memberof ImportAssetDto
*/
'fileModifiedAt': string;
/**
*
* @type {boolean}
* @memberof ImportAssetDto
*/
'isFavorite': boolean;
/**
*
* @type {boolean}
* @memberof ImportAssetDto
*/
'isArchived'?: boolean;
/**
*
* @type {boolean}
* @memberof ImportAssetDto
*/
'isVisible'?: boolean;
/**
*
* @type {string}
* @memberof ImportAssetDto
*/
'duration'?: string;
}
/** /**
* *
* @export * @export
@ -2736,6 +2823,12 @@ export interface UpdateUserDto {
* @memberof UpdateUserDto * @memberof UpdateUserDto
*/ */
'storageLabel'?: string; 'storageLabel'?: string;
/**
*
* @type {string}
* @memberof UpdateUserDto
*/
'externalPath'?: string;
/** /**
* *
* @type {boolean} * @type {boolean}
@ -2841,6 +2934,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto * @memberof UserResponseDto
*/ */
'storageLabel': string | null; 'storageLabel': string | null;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'externalPath': string | null;
/** /**
* *
* @type {string} * @type {string}
@ -5412,6 +5511,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {ImportAssetDto} importAssetDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
importFile: async (importAssetDto: ImportAssetDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'importAssetDto' is not null or undefined
assertParamExists('importFile', 'importAssetDto', importAssetDto)
const localVarPath = `/asset/import`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(importAssetDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* *
* @param {SearchAssetDto} searchAssetDto * @param {SearchAssetDto} searchAssetDto
@ -5565,26 +5708,29 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
* *
* @param {AssetTypeEnum} assetType * @param {AssetTypeEnum} assetType
* @param {File} assetData * @param {File} assetData
* @param {string} fileExtension
* @param {string} deviceAssetId * @param {string} deviceAssetId
* @param {string} deviceId * @param {string} deviceId
* @param {string} fileCreatedAt * @param {string} fileCreatedAt
* @param {string} fileModifiedAt * @param {string} fileModifiedAt
* @param {boolean} isFavorite * @param {boolean} isFavorite
* @param {string} fileExtension
* @param {string} [key] * @param {string} [key]
* @param {File} [livePhotoData] * @param {File} [livePhotoData]
* @param {File} [sidecarData] * @param {File} [sidecarData]
* @param {boolean} [isReadOnly]
* @param {boolean} [isArchived] * @param {boolean} [isArchived]
* @param {boolean} [isVisible] * @param {boolean} [isVisible]
* @param {string} [duration] * @param {string} [duration]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
uploadFile: async (assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { uploadFile: async (assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetType' is not null or undefined // verify required parameter 'assetType' is not null or undefined
assertParamExists('uploadFile', 'assetType', assetType) assertParamExists('uploadFile', 'assetType', assetType)
// verify required parameter 'assetData' is not null or undefined // verify required parameter 'assetData' is not null or undefined
assertParamExists('uploadFile', 'assetData', assetData) assertParamExists('uploadFile', 'assetData', assetData)
// verify required parameter 'fileExtension' is not null or undefined
assertParamExists('uploadFile', 'fileExtension', fileExtension)
// verify required parameter 'deviceAssetId' is not null or undefined // verify required parameter 'deviceAssetId' is not null or undefined
assertParamExists('uploadFile', 'deviceAssetId', deviceAssetId) assertParamExists('uploadFile', 'deviceAssetId', deviceAssetId)
// verify required parameter 'deviceId' is not null or undefined // verify required parameter 'deviceId' is not null or undefined
@ -5595,8 +5741,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
assertParamExists('uploadFile', 'fileModifiedAt', fileModifiedAt) assertParamExists('uploadFile', 'fileModifiedAt', fileModifiedAt)
// verify required parameter 'isFavorite' is not null or undefined // verify required parameter 'isFavorite' is not null or undefined
assertParamExists('uploadFile', 'isFavorite', isFavorite) assertParamExists('uploadFile', 'isFavorite', isFavorite)
// verify required parameter 'fileExtension' is not null or undefined
assertParamExists('uploadFile', 'fileExtension', fileExtension)
const localVarPath = `/asset/upload`; const localVarPath = `/asset/upload`;
// use dummy base URL string because the URL constructor only accepts absolute URLs. // use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -5640,6 +5784,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarFormParams.append('sidecarData', sidecarData as any); localVarFormParams.append('sidecarData', sidecarData as any);
} }
if (isReadOnly !== undefined) {
localVarFormParams.append('isReadOnly', isReadOnly as any);
}
if (fileExtension !== undefined) {
localVarFormParams.append('fileExtension', fileExtension as any);
}
if (deviceAssetId !== undefined) { if (deviceAssetId !== undefined) {
localVarFormParams.append('deviceAssetId', deviceAssetId as any); localVarFormParams.append('deviceAssetId', deviceAssetId as any);
} }
@ -5668,10 +5820,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
localVarFormParams.append('isVisible', isVisible as any); localVarFormParams.append('isVisible', isVisible as any);
} }
if (fileExtension !== undefined) {
localVarFormParams.append('fileExtension', fileExtension as any);
}
if (duration !== undefined) { if (duration !== undefined) {
localVarFormParams.append('duration', duration as any); localVarFormParams.append('duration', duration as any);
} }
@ -5909,6 +6057,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getUserAssetsByDeviceId(deviceId, options); const localVarAxiosArgs = await localVarAxiosParamCreator.getUserAssetsByDeviceId(deviceId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {ImportAssetDto} importAssetDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async importFile(importAssetDto: ImportAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFileUploadResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {SearchAssetDto} searchAssetDto * @param {SearchAssetDto} searchAssetDto
@ -5947,23 +6105,24 @@ export const AssetApiFp = function(configuration?: Configuration) {
* *
* @param {AssetTypeEnum} assetType * @param {AssetTypeEnum} assetType
* @param {File} assetData * @param {File} assetData
* @param {string} fileExtension
* @param {string} deviceAssetId * @param {string} deviceAssetId
* @param {string} deviceId * @param {string} deviceId
* @param {string} fileCreatedAt * @param {string} fileCreatedAt
* @param {string} fileModifiedAt * @param {string} fileModifiedAt
* @param {boolean} isFavorite * @param {boolean} isFavorite
* @param {string} fileExtension
* @param {string} [key] * @param {string} [key]
* @param {File} [livePhotoData] * @param {File} [livePhotoData]
* @param {File} [sidecarData] * @param {File} [sidecarData]
* @param {boolean} [isReadOnly]
* @param {boolean} [isArchived] * @param {boolean} [isArchived]
* @param {boolean} [isVisible] * @param {boolean} [isVisible]
* @param {string} [duration] * @param {string} [duration]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFileUploadResponseDto>> { async uploadFile(assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFileUploadResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options); const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
} }
@ -6166,6 +6325,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getUserAssetsByDeviceId(deviceId: string, options?: any): AxiosPromise<Array<string>> { getUserAssetsByDeviceId(deviceId: string, options?: any): AxiosPromise<Array<string>> {
return localVarFp.getUserAssetsByDeviceId(deviceId, options).then((request) => request(axios, basePath)); return localVarFp.getUserAssetsByDeviceId(deviceId, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {ImportAssetDto} importAssetDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
importFile(importAssetDto: ImportAssetDto, options?: any): AxiosPromise<AssetFileUploadResponseDto> {
return localVarFp.importFile(importAssetDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {SearchAssetDto} searchAssetDto * @param {SearchAssetDto} searchAssetDto
@ -6201,23 +6369,24 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* *
* @param {AssetTypeEnum} assetType * @param {AssetTypeEnum} assetType
* @param {File} assetData * @param {File} assetData
* @param {string} fileExtension
* @param {string} deviceAssetId * @param {string} deviceAssetId
* @param {string} deviceId * @param {string} deviceId
* @param {string} fileCreatedAt * @param {string} fileCreatedAt
* @param {string} fileModifiedAt * @param {string} fileModifiedAt
* @param {boolean} isFavorite * @param {boolean} isFavorite
* @param {string} fileExtension
* @param {string} [key] * @param {string} [key]
* @param {File} [livePhotoData] * @param {File} [livePhotoData]
* @param {File} [sidecarData] * @param {File} [sidecarData]
* @param {boolean} [isReadOnly]
* @param {boolean} [isArchived] * @param {boolean} [isArchived]
* @param {boolean} [isVisible] * @param {boolean} [isVisible]
* @param {string} [duration] * @param {string} [duration]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise<AssetFileUploadResponseDto> { uploadFile(assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise<AssetFileUploadResponseDto> {
return localVarFp.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options).then((request) => request(axios, basePath)); return localVarFp.uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration, options).then((request) => request(axios, basePath));
}, },
}; };
}; };
@ -6537,6 +6706,20 @@ export interface AssetApiGetUserAssetsByDeviceIdRequest {
readonly deviceId: string readonly deviceId: string
} }
/**
* Request parameters for importFile operation in AssetApi.
* @export
* @interface AssetApiImportFileRequest
*/
export interface AssetApiImportFileRequest {
/**
*
* @type {ImportAssetDto}
* @memberof AssetApiImportFile
*/
readonly importAssetDto: ImportAssetDto
}
/** /**
* Request parameters for searchAsset operation in AssetApi. * Request parameters for searchAsset operation in AssetApi.
* @export * @export
@ -6627,6 +6810,13 @@ export interface AssetApiUploadFileRequest {
*/ */
readonly assetData: File readonly assetData: File
/**
*
* @type {string}
* @memberof AssetApiUploadFile
*/
readonly fileExtension: string
/** /**
* *
* @type {string} * @type {string}
@ -6662,13 +6852,6 @@ export interface AssetApiUploadFileRequest {
*/ */
readonly isFavorite: boolean readonly isFavorite: boolean
/**
*
* @type {string}
* @memberof AssetApiUploadFile
*/
readonly fileExtension: string
/** /**
* *
* @type {string} * @type {string}
@ -6690,6 +6873,13 @@ export interface AssetApiUploadFileRequest {
*/ */
readonly sidecarData?: File readonly sidecarData?: File
/**
*
* @type {boolean}
* @memberof AssetApiUploadFile
*/
readonly isReadOnly?: boolean
/** /**
* *
* @type {boolean} * @type {boolean}
@ -6934,6 +7124,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getUserAssetsByDeviceId(requestParameters.deviceId, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).getUserAssetsByDeviceId(requestParameters.deviceId, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {AssetApiImportFileRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {AssetApiSearchAssetRequest} requestParameters Request parameters. * @param {AssetApiSearchAssetRequest} requestParameters Request parameters.
@ -6975,7 +7176,7 @@ export class AssetApi extends BaseAPI {
* @memberof AssetApi * @memberof AssetApi
*/ */
public uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig) { public uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).uploadFile(requestParameters.assetType, requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.fileExtension, requestParameters.key, requestParameters.livePhotoData, requestParameters.sidecarData, requestParameters.isArchived, requestParameters.isVisible, requestParameters.duration, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).uploadFile(requestParameters.assetType, requestParameters.assetData, requestParameters.fileExtension, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.livePhotoData, requestParameters.sidecarData, requestParameters.isReadOnly, requestParameters.isArchived, requestParameters.isVisible, requestParameters.duration, options).then((request) => request(this.axios, this.basePath));
} }
} }

View File

@ -19,14 +19,15 @@
const editUser = async () => { const editUser = async () => {
try { try {
const { id, email, firstName, lastName, storageLabel } = user; const { id, email, firstName, lastName, storageLabel, externalPath } = user;
const { status } = await api.userApi.updateUser({ const { status } = await api.userApi.updateUser({
updateUserDto: { updateUserDto: {
id, id,
email, email,
firstName, firstName,
lastName, lastName,
storageLabel: storageLabel || '' storageLabel: storageLabel || '',
externalPath: externalPath || ''
} }
}); });
@ -131,6 +132,22 @@
</p> </p>
</div> </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} {#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p> <p class="text-red-400 ml-4 text-sm">{error}</p>
{/if} {/if}

View File

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

View File

@ -5,6 +5,8 @@
import PencilOutline from 'svelte-material-icons/PencilOutline.svelte'; import PencilOutline from 'svelte-material-icons/PencilOutline.svelte';
import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte'; import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte';
import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte'; import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte';
import Check from 'svelte-material-icons/Check.svelte';
import Close from 'svelte-material-icons/Close.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte'; import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte'; import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
@ -171,6 +173,7 @@
<th class="text-center w-1/4 font-medium text-sm">Email</th> <th class="text-center w-1/4 font-medium text-sm">Email</th>
<th class="text-center w-1/4 font-medium text-sm">First name</th> <th class="text-center w-1/4 font-medium text-sm">First name</th>
<th class="text-center w-1/4 font-medium text-sm">Last name</th> <th class="text-center w-1/4 font-medium text-sm">Last name</th>
<th class="text-center w-1/4 font-medium text-sm">Can import</th>
<th class="text-center w-1/4 font-medium text-sm">Action</th> <th class="text-center w-1/4 font-medium text-sm">Action</th>
</tr> </tr>
</thead> </thead>
@ -191,6 +194,15 @@
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td> <td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td> <td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td> <td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td>
<td class="text-sm px-4 w-1/4 text-ellipsis">
<div class="container flex flex-wrap mx-auto justify-center">
{#if user.externalPath}
<Check size="16" />
{:else}
<Close size="16" />
{/if}
</div>
</td>
<td class="text-sm px-4 w-1/4 text-ellipsis"> <td class="text-sm px-4 w-1/4 text-ellipsis">
{#if !isDeleted(user)} {#if !isDeleted(user)}
<button <button

View File

@ -8,6 +8,7 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
firstName: Sync.each(() => faker.name.firstName()), firstName: Sync.each(() => faker.name.firstName()),
lastName: Sync.each(() => faker.name.lastName()), lastName: Sync.each(() => faker.name.lastName()),
storageLabel: Sync.each(() => faker.random.alphaNumeric()), storageLabel: Sync.each(() => faker.random.alphaNumeric()),
externalPath: Sync.each(() => faker.random.alphaNumeric()),
profileImagePath: '', profileImagePath: '',
shouldChangePassword: Sync.each(() => faker.datatype.boolean()), shouldChangePassword: Sync.each(() => faker.datatype.boolean()),
isAdmin: true, isAdmin: true,