mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00: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:
@@ -169,6 +169,7 @@ describe(AlbumService.name, () => {
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
externalPath: null,
|
||||
},
|
||||
ownerId: 'admin_id',
|
||||
shared: false,
|
||||
|
||||
@@ -19,6 +19,7 @@ export class APIKeyCore {
|
||||
isAdmin: user.isAdmin,
|
||||
isPublicUser: false,
|
||||
isAllowUpload: true,
|
||||
externalPath: user.externalPath,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,4 +8,5 @@ export class AuthUserDto {
|
||||
isAllowDownload?: boolean;
|
||||
isShowExif?: boolean;
|
||||
accessTokenId?: string;
|
||||
externalPath?: string | null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export const ICryptoRepository = 'ICryptoRepository';
|
||||
|
||||
export interface ICryptoRepository {
|
||||
randomBytes(size: number): Buffer;
|
||||
hashFile(filePath: string): Promise<Buffer>;
|
||||
hashSha256(data: string): string;
|
||||
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareBcrypt(data: string | Buffer, encrypted: string): boolean;
|
||||
|
||||
@@ -27,3 +27,60 @@ export function assertMachineLearningEnabled() {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ const responseDto = {
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
externalPath: null,
|
||||
},
|
||||
user1: {
|
||||
email: 'immich@test.com',
|
||||
@@ -31,6 +32,7 @@ const responseDto = {
|
||||
createdAt: new Date('2021-01-01'),
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
externalPath: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -194,5 +194,26 @@ describe(StorageTemplateService.name, () => {
|
||||
['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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +76,11 @@ export class StorageTemplateService {
|
||||
|
||||
// TODO: use asset core (once in domain)
|
||||
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);
|
||||
if (asset.originalPath !== destination) {
|
||||
const source = asset.originalPath;
|
||||
|
||||
@@ -23,6 +23,10 @@ export class CreateUserDto {
|
||||
@IsString()
|
||||
@Transform(toSanitized)
|
||||
storageLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
externalPath?: string | null;
|
||||
}
|
||||
|
||||
export class CreateAdminDto {
|
||||
|
||||
@@ -29,6 +29,10 @@ export class UpdateUserDto {
|
||||
@Transform(toSanitized)
|
||||
storageLabel?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
externalPath?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsUUID('4')
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
|
||||
@@ -6,6 +6,7 @@ export class UserResponseDto {
|
||||
firstName!: string;
|
||||
lastName!: string;
|
||||
storageLabel!: string | null;
|
||||
externalPath!: string | null;
|
||||
profileImagePath!: string;
|
||||
shouldChangePassword!: boolean;
|
||||
isAdmin!: boolean;
|
||||
@@ -22,6 +23,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
|
||||
firstName: entity.firstName,
|
||||
lastName: entity.lastName,
|
||||
storageLabel: entity.storageLabel,
|
||||
externalPath: entity.externalPath,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
shouldChangePassword: entity.shouldChangePassword,
|
||||
isAdmin: entity.isAdmin,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { hash } from 'bcrypt';
|
||||
import { constants, createReadStream, ReadStream } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import { AuthUserDto } from '../auth';
|
||||
@@ -28,6 +27,7 @@ export class UserCore {
|
||||
// Users can never update the isAdmin property.
|
||||
delete dto.isAdmin;
|
||||
delete dto.storageLabel;
|
||||
delete dto.externalPath;
|
||||
} else if (dto.isAdmin && authUser.id !== id) {
|
||||
// Admin cannot create another admin.
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
@@ -56,6 +56,10 @@ export class UserCore {
|
||||
dto.storageLabel = null;
|
||||
}
|
||||
|
||||
if (dto.externalPath === '') {
|
||||
dto.externalPath = null;
|
||||
}
|
||||
|
||||
return this.userRepository.update(id, dto);
|
||||
} catch (e) {
|
||||
Logger.error(e, 'Failed to update user info');
|
||||
@@ -79,7 +83,7 @@ export class UserCore {
|
||||
try {
|
||||
const payload: Partial<UserEntity> = { ...createUserDto };
|
||||
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);
|
||||
} catch (e) {
|
||||
|
||||
@@ -53,6 +53,7 @@ const adminUser: UserEntity = Object.freeze({
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
});
|
||||
|
||||
const immichUser: UserEntity = Object.freeze({
|
||||
@@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: null,
|
||||
externalPath: null,
|
||||
});
|
||||
|
||||
const updatedImmichUser: UserEntity = Object.freeze({
|
||||
@@ -89,6 +91,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: null,
|
||||
externalPath: null,
|
||||
});
|
||||
|
||||
const adminUserResponse = Object.freeze({
|
||||
@@ -104,6 +107,7 @@ const adminUserResponse = Object.freeze({
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
});
|
||||
|
||||
describe(UserService.name, () => {
|
||||
@@ -153,6 +157,7 @@ describe(UserService.name, () => {
|
||||
deletedAt: null,
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
storageLabel: 'admin',
|
||||
externalPath: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ describe('Album service', () => {
|
||||
tags: [],
|
||||
assets: [],
|
||||
storageLabel: null,
|
||||
externalPath: null,
|
||||
});
|
||||
const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
|
||||
const sharedAlbumOwnerId = '2222';
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface AssetCheck {
|
||||
checksum: Buffer;
|
||||
}
|
||||
|
||||
export interface AssetOwnerCheck extends AssetCheck {
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface IAssetRepository {
|
||||
get(id: string): Promise<AssetEntity | null>;
|
||||
create(
|
||||
@@ -39,6 +43,7 @@ export interface IAssetRepository {
|
||||
getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
|
||||
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
|
||||
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
|
||||
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
|
||||
}
|
||||
|
||||
export const IAssetRepository = 'IAssetRepository';
|
||||
@@ -350,4 +355,17 @@ export class AssetRepository implements IAssetRepository {
|
||||
|
||||
return assetCountByUserId;
|
||||
}
|
||||
|
||||
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null> {
|
||||
return this.assetRepository.findOne({
|
||||
select: {
|
||||
id: true,
|
||||
ownerId: true,
|
||||
checksum: true,
|
||||
},
|
||||
where: {
|
||||
originalPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.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 { DeviceIdDto } from './dto/device-id.dto';
|
||||
import { DownloadFilesDto } from './dto/download-files.dto';
|
||||
@@ -114,6 +114,20 @@ export class AssetController {
|
||||
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()
|
||||
@Get('/download/:id')
|
||||
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
|
||||
|
||||
@@ -2,17 +2,17 @@ import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
|
||||
import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
|
||||
import { parse } from 'node:path';
|
||||
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 {
|
||||
constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
|
||||
|
||||
async create(
|
||||
authUser: AuthUserDto,
|
||||
dto: CreateAssetDto,
|
||||
dto: CreateAssetDto | ImportAssetDto,
|
||||
file: UploadFile,
|
||||
livePhotoAssetId?: string,
|
||||
sidecarFile?: UploadFile,
|
||||
sidecarPath?: string,
|
||||
): Promise<AssetEntity> {
|
||||
const asset = await this.repository.create({
|
||||
owner: { id: authUser.id } as UserEntity,
|
||||
@@ -41,7 +41,8 @@ export class AssetCore {
|
||||
sharedLinks: [],
|
||||
originalFileName: parse(file.originalName).name,
|
||||
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' } });
|
||||
|
||||
@@ -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 { ForbiddenException } from '@nestjs/common';
|
||||
import {
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
authStub,
|
||||
fileStub,
|
||||
newAccessRepositoryMock,
|
||||
newCryptoRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
} from '@test';
|
||||
@@ -121,6 +122,7 @@ describe('AssetService', () => {
|
||||
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
|
||||
let accessMock: jest.Mocked<IAccessRepository>;
|
||||
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
@@ -144,13 +146,17 @@ describe('AssetService', () => {
|
||||
getAssetCountByUserId: jest.fn(),
|
||||
getArchivedAssetCountByUserId: jest.fn(),
|
||||
getExistingAssets: jest.fn(),
|
||||
getByOriginalPath: jest.fn(),
|
||||
};
|
||||
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
|
||||
downloadServiceMock = {
|
||||
downloadArchive: jest.fn(),
|
||||
};
|
||||
|
||||
accessMock = newAccessRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
@@ -158,6 +164,7 @@ describe('AssetService', () => {
|
||||
accessMock,
|
||||
assetRepositoryMock,
|
||||
a,
|
||||
cryptoMock,
|
||||
downloadServiceMock as DownloadService,
|
||||
jobMock,
|
||||
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', () => {
|
||||
it('should allow owner access', async () => {
|
||||
accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
AssetResponseDto,
|
||||
AuthUserDto,
|
||||
getLivePhotoMotionFilename,
|
||||
IAccessRepository,
|
||||
ICryptoRepository,
|
||||
IJobRepository,
|
||||
ImmichReadStream,
|
||||
isSidecarFileType,
|
||||
isSupportedFileType,
|
||||
IStorageRepository,
|
||||
JobName,
|
||||
mapAsset,
|
||||
@@ -21,12 +25,14 @@ import {
|
||||
StreamableFile,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { R_OK, W_OK } from 'constants';
|
||||
import { Response as Res } from 'express';
|
||||
import { constants, createReadStream, stat } from 'fs';
|
||||
import { createReadStream, stat } from 'fs';
|
||||
import fs from 'fs/promises';
|
||||
import mime from 'mime-types';
|
||||
import path from 'path';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
import { promisify } from 'util';
|
||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
|
||||
import { DownloadService } from '../../modules/download/download.service';
|
||||
import { IAssetRepository } from './asset-repository';
|
||||
import { AssetCore } from './asset.core';
|
||||
@@ -34,7 +40,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
|
||||
import { AssetSearchDto } from './dto/asset-search.dto';
|
||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.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 { DownloadFilesDto } from './dto/download-files.dto';
|
||||
import { DownloadDto } from './dto/download-library.dto';
|
||||
@@ -78,6 +84,7 @@ export class AssetService {
|
||||
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
|
||||
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
private downloadService: DownloadService,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@@ -107,7 +114,7 @@ export class AssetService {
|
||||
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 };
|
||||
} 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) {
|
||||
return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
|
||||
}
|
||||
@@ -291,7 +365,7 @@ export class AssetService {
|
||||
let videoPath = asset.originalPath;
|
||||
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) {
|
||||
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] } });
|
||||
|
||||
result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
|
||||
deleteQueue.push(
|
||||
asset.originalPath,
|
||||
asset.webpPath,
|
||||
asset.resizePath,
|
||||
asset.encodedVideoPath,
|
||||
asset.sidecarPath,
|
||||
);
|
||||
|
||||
if (!asset.isReadOnly) {
|
||||
deleteQueue.push(
|
||||
asset.originalPath,
|
||||
asset.webpPath,
|
||||
asset.resizePath,
|
||||
asset.encodedVideoPath,
|
||||
asset.sidecarPath,
|
||||
);
|
||||
}
|
||||
|
||||
// TODO refactor this to use cascades
|
||||
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
|
||||
@@ -665,7 +742,7 @@ export class AssetService {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.access(filepath, constants.R_OK);
|
||||
await fs.access(filepath, R_OK);
|
||||
|
||||
return new StreamableFile(createReadStream(filepath));
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { AssetType } from '@app/infra/entities';
|
||||
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 { toSanitized } from '../../../utils/transform.util';
|
||||
|
||||
export class CreateAssetDto {
|
||||
export class CreateAssetBase {
|
||||
@IsNotEmpty()
|
||||
deviceAssetId!: string;
|
||||
|
||||
@@ -32,11 +34,17 @@ export class CreateAssetDto {
|
||||
@IsBoolean()
|
||||
isVisible?: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
fileExtension!: string;
|
||||
|
||||
@IsOptional()
|
||||
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
|
||||
// and client SDKs. Validation should be handled in the controller.
|
||||
@@ -50,6 +58,23 @@ export class CreateAssetDto {
|
||||
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 {
|
||||
mimeType: string;
|
||||
checksum: Buffer;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isSidecarFileType, isSupportedFileType } from '@app/domain';
|
||||
import { StorageCore, StorageFolder } from '@app/domain/storage';
|
||||
import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
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 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) {
|
||||
if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
|
||||
return cb(new UnauthorizedException());
|
||||
}
|
||||
|
||||
if (validMimeTypes.includes(file.mimetype)) {
|
||||
if (isSupportedFileType(file.mimetype)) {
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export class AssetEntity {
|
||||
@Column()
|
||||
type!: AssetType;
|
||||
|
||||
@Column()
|
||||
@Column({ unique: true })
|
||||
originalPath!: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
@@ -75,6 +75,9 @@ export class AssetEntity {
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isArchived!: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isReadOnly!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
mimeType!: string | null;
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ export class UserEntity {
|
||||
@Column({ type: 'varchar', unique: true, default: null })
|
||||
storageLabel!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', default: null })
|
||||
externalPath!: string | null;
|
||||
|
||||
@Column({ default: '', select: false })
|
||||
password?: string;
|
||||
|
||||
|
||||
18
server/src/infra/migrations/1686584273471-ImportAsset.ts
Normal file
18
server/src/infra/migrations/1686584273471-ImportAsset.ts
Normal 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"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { ICryptoRepository } from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { compareSync, hash } from 'bcrypt';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { createReadStream } from 'fs';
|
||||
|
||||
@Injectable()
|
||||
export class CryptoRepository implements ICryptoRepository {
|
||||
@@ -13,4 +14,14 @@ export class CryptoRepository implements ICryptoRepository {
|
||||
hashSha256(value: string) {
|
||||
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()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user