import { HttpException, StreamableFile } from '@nestjs/common'; import { NextFunction, Response } from 'express'; import { access, constants } from 'node:fs/promises'; import { basename, extname, isAbsolute } from 'node:path'; import { promisify } from 'node:util'; import { CacheControl } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { ImmichReadStream } from 'src/repositories/storage.repository'; import { isConnectionAborted } from 'src/utils/misc'; export function getFileNameWithoutExtension(path: string): string { return basename(path, extname(path)); } export function getFilenameExtension(path: string): string { return extname(path); } export function getLivePhotoMotionFilename(stillName: string, motionName: string) { return getFileNameWithoutExtension(stillName) + extname(motionName); } export class ImmichFileResponse { public readonly path!: string; public readonly contentType!: string; public readonly cacheControl!: CacheControl; public readonly fileName?: string; constructor(response: ImmichFileResponse) { Object.assign(this, response); } } type SendFile = Parameters; type SendFileOptions = SendFile[1]; const cacheControlHeaders: Record = { [CacheControl.PRIVATE_WITH_CACHE]: 'private, max-age=86400, no-transform', [CacheControl.PRIVATE_WITHOUT_CACHE]: 'private, no-cache, no-transform', [CacheControl.NONE]: null, // falsy value to prevent adding Cache-Control header }; export const sendFile = async ( res: Response, next: NextFunction, handler: () => Promise, logger: LoggingRepository, ): Promise => { // promisified version of 'res.sendFile' for cleaner async handling const _sendFile = (path: string, options: SendFileOptions) => promisify(res.sendFile).bind(res)(path, options); try { const file = await handler(); const cacheControlHeader = cacheControlHeaders[file.cacheControl]; if (cacheControlHeader) { // set the header to Cache-Control res.set('Cache-Control', cacheControlHeader); } res.header('Content-Type', file.contentType); if (file.fileName) { res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`); } // configure options for serving const options: SendFileOptions = { dotfiles: 'allow' }; if (!isAbsolute(file.path)) { options.root = process.cwd(); } await access(file.path, constants.R_OK); return await _sendFile(file.path, options); } catch (error: Error | any) { // ignore client-closed connection if (isConnectionAborted(error) || res.headersSent) { return; } // log non-http errors if (error instanceof HttpException === false) { logger.error(`Unable to send file: ${error.name}`, error.stack); } res.header('Cache-Control', 'none'); next(error); } }; export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { return new StreamableFile(stream, { type, length }); };