immich/server/src/utils/file.ts

92 lines
3.1 KiB
TypeScript
Raw Normal View History

2026-06-04 16:19:16 -04:00
import { HttpException, NotFoundException, StreamableFile } from '@nestjs/common';
2024-03-21 08:07:47 -05:00
import { NextFunction, Response } from 'express';
import { access, constants } from 'node:fs/promises';
import { basename, extname } from 'node:path';
2024-03-21 08:07:47 -05:00
import { promisify } from 'node:util';
2024-09-27 10:28:42 -04:00
import { CacheControl } from 'src/enum';
2025-02-10 18:47:42 -05:00
import { LoggingRepository } from 'src/repositories/logging.repository';
2025-02-11 14:08:13 -05:00
import { ImmichReadStream } from 'src/repositories/storage.repository';
2024-03-21 08:07:47 -05:00
import { isConnectionAborted } from 'src/utils/misc';
2024-03-20 22:15:09 -05:00
export function getFileNameWithoutExtension(path: string): string {
return basename(path, getFilenameExtension(path));
2024-03-20 22:15:09 -05:00
}
export function getFilenameExtension(path: string) {
const extension = extname(path);
if (!extension && path.startsWith('.') && !path.includes('.', 1)) {
return path;
}
return extension;
}
2024-03-20 22:15:09 -05:00
export function getLivePhotoMotionFilename(stillName: string, motionName: string) {
return getFileNameWithoutExtension(stillName) + getFilenameExtension(motionName);
2024-03-20 22:15:09 -05:00
}
export class ImmichFileResponse {
public readonly path!: string;
public readonly contentType!: string;
public readonly cacheControl!: CacheControl;
public readonly fileName?: string;
2024-03-20 22:15:09 -05:00
constructor(response: ImmichFileResponse) {
Object.assign(this, response);
}
}
2024-03-21 08:07:47 -05:00
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
const cacheControlHeaders: Record<CacheControl, string | null> = {
[CacheControl.PrivateWithCache]:
'private, max-age=86400, no-transform, stale-while-revalidate=2592000, stale-if-error=2592000',
2025-07-15 14:50:13 -04:00
[CacheControl.PrivateWithoutCache]: 'private, no-cache, no-transform',
[CacheControl.None]: null, // falsy value to prevent adding Cache-Control header
};
2024-03-21 08:07:47 -05:00
export const sendFile = async (
res: Response,
next: NextFunction,
feat: restore database backups (#23978) * feat: ProcessRepository#createSpawnDuplexStream * test: write tests for ProcessRepository#createSpawnDuplexStream * feat: StorageRepository#createGzip,createGunzip,createPlainReadStream * feat: backups util (args, create, restore, progress) * feat: wait on maintenance operation lock on boot * chore: use backup util from backup.service.ts test: update backup.service.ts tests with new util * feat: list/delete backups (maintenance services) * chore: open api fix: missing action in cli.service.ts * chore: add missing repositories to MaintenanceModule * refactor: move logSecret into module init * feat: initialise StorageCore in maintenance mode * feat: authenticate websocket requests in maintenance mode * test: add mock for new storage fns * feat: add MaintenanceEphemeralStateRepository refactor: cache the secret in memory * test: update service worker tests * feat: add external maintenance mode status * feat: synchronised status, restore db action * test: backup restore service tests * refactor: DRY end maintenance * feat: list and delete backup routes * feat: start action on boot * fix: should set status on restore end * refactor: add maintenanceStore to hold writables * feat: sync status to web app * feat: web impl. * test: various utils for testings * test: web e2e tests * test: e2e maintenance spec * test: update cli spec * chore: e2e lint * chore: lint fixes * chore: lint fixes * feat: start restore flow route * test: update e2e tests * chore: remove neon lights on maintenance action pages * fix: use 'startRestoreFlow' on onboarding page * chore: ignore any library folder in `docker/` * fix: load status on boot * feat: upload backups * refactor: permit any .sql(.gz) to be listed/restored * feat: download backups from list * fix: permit uploading just .sql files * feat: restore just .sql files * fix: don't show backups list if logged out * feat: system integrity check in restore flow * test: not providing failed backups in API anymore * test: util should also not try to use failedBackups * fix: actually assign inputStream * test: correct test backup prep. * fix: ensure task is defined to show error * test: fix docker cp command * test: update e2e web spec to select next button * test: update e2e api tests * test: refactor timeouts * chore: remove `showDelete` from maint. settings * chore: lint * chore: lint * fix: make sure backups are correctly sorted for clean up * test: update service spec * test: adjust e2e timeout * test: increase web timeouts for ci * chore: move gitignore changes * chore: additional filename validation * refactor: better typings for integrity API * feat: higher accuracy progress tracking * chore: delay lock retry * refactor: remove old maintenance settings * refactor: clean up tailwind classes * refactor: use while loop rather than recursive calls * test: update service specs * chore: check canParse too * chore: lint * fix: logic error causing infinite loop * refactor: use <ProgressBar /> from ui library * fix: create or overwrite file * chore: i18n pass, update progress bar * fix: wrong translation string * chore: update colour variables * test: update web test for new maint. page * chore: format, fix key * test: update tests to be more linter complaint & use new routines * chore: update onClick -> onAction, title -> breadcrumbs * fix: use wrench icon in admin settings sidebar * chore: add translation strings to accordion * chore: lint * refactor: move maintenance worker init into service * refactor: `maintenanceStatus` -> `getMaintenanceStatus` refactor: `integrityCheck` -> `detectPriorInstall` chore: add `v2.4.0` version refactor: `/backups/list` -> `/backups` refactor: use sendFile in download route refactor: use separate backups permissions chore: correct descriptions refactor: permit handler that doesn't return promise for sendfile * refactor: move status impl into service refactor: add active flag to maintenance status * refactor: split into database backup controller * test: split api e2e tests and passing * fix: move end button into authed default maint page * fix: also show in restore flow * fix: import getMaintenanceStatus * test: split web e2e tests * refactor: ensure detect install is consistently named * chore: ensure admin for detect install while out of maint. * refactor: remove state repository * test: update maint. worker service spec * test: split backup service spec * refactor: rename db backup routes * refactor: instead of param, allow bulk backup deletion * test: update sdk use in e2e test * test: correct deleteBackup call * fix: correct type for serverinstall response dto * chore: validate filename for deletion * test: wip * test: backups no longer take path param * refactor: scope util to database-backups instead of backups * fix: update worker controller with new route * chore: use new admin page actions * chore: remove stray comment * test: rename outdated test * refactor: getter pattern for maintenance secret * refactor: `createSpawnDuplexStream` -> `spawnDuplexStream` * refactor: prefer `Object.assign` * refactor: remove useless try {} block * refactor: prefer `type Props` refactor: prefer arrow function * refactor: use luxon API for minutesAgo * chore: remove change to gitignore * refactor: prefer `type Props` * refactor: remove async from onMount * refactor: use luxon toRelative for relative time * refactor: duplicate logic check * chore: open api * refactor: begin moving code into web//services * refactor: don't use template string with $t * test: use dialog role to match prompt * refactor: split actions into flow/restore * test: fix action value * refactor: move more service calls into web//services * chore: should void fn return * chore: bump 2.4.0 to 2.5.0 in controller * chore: bump 2.4.0 to 2.5.0 in controller * refactor: use events for web//services * chore: open api * chore: open api * refactor: don't await returned promise * refactor: remove redundant check * refactor: add `type: command` to actions * refactor: split backup entries into own component * refactor: split restore flow into separate components * refactor(web): split BackupDelete event * chore: stylings * chore: stylings * fix: don't log query failure on first boot * feat: support pg_dumpall backups * feat: display information about each backup * chore: i18n * feat: rollback to restore point on migrations failure * feat: health check after restore * chore: format * refactor: split health check into separate function * refactor: split health into repository test: write tests covering rollbacks * fix: omit 'health' requirement from createDbBackup * test(e2e): rollback test * fix: wrap text in backup entry * fix: don't shrink context menu button * fix: correct CREATE DB syntax for postgres * test: rename backups generated by test * feat: add filesize to backup response dto * feat: restore list * feat: ui work * fix: e2e test * fix: e2e test * pr feedback * pr feedback --------- Co-authored-by: Alex <alex.tran1502@gmail.com> Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-01-20 15:22:28 +00:00
handler: () => Promise<ImmichFileResponse> | ImmichFileResponse,
2025-02-10 18:47:42 -05:00
logger: LoggingRepository,
2024-03-21 08:07:47 -05:00
): Promise<void> => {
// promisified version of 'res.sendFile' for cleaner async handling
2024-03-21 08:07:47 -05:00
const _sendFile = (path: string, options: SendFileOptions) =>
promisify<string, SendFileOptions>(res.sendFile).bind(res)(path, options);
try {
const file = await handler();
2026-06-04 16:19:16 -04:00
await access(file.path, constants.R_OK);
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
if (cacheControlHeader) {
// set the header to Cache-Control
res.set('Cache-Control', cacheControlHeader);
2024-03-21 08:07:47 -05:00
}
res.header('Content-Type', file.contentType);
if (file.fileName) {
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
}
2024-03-21 08:07:47 -05:00
return await _sendFile(file.path, { dotfiles: 'allow' });
2024-03-21 08:07:47 -05:00
} catch (error: Error | any) {
// ignore client-closed connection
if (isConnectionAborted(error) || res.headersSent) {
2024-03-21 08:07:47 -05:00
return;
}
// log non-http errors
if (!(error instanceof HttpException)) {
logger.error(`Unable to send file: ${error}`, error.stack);
2024-03-21 08:07:47 -05:00
}
2026-06-04 16:19:16 -04:00
next(new NotFoundException());
2024-03-21 08:07:47 -05:00
}
};
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length });
};