import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; import { StorageCore } from 'src/cores/storage.core'; import { AuthSharedLink } from 'src/database'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, AssetMediaStatus, AssetRejectReason, AssetUploadAction, } from 'src/dtos/asset-media-response.dto'; import { AssetBulkUploadCheckDto, AssetMediaCreateDto, AssetMediaOptionsDto, AssetMediaSize, UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFileType, AssetVisibility, CacheControl, ChecksumAlgorithm, JobName, Permission, StorageFolder, } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { BaseService } from 'src/services/base.service'; import { UploadFile, UploadRequest } from 'src/types'; import { requireUploadAccess } from 'src/utils/access'; import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util'; import { isAssetChecksumConstraint } from 'src/utils/database'; import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; export interface AssetMediaRedirectResponse { targetSize: AssetMediaSize | 'original'; } @Injectable() export class AssetMediaService extends BaseService { async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise { if (!checksum) { return; } const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum)); if (!assetId) { return; } return { id: assetId, status: AssetMediaStatus.DUPLICATE }; } canUploadFile({ auth, fieldName, file, body }: UploadRequest): true { requireUploadAccess(auth); const filename = body.filename || file.originalName; switch (fieldName) { case UploadFieldName.ASSET_DATA: { if (mimeTypes.isAsset(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({ auth, fieldName, file, body }: UploadRequest): string { requireUploadAccess(auth); const extension = extname(body.filename || file.originalName); const lookup = { [UploadFieldName.ASSET_DATA]: extension, [UploadFieldName.SIDECAR_DATA]: '.xmp', [UploadFieldName.PROFILE_DATA]: extension, }; return sanitize(`${file.uuid}${lookup[fieldName]}`); } getUploadFolder({ auth, fieldName, file }: UploadRequest): string { auth = requireUploadAccess(auth); let folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, file.uuid); if (fieldName === UploadFieldName.PROFILE_DATA) { folder = StorageCore.getFolderLocation(StorageFolder.Profile, auth.user.id); } this.storageRepository.mkdirSync(folder); return folder; } async onUploadError(request: AuthRequest, file: Express.Multer.File) { const uploadFilename = this.getUploadFilename(asUploadRequest(request, file)); const uploadFolder = this.getUploadFolder(asUploadRequest(request, file)); const uploadPath = `${uploadFolder}/${uploadFilename}`; await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } }); } async uploadAsset( auth: AuthDto, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile, ): Promise { try { await this.requireAccess({ auth, permission: Permission.AssetUpload, // do not need an id here, but the interface requires it ids: [auth.user.id], }); this.requireQuota(auth, file.size); if (dto.livePhotoVideoId) { await onBeforeLink( { asset: this.assetRepository, event: this.eventRepository }, { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId }, ); } const asset = await this.create(auth.user.id, dto, file, sidecarFile); if (auth.sharedLink) { await this.addToSharedLink(auth.sharedLink, asset.id); } await this.userRepository.updateUsage(auth.user.id, file.size); return { id: asset.id, status: AssetMediaStatus.CREATED }; } catch (error: any) { return this.handleUploadError(error, auth, file, sidecarFile); } } async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] }); if (auth.sharedLink) { dto.edited = true; } const { originalPath, originalFileName, editedPath } = await this.assetRepository.getForOriginal( id, dto.edited ?? false, ); const path = editedPath ?? originalPath!; return new ImmichFileResponse({ path, fileName: getFileNameWithoutExtension(originalFileName) + getFilenameExtension(path), contentType: mimeTypes.lookup(path), cacheControl: CacheControl.PrivateWithCache, }); } async viewThumbnail( auth: AuthDto, id: string, dto: AssetMediaOptionsDto, ): Promise { await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); if (dto.size === AssetMediaSize.Original) { throw new BadRequestException('May not request original file'); } if (auth.sharedLink) { dto.edited = true; } const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType; const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail( id, size, dto.edited ?? false, ); if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) { // use original file for web supported images return { targetSize: 'original' }; } if (dto.size === AssetMediaSize.FULLSIZE && !path) { // downgrade to preview if fullsize is not available. // e.g. disabled or not yet (re)generated return { targetSize: AssetMediaSize.PREVIEW }; } if (!path) { throw new NotFoundException('Asset media not found'); } const fileNameBase = auth.sharedLink && !auth.sharedLink.showExif ? id : getFileNameWithoutExtension(originalFileName); const fileName = `${fileNameBase}_${size}${getFilenameExtension(path)}`; return new ImmichFileResponse({ fileName, path, contentType: mimeTypes.lookup(path), cacheControl: CacheControl.PrivateWithCache, }); } async playbackVideo(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); const asset = await this.assetRepository.getForVideo(id); if (!asset) { throw new NotFoundException('Asset not found or asset is not a video'); } const filepath = asset.encodedVideoPath || asset.originalPath; return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: CacheControl.PrivateWithCache, }); } 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, deletedAt, checksum } of results) { checksumMap[checksum.toString('hex')] = { id, isTrashed: !!deletedAt }; } return { results: dto.assets.map(({ id, checksum }) => { const duplicate = checksumMap[fromChecksum(checksum).toString('hex')]; if (duplicate) { return { id, action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE, assetId: duplicate.id, isTrashed: duplicate.isTrashed, }; } return { id, action: AssetUploadAction.ACCEPT, }; }), }; } private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) { await (sharedLink.albumId ? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId]) : this.sharedLinkRepository.addAssets(sharedLink.id, [assetId])); } private async handleUploadError( error: any, auth: AuthDto, file: UploadFile, sidecarFile?: UploadFile, ): Promise { // clean up files await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [file.originalPath, sidecarFile?.originalPath] }, }); // handle duplicates with a success response if (isAssetChecksumConstraint(error)) { 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(); } if (auth.sharedLink) { await this.addToSharedLink(auth.sharedLink, duplicateId); } this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`); return { status: AssetMediaStatus.DUPLICATE, id: duplicateId }; } this.logger.error(`Error uploading file ${error}`, error?.stack); throw error; } private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) { const asset = await this.assetRepository.create({ ownerId, libraryId: null, checksum: file.checksum, checksumAlgorithm: ChecksumAlgorithm.sha1File, originalPath: file.originalPath, fileCreatedAt: dto.fileCreatedAt, fileModifiedAt: dto.fileModifiedAt, localDateTime: dto.fileCreatedAt, type: mimeTypes.assetType(file.originalPath), isFavorite: dto.isFavorite, duration: dto.duration || null, visibility: dto.visibility ?? AssetVisibility.Timeline, livePhotoVideoId: dto.livePhotoVideoId, originalFileName: dto.filename || file.originalName, }); if (dto.metadata?.length) { await this.assetRepository.upsertMetadata(asset.id, dto.metadata); } if (sidecarFile) { await this.assetRepository.upsertFile({ assetId: asset.id, path: sidecarFile.originalPath, type: AssetFileType.Sidecar, }); await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt)); } await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ exif: { assetId: asset.id, fileSizeInByte: file.size }, lockedPropertiesBehavior: 'override', }); await this.eventRepository.emit('AssetCreate', { asset }); await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } }); return asset; } private requireQuota(auth: AuthDto, size: number) { if (auth.user.quotaSizeInBytes !== null && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) { throw new BadRequestException('Quota has been exceeded!'); } } }