1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server): upload config (#3252)

This commit is contained in:
Jason Rasmussen 2023-07-14 21:31:42 -04:00 committed by GitHub
parent 382341f550
commit 1064128fde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 213 additions and 217 deletions

View File

@ -1,18 +1,21 @@
import { AssetType } from '@app/infra/entities'; import { AssetType } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { import {
assetEntityStub, assetEntityStub,
authStub, authStub,
IAccessRepositoryMock, IAccessRepositoryMock,
newAccessRepositoryMock, newAccessRepositoryMock,
newAssetRepositoryMock, newAssetRepositoryMock,
newCryptoRepositoryMock,
newStorageRepositoryMock, newStorageRepositoryMock,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { ICryptoRepository } from '../crypto';
import { mimeTypes } from '../domain.constant';
import { IStorageRepository } from '../storage'; import { IStorageRepository } from '../storage';
import { AssetStats, IAssetRepository } from './asset.repository'; import { AssetStats, IAssetRepository } from './asset.repository';
import { AssetService } from './asset.service'; import { AssetService, UploadFieldName } from './asset.service';
import { AssetStatsResponseDto, DownloadResponseDto } from './dto'; import { AssetStatsResponseDto, DownloadResponseDto } from './dto';
import { mapAsset } from './response-dto'; import { mapAsset } from './response-dto';
@ -39,10 +42,62 @@ const statResponse: AssetStatsResponseDto = {
total: 33, total: 33,
}; };
const uploadFile = {
nullAuth: {
authUser: null,
fieldName: UploadFieldName.ASSET_DATA,
file: {
checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg',
originalName: 'image.jpeg',
},
},
filename: (fieldName: UploadFieldName, filename: string) => {
return {
authUser: authStub.admin,
fieldName,
file: {
mimeType: 'image/jpeg',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`,
originalName: filename,
},
};
},
};
const uploadTests = [
{
label: 'asset',
fieldName: UploadFieldName.ASSET_DATA,
filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }),
invalid: ['.xml', '.html'],
},
{
label: 'live photo',
fieldName: UploadFieldName.LIVE_PHOTO_DATA,
filetypes: Object.keys(mimeTypes.video),
invalid: ['.xml', '.html', '.jpg', '.jpeg'],
},
{
label: 'sidecar',
fieldName: UploadFieldName.SIDECAR_DATA,
filetypes: Object.keys(mimeTypes.sidecar),
invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'],
},
{
label: 'profile',
fieldName: UploadFieldName.PROFILE_DATA,
filetypes: Object.keys(mimeTypes.profile),
invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'],
},
];
describe(AssetService.name, () => { describe(AssetService.name, () => {
let sut: AssetService; let sut: AssetService;
let accessMock: IAccessRepositoryMock; let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let storageMock: jest.Mocked<IStorageRepository>; let storageMock: jest.Mocked<IStorageRepository>;
it('should work', () => { it('should work', () => {
@ -52,8 +107,83 @@ describe(AssetService.name, () => {
beforeEach(async () => { beforeEach(async () => {
accessMock = newAccessRepositoryMock(); accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
sut = new AssetService(accessMock, assetMock, storageMock); sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock);
});
describe('canUpload', () => {
it('should require an authenticated user', () => {
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
for (const { fieldName, filetypes, invalid } of uploadTests) {
describe(`${fieldName}`, () => {
for (const filetype of filetypes) {
it(`should accept ${filetype}`, () => {
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
});
}
for (const filetype of invalid) {
it(`should reject ${filetype}`, () => {
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
BadRequestException,
);
});
}
});
}
});
describe('getUploadFilename', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should be the original extension for asset upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
it('should be the mov extension for live photo upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual(
'random-uuid.mov',
);
});
it('should be the xmp extension for sidecar upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
'random-uuid.xmp',
);
});
it('should be the original extension for profile upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
});
describe('getUploadFolder', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should return profile for profile uploads', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'upload/profile/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
});
it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'upload/upload/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id');
});
}); });
describe('getMapMarkers', () => { describe('getMapMarkers', () => {

View File

@ -1,12 +1,14 @@
import { AssetEntity } from '@app/infra/entities'; import { AssetEntity } from '@app/infra/entities';
import { BadRequestException, Inject } from '@nestjs/common'; import { BadRequestException, Inject, Logger } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { extname } from 'path'; import { extname } from 'path';
import sanitize from 'sanitize-filename';
import { AccessCore, IAccessRepository, Permission } from '../access'; import { AccessCore, IAccessRepository, Permission } from '../access';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
import { mimeTypes } from '../domain.constant'; import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util'; import { HumanReadableSize, usePagination } from '../domain.util';
import { ImmichReadStream, IStorageRepository } from '../storage'; import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IAssetRepository } from './asset.repository'; import { IAssetRepository } from './asset.repository';
import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto'; import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto';
import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto'; import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto';
@ -21,6 +23,12 @@ export enum UploadFieldName {
PROFILE_DATA = 'file', PROFILE_DATA = 'file',
} }
export interface UploadRequest {
authUser: AuthUserDto | null;
fieldName: UploadFieldName;
file: UploadFile;
}
export interface UploadFile { export interface UploadFile {
checksum: Buffer; checksum: Buffer;
originalPath: string; originalPath: string;
@ -28,16 +36,82 @@ export interface UploadFile {
} }
export class AssetService { export class AssetService {
private logger = new Logger(AssetService.name);
private access: AccessCore; private access: AccessCore;
private storageCore = new StorageCore();
constructor( constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
) { ) {
this.access = new AccessCore(accessRepository); this.access = new AccessCore(accessRepository);
} }
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
this.access.requireUploadAccess(authUser);
const filename = file.originalName;
switch (fieldName) {
case UploadFieldName.ASSET_DATA:
if (mimeTypes.isAsset(filename)) {
return true;
}
break;
case UploadFieldName.LIVE_PHOTO_DATA:
if (mimeTypes.isVideo(filename)) {
return true;
}
break;
case UploadFieldName.SIDECAR_DATA:
if (mimeTypes.isSidecar(filename)) {
return true;
}
break;
case UploadFieldName.PROFILE_DATA:
if (mimeTypes.isProfile(filename)) {
return true;
}
break;
}
this.logger.error(`Unsupported file type ${filename}`);
throw new BadRequestException(`Unsupported file type ${filename}`);
}
getUploadFilename({ authUser, fieldName, file }: UploadRequest): string {
this.access.requireUploadAccess(authUser);
const originalExt = extname(file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: originalExt,
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExt,
};
return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`);
}
getUploadFolder({ authUser, fieldName }: UploadRequest): string {
authUser = this.access.requireUploadAccess(authUser);
let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id);
if (fieldName === UploadFieldName.PROFILE_DATA) {
folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id);
}
this.storageRepository.mkdirSync(folder);
return folder;
}
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> { getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options); return this.assetRepository.getMapMarkers(authUser.id, options);
} }

