fix: run profile picture through thumbnail pipeline (#27890)

* fix: run profile picture through thumbnail pipeline

* fix: format
This commit is contained in:
bo0tzz
2026-04-17 22:15:59 +02:00
committed by GitHub
parent dbf30b77bf
commit a46e46452c
5 changed files with 136 additions and 24 deletions
+34 -2
View File
@@ -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 () => {
+9 -11
View File
@@ -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) {
+32 -5
View File
@@ -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', () => {
+21 -6
View File
@@ -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,
}); });
} }
+40
View File
@@ -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;
};