refactor(server): user profile picture (#4728)

This commit is contained in:
Jason Rasmussen 2023-10-30 19:38:34 -04:00 committed by GitHub
parent 431536cdbb
commit 3212a47720
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 49 additions and 30 deletions

View File

@ -123,15 +123,6 @@ export class UserCore {
} }
} }
async createProfileImage(authUser: AuthUserDto, filePath: string): Promise<UserEntity> {
try {
return this.userRepository.update(authUser.id, { profileImagePath: filePath });
} catch (e) {
Logger.error(e, 'Create User Profile Image');
throw new InternalServerErrorException('Failed to create new user profile image');
}
}
async restoreUser(authUser: AuthUserDto, userToRestore: UserEntity): Promise<UserEntity> { async restoreUser(authUser: AuthUserDto, userToRestore: UserEntity): Promise<UserEntity> {
if (!authUser.isAdmin) { if (!authUser.isAdmin) {
throw new ForbiddenException('Unauthorized'); throw new ForbiddenException('Unauthorized');

View File

@ -16,6 +16,7 @@ import {
userStub, userStub,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { Readable } from 'stream';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { JobName } from '../job'; import { JobName } from '../job';
import { import {
@ -461,7 +462,7 @@ describe(UserService.name, () => {
it('should throw an error if the user does not exist', async () => { it('should throw an error if the user does not exist', async () => {
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(null);
await expect(sut.getProfileImage(adminUserAuth.id)).rejects.toBeInstanceOf(NotFoundException); await expect(sut.getProfileImage(adminUserAuth.id)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.get).toHaveBeenCalledWith(adminUserAuth.id); expect(userMock.get).toHaveBeenCalledWith(adminUserAuth.id);
}); });
@ -473,6 +474,18 @@ describe(UserService.name, () => {
expect(userMock.get).toHaveBeenCalledWith(adminUserAuth.id); expect(userMock.get).toHaveBeenCalledWith(adminUserAuth.id);
}); });
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 });
expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id);
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/profile.jpg', 'image/jpeg');
});
}); });
describe('resetAdminPassword', () => { describe('resetAdminPassword', () => {

View File

@ -1,8 +1,6 @@
import { UserEntity } from '@app/infra/entities'; import { UserEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { ReadStream, constants, createReadStream } from 'fs';
import fs from 'fs/promises';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { IEntityJob, JobName } from '../job'; import { IEntityJob, JobName } from '../job';
import { import {
@ -13,6 +11,7 @@ import {
ILibraryRepository, ILibraryRepository,
IStorageRepository, IStorageRepository,
IUserRepository, IUserRepository,
ImmichReadStream,
} from '../repositories'; } from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore, StorageFolder } from '../storage';
import { CreateUserDto, UpdateUserDto } from './dto'; import { CreateUserDto, UpdateUserDto } from './dto';
@ -41,8 +40,8 @@ export class UserService {
return users.map(mapUser); return users.map(mapUser);
} }
async get(userId: string, withDeleted = false): Promise<UserResponseDto> { async get(userId: string): Promise<UserResponseDto> {
const user = await this.userRepository.get(userId, withDeleted); const user = await this.userRepository.get(userId, false);
if (!user) { if (!user) {
throw new NotFoundException('User not found'); throw new NotFoundException('User not found');
} }
@ -97,20 +96,16 @@ export class UserService {
authUser: AuthUserDto, authUser: AuthUserDto,
fileInfo: Express.Multer.File, fileInfo: Express.Multer.File,
): Promise<CreateProfileImageResponseDto> { ): Promise<CreateProfileImageResponseDto> {
const updatedUser = await this.userCore.createProfileImage(authUser, fileInfo.path); const updatedUser = await this.userRepository.update(authUser.id, { profileImagePath: fileInfo.path });
return mapCreateProfileImageResponse(updatedUser.id, updatedUser.profileImagePath); return mapCreateProfileImageResponse(updatedUser.id, updatedUser.profileImagePath);
} }
async getProfileImage(userId: string): Promise<ReadStream> { async getProfileImage(id: string): Promise<ImmichReadStream> {
const user = await this.userRepository.get(userId); const user = await this.findOrFail(id);
if (!user) {
throw new NotFoundException('User not found');
}
if (!user.profileImagePath) { if (!user.profileImagePath) {
throw new NotFoundException('User does not have a profile image'); throw new NotFoundException('User does not have a profile image');
} }
await fs.access(user.profileImagePath, constants.R_OK); return this.storageRepository.createReadStream(user.profileImagePath, 'image/jpeg');
return createReadStream(user.profileImagePath);
} }
async resetAdminPassword(ask: (admin: UserResponseDto) => Promise<string | undefined>) { async resetAdminPassword(ask: (admin: UserResponseDto) => Promise<string | undefined>) {
@ -185,4 +180,12 @@ export class UserService {
return msSinceDelete >= msDeleteWait; return msSinceDelete >= msDeleteWait;
} }
private async findOrFail(id: string) {
const user = await this.userRepository.get(id);
if (!user) {
throw new BadRequestException('User not found');
}
return user;
}
} }

View File

@ -17,16 +17,13 @@ import {
Post, Post,
Put, Put,
Query, Query,
Response,
StreamableFile,
UploadedFile, UploadedFile,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { AdminRoute, AuthUser, Authenticated } from '../app.guard'; import { AdminRoute, AuthUser, Authenticated } from '../app.guard';
import { FileUploadInterceptor, Route } from '../app.interceptor'; import { FileUploadInterceptor, Route } from '../app.interceptor';
import { UseValidation } from '../app.utils'; import { UseValidation, asStreamableFile } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('User') @ApiTags('User')
@ -88,9 +85,7 @@ export class UserController {
@Get('profile-image/:id') @Get('profile-image/:id')
@Header('Cache-Control', 'private, no-cache, no-transform') @Header('Cache-Control', 'private, no-cache, no-transform')
async getProfileImage(@Param() { id }: UUIDParamDto, @Response({ passthrough: true }) res: Res): Promise<any> { getProfileImage(@Param() { id }: UUIDParamDto): Promise<any> {
const readableStream = await this.service.getProfileImage(id); return this.service.getProfileImage(id).then(asStreamableFile);
res.header('Content-Type', 'image/jpeg');
return new StreamableFile(readableStream);
} }
} }

View File

@ -104,4 +104,21 @@ export const userStub = {
assets: [], assets: [],
memoriesEnabled: true, memoriesEnabled: true,
}), }),
profilePath: Object.freeze<UserEntity>({
...authStub.user1,
password: 'immich_password',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: 'label-1',
externalPath: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '/path/to/profile.jpg',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
memoriesEnabled: true,
}),
}; };