View File

@ -1,13 +1,6 @@
import { import { ICryptoRepository, IJobRepository, IStorageRepository, JobName, mimeTypes } from '@app/domain';
ICryptoRepository,
IJobRepository,
IStorageRepository,
JobName,
mimeTypes,
UploadFieldName,
} from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { import {
assetEntityStub, assetEntityStub,
authStub, authStub,
@ -102,57 +95,6 @@ const _getAssetCountByTimeBucket = (): AssetCountByTimeBucket[] => {
return [result1, result2]; return [result1, result2];
}; };
const uploadFile = {
nullAuth: {
authUser: null,
fieldName: UploadFieldName.ASSET_DATA,
file: {
checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg',
originalName: 'image.jpeg',
},
},
filename: (fieldName: UploadFieldName, filename: string) => {
return {
authUser: authStub.admin,
fieldName,
file: {
mimeType: 'image/jpeg',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`,
originalName: filename,
},
};
},
};
const uploadTests = [
{
label: 'asset',
fieldName: UploadFieldName.ASSET_DATA,
filetypes: Object.keys({ ...mimeTypes.image, ...mimeTypes.video }),
invalid: ['.xml', '.html'],
},
{
label: 'live photo',
fieldName: UploadFieldName.LIVE_PHOTO_DATA,
filetypes: Object.keys(mimeTypes.video),
invalid: ['.xml', '.html', '.jpg', '.jpeg'],
},
{
label: 'sidecar',
fieldName: UploadFieldName.SIDECAR_DATA,
filetypes: Object.keys(mimeTypes.sidecar),
invalid: ['.xml', '.html', '.jpg', '.jpeg', '.mov', '.mp4'],
},
{
label: 'profile',
fieldName: UploadFieldName.PROFILE_DATA,
filetypes: Object.keys(mimeTypes.profile),
invalid: ['.xml', '.html', '.cr2', '.arf', '.mov', '.mp4'],
},
];
describe('AssetService', () => { describe('AssetService', () => {
let sut: AssetService; let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
@ -275,80 +217,6 @@ describe('AssetService', () => {
}); });
}); });
describe('canUpload', () => {
it('should require an authenticated user', () => {
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
for (const { fieldName, filetypes, invalid } of uploadTests) {
describe(`${fieldName}`, () => {
for (const filetype of filetypes) {
it(`should accept ${filetype}`, () => {
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
});
}
for (const filetype of invalid) {
it(`should reject ${filetype}`, () => {
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
BadRequestException,
);
});
}
});
}
});
describe('getUploadFilename', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should be the original extension for asset upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
it('should be the mov extension for live photo upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual(
'random-uuid.mov',
);
});
it('should be the xmp extension for sidecar upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
'random-uuid.xmp',
);
});
it('should be the original extension for profile upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
});
describe('getUploadFolder', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should return profile for profile uploads', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'upload/profile/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
});
it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'upload/upload/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id');
});
});
describe('uploadFile', () => { describe('uploadFile', () => {
it('should handle a file upload', async () => { it('should handle a file upload', async () => {
const assetEntity = _getAsset_1(); const assetEntity = _getAsset_1();

View File

@ -12,9 +12,6 @@ import {
mapAssetWithoutExif, mapAssetWithoutExif,
mimeTypes, mimeTypes,
Permission, Permission,
StorageCore,
StorageFolder,
UploadFieldName,
UploadFile, UploadFile,
} from '@app/domain'; } from '@app/domain';
import { AssetEntity, AssetType } from '@app/infra/entities'; import { AssetEntity, AssetType } from '@app/infra/entities';
@ -30,10 +27,8 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Response as Res } from 'express'; import { Response as Res } from 'express';
import { constants } from 'fs'; import { constants } from 'fs';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path, { extname } from 'path'; import path from 'path';
import sanitize from 'sanitize-filename';
import { QueryFailedError, Repository } from 'typeorm'; import { QueryFailedError, Repository } from 'typeorm';
import { UploadRequest } from '../../app.interceptor';
import { IAssetRepository } from './asset-repository'; import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core'; import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
@ -70,7 +65,6 @@ export class AssetService {
readonly logger = new Logger(AssetService.name); readonly logger = new Logger(AssetService.name);
private assetCore: AssetCore; private assetCore: AssetCore;
private access: AccessCore; private access: AccessCore;
private storageCore = new StorageCore();
constructor( constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAccessRepository) accessRepository: IAccessRepository,
@ -84,69 +78,6 @@ export class AssetService {
this.access = new AccessCore(accessRepository); this.access = new AccessCore(accessRepository);
} }
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
this.access.requireUploadAccess(authUser);
const filename = file.originalName;
switch (fieldName) {
case UploadFieldName.ASSET_DATA:
if (mimeTypes.isAsset(filename)) {
return true;
}
break;
case UploadFieldName.LIVE_PHOTO_DATA:
if (mimeTypes.isVideo(filename)) {
return true;
}
break;
case UploadFieldName.SIDECAR_DATA:
if (mimeTypes.isSidecar(filename)) {
return true;
}
break;
case UploadFieldName.PROFILE_DATA:
if (mimeTypes.isProfile(filename)) {
return true;
}
break;
}
this.logger.error(`Unsupported file type ${filename}`);
throw new BadRequestException(`Unsupported file type ${filename}`);
}
getUploadFilename({ authUser, fieldName, file }: UploadRequest): string {
this.access.requireUploadAccess(authUser);
const originalExt = extname(file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: originalExt,
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExt,
};
return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`);
}
getUploadFolder({ authUser, fieldName }: UploadRequest): string {
authUser = this.access.requireUploadAccess(authUser);
let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id);
if (fieldName === UploadFieldName.PROFILE_DATA) {
folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id);
}
this.storageRepository.mkdirSync(folder);
return folder;
}
public async uploadFile( public async uploadFile(
authUser: AuthUserDto, authUser: AuthUserDto,
dto: CreateAssetDto, dto: CreateAssetDto,

View File

@ -1,4 +1,4 @@
import { AuthUserDto, UploadFieldName, UploadFile } from '@app/domain'; import { AssetService, UploadFieldName, UploadFile } from '@app/domain';
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants'; import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
@ -7,7 +7,6 @@ import { createHash } from 'crypto';
import { NextFunction, RequestHandler } from 'express'; import { NextFunction, RequestHandler } from 'express';
import multer, { diskStorage, StorageEngine } from 'multer'; import multer, { diskStorage, StorageEngine } from 'multer';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AssetService } from './api-v1/asset/asset.service';
import { AuthRequest } from './app.guard'; import { AuthRequest } from './app.guard';
export enum Route { export enum Route {
@ -43,12 +42,6 @@ const callbackify = async <T>(fn: (...args: any[]) => T, callback: Callback<T>)
} }
}; };
export interface UploadRequest {
authUser: AuthUserDto | null;
fieldName: UploadFieldName;
file: UploadFile;
}
const asRequest = (req: AuthRequest, file: Express.Multer.File) => { const asRequest = (req: AuthRequest, file: Express.Multer.File) => {
return { return {
authUser: req.user || null, authUser: req.user || null,