mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fix: run profile picture through thumbnail pipeline (#27890)
* fix: run profile picture through thumbnail pipeline * fix: format
This commit is contained in:
@@ -928,6 +928,7 @@ describe(AuthService.name, () => {
|
|||||||
const fileId = newUuid();
|
const fileId = newUuid();
|
||||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
||||||
|
const pictureBytes = new Uint8Array([1, 2, 3, 4, 5]);
|
||||||
|
|
||||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||||
@@ -935,7 +936,7 @@ describe(AuthService.name, () => {
|
|||||||
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
||||||
mocks.oauth.getProfilePicture.mockResolvedValue({
|
mocks.oauth.getProfilePicture.mockResolvedValue({
|
||||||
contentType: 'image/jpeg',
|
contentType: 'image/jpeg',
|
||||||
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
|
data: pictureBytes.buffer,
|
||||||
});
|
});
|
||||||
mocks.user.update.mockResolvedValue(user);
|
mocks.user.update.mockResolvedValue(user);
|
||||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
@@ -947,10 +948,41 @@ describe(AuthService.name, () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
|
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
|
||||||
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
|
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
|
||||||
profileChangedAt: expect.any(Date),
|
profileChangedAt: expect.any(Date),
|
||||||
});
|
});
|
||||||
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
|
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
|
||||||
|
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||||
|
Buffer.from(pictureBytes.buffer),
|
||||||
|
expect.objectContaining({ format: 'webp', processInvalidImages: false }),
|
||||||
|
expect.stringContaining(`/data/profile/${user.id}/${fileId}.webp`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not update the user when thumbnail processing fails on the OAuth picture', async () => {
|
||||||
|
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||||
|
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
|
||||||
|
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||||
|
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||||
|
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||||
|
mocks.oauth.getProfilePicture.mockResolvedValue({
|
||||||
|
contentType: 'text/html',
|
||||||
|
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
|
||||||
|
});
|
||||||
|
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
|
||||||
|
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
sut.callback(
|
||||||
|
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||||
|
{},
|
||||||
|
loginDetails,
|
||||||
|
),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
|
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not sync the profile picture if the user already has one', async () => {
|
it('should not sync the profile picture if the user already has one', async () => {
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
|
|||||||
import { parse } from 'cookie';
|
import { parse } from 'cookie';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { IncomingHttpHeaders } from 'node:http';
|
import { IncomingHttpHeaders } from 'node:http';
|
||||||
import { join } from 'node:path';
|
|
||||||
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
|
||||||
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
import { AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||||
import {
|
import {
|
||||||
AuthDto,
|
AuthDto,
|
||||||
@@ -23,12 +21,12 @@ import {
|
|||||||
mapLoginResponse,
|
mapLoginResponse,
|
||||||
} from 'src/dtos/auth.dto';
|
} from 'src/dtos/auth.dto';
|
||||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission, StorageFolder } from 'src/enum';
|
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission } from 'src/enum';
|
||||||
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
import { OAuthProfile } from 'src/repositories/oauth.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { isGranted } from 'src/utils/access';
|
import { isGranted } from 'src/utils/access';
|
||||||
import { HumanReadableSize } from 'src/utils/bytes';
|
import { HumanReadableSize } from 'src/utils/bytes';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { generateProfileImage } from 'src/utils/profile-image';
|
||||||
import { getUserAgentDetails } from 'src/utils/request';
|
import { getUserAgentDetails } from 'src/utils/request';
|
||||||
export interface LoginDetails {
|
export interface LoginDetails {
|
||||||
isSecure: boolean;
|
isSecure: boolean;
|
||||||
@@ -388,16 +386,16 @@ export class AuthService extends BaseService {
|
|||||||
private async syncProfilePicture(user: UserAdmin, url: string) {
|
private async syncProfilePicture(user: UserAdmin, url: string) {
|
||||||
try {
|
try {
|
||||||
const oldPath = user.profileImagePath;
|
const oldPath = user.profileImagePath;
|
||||||
|
const { data } = await this.oauthRepository.getProfilePicture(url);
|
||||||
|
|
||||||
const { contentType, data } = await this.oauthRepository.getProfilePicture(url);
|
const config = await this.getConfig({ withCache: true });
|
||||||
const extensionWithDot = mimeTypes.toExtension(contentType || 'image/jpeg') ?? 'jpg';
|
const profileImagePath = await generateProfileImage(
|
||||||
const profileImagePath = join(
|
{ media: this.mediaRepository, crypto: this.cryptoRepository, storageCore: this.storageCore },
|
||||||
StorageCore.getFolderLocation(StorageFolder.Profile, user.id),
|
config,
|
||||||
`${this.cryptoRepository.randomUUID()}${extensionWithDot}`,
|
user.id,
|
||||||
|
Buffer.from(data),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.storageCore.ensureFolders(profileImagePath);
|
|
||||||
await this.storageRepository.createFile(profileImagePath, Buffer.from(data));
|
|
||||||
await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() });
|
await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() });
|
||||||
|
|
||||||
if (oldPath) {
|
if (oldPath) {
|
||||||
|
|||||||
@@ -113,20 +113,34 @@ describe(UserService.name, () => {
|
|||||||
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
|
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(InternalServerErrorException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete the previous profile image', async () => {
|
it('should throw BadRequestException and clean up raw upload when thumbnail processing fails', async () => {
|
||||||
|
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||||
|
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
|
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
mocks.media.generateThumbnail.mockRejectedValue(new Error('not an image'));
|
||||||
|
|
||||||
|
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException);
|
||||||
|
|
||||||
|
expect(mocks.user.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.FileDelete, data: { files: [file.path] } }]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete the raw upload and the previous profile image', async () => {
|
||||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||||
const files = [user.profileImagePath];
|
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(user);
|
mocks.user.get.mockResolvedValue(user);
|
||||||
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
|
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
|
||||||
|
|
||||||
await sut.createProfileImage(authStub.admin, file);
|
await sut.createProfileImage(authStub.admin, file);
|
||||||
|
|
||||||
expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.FileDelete, data: { files } }]]);
|
expect(mocks.job.queue.mock.calls).toEqual([
|
||||||
|
[{ name: JobName.FileDelete, data: { files: [file.path, user.profileImagePath] } }],
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not delete the profile image if it has not been set', async () => {
|
it('should delete only the raw upload if no previous profile image is set', async () => {
|
||||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(userStub.admin);
|
mocks.user.get.mockResolvedValue(userStub.admin);
|
||||||
@@ -134,7 +148,7 @@ describe(UserService.name, () => {
|
|||||||
|
|
||||||
await sut.createProfileImage(authStub.admin, file);
|
await sut.createProfileImage(authStub.admin, file);
|
||||||
|
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
expect(mocks.job.queue.mock.calls).toEqual([[{ name: JobName.FileDelete, data: { files: [file.path] } }]]);
|
||||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -192,6 +206,19 @@ describe(UserService.name, () => {
|
|||||||
|
|
||||||
expect(mocks.user.get).toHaveBeenCalledWith(user.id, {});
|
expect(mocks.user.get).toHaveBeenCalledWith(user.id, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return the profile picture with the content-type matching the stored file', async () => {
|
||||||
|
const user = UserFactory.create({ profileImagePath: '/path/to/profile.webp' });
|
||||||
|
mocks.user.get.mockResolvedValue(user);
|
||||||
|
|
||||||
|
await expect(sut.getProfileImage(user.id)).resolves.toEqual(
|
||||||
|
new ImmichFileResponse({
|
||||||
|
path: '/path/to/profile.webp',
|
||||||
|
contentType: 'image/webp',
|
||||||
|
cacheControl: CacheControl.None,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('handleQueueUserDelete', () => {
|
describe('handleQueueUserDelete', () => {
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { UserTable } from 'src/schema/tables/user.table';
|
|||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobOf, UserMetadataItem } from 'src/types';
|
import { JobOf, UserMetadataItem } from 'src/types';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
||||||
|
import { generateProfileImage } from 'src/utils/profile-image';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService extends BaseService {
|
export class UserService extends BaseService {
|
||||||
@@ -91,16 +93,29 @@ export class UserService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
||||||
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
const { profileImagePath: oldPath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
||||||
|
|
||||||
|
let profileImagePath: string;
|
||||||
|
try {
|
||||||
|
const config = await this.getConfig({ withCache: true });
|
||||||
|
profileImagePath = await generateProfileImage(
|
||||||
|
{ media: this.mediaRepository, crypto: this.cryptoRepository, storageCore: this.storageCore },
|
||||||
|
config,
|
||||||
|
auth.user.id,
|
||||||
|
file.path,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [file.path] } });
|
||||||
|
throw new BadRequestException('Unable to process profile image', { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.userRepository.update(auth.user.id, {
|
const user = await this.userRepository.update(auth.user.id, {
|
||||||
profileImagePath: file.path,
|
profileImagePath,
|
||||||
profileChangedAt: new Date(),
|
profileChangedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (oldpath !== '') {
|
const toDelete = [file.path, ...(oldPath ? [oldPath] : [])];
|
||||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldpath] } });
|
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: toDelete } });
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -126,7 +141,7 @@ export class UserService extends BaseService {
|
|||||||
|
|
||||||
return new ImmichFileResponse({
|
return new ImmichFileResponse({
|
||||||
path: user.profileImagePath,
|
path: user.profileImagePath,
|
||||||
contentType: 'image/jpeg',
|
contentType: mimeTypes.lookup(user.profileImagePath),
|
||||||
cacheControl: CacheControl.None,
|
cacheControl: CacheControl.None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { SystemConfig } from 'src/config';
|
||||||
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
|
import { StorageFolder } from 'src/enum';
|
||||||
|
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||||
|
import { MediaRepository } from 'src/repositories/media.repository';
|
||||||
|
|
||||||
|
type Repos = {
|
||||||
|
media: MediaRepository;
|
||||||
|
crypto: CryptoRepository;
|
||||||
|
storageCore: StorageCore;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateProfileImage = async (
|
||||||
|
{ media, crypto, storageCore }: Repos,
|
||||||
|
{ image }: SystemConfig,
|
||||||
|
userId: string,
|
||||||
|
input: string | Buffer,
|
||||||
|
): Promise<string> => {
|
||||||
|
const outputPath = join(
|
||||||
|
StorageCore.getFolderLocation(StorageFolder.Profile, userId),
|
||||||
|
`${crypto.randomUUID()}.${image.thumbnail.format}`,
|
||||||
|
);
|
||||||
|
storageCore.ensureFolders(outputPath);
|
||||||
|
|
||||||
|
await media.generateThumbnail(
|
||||||
|
input,
|
||||||
|
{
|
||||||
|
colorspace: image.colorspace,
|
||||||
|
format: image.thumbnail.format,
|
||||||
|
quality: image.thumbnail.quality,
|
||||||
|
progressive: image.thumbnail.progressive,
|
||||||
|
size: image.thumbnail.size,
|
||||||
|
processInvalidImages: false,
|
||||||
|
},
|
||||||
|
outputPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user