feat(server): harden move file (#4361)
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user