mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge remote-tracking branch 'origin/main' into feat/yucca-integration
This commit is contained in:
@@ -148,7 +148,6 @@ const createDto = Object.freeze({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
duration: '0:00:00.000000',
|
||||
}) as AssetMediaCreateDto;
|
||||
|
||||
const assetEntity = Object.freeze({
|
||||
@@ -160,7 +159,7 @@ const assetEntity = Object.freeze({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
duration: '0:00:00.000000',
|
||||
duration: null,
|
||||
files: [] as AssetFile[],
|
||||
exifInfo: {
|
||||
latitude: 49.533_547,
|
||||
|
||||
@@ -164,6 +164,32 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the custom end session endpoint if provided', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
oauth: { enabled: true, endSessionEndpoint: 'http://custom-logout-url' },
|
||||
});
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://custom-logout-url',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the auto-discovered end session endpoint if custom endpoint is not provided', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
oauth: { enabled: true, endSessionEndpoint: '' },
|
||||
});
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the default redirect', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
@@ -196,6 +222,64 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('backchannelLogout', () => {
|
||||
const dto = { logout_token: 'fake-jwt-token' };
|
||||
|
||||
it('should throw a Bad Request Exception if OAuth is not enabled', async () => {
|
||||
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.backchannelLogout(dto)).rejects.toThrow(
|
||||
'Received backchannel logout request but OAuth is not enabled',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a Bad Request Exception if the logout token validation fails', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.validateLogoutToken.mockRejectedValue(new Error('Token validation failed'));
|
||||
|
||||
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.backchannelLogout(dto)).rejects.toThrow('Error backchannel logout: token validation failed');
|
||||
});
|
||||
|
||||
it('should throw a Bad Request Exception if there are no claims in the logout token', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.validateLogoutToken.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.backchannelLogout(dto)).rejects.toThrow('Invalid logout token: no claims found');
|
||||
});
|
||||
|
||||
it('should throw a Bad Request Exception if there is neither the sub nor the sid claim', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.validateLogoutToken.mockResolvedValue({ sub: '', sid: '' });
|
||||
|
||||
await expect(sut.backchannelLogout(dto)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.backchannelLogout(dto)).rejects.toThrow(
|
||||
'Invalid logout token: it must contain either a sub or a sid claim',
|
||||
);
|
||||
});
|
||||
|
||||
it('should invalidate the OAuth session(s) if the logout token is valid', async () => {
|
||||
const claims = { sub: 'fake-sub', sid: 'fake-sid' };
|
||||
const deletedSessionIds: string[] = ['fake-session-1', 'fake-session-2'];
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.validateLogoutToken.mockResolvedValue(claims);
|
||||
mocks.session.invalidateOAuth.mockResolvedValue(deletedSessionIds);
|
||||
mocks.event.emit.mockResolvedValue(void 0);
|
||||
mocks.event.emit.mockResolvedValue(void 0);
|
||||
|
||||
await sut.backchannelLogout(dto);
|
||||
|
||||
expect(mocks.session.invalidateOAuth).toHaveBeenCalledWith({
|
||||
oauthSid: claims.sid,
|
||||
oauthId: claims.sub,
|
||||
});
|
||||
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('SessionDelete', { sessionId: 'fake-session-1' });
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('SessionDelete', { sessionId: 'fake-session-2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminSignUp', () => {
|
||||
const dto: SignUpDto = { email: 'test@immich.com', password: 'password', name: 'immich admin' };
|
||||
|
||||
@@ -250,6 +334,7 @@ describe(AuthService.name, () => {
|
||||
user: UserFactory.create(),
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
oauthSid: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
@@ -416,6 +501,7 @@ describe(AuthService.name, () => {
|
||||
user: UserFactory.create(),
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
oauthSid: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
@@ -444,6 +530,7 @@ describe(AuthService.name, () => {
|
||||
isPendingSyncReset: false,
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
oauthSid: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
@@ -466,6 +553,7 @@ describe(AuthService.name, () => {
|
||||
isPendingSyncReset: false,
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
oauthSid: null,
|
||||
};
|
||||
|
||||
mocks.session.getByToken.mockResolvedValue(sessionWithToken);
|
||||
@@ -601,7 +689,7 @@ describe(AuthService.name, () => {
|
||||
it('should not allow auto registering', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -619,7 +707,7 @@ describe(AuthService.name, () => {
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
@@ -634,11 +722,31 @@ describe(AuthService.name, () => {
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
||||
});
|
||||
|
||||
it('should normalize the email from the OAuth profile before linking', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
|
||||
{},
|
||||
loginDetails,
|
||||
);
|
||||
|
||||
expect(mocks.user.getByEmail).toHaveBeenCalledWith('test@immich.cloud');
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
||||
});
|
||||
|
||||
it('should not link to a user with a different oauth sub', async () => {
|
||||
const user = UserFactory.create({ oauthId: 'existing-sub' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
|
||||
@@ -659,7 +767,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback(
|
||||
@@ -678,7 +786,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: { sub: 'sub' } });
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -700,12 +808,12 @@ describe(AuthService.name, () => {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||
|
||||
expect(mocks.oauth.getProfile).toHaveBeenCalledWith(
|
||||
expect(mocks.oauth.getProfileAndOAuthSid).toHaveBeenCalledWith(
|
||||
expect.objectContaining({}),
|
||||
'http://mobile-redirect?code=abc123',
|
||||
'xyz789',
|
||||
@@ -718,7 +826,7 @@ describe(AuthService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
@@ -733,9 +841,9 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should infer name from given and family names', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(
|
||||
OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
|
||||
);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
|
||||
});
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
@@ -754,7 +862,7 @@ describe(AuthService.name, () => {
|
||||
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create());
|
||||
@@ -771,7 +879,9 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 'abc' }));
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ immich_quota: 'abc' }),
|
||||
});
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
@@ -788,7 +898,9 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should ignore a negative quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ immich_quota: -5 }),
|
||||
});
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
@@ -805,7 +917,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should set quota for 0 quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 0 }) });
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
@@ -822,7 +934,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 5 }));
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create({ immich_quota: 5 }) });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
@@ -842,14 +954,15 @@ describe(AuthService.name, () => {
|
||||
const fileId = newUuid();
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
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.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.crypto.randomUUID.mockReturnValue(fileId);
|
||||
mocks.oauth.getProfilePicture.mockResolvedValue({
|
||||
contentType: 'image/jpeg',
|
||||
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
|
||||
data: pictureBytes.buffer,
|
||||
});
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
@@ -861,23 +974,54 @@ describe(AuthService.name, () => {
|
||||
);
|
||||
|
||||
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),
|
||||
});
|
||||
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.getProfileAndOAuthSid.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 () => {
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(
|
||||
OAuthProfileFactory.create({
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({
|
||||
sub: user.oauthId,
|
||||
email: user.email,
|
||||
picture: 'https://auth.immich.cloud/profiles/1.jpg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
@@ -894,7 +1038,9 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should only allow "admin" and "user" for the role claim', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'foo' }));
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ immich_role: 'foo' }),
|
||||
});
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
@@ -912,7 +1058,9 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should create an admin user if the role claim is set to admin', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ immich_role: 'admin' }),
|
||||
});
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
@@ -931,7 +1079,9 @@ describe(AuthService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
|
||||
});
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: OAuthProfileFactory.create({ my_role: 'admin' }),
|
||||
});
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
|
||||
@@ -954,7 +1104,7 @@ describe(AuthService.name, () => {
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(profile);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile });
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
await sut.link(
|
||||
@@ -966,13 +1116,36 @@ describe(AuthService.name, () => {
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
|
||||
});
|
||||
|
||||
it('should link an account and update the session with the oauthSid', async () => {
|
||||
const user = UserFactory.create();
|
||||
const session = SessionFactory.create();
|
||||
const auth = AuthFactory.from(user).session(session).build();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: { sub: 'sub' },
|
||||
sid: session.oauthSid ?? undefined,
|
||||
});
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.update.mockResolvedValue(session);
|
||||
|
||||
await sut.link(
|
||||
auth,
|
||||
{ url: 'http://immich/user-settings?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.create({ permissions: [] });
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile: OAuthProfileFactory.create() });
|
||||
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
|
||||
|
||||
await expect(
|
||||
@@ -995,6 +1168,21 @@ describe(AuthService.name, () => {
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
|
||||
});
|
||||
|
||||
it('should unlink an account and remove the oauthSid from the session', async () => {
|
||||
const user = UserFactory.create();
|
||||
const session = SessionFactory.create();
|
||||
const auth = AuthFactory.from(user).session(session).build();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.session.update.mockResolvedValue(session);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
|
||||
await sut.unlink(auth);
|
||||
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('setupPinCode', () => {
|
||||
|
||||
@@ -2,9 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, UnauthorizedExcept
|
||||
import { parse } from 'cookie';
|
||||
import { DateTime } from 'luxon';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
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 {
|
||||
AuthDto,
|
||||
@@ -12,6 +10,7 @@ import {
|
||||
ChangePasswordDto,
|
||||
LoginCredentialDto,
|
||||
LogoutResponseDto,
|
||||
OAuthBackchannelLogoutDto,
|
||||
OAuthCallbackDto,
|
||||
OAuthConfigDto,
|
||||
PinCodeChangeDto,
|
||||
@@ -22,12 +21,12 @@ import {
|
||||
mapLoginResponse,
|
||||
} from 'src/dtos/auth.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 { BaseService } from 'src/services/base.service';
|
||||
import { isGranted } from 'src/utils/access';
|
||||
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';
|
||||
export interface LoginDetails {
|
||||
isSecure: boolean;
|
||||
@@ -91,6 +90,40 @@ export class AuthService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async backchannelLogout(dto: OAuthBackchannelLogoutDto): Promise<void> {
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
if (!oauth.enabled) {
|
||||
throw new BadRequestException('Received backchannel logout request but OAuth is not enabled');
|
||||
}
|
||||
|
||||
let claims;
|
||||
try {
|
||||
claims = await this.oauthRepository.validateLogoutToken(oauth, dto.logout_token);
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Error backchannel logout: ${error.message}`);
|
||||
this.logger.error(error);
|
||||
|
||||
throw new BadRequestException('Error backchannel logout: token validation failed');
|
||||
}
|
||||
|
||||
if (!claims) {
|
||||
throw new BadRequestException('Invalid logout token: no claims found');
|
||||
}
|
||||
|
||||
if (!claims.sub && !claims.sid) {
|
||||
throw new BadRequestException('Invalid logout token: it must contain either a sub or a sid claim');
|
||||
}
|
||||
|
||||
const deletedSessionIds = await this.sessionRepository.invalidateOAuth({
|
||||
oauthSid: claims.sid,
|
||||
oauthId: claims.sub,
|
||||
});
|
||||
|
||||
for (const sessionId of deletedSessionIds) {
|
||||
await this.eventRepository.emit('SessionDelete', { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
|
||||
const { password, newPassword } = dto;
|
||||
const user = await this.userRepository.getForChangePassword(auth.user.id);
|
||||
@@ -276,14 +309,20 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
const url = this.resolveRedirectUri(oauth, dto.url);
|
||||
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
|
||||
const { profile, sid: oauthSid } = await this.oauthRepository.getProfileAndOAuthSid(
|
||||
oauth,
|
||||
url,
|
||||
expectedState,
|
||||
codeVerifier,
|
||||
);
|
||||
const normalizedEmail = profile.email ? profile.email.trim().toLowerCase() : undefined;
|
||||
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
|
||||
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
|
||||
let user: UserAdmin | undefined = await this.userRepository.getByOAuthId(profile.sub);
|
||||
|
||||
// link by email
|
||||
if (!user && profile.email) {
|
||||
const emailUser = await this.userRepository.getByEmail(profile.email);
|
||||
if (!user && normalizedEmail) {
|
||||
const emailUser = await this.userRepository.getByEmail(normalizedEmail);
|
||||
if (emailUser) {
|
||||
if (emailUser.oauthId) {
|
||||
throw new BadRequestException('User already exists, but is linked to another account.');
|
||||
@@ -296,17 +335,16 @@ export class AuthService extends BaseService {
|
||||
if (!user) {
|
||||
if (!autoRegister) {
|
||||
this.logger.warn(
|
||||
`Unable to register ${profile.sub}/${profile.email || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
|
||||
`Unable to register ${profile.sub}/${normalizedEmail || '(no email)'}. To enable set OAuth Auto Register to true in admin settings.`,
|
||||
);
|
||||
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
|
||||
}
|
||||
|
||||
const email = profile.email;
|
||||
if (!email) {
|
||||
if (!normalizedEmail) {
|
||||
throw new BadRequestException('OAuth profile does not have an email address');
|
||||
}
|
||||
|
||||
this.logger.log(`Registering new user: ${profile.sub}/${profile.email}`);
|
||||
this.logger.log(`Registering new user: ${profile.sub}/${normalizedEmail}`);
|
||||
|
||||
const storageLabel = this.getClaim(profile, {
|
||||
key: storageLabelClaim,
|
||||
@@ -329,8 +367,8 @@ export class AuthService extends BaseService {
|
||||
profile.name ||
|
||||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
|
||||
profile.preferred_username ||
|
||||
email,
|
||||
email,
|
||||
normalizedEmail,
|
||||
email: normalizedEmail,
|
||||
oauthId: profile.sub,
|
||||
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
|
||||
storageLabel: storageLabel || null,
|
||||
@@ -342,22 +380,22 @@ export class AuthService extends BaseService {
|
||||
await this.syncProfilePicture(user, profile.picture);
|
||||
}
|
||||
|
||||
return this.createLoginResponse(user, loginDetails);
|
||||
return this.createLoginResponse(user, loginDetails, oauthSid);
|
||||
}
|
||||
|
||||
private async syncProfilePicture(user: UserAdmin, url: string) {
|
||||
try {
|
||||
const oldPath = user.profileImagePath;
|
||||
const { data } = await this.oauthRepository.getProfilePicture(url);
|
||||
|
||||
const { contentType, data } = await this.oauthRepository.getProfilePicture(url);
|
||||
const extensionWithDot = mimeTypes.toExtension(contentType || 'image/jpeg') ?? 'jpg';
|
||||
const profileImagePath = join(
|
||||
StorageCore.getFolderLocation(StorageFolder.Profile, user.id),
|
||||
`${this.cryptoRepository.randomUUID()}${extensionWithDot}`,
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
const profileImagePath = await generateProfileImage(
|
||||
{ media: this.mediaRepository, crypto: this.cryptoRepository, storageCore: this.storageCore },
|
||||
config,
|
||||
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() });
|
||||
|
||||
if (oldPath) {
|
||||
@@ -380,18 +418,29 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
const { oauth } = await this.getConfig({ withCache: false });
|
||||
const { sub: oauthId } = await this.oauthRepository.getProfile(oauth, dto.url, expectedState, codeVerifier);
|
||||
const {
|
||||
profile: { sub: oauthId },
|
||||
sid,
|
||||
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
|
||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||
if (duplicate && duplicate.id !== auth.user.id) {
|
||||
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
|
||||
if (auth.session) {
|
||||
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
|
||||
}
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, { oauthId });
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
|
||||
if (auth.session) {
|
||||
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
|
||||
}
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
@@ -406,6 +455,10 @@ export class AuthService extends BaseService {
|
||||
return LOGIN_URL;
|
||||
}
|
||||
|
||||
if (config.oauth.endSessionEndpoint) {
|
||||
return config.oauth.endSessionEndpoint;
|
||||
}
|
||||
|
||||
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
|
||||
}
|
||||
|
||||
@@ -548,7 +601,7 @@ export class AuthService extends BaseService {
|
||||
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
|
||||
}
|
||||
|
||||
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails) {
|
||||
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails, oauthSid?: string) {
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
|
||||
@@ -558,6 +611,7 @@ export class AuthService extends BaseService {
|
||||
deviceType: loginDetails.deviceType,
|
||||
appVersion: loginDetails.appVersion,
|
||||
userId: user.id,
|
||||
oauthSid: oauthSid ?? null,
|
||||
});
|
||||
|
||||
return mapLoginResponse(user, token);
|
||||
|
||||
@@ -142,6 +142,61 @@ describe(DownloadService.name, () => {
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_123.jpg', 'IMG_123+1.jpg');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ input: '../../../../tmp/pwn.jpg', expected: '........tmppwn.jpg' },
|
||||
{ input: String.raw`C:\temp\abs3.jpg`, expected: 'Ctempabs3.jpg' },
|
||||
{ input: 'a/../../b.jpg', expected: 'a....b.jpg' },
|
||||
{ input: String.raw`..\..\win1.jpg`, expected: '....win1.jpg' },
|
||||
{ input: '/etc/passwd', expected: 'etcpasswd' },
|
||||
{ input: '..', expected: 'unnamed' },
|
||||
{ input: '', expected: 'unnamed' },
|
||||
])('should sanitize unsafe originalFileName "$input" to "$expected"', async ({ input, expected }) => {
|
||||
const archiveMock = {
|
||||
addFile: vitest.fn(),
|
||||
finalize: vitest.fn(),
|
||||
stream: new Readable(),
|
||||
};
|
||||
const asset = AssetFactory.create({ originalFileName: input, originalPath: '/data/library/safe.jpg' });
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getForOriginals.mockResolvedValue([asset]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id] })).resolves.toEqual({
|
||||
stream: archiveMock.stream,
|
||||
});
|
||||
|
||||
expect(archiveMock.addFile).toHaveBeenCalledWith('/data/library/safe.jpg', expected);
|
||||
});
|
||||
|
||||
it('should dedupe sanitized duplicate unsafe filenames', async () => {
|
||||
const archiveMock = {
|
||||
addFile: vitest.fn(),
|
||||
finalize: vitest.fn(),
|
||||
stream: new Readable(),
|
||||
};
|
||||
const asset1 = AssetFactory.create({
|
||||
originalFileName: '../../../tmp/pwn.jpg',
|
||||
originalPath: '/data/library/a.jpg',
|
||||
});
|
||||
const asset2 = AssetFactory.create({
|
||||
originalFileName: '../../../tmp/pwn.jpg',
|
||||
originalPath: '/data/library/b.jpg',
|
||||
});
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||
mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
|
||||
stream: archiveMock.stream,
|
||||
});
|
||||
|
||||
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/a.jpg', '......tmppwn.jpg');
|
||||
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/b.jpg', '......tmppwn+1.jpg');
|
||||
});
|
||||
|
||||
it('should resolve symlinks', async () => {
|
||||
const archiveMock = {
|
||||
addFile: vitest.fn(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { parse } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DownloadArchiveDto, DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
@@ -95,11 +96,11 @@ export class DownloadService extends BaseService {
|
||||
|
||||
const { originalPath, editedPath, originalFileName } = asset;
|
||||
|
||||
let filename = originalFileName;
|
||||
let filename = sanitize(originalFileName) || 'unnamed';
|
||||
const count = paths[filename] || 0;
|
||||
paths[filename] = count + 1;
|
||||
if (count !== 0) {
|
||||
const parsedFilename = parse(originalFileName);
|
||||
const parsedFilename = parse(filename);
|
||||
filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,11 +109,6 @@ export class ServerService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
async getTheme() {
|
||||
const { theme } = await this.getConfig({ withCache: false });
|
||||
return theme;
|
||||
}
|
||||
|
||||
async getSystemConfig(): Promise<ServerConfigDto> {
|
||||
const { setup } = this.configRepository.getEnv();
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
|
||||
@@ -46,11 +46,14 @@ describe('SessionService', () => {
|
||||
const currentSession = SessionFactory.create();
|
||||
const auth = AuthFactory.from().session(currentSession).build();
|
||||
|
||||
mocks.session.invalidate.mockResolvedValue();
|
||||
mocks.session.invalidateAll.mockResolvedValue();
|
||||
|
||||
await sut.deleteAll(auth);
|
||||
|
||||
expect(mocks.session.invalidate).toHaveBeenCalledWith({ userId: auth.user.id, excludeId: currentSession.id });
|
||||
expect(mocks.session.invalidateAll).toHaveBeenCalledWith({
|
||||
userId: auth.user.id,
|
||||
excludeId: currentSession.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export class SessionService extends BaseService {
|
||||
async deleteAll(auth: AuthDto): Promise<void> {
|
||||
const userId = auth.user.id;
|
||||
const currentSessionId = auth.session?.id;
|
||||
await this.sessionRepository.invalidate({ userId, excludeId: currentSessionId });
|
||||
await this.sessionRepository.invalidateAll({ userId, excludeId: currentSessionId });
|
||||
}
|
||||
|
||||
async lock(auth: AuthDto, id: string): Promise<void> {
|
||||
@@ -83,6 +83,6 @@ export class SessionService extends BaseService {
|
||||
|
||||
@OnEvent({ name: 'AuthChangePassword' })
|
||||
async onAuthChangePassword({ userId, currentSessionId }: ArgOf<'AuthChangePassword'>): Promise<void> {
|
||||
await this.sessionRepository.invalidate({ userId, excludeId: currentSessionId });
|
||||
await this.sessionRepository.invalidateAll({ userId, excludeId: currentSessionId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,13 +138,16 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
defaultStorageQuota: null,
|
||||
enabled: false,
|
||||
issuerUrl: '',
|
||||
endSessionEndpoint: '',
|
||||
mobileOverrideEnabled: false,
|
||||
mobileRedirectUri: '',
|
||||
prompt: '',
|
||||
scope: 'openid email profile',
|
||||
signingAlgorithm: 'RS256',
|
||||
profileSigningAlgorithm: 'none',
|
||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethod.ClientSecretPost,
|
||||
timeout: 30_000,
|
||||
allowInsecureRequests: false,
|
||||
storageLabelClaim: 'preferred_username',
|
||||
storageQuotaClaim: 'immich_quota',
|
||||
roleClaim: 'immich_role',
|
||||
|
||||
@@ -113,20 +113,34 @@ describe(UserService.name, () => {
|
||||
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 file = { path: '/profile/path' } as Express.Multer.File;
|
||||
const files = [user.profileImagePath];
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
|
||||
|
||||
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;
|
||||
|
||||
mocks.user.get.mockResolvedValue(userStub.admin);
|
||||
@@ -134,7 +148,7 @@ describe(UserService.name, () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -192,6 +206,19 @@ describe(UserService.name, () => {
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -16,7 +16,9 @@ import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf, UserMetadataItem } from 'src/types';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
||||
import { generateProfileImage } from 'src/utils/profile-image';
|
||||
|
||||
@Injectable()
|
||||
export class UserService extends BaseService {
|
||||
@@ -91,16 +93,29 @@ export class UserService extends BaseService {
|
||||
}
|
||||
|
||||
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, {
|
||||
profileImagePath: file.path,
|
||||
profileImagePath,
|
||||
profileChangedAt: new Date(),
|
||||
});
|
||||
|
||||
if (oldpath !== '') {
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldpath] } });
|
||||
}
|
||||
const toDelete = [file.path, ...(oldPath ? [oldPath] : [])];
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: toDelete } });
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
@@ -126,7 +141,7 @@ export class UserService extends BaseService {
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: user.profileImagePath,
|
||||
contentType: 'image/jpeg',
|
||||
contentType: mimeTypes.lookup(user.profileImagePath),
|
||||
cacheControl: CacheControl.None,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user