Skip to content

Commit d7a782d

Browse files
Etienne-bdtjrasm91
andauthored
feat: sync pictureFile with oidc if it isn't set already (#17397)
* feat: sync pictureFile with oidc if it isn't set already fix: move picture writer to get userId fix: move await promise to the top of the setPicure function before checking its value and automatically create the user folder chore: code cleanup * fix: extension double dot --------- Co-authored-by: Jason Rasmussen <[email protected]>
1 parent 08b5952 commit d7a782d

File tree

5 files changed

+133
-5
lines changed

5 files changed

+133
-5
lines changed

server/src/repositories/oauth.repository.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ export class OAuthRepository {
6363
}
6464
}
6565

66+
async getProfilePicture(url: string) {
67+
const response = await fetch(url);
68+
if (!response.ok) {
69+
throw new Error(`Failed to fetch picture: ${response.statusText}`);
70+
}
71+
72+
return {
73+
data: await response.arrayBuffer(),
74+
contentType: response.headers.get('content-type'),
75+
};
76+
}
77+
6678
private async getClient({
6779
issuerUrl,
6880
clientId,

server/src/services/auth.service.spec.ts

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,25 @@ import { AuthService } from 'src/services/auth.service';
77
import { UserMetadataItem } from 'src/types';
88
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
99
import { systemConfigStub } from 'test/fixtures/system-config.stub';
10-
import { factory } from 'test/small.factory';
10+
import { factory, newUuid } from 'test/small.factory';
1111
import { newTestService, ServiceMocks } from 'test/utils';
1212

13-
const oauthResponse = ({ id, email, name }: { id: string; email: string; name: string }) => ({
13+
const oauthResponse = ({
14+
id,
15+
email,
16+
name,
17+
profileImagePath,
18+
}: {
19+
id: string;
20+
email: string;
21+
name: string;
22+
profileImagePath?: string;
23+
}) => ({
1424
accessToken: 'cmFuZG9tLWJ5dGVz',
1525
userId: id,
1626
userEmail: email,
1727
name,
18-
profileImagePath: '',
28+
profileImagePath,
1929
isAdmin: false,
2030
shouldChangePassword: false,
2131
});
@@ -707,6 +717,58 @@ describe(AuthService.name, () => {
707717
storageLabel: null,
708718
});
709719
});
720+
721+
it('should sync the profile picture', async () => {
722+
const fileId = newUuid();
723+
const user = factory.userAdmin({ oauthId: 'oauth-id' });
724+
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
725+
726+
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
727+
mocks.oauth.getProfile.mockResolvedValue({
728+
sub: user.oauthId,
729+
email: user.email,
730+
picture: pictureUrl,
731+
});
732+
mocks.user.getByOAuthId.mockResolvedValue(user);
733+
mocks.crypto.randomUUID.mockReturnValue(fileId);
734+
mocks.oauth.getProfilePicture.mockResolvedValue({
735+
contentType: 'image/jpeg',
736+
data: new Uint8Array([1, 2, 3, 4, 5]),
737+
});
738+
mocks.user.update.mockResolvedValue(user);
739+
mocks.session.create.mockResolvedValue(factory.session());
740+
741+
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
742+
oauthResponse(user),
743+
);
744+
745+
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
746+
profileImagePath: `upload/profile/${user.id}/${fileId}.jpg`,
747+
profileChangedAt: expect.any(Date),
748+
});
749+
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
750+
});
751+
752+
it('should not sync the profile picture if the user already has one', async () => {
753+
const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
754+
755+
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
756+
mocks.oauth.getProfile.mockResolvedValue({
757+
sub: user.oauthId,
758+
email: user.email,
759+
picture: 'https://auth.immich.cloud/profiles/1.jpg',
760+
});
761+
mocks.user.getByOAuthId.mockResolvedValue(user);
762+
mocks.user.update.mockResolvedValue(user);
763+
mocks.session.create.mockResolvedValue(factory.session());
764+
765+
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
766+
oauthResponse(user),
767+
);
768+
769+
expect(mocks.user.update).not.toHaveBeenCalled();
770+
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
771+
});
710772
});
711773

