diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index b6fb87e1df..e74361de77 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -17843,7 +17843,7 @@ export const UserApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getProfileImage(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -17945,7 +17945,7 @@ export const UserApiFactory = function (configuration?: Configuration, basePath? * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise { + getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.getProfileImage(requestParameters.id, options).then((request) => request(axios, basePath)); }, /** diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index b479c08f34..fb2ad2411c 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -725,7 +725,7 @@ Name | Type | Description | Notes ### HTTP request headers - **Content-Type**: Not defined - - **Accept**: image/jpeg, image/webp + - **Accept**: application/octet-stream [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index 6ad2d19853..f9e3100186 100644 --- a/mobile/openapi/doc/PersonApi.md +++ b/mobile/openapi/doc/PersonApi.md @@ -343,7 +343,7 @@ Name | Type | Description | Notes ### HTTP request headers - **Content-Type**: Not defined - - **Accept**: image/jpeg + - **Accept**: application/octet-stream [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/UserApi.md b/mobile/openapi/doc/UserApi.md index 6d2a028da4..62f3148061 100644 --- a/mobile/openapi/doc/UserApi.md +++ b/mobile/openapi/doc/UserApi.md @@ -343,7 +343,7 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **getProfileImage** -> Object getProfileImage(id) +> MultipartFile getProfileImage(id) @@ -384,7 +384,7 @@ Name | Type | Description | Notes ### Return type -[**Object**](Object.md) +[**MultipartFile**](MultipartFile.md) ### Authorization @@ -393,7 +393,7 @@ Name | Type | Description | Notes ### HTTP request headers - **Content-Type**: Not defined - - **Accept**: application/json + - **Accept**: application/octet-stream [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index 26ab3dcd0d..4e1ba8effb 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/mobile/openapi/lib/api/user_api.dart @@ -327,7 +327,7 @@ class UserApi { /// Parameters: /// /// * [String] id (required): - Future getProfileImage(String id,) async { + Future getProfileImage(String id,) async { final response = await getProfileImageWithHttpInfo(id,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); @@ -336,7 +336,7 @@ class UserApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object; + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; } return null; diff --git a/mobile/openapi/test/user_api_test.dart b/mobile/openapi/test/user_api_test.dart index 26ebf3d7ed..b0a3ba85f1 100644 --- a/mobile/openapi/test/user_api_test.dart +++ b/mobile/openapi/test/user_api_test.dart @@ -47,7 +47,7 @@ void main() { // TODO }); - //Future getProfileImage(String id) async + //Future getProfileImage(String id) async test('test getProfileImage', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 43ac6615bf..8007e91a87 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1465,6 +1465,15 @@ "get": { "operationId": "serveFile", "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "isThumb", "required": false, @@ -1483,15 +1492,6 @@ "type": "boolean" } }, - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - }, { "name": "key", "required": false, @@ -1926,13 +1926,7 @@ "responses": { "200": { "content": { - "image/jpeg": { - "schema": { - "format": "binary", - "type": "string" - } - }, - "image/webp": { + "application/octet-stream": { "schema": { "format": "binary", "type": "string" @@ -4499,7 +4493,7 @@ "responses": { "200": { "content": { - "image/jpeg": { + "application/octet-stream": { "schema": { "format": "binary", "type": "string" @@ -6080,9 +6074,10 @@ "responses": { "200": { "content": { - "application/json": { + "application/octet-stream": { "schema": { - "type": "object" + "format": "binary", + "type": "string" } } }, diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index c065352f8c..f3dab855c7 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -16,6 +16,7 @@ import { } from '@test'; import { when } from 'jest-when'; import { Readable } from 'stream'; +import { ImmichFileResponse } from '../domain.util'; import { JobName } from '../job'; import { AssetStats, @@ -474,15 +475,16 @@ describe(AssetService.name, () => { }); it('should download a file', async () => { - const stream = new Readable(); - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); assetMock.getByIds.mockResolvedValue([assetStub.image]); - storageMock.createReadStream.mockResolvedValue({ stream }); - await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream }); - - expect(storageMock.createReadStream).toHaveBeenCalledWith(assetStub.image.originalPath, 'image/jpeg'); + await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual( + new ImmichFileResponse({ + path: '/original/path.jpg', + contentType: 'image/jpeg', + cacheControl: false, + }), + ); }); it('should download an archive', async () => { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 7a7ec3cef6..aad36c05cf 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -7,7 +7,7 @@ import sanitize from 'sanitize-filename'; import { AccessCore, Permission } from '../access'; import { AuthDto } from '../auth'; import { mimeTypes } from '../domain.constant'; -import { HumanReadableSize, usePagination } from '../domain.util'; +import { HumanReadableSize, ImmichFileResponse, usePagination } from '../domain.util'; import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { CommunicationEvent, @@ -274,7 +274,7 @@ export class AssetService { return { ...options, userIds }; } - async downloadFile(auth: AuthDto, id: string): Promise { + async downloadFile(auth: AuthDto, id: string): Promise { await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); const [asset] = await this.assetRepository.getByIds([id]); @@ -286,7 +286,11 @@ export class AssetService { throw new BadRequestException('Asset is offline'); } - return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath)); + return new ImmichFileResponse({ + path: asset.originalPath, + contentType: mimeTypes.lookup(asset.originalPath), + cacheControl: false, + }); } async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 5ef6da6a94..fae08fe8cf 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -16,6 +16,16 @@ import { CronJob } from 'cron'; import { basename, extname } from 'node:path'; import sanitize from 'sanitize-filename'; +export class ImmichFileResponse { + public readonly path!: string; + public readonly contentType!: string; + public readonly cacheControl!: boolean; + + constructor(response: ImmichFileResponse) { + Object.assign(this, response); + } +} + export interface OpenGraphTags { title: string; description: string; diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 81baa6a835..5154bd43de 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -18,6 +18,7 @@ import { personStub, } from '@test'; import { BulkIdErrorReason } from '../asset'; +import { ImmichFileResponse } from '../domain.util'; import { JobName } from '../job'; import { IAssetRepository, @@ -203,8 +204,13 @@ describe(PersonService.name, () => { it('should serve the thumbnail', async () => { personMock.getById.mockResolvedValue(personStub.noName); accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); - await sut.getThumbnail(authStub.admin, 'person-1'); - expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg'); + await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual( + new ImmichFileResponse({ + path: '/path/to/thumbnail.jpg', + contentType: 'image/jpeg', + cacheControl: true, + }), + ); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 1d7ef86f18..b2d4b9c34f 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -5,7 +5,7 @@ import { AccessCore, Permission } from '../access'; import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset'; import { AuthDto } from '../auth'; import { mimeTypes } from '../domain.constant'; -import { usePagination } from '../domain.util'; +import { ImmichFileResponse, usePagination } from '../domain.util'; import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { FACE_THUMBNAIL_SIZE } from '../media'; import { @@ -20,7 +20,6 @@ import { ISmartInfoRepository, IStorageRepository, ISystemConfigRepository, - ImmichReadStream, UpdateFacesData, WithoutProperty, } from '../repositories'; @@ -173,14 +172,18 @@ export class PersonService { return this.repository.getStatistics(id); } - async getThumbnail(auth: AuthDto, id: string): Promise { + async getThumbnail(auth: AuthDto, id: string): Promise { await this.access.requirePermission(auth, Permission.PERSON_READ, id); const person = await this.repository.getById(id); if (!person || !person.thumbnailPath) { throw new NotFoundException(); } - return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath)); + return new ImmichFileResponse({ + path: person.thumbnailPath, + contentType: mimeTypes.lookup(person.thumbnailPath), + cacheControl: true, + }); } async getAssets(auth: AuthDto, id: string): Promise { diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index 2758e3eda5..082acb82c1 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -17,7 +17,7 @@ import { userStub, } from '@test'; import { when } from 'jest-when'; -import { Readable } from 'stream'; +import { ImmichFileResponse } from '../domain.util'; import { JobName } from '../job'; import { IAlbumRepository, @@ -390,15 +390,17 @@ describe(UserService.name, () => { }); it('should return the profile picture', async () => { - const stream = new Readable(); - userMock.get.mockResolvedValue(userStub.profilePath); - storageMock.createReadStream.mockResolvedValue({ stream }); - await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual({ stream }); + await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual( + new ImmichFileResponse({ + path: '/path/to/profile.jpg', + contentType: 'image/jpeg', + cacheControl: false, + }), + ); expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id, {}); - expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/profile.jpg', 'image/jpeg'); }); }); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index cd4d4547c0..8cc6580293 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -2,6 +2,7 @@ import { UserEntity } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { randomBytes } from 'crypto'; import { AuthDto } from '../auth'; +import { ImmichFileResponse } from '../domain.util'; import { IEntityJob, JobName } from '../job'; import { IAlbumRepository, @@ -11,7 +12,6 @@ import { ILibraryRepository, IStorageRepository, IUserRepository, - ImmichReadStream, UserFindOptions, } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; @@ -99,12 +99,17 @@ export class UserService { await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } }); } - async getProfileImage(id: string): Promise { + async getProfileImage(id: string): Promise { const user = await this.findOrFail(id, {}); if (!user.profileImagePath) { throw new NotFoundException('User does not have a profile image'); } - return this.storageRepository.createReadStream(user.profileImagePath, 'image/jpeg'); + + return new ImmichFileResponse({ + path: user.profileImagePath, + contentType: 'image/jpeg', + cacheControl: false, + }); } async resetAdminPassword(ask: (admin: UserResponseDto) => Promise) { diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index 1060598740..fb78126aa8 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -14,9 +14,9 @@ import { UseInterceptors, ValidationPipe, } from '@nestjs/common'; -import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; -import { Auth, Authenticated, SharedLinkRoute } from '../../app.guard'; +import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../../app.guard'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from '../../interceptors'; import FileNotEmptyValidator from '../validation/file-not-empty-validator'; @@ -83,35 +83,24 @@ export class AssetController { @SharedLinkRoute() @Get('/file/:id') - @ApiOkResponse({ - content: { - 'application/octet-stream': { schema: { type: 'string', format: 'binary' } }, - }, - }) - async serveFile( + @FileResponse() + serveFile( @Auth() auth: AuthDto, - @Response() res: Res, - @Query(new ValidationPipe({ transform: true })) query: ServeFileDto, @Param() { id }: UUIDParamDto, + @Query(new ValidationPipe({ transform: true })) dto: ServeFileDto, ) { - await this.assetService.serveFile(auth, id, query, res); + return this.assetService.serveFile(auth, id, dto); } @SharedLinkRoute() @Get('/thumbnail/:id') - @ApiOkResponse({ - content: { - 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, - 'image/webp': { schema: { type: 'string', format: 'binary' } }, - }, - }) - async getAssetThumbnail( + @FileResponse() + getAssetThumbnail( @Auth() auth: AuthDto, - @Response() res: Res, @Param() { id }: UUIDParamDto, - @Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto, + @Query(new ValidationPipe({ transform: true })) dto: GetAssetThumbnailDto, ) { - await this.assetService.serveThumbnail(auth, id, query, res); + return this.assetService.serveThumbnail(auth, id, dto); } @Get('/curated-objects') diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 80cbd5a5d0..c3742a3475 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -6,7 +6,7 @@ import { IAccessRepository, IJobRepository, ILibraryRepository, - isConnectionAborted, + ImmichFileResponse, JobName, mapAsset, mimeTypes, @@ -16,12 +16,7 @@ import { } from '@app/domain'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; import { Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common'; -import { Response as Res, Response } from 'express'; -import { constants } from 'fs'; -import fs from 'fs/promises'; -import path from 'path'; import { QueryFailedError } from 'typeorm'; -import { promisify } from 'util'; import { IAssetRepository } from './asset-repository'; import { AssetCore } from './asset.core'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; @@ -41,13 +36,6 @@ import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-as import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; -type SendFile = Parameters; -type SendFileOptions = SendFile[1]; - -// TODO: move file sending logic to an interceptor -const sendFile = (res: Response, path: string, options: SendFileOptions) => - promisify(res.sendFile).bind(res)(path, options); - @Injectable() export class AssetService { readonly logger = new Logger(AssetService.name); @@ -148,7 +136,7 @@ export class AssetService { } } - async serveThumbnail(auth: AuthDto, assetId: string, query: GetAssetThumbnailDto, res: Res) { + async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise { await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId); const asset = await this._assetRepository.get(assetId); @@ -156,19 +144,12 @@ export class AssetService { throw new NotFoundException('Asset not found'); } - try { - await this.sendFile(res, this.getThumbnailPath(asset, query.format)); - } catch (e) { - res.header('Cache-Control', 'none'); - this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail'); - throw new InternalServerErrorException( - `Cannot read thumbnail file for asset ${asset.id} - contact your administrator`, - { cause: e as Error }, - ); - } + const filepath = this.getThumbnailPath(asset, dto.format); + + return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true }); } - public async serveFile(auth: AuthDto, assetId: string, query: ServeFileDto, res: Res) { + public async serveFile(auth: AuthDto, assetId: string, dto: ServeFileDto): Promise { // this is not quite right as sometimes this returns the original still await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId); @@ -181,10 +162,10 @@ export class AssetService { const filepath = asset.type === AssetType.IMAGE - ? this.getServePath(asset, query, allowOriginalFile) + ? this.getServePath(asset, dto, allowOriginalFile) : asset.encodedVideoPath || asset.originalPath; - await this.sendFile(res, filepath); + return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true }); } async getAssetSearchTerm(auth: AuthDto): Promise { @@ -292,13 +273,13 @@ export class AssetService { } } - private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): string { + private getServePath(asset: AssetEntity, dto: ServeFileDto, allowOriginalFile: boolean): string { const mimeType = mimeTypes.lookup(asset.originalPath); /** * Serve file viewer on the web */ - if (query.isWeb && mimeType != 'image/gif') { + if (dto.isWeb && mimeType != 'image/gif') { if (!asset.resizePath) { this.logger.error('Error serving IMAGE asset for web'); throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile'); @@ -310,7 +291,7 @@ export class AssetService { /** * Serve thumbnail image for both web and mobile app */ - if ((!query.isThumb && allowOriginalFile) || (query.isWeb && mimeType === 'image/gif')) { + if ((!dto.isThumb && allowOriginalFile) || (dto.isWeb && mimeType === 'image/gif')) { return asset.originalPath; } @@ -325,27 +306,6 @@ export class AssetService { return asset.resizePath; } - private async sendFile(res: Res, filepath: string): Promise { - await fs.access(filepath, constants.R_OK); - const options: SendFileOptions = { dotfiles: 'allow' }; - if (!path.isAbsolute(filepath)) { - options.root = process.cwd(); - } - - res.set('Cache-Control', 'private, max-age=86400, no-transform'); - res.header('Content-Type', mimeTypes.lookup(filepath)); - - try { - await sendFile(res, filepath, options); - } catch (error: Error | any) { - if (!isConnectionAborted(error)) { - this.logger.error(`Unable to send file: ${error.name}`, error.stack); - } - // throwing closes the connection and prevents `Error: write EPIPE` - throw error; - } - } - private async getLibraryId(auth: AuthDto, libraryId?: string) { if (libraryId) { return libraryId; diff --git a/server/src/immich/app.guard.ts b/server/src/immich/app.guard.ts index e54a9c4d28..da802ba4a3 100644 --- a/server/src/immich/app.guard.ts +++ b/server/src/immich/app.guard.ts @@ -9,7 +9,7 @@ import { createParamDecorator, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { ApiBearerAuth, ApiCookieAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { Request } from 'express'; import { UAParser } from 'ua-parser-js'; @@ -54,6 +54,11 @@ export const Auth = createParamDecorator((data, ctx: ExecutionContext): AuthDto return ctx.switchToHttp().getRequest<{ user: AuthDto }>().user; }); +export const FileResponse = () => + ApiOkResponse({ + content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } }, + }); + export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => { const req = ctx.switchToHttp().getRequest(); const userAgent = UAParser(req.headers['user-agent']); diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 3694626f26..c914a2905d 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -32,7 +32,7 @@ import { TagController, UserController, } from './controllers'; -import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; +import { ErrorInterceptor, FileServeInterceptor, FileUploadInterceptor } from './interceptors'; @Module({ imports: [ @@ -66,6 +66,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; ], providers: [ { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, + { provide: APP_INTERCEPTOR, useClass: FileServeInterceptor }, { provide: APP_GUARD, useClass: AppGuard }, { provide: IAssetRepository, useClass: AssetRepository }, AppService, diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index ae72d39922..1fe2b0d18a 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -12,6 +12,7 @@ import { BulkIdsDto, DownloadInfoDto, DownloadResponseDto, + ImmichFileResponse, MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto, @@ -37,9 +38,9 @@ import { Query, StreamableFile, } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; import { DeviceIdDto } from '../api-v1/asset/dto/device-id.dto'; -import { Auth, Authenticated, SharedLinkRoute } from '../app.guard'; +import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard'; import { UseValidation, asStreamableFile } from '../app.utils'; import { Route } from '../interceptors'; import { UUIDParamDto } from './dto/uuid-param.dto'; @@ -88,7 +89,7 @@ export class AssetController { @SharedLinkRoute() @Post('download/archive') @HttpCode(HttpStatus.OK) - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + @FileResponse() downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { return this.service.downloadArchive(auth, dto).then(asStreamableFile); } @@ -96,9 +97,9 @@ export class AssetController { @SharedLinkRoute() @Post('download/:id') @HttpCode(HttpStatus.OK) - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { - return this.service.downloadFile(auth, id).then(asStreamableFile); + @FileResponse() + downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.downloadFile(auth, id); } /** diff --git a/server/src/immich/controllers/person.controller.ts b/server/src/immich/controllers/person.controller.ts index da1bd88ff0..76eec9f1ac 100644 --- a/server/src/immich/controllers/person.controller.ts +++ b/server/src/immich/controllers/person.controller.ts @@ -3,7 +3,6 @@ import { AssetResponseDto, AuthDto, BulkIdResponseDto, - ImmichReadStream, MergePersonDto, PeopleResponseDto, PeopleUpdateDto, @@ -13,16 +12,12 @@ import { PersonStatisticsResponseDto, PersonUpdateDto, } from '@app/domain'; -import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { Auth, Authenticated } from '../app.guard'; +import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { Auth, Authenticated, FileResponse } from '../app.guard'; import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; -function asStreamableFile({ stream, type, length }: ImmichReadStream) { - return new StreamableFile(stream, { type, length }); -} - @ApiTags('Person') @Controller('person') @Authenticated() @@ -74,13 +69,9 @@ export class PersonController { } @Get(':id/thumbnail') - @ApiOkResponse({ - content: { - 'image/jpeg': { schema: { type: 'string', format: 'binary' } }, - }, - }) + @FileResponse() getPersonThumbnail(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { - return this.service.getThumbnail(auth, id).then(asStreamableFile); + return this.service.getThumbnail(auth, id); } @Get(':id/assets') diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 64b6da8516..4e48c0542d 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/server/src/immich/controllers/user.controller.ts @@ -3,6 +3,7 @@ import { CreateUserDto as CreateDto, CreateProfileImageDto, CreateProfileImageResponseDto, + ImmichFileResponse, UpdateUserDto as UpdateDto, UserResponseDto, UserService, @@ -23,8 +24,8 @@ import { UseInterceptors, } from '@nestjs/common'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; -import { AdminRoute, Auth, Authenticated } from '../app.guard'; -import { UseValidation, asStreamableFile } from '../app.utils'; +import { AdminRoute, Auth, Authenticated, FileResponse } from '../app.guard'; +import { UseValidation } from '../app.utils'; import { FileUploadInterceptor, Route } from '../interceptors'; import { UUIDParamDto } from './dto/uuid-param.dto'; @@ -93,7 +94,8 @@ export class UserController { @Get('profile-image/:id') @Header('Cache-Control', 'private, no-cache, no-transform') - getProfileImage(@Param() { id }: UUIDParamDto): Promise { - return this.service.getProfileImage(id).then(asStreamableFile); + @FileResponse() + getProfileImage(@Param() { id }: UUIDParamDto): Promise { + return this.service.getProfileImage(id); } } diff --git a/server/src/immich/interceptors/file-serve.interceptor.ts b/server/src/immich/interceptors/file-serve.interceptor.ts new file mode 100644 index 0000000000..39e9aa4d64 --- /dev/null +++ b/server/src/immich/interceptors/file-serve.interceptor.ts @@ -0,0 +1,55 @@ +import { ImmichFileResponse, isConnectionAborted } from '@app/domain'; +import { CallHandler, ExecutionContext, Logger, NestInterceptor } from '@nestjs/common'; +import { Response } from 'express'; +import { access, constants } from 'fs/promises'; +import { isAbsolute } from 'path'; +import { Observable, mergeMap } from 'rxjs'; +import { promisify } from 'util'; + +type SendFile = Parameters; +type SendFileOptions = SendFile[1]; + +export class FileServeInterceptor implements NestInterceptor { + private logger = new Logger(FileServeInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const http = context.switchToHttp(); + const res = http.getResponse(); + + const sendFile = (path: string, options: SendFileOptions) => + promisify(res.sendFile).bind(res)(path, options); + + return next.handle().pipe( + mergeMap(async (file) => { + if (file instanceof ImmichFileResponse === false) { + return file; + } + + try { + if (file.cacheControl) { + res.set('Cache-Control', 'private, max-age=86400, no-transform'); + } + + res.header('Content-Type', file.contentType); + + const options: SendFileOptions = { dotfiles: 'allow' }; + if (!isAbsolute(file.path)) { + options.root = process.cwd(); + } + + await access(file.path, constants.R_OK); + + return sendFile(file.path, options); + } catch (error: Error | any) { + res.header('Cache-Control', 'none'); + + if (!isConnectionAborted(error)) { + this.logger.error(`Unable to send file: ${error.name}`, error.stack); + } + // throwing closes the connection and prevents `Error: write EPIPE` + throw error; + } + }), + ); + } +} diff --git a/server/src/immich/interceptors/file.interceptor.ts b/server/src/immich/interceptors/file-upload.interceptor.ts similarity index 100% rename from server/src/immich/interceptors/file.interceptor.ts rename to server/src/immich/interceptors/file-upload.interceptor.ts diff --git a/server/src/immich/interceptors/index.ts b/server/src/immich/interceptors/index.ts index 27c858abad..79b370f62d 100644 --- a/server/src/immich/interceptors/index.ts +++ b/server/src/immich/interceptors/index.ts @@ -1,2 +1,3 @@ export * from './error.interceptor'; -export * from './file.interceptor'; +export * from './file-serve.interceptor'; +export * from './file-upload.interceptor'; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index b6fb87e1df..e74361de77 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -17843,7 +17843,7 @@ export const UserApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { const localVarAxiosArgs = await localVarAxiosParamCreator.getProfileImage(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, @@ -17945,7 +17945,7 @@ export const UserApiFactory = function (configuration?: Configuration, basePath? * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise { + getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.getProfileImage(requestParameters.id, options).then((request) => request(axios, basePath)); }, /**