mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
refactor(server): immich file responses (#5641)
* refactor(server): immich file response * chore: open api * chore: tests * chore: fix logger import --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
af7c4ae090
commit
cbca69841a
25 changed files with 178 additions and 146 deletions
4
cli/src/api/open-api/api.ts
generated
4
cli/src/api/open-api/api.ts
generated
|
@ -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<object>> {
|
||||
async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
|
||||
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<object> {
|
||||
getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
|
||||
return localVarFp.getProfileImage(requestParameters.id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
|
|
BIN
mobile/openapi/doc/AssetApi.md
generated
BIN
mobile/openapi/doc/AssetApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/PersonApi.md
generated
BIN
mobile/openapi/doc/PersonApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/UserApi.md
generated
BIN
mobile/openapi/doc/UserApi.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/user_api.dart
generated
BIN
mobile/openapi/lib/api/user_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/user_api_test.dart
generated
BIN
mobile/openapi/test/user_api_test.dart
generated
Binary file not shown.
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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<ImmichReadStream> {
|
||||
async downloadFile(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
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<DownloadResponseDto> {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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']));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<ImmichReadStream> {
|
||||
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
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<AssetResponseDto[]> {
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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<ImmichReadStream> {
|
||||
async getProfileImage(id: string): Promise<ImmichFileResponse> {
|
||||
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<string | undefined>) {
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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<Response['sendFile']>;
|
||||
type SendFileOptions = SendFile[1];
|
||||
|
||||
// TODO: move file sending logic to an interceptor
|
||||
const sendFile = (res: Response, path: string, options: SendFileOptions) =>
|
||||
promisify<string, SendFileOptions>(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<ImmichFileResponse> {
|
||||
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<ImmichFileResponse> {
|
||||
// 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<string[]> {
|
||||
|
@ -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<void> {
|
||||
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;
|
||||
|
|
|
@ -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<Request>();
|
||||
const userAgent = UAParser(req.headers['user-agent']);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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<StreamableFile> {
|
||||
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<ImmichFileResponse> {
|
||||
return this.service.downloadFile(auth, id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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<any> {
|
||||
return this.service.getProfileImage(id).then(asStreamableFile);
|
||||
@FileResponse()
|
||||
getProfileImage(@Param() { id }: UUIDParamDto): Promise<ImmichFileResponse> {
|
||||
return this.service.getProfileImage(id);
|
||||
}
|
||||
}
|
||||
|
|
55
server/src/immich/interceptors/file-serve.interceptor.ts
Normal file
55
server/src/immich/interceptors/file-serve.interceptor.ts
Normal file
|
@ -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<Response['sendFile']>;
|
||||
type SendFileOptions = SendFile[1];
|
||||
|
||||
export class FileServeInterceptor implements NestInterceptor {
|
||||
private logger = new Logger(FileServeInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
|
||||
const http = context.switchToHttp();
|
||||
const res = http.getResponse<Response>();
|
||||
|
||||
const sendFile = (path: string, options: SendFileOptions) =>
|
||||
promisify<string, SendFileOptions>(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;
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,2 +1,3 @@
|
|||
export * from './error.interceptor';
|
||||
export * from './file.interceptor';
|
||||
export * from './file-serve.interceptor';
|
||||
export * from './file-upload.interceptor';
|
||||
|
|
4
web/src/api/open-api/api.ts
generated
4
web/src/api/open-api/api.ts
generated
|
@ -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<object>> {
|
||||
async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
|
||||
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<object> {
|
||||
getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
|
||||
return localVarFp.getProfileImage(requestParameters.id, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
|
|
Loading…
Reference in a new issue