712774
describe('link', () => {

server/src/services/auth.service.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { isString } from 'class-validator';
33
import { parse } from 'cookie';
44
import { DateTime } from 'luxon';
55
import { IncomingHttpHeaders } from 'node:http';
6+
import { join } from 'node:path';
67
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
8+
import { StorageCore } from 'src/cores/storage.core';
79
import { UserAdmin } from 'src/database';
810
import { OnEvent } from 'src/decorators';
911
import {
@@ -18,12 +20,12 @@ import {
1820
mapLoginResponse,
1921
} from 'src/dtos/auth.dto';
2022
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
21-
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, Permission } from 'src/enum';
23+
import { AuthType, ImmichCookie, ImmichHeader, ImmichQuery, JobName, Permission, StorageFolder } from 'src/enum';
2224
import { OAuthProfile } from 'src/repositories/oauth.repository';
2325
import { BaseService } from 'src/services/base.service';
2426
import { isGranted } from 'src/utils/access';
2527
import { HumanReadableSize } from 'src/utils/bytes';
26-
28+
import { mimeTypes } from 'src/utils/mime-types';
2729
export interface LoginDetails {
2830
isSecure: boolean;
2931
clientIp: string;
@@ -239,9 +241,36 @@ export class AuthService extends BaseService {
239241
});
240242
}
241243

244+
if (!user.profileImagePath && profile.picture) {
245+
await this.syncProfilePicture(user, profile.picture);
246+
}
247+
242248
return this.createLoginResponse(user, loginDetails);
243249
}
244250

251+
private async syncProfilePicture(user: UserAdmin, url: string) {
252+
try {
253+
const oldPath = user.profileImagePath;
254+
255+
const { contentType, data } = await this.oauthRepository.getProfilePicture(url);
256+
const extensionWithDot = mimeTypes.toExtension(contentType || 'image/jpeg') ?? 'jpg';
257+
const profileImagePath = join(
258+
StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
259+
`${this.cryptoRepository.randomUUID()}${extensionWithDot}`,
260+
);
261+
262+
this.storageCore.ensureFolders(profileImagePath);
263+
await this.storageRepository.createFile(profileImagePath, Buffer.from(data));
264+
await this.userRepository.update(user.id, { profileImagePath, profileChangedAt: new Date() });
265+
266+
if (oldPath) {
267+
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldPath] } });
268+
}
269+
} catch (error: Error | any) {
270+
this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack);
271+
}
272+
}
273+
245274
async link(auth: AuthDto, dto: OAuthCallbackDto): Promise<UserAdminResponseDto> {
246275
const { oauth } = await this.getConfig({ withCache: false });
247276
const { sub: oauthId } = await this.oauthRepository.getProfile(

server/src/utils/mime-types.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,20 @@ describe('mimeTypes', () => {
101101
});
102102
}
103103

104+
describe('toExtension', () => {
105+
it('should get an extension for a png file', () => {
106+
expect(mimeTypes.toExtension('image/png')).toEqual('.png');
107+
});
108+
109+
it('should get an extension for a jpeg file', () => {
110+
expect(mimeTypes.toExtension('image/jpeg')).toEqual('.jpg');
111+
});
112+
113+
it('should get an extension from a webp file', () => {
114+
expect(mimeTypes.toExtension('image/webp')).toEqual('.webp');
115+
});
116+
});
117+
104118
describe('profile', () => {
105119
it('should contain only lowercase mime types', () => {
106120
const keys = Object.keys(mimeTypes.profile);

server/src/utils/mime-types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ const image: Record<string, string[]> = {
5555
'.webp': ['image/webp'],
5656
};
5757

58+
const extensionOverrides: Record<string, string> = {
59+
'image/jpeg': '.jpg',
60+
};
61+
5862
/**
5963
* list of supported image extensions from https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Image_types excluding svg
6064
* @TODO share with the client
@@ -104,6 +108,11 @@ const types = { ...image, ...video, ...sidecar };
104108
const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r;
105109

106110
const lookup = (filename: string) => types[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream';
111+
const toExtension = (mimeType: string) => {
112+
return (
113+
extensionOverrides[mimeType] || Object.entries(types).find(([, mimeTypes]) => mimeTypes.includes(mimeType))?.[0]
114+
);
115+
};
107116

108117
export const mimeTypes = {
109118
image,
@@ -120,6 +129,8 @@ export const mimeTypes = {
120129
isVideo: (filename: string) => isType(filename, video),
121130
isRaw: (filename: string) => isType(filename, raw),
122131
lookup,
132+
/** return an extension (including a leading `.`) for a mime-type */
133+
toExtension,
123134
assetType: (filename: string) => {
124135
const contentType = lookup(filename);
125136
if (contentType.startsWith('image/')) {

0 commit comments

Comments
 (0)