import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common'; import { AccessCore, Permission } from 'src/cores/access.core'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, AssetMediaStatusEnum, AssetRejectReason, AssetUploadAction, CheckExistingAssetsResponseDto, } from 'src/dtos/asset-media-response.dto'; import { AssetBulkUploadCheckDto, AssetMediaReplaceDto, CheckExistingAssetsDto, UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; import { QueryFailedError } from 'typeorm'; export interface UploadRequest { auth: AuthDto | null; fieldName: UploadFieldName; file: UploadFile; } export interface UploadFile { uuid: string; checksum: Buffer; originalPath: string; originalName: string; size: number; } @Injectable() export class AssetMediaService { private access: AccessCore; constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(AssetMediaService.name); this.access = AccessCore.create(accessRepository); } public async replaceAsset( auth: AuthDto, id: string, dto: AssetMediaReplaceDto, file: UploadFile, sidecarFile?: UploadFile, ): Promise { try { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); const existingAssetEntity = (await this.assetRepository.getById(id)) as AssetEntity; this.requireQuota(auth, file.size); await this.replaceFileData(existingAssetEntity.id, dto, file, sidecarFile?.originalPath); // Next, create a backup copy of the existing record. The db record has already been updated above, // but the local variable holds the original file data paths. const copiedPhoto = await this.createCopy(existingAssetEntity); // and immediate trash it await this.assetRepository.softDeleteAll([copiedPhoto.id]); this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]); await this.userRepository.updateUsage(auth.user.id, file.size); return { status: AssetMediaStatusEnum.REPLACED, id: copiedPhoto.id }; } catch (error: any) { return await this.handleUploadError(error, auth, file, sidecarFile); } } private async handleUploadError( error: any, auth: AuthDto, file: UploadFile, sidecarFile?: UploadFile, ): Promise { // clean up files await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [file.originalPath, sidecarFile?.originalPath] }, }); // handle duplicates with a success response if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) { const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum); if (!duplicateId) { this.logger.error(`Error locating duplicate for checksum constraint`); throw new InternalServerErrorException(); } return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId }; } this.logger.error(`Error uploading file ${error}`, error?.stack); throw error; } /** * Updates the specified assetId to the specified photo data file properties: checksum, path, * timestamps, deviceIds, and sidecar. Derived properties like: faces, smart search info, etc * are UNTOUCHED. The photo data files modification times on the filesysytem are updated to * the specified timestamps. The exif db record is upserted, and then A METADATA_EXTRACTION * job is queued to update these derived properties. */ private async replaceFileData( assetId: string, dto: AssetMediaReplaceDto, file: UploadFile, sidecarPath?: string, ): Promise { await this.assetRepository.update({ id: assetId, checksum: file.checksum, originalPath: file.originalPath, type: mimeTypes.assetType(file.originalPath), originalFileName: file.originalName, deviceAssetId: dto.deviceAssetId, deviceId: dto.deviceId, fileCreatedAt: dto.fileCreatedAt, fileModifiedAt: dto.fileModifiedAt, localDateTime: dto.fileCreatedAt, duration: dto.duration || null, livePhotoVideo: null, sidecarPath: sidecarPath || null, }); await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId, fileSizeInByte: file.size }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: assetId, source: 'upload' }, }); } /** * Create a 'shallow' copy of the specified asset record creating a new asset record in the database. * Uses only vital properties excluding things like: stacks, faces, smart search info, etc, * and then queues a METADATA_EXTRACTION job. */ private async createCopy(asset: AssetEntity): Promise { const created = await this.assetRepository.create({ ownerId: asset.ownerId, originalPath: asset.originalPath, originalFileName: asset.originalFileName, libraryId: asset.libraryId, deviceAssetId: asset.deviceAssetId, deviceId: asset.deviceId, type: asset.type, checksum: asset.checksum, fileCreatedAt: asset.fileCreatedAt, localDateTime: asset.localDateTime, fileModifiedAt: asset.fileModifiedAt, livePhotoVideoId: asset.livePhotoVideoId, sidecarPath: asset.sidecarPath, }); const { size } = await this.storageRepository.stat(created.originalPath); await this.assetRepository.upsertExif({ assetId: created.id, fileSizeInByte: size }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: created.id, source: 'copy' } }); return created; } private requireQuota(auth: AuthDto, size: number) { if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { throw new BadRequestException('Quota has been exceeded!'); } } async checkExistingAssets( auth: AuthDto, checkExistingAssetsDto: CheckExistingAssetsDto, ): Promise { const assets = await this.assetRepository.getByDeviceIds( auth.user.id, checkExistingAssetsDto.deviceId, checkExistingAssetsDto.deviceAssetIds, ); return { existingIds: assets.map((asset) => asset.id), }; } async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise { const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum)); const results = await this.assetRepository.getByChecksums(auth.user.id, checksums); const checksumMap: Record = {}; for (const { id, checksum } of results) { checksumMap[checksum.toString('hex')] = id; } return { results: dto.assets.map(({ id, checksum }) => { const duplicate = checksumMap[fromChecksum(checksum).toString('hex')]; if (duplicate) { return { id, assetId: duplicate, action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE, }; } // TODO mime-check return { id, action: AssetUploadAction.ACCEPT, }; }), }; } }