feat(server): harden move file (#4361)

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Daniel Dietzler
2023-10-11 04:14:44 +02:00
committed by GitHub
parent 332a8d80f2
commit 09bf1c9175
31 changed files with 564 additions and 190 deletions
+133 -20
View File
@@ -1,6 +1,8 @@
import { join } from 'node:path';
import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
import { Logger } from '@nestjs/common';
import { dirname, join } from 'node:path';
import { APP_MEDIA_LOCATION } from '../domain.constant';
import { IStorageRepository } from '../repositories';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
export enum StorageFolder {
ENCODED_VIDEO = 'encoded-video',
@@ -10,13 +12,26 @@ export enum StorageFolder {
THUMBNAILS = 'thumbs',
}
export class StorageCore {
constructor(private repository: IStorageRepository) {}
export interface MoveRequest {
entityId: string;
pathType: PathType;
oldPath: string | null;
newPath: string;
}
getFolderLocation(
folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
userId: string,
) {
type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUMBNAIL | AssetPathType.ENCODED_VIDEO;
export class StorageCore {
private logger = new Logger(StorageCore.name);
constructor(
private repository: IStorageRepository,
private assetRepository: IAssetRepository,
private moveRepository: IMoveRepository,
private personRepository: IPersonRepository,
) {}
getFolderLocation(folder: StorageFolder, userId: string) {
return join(this.getBaseFolder(folder), userId);
}
@@ -28,21 +43,119 @@ export class StorageCore {
return join(APP_MEDIA_LOCATION, folder);
}
ensurePath(
folder: StorageFolder.ENCODED_VIDEO | StorageFolder.UPLOAD | StorageFolder.PROFILE | StorageFolder.THUMBNAILS,
ownerId: string,
fileName: string,
): string {
const folderPath = join(
this.getFolderLocation(folder, ownerId),
fileName.substring(0, 2),
fileName.substring(2, 4),
);
this.repository.mkdirSync(folderPath);
return join(folderPath, fileName);
getPersonThumbnailPath(person: PersonEntity) {
return this.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
}
getLargeThumbnailPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.jpeg`);
}
getSmallThumbnailPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}.webp`);
}
getEncodedVideoPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
}
getAndroidMotionPath(asset: AssetEntity) {
return this.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`);
}
isAndroidMotionPath(originalPath: string) {
return originalPath.startsWith(this.getBaseFolder(StorageFolder.ENCODED_VIDEO));
}
async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset;
switch (pathType) {
case AssetPathType.JPEG_THUMBNAIL:
return this.moveFile({ entityId, pathType, oldPath: resizePath, newPath: this.getLargeThumbnailPath(asset) });
case AssetPathType.WEBP_THUMBNAIL:
return this.moveFile({ entityId, pathType, oldPath: webpPath, newPath: this.getSmallThumbnailPath(asset) });
case AssetPathType.ENCODED_VIDEO:
return this.moveFile({
entityId,
pathType,
oldPath: encodedVideoPath,
newPath: this.getEncodedVideoPath(asset),
});
}
}
async movePersonFile(person: PersonEntity, pathType: PersonPathType) {
const { id: entityId, thumbnailPath } = person;
switch (pathType) {
case PersonPathType.FACE:
await this.moveFile({
entityId,
pathType,
oldPath: thumbnailPath,
newPath: this.getPersonThumbnailPath(person),
});
}
}
async moveFile(request: MoveRequest) {
const { entityId, pathType, oldPath, newPath } = request;
if (!oldPath || oldPath === newPath) {
return;
}
this.ensureFolders(newPath);
let move = await this.moveRepository.getByEntity(entityId, pathType);
if (move) {
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
const oldPathExists = await this.repository.checkFileExists(move.oldPath);
const newPathExists = await this.repository.checkFileExists(move.newPath);
const actualPath = newPathExists ? move.newPath : oldPathExists ? move.oldPath : null;
if (!actualPath) {
this.logger.warn('Unable to complete move. File does not exist at either location.');
return;
}
this.logger.log(`Found file at ${actualPath === move.oldPath ? 'old' : 'new'} location`);
move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath });
} else {
move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath });
}
if (move.oldPath !== newPath) {
await this.repository.moveFile(move.oldPath, newPath);
}
await this.savePath(pathType, entityId, newPath);
await this.moveRepository.delete(move);
}
ensureFolders(input: string) {
this.repository.mkdirSync(dirname(input));
}
removeEmptyDirs(folder: StorageFolder) {
return this.repository.removeEmptyDirs(this.getBaseFolder(folder));
}
private savePath(pathType: PathType, id: string, newPath: string) {
switch (pathType) {
case AssetPathType.ORIGINAL:
return this.assetRepository.save({ id, originalPath: newPath });
case AssetPathType.JPEG_THUMBNAIL:
return this.assetRepository.save({ id, resizePath: newPath });
case AssetPathType.WEBP_THUMBNAIL:
return this.assetRepository.save({ id, webpPath: newPath });
case AssetPathType.ENCODED_VIDEO:
return this.assetRepository.save({ id, encodedVideoPath: newPath });
case AssetPathType.SIDECAR:
return this.assetRepository.save({ id, sidecarPath: newPath });
case PersonPathType.FACE:
return this.personRepository.update({ id, thumbnailPath: newPath });
}
}
private getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
return join(this.getFolderLocation(folder, ownerId), filename.substring(0, 2), filename.substring(2, 4), filename);
}
}
@@ -1,14 +1,25 @@
import { newStorageRepositoryMock } from '@test';
import { IStorageRepository } from '../repositories';
import {
newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
import { StorageService } from './storage.service';
describe(StorageService.name, () => {
let sut: StorageService;
let assetMock: jest.Mocked<IAssetRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new StorageService(storageMock);
sut = new StorageService(assetMock, moveMock, personMock, storageMock);
});
it('should work', () => {
+8 -3
View File
@@ -1,6 +1,6 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { IDeleteFilesJob } from '../job';
import { IStorageRepository } from '../repositories';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
import { StorageCore, StorageFolder } from './storage.core';
@Injectable()
@@ -8,8 +8,13 @@ export class StorageService {
private logger = new Logger(StorageService.name);
private storageCore: StorageCore;
constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {
this.storageCore = new StorageCore(storageRepository);
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IMoveRepository) private moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
}
init() {