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
55 changed files with 1321 additions and 128 deletions

View File

@@ -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,

View File

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

View File

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

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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,
},
};

View File

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

View File

@@ -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;

View File

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

View File

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

View File

@@ -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,

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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' } } } })

View File

@@ -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' } });

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 { 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);

View File

@@ -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));
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

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