mirror of
https://github.com/immich-app/immich.git
synced 2026-07-21 21:34:17 +03:00
Compare commits
2 Commits
main
...
fix/backup
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e67126823d | ||
|
|
24e52c6b8d |
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
@@ -10,6 +9,7 @@ import 'package:immich_mobile/domain/services/sync_linked_album.service.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/platform_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
@@ -255,11 +255,11 @@ class _BackupDelaySlider extends ConsumerWidget {
|
||||
_ => 600,
|
||||
};
|
||||
|
||||
static String formatBackupDelaySliderValue(int v) => switch (v) {
|
||||
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
|
||||
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
|
||||
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
|
||||
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
|
||||
static String formatBackupDelaySliderValue(BuildContext context, int v) => switch (v) {
|
||||
0 => context.t.setting_notifications_notify_seconds(count: 5),
|
||||
1 => context.t.setting_notifications_notify_seconds(count: 30),
|
||||
2 => context.t.setting_notifications_notify_minutes(count: 2),
|
||||
_ => context.t.setting_notifications_notify_minutes(count: 10),
|
||||
};
|
||||
|
||||
@override
|
||||
@@ -272,8 +272,8 @@ class _BackupDelaySlider extends ConsumerWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24.0, top: 8.0),
|
||||
child: Text(
|
||||
'backup_controller_page_background_delay'.tr(
|
||||
namedArgs: {'duration': formatBackupDelaySliderValue(currentValue)},
|
||||
context.t.backup_controller_page_background_delay(
|
||||
duration: formatBackupDelaySliderValue(context, currentValue),
|
||||
),
|
||||
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
@@ -291,7 +291,7 @@ class _BackupDelaySlider extends ConsumerWidget {
|
||||
max: 3.0,
|
||||
min: 0.0,
|
||||
divisions: 3,
|
||||
label: formatBackupDelaySliderValue(currentValue),
|
||||
label: formatBackupDelaySliderValue(context, currentValue),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
select
|
||||
"id",
|
||||
"expiresAt",
|
||||
"pinExpiresAt",
|
||||
"oauthBearerToken"
|
||||
"pinExpiresAt"
|
||||
from
|
||||
"session"
|
||||
where
|
||||
|
||||
@@ -83,7 +83,7 @@ export class OAuthRepository {
|
||||
url: string,
|
||||
expectedState: string,
|
||||
codeVerifier: string,
|
||||
): Promise<{ profile: OAuthProfile; sid?: string; idToken?: string }> {
|
||||
): Promise<{ profile: OAuthProfile; sid?: string }> {
|
||||
const client = await this.getClient(config);
|
||||
const pkceCodeVerifier = client.serverMetadata().supportsPKCE() ? codeVerifier : undefined;
|
||||
|
||||
@@ -111,7 +111,7 @@ export class OAuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
return { profile, sid, idToken: tokens.id_token };
|
||||
return { profile, sid };
|
||||
} catch (error: Error | any) {
|
||||
if (error.message.includes('unexpected JWT alg received')) {
|
||||
this.logger.warn(
|
||||
|
||||
@@ -32,7 +32,7 @@ export class SessionRepository {
|
||||
get(id: string) {
|
||||
return this.db
|
||||
.selectFrom('session')
|
||||
.select(['id', 'expiresAt', 'pinExpiresAt', 'oauthBearerToken'])
|
||||
.select(['id', 'expiresAt', 'pinExpiresAt'])
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" ADD "oauthBearerToken" character varying;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "session" DROP COLUMN "oauthBearerToken";`.execute(db);
|
||||
}
|
||||
@@ -55,7 +55,4 @@ export class SessionTable {
|
||||
|
||||
@Column({ nullable: true, index: true })
|
||||
oauthSid!: string | null;
|
||||
|
||||
@Column({ nullable: true })
|
||||
oauthBearerToken!: string | null;
|
||||
}
|
||||
|
||||
@@ -160,25 +160,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint/',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include the id token hint for OAuth sessions', async () => {
|
||||
const auth = AuthFactory.from().session().build();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.session.get.mockResolvedValue({
|
||||
id: auth.session!.id,
|
||||
expiresAt: null,
|
||||
oauthBearerToken: 'id-token',
|
||||
pinExpiresAt: null,
|
||||
});
|
||||
mocks.session.delete.mockResolvedValue();
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint/?id_token_hint=id-token',
|
||||
redirectUri: 'http://end-session-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,7 +173,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://custom-logout-url/',
|
||||
redirectUri: 'http://custom-logout-url',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,7 +186,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
await expect(sut.logout(auth, AuthType.OAuth)).resolves.toEqual({
|
||||
successful: true,
|
||||
redirectUri: 'http://end-session-endpoint/',
|
||||
redirectUri: 'http://end-session-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,12 +201,6 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should delete the access token', async () => {
|
||||
const auth = { user: { id: '123' }, session: { id: 'token123' } } as AuthDto;
|
||||
mocks.session.get.mockResolvedValue({
|
||||
id: auth.session!.id,
|
||||
expiresAt: null,
|
||||
oauthBearerToken: null,
|
||||
pinExpiresAt: null,
|
||||
});
|
||||
mocks.session.delete.mockResolvedValue();
|
||||
|
||||
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
|
||||
@@ -746,27 +722,6 @@ describe(AuthService.name, () => {
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
|
||||
});
|
||||
|
||||
it('should store the OAuth bearer token on the new session', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({ profile, sid: 'oauth-sid', idToken: 'oauth-bearer-token' });
|
||||
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.session.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ oauthSid: 'oauth-sid', oauthBearerToken: 'oauth-bearer-token' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should normalize the email from the OAuth profile before linking', async () => {
|
||||
const user = UserFactory.create();
|
||||
const profile = OAuthProfileFactory.create({ email: ' TEST@IMMICH.CLOUD ' });
|
||||
@@ -1170,7 +1125,6 @@ describe(AuthService.name, () => {
|
||||
mocks.oauth.getProfileAndOAuthSid.mockResolvedValue({
|
||||
profile: { sub: 'sub' },
|
||||
sid: session.oauthSid ?? undefined,
|
||||
idToken: session.oauthBearerToken ?? undefined,
|
||||
});
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.update.mockResolvedValue(session);
|
||||
@@ -1181,10 +1135,7 @@ describe(AuthService.name, () => {
|
||||
{},
|
||||
);
|
||||
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, {
|
||||
oauthSid: session.oauthSid,
|
||||
oauthBearerToken: session.oauthBearerToken,
|
||||
});
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: session.oauthSid });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: 'sub' });
|
||||
});
|
||||
|
||||
@@ -1218,7 +1169,7 @@ describe(AuthService.name, () => {
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
|
||||
});
|
||||
|
||||
it('should unlink an account and remove the OAuth data from the session', async () => {
|
||||
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();
|
||||
@@ -1229,7 +1180,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
await sut.unlink(auth);
|
||||
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null, oauthBearerToken: null });
|
||||
expect(mocks.session.update).toHaveBeenCalledWith(session.id, { oauthSid: null });
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,17 +76,14 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||
let oauthBearerToken: string | undefined;
|
||||
if (auth.session) {
|
||||
const session = await this.sessionRepository.get(auth.session.id);
|
||||
oauthBearerToken = session?.oauthBearerToken ?? undefined;
|
||||
await this.sessionRepository.delete(auth.session.id);
|
||||
await this.eventRepository.emit('SessionDelete', { sessionId: auth.session.id });
|
||||
}
|
||||
|
||||
return {
|
||||
successful: true,
|
||||
redirectUri: await this.getLogoutEndpoint(authType, oauthBearerToken),
|
||||
redirectUri: await this.getLogoutEndpoint(authType),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,11 +306,12 @@ export class AuthService extends BaseService {
|
||||
}
|
||||
|
||||
const url = this.resolveRedirectUri(oauth, dto.url);
|
||||
const {
|
||||
profile,
|
||||
sid: oauthSid,
|
||||
idToken: oauthBearerToken,
|
||||
} = await this.oauthRepository.getProfileAndOAuthSid(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)}`);
|
||||
@@ -380,7 +378,7 @@ export class AuthService extends BaseService {
|
||||
await this.syncProfilePicture(user, profile.picture);
|
||||
}
|
||||
|
||||
return this.createLoginResponse(user, loginDetails, oauthSid, oauthBearerToken);
|
||||
return this.createLoginResponse(user, loginDetails, oauthSid);
|
||||
}
|
||||
|
||||
private async syncProfilePicture(user: UserAdmin, url: string) {
|
||||
@@ -421,7 +419,6 @@ export class AuthService extends BaseService {
|
||||
const {
|
||||
profile: { sub: oauthId },
|
||||
sid,
|
||||
idToken,
|
||||
} = await this.oauthRepository.getProfileAndOAuthSid(oauth, dto.url, expectedState, codeVerifier);
|
||||
const duplicate = await this.userRepository.getByOAuthId(oauthId);
|
||||
if (duplicate && duplicate.id !== auth.user.id) {
|
||||
@@ -429,11 +426,8 @@ export class AuthService extends BaseService {
|
||||
throw new BadRequestException('This OAuth account has already been linked to another user.');
|
||||
}
|
||||
|
||||
if (auth.session && (sid || idToken)) {
|
||||
await this.sessionRepository.update(auth.session.id, {
|
||||
oauthSid: sid,
|
||||
oauthBearerToken: idToken,
|
||||
});
|
||||
if (auth.session && sid) {
|
||||
await this.sessionRepository.update(auth.session.id, { oauthSid: sid });
|
||||
}
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, { oauthId });
|
||||
@@ -442,14 +436,14 @@ export class AuthService extends BaseService {
|
||||
|
||||
async unlink(auth: AuthDto): Promise<UserAdminResponseDto> {
|
||||
if (auth.session) {
|
||||
await this.sessionRepository.update(auth.session.id, { oauthSid: null, oauthBearerToken: null });
|
||||
await this.sessionRepository.update(auth.session.id, { oauthSid: null });
|
||||
}
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, { oauthId: '' });
|
||||
return mapUserAdmin(user);
|
||||
}
|
||||
|
||||
private async getLogoutEndpoint(authType: AuthType, oauthBearerToken?: string | null): Promise<string> {
|
||||
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
|
||||
if (authType !== AuthType.OAuth) {
|
||||
return LOGIN_URL;
|
||||
}
|
||||
@@ -459,20 +453,11 @@ export class AuthService extends BaseService {
|
||||
return LOGIN_URL;
|
||||
}
|
||||
|
||||
const endSessionEndpoint =
|
||||
config.oauth.endSessionEndpoint || (await this.oauthRepository.getLogoutEndpoint(config.oauth));
|
||||
|
||||
if (!endSessionEndpoint) {
|
||||
return LOGIN_URL;
|
||||
if (config.oauth.endSessionEndpoint) {
|
||||
return config.oauth.endSessionEndpoint;
|
||||
}
|
||||
|
||||
const url = new URL(endSessionEndpoint);
|
||||
|
||||
if (oauthBearerToken) {
|
||||
url.searchParams.set('id_token_hint', oauthBearerToken);
|
||||
}
|
||||
|
||||
return url.href;
|
||||
return (await this.oauthRepository.getLogoutEndpoint(config.oauth)) || LOGIN_URL;
|
||||
}
|
||||
|
||||
private getBearerToken(headers: IncomingHttpHeaders): string | null {
|
||||
@@ -614,12 +599,7 @@ export class AuthService extends BaseService {
|
||||
await this.sessionRepository.update(auth.session.id, { pinExpiresAt: null });
|
||||
}
|
||||
|
||||
private async createLoginResponse(
|
||||
user: UserAdmin,
|
||||
loginDetails: LoginDetails,
|
||||
oauthSid?: string,
|
||||
oauthBearerToken?: string,
|
||||
) {
|
||||
private async createLoginResponse(user: UserAdmin, loginDetails: LoginDetails, oauthSid?: string) {
|
||||
const token = this.cryptoRepository.randomBytesAsText(32);
|
||||
const hashed = this.cryptoRepository.hashSha256(token);
|
||||
|
||||
@@ -630,7 +610,6 @@ export class AuthService extends BaseService {
|
||||
appVersion: loginDetails.appVersion,
|
||||
userId: user.id,
|
||||
oauthSid: oauthSid ?? null,
|
||||
oauthBearerToken: oauthBearerToken ?? null,
|
||||
});
|
||||
|
||||
return mapLoginResponse(user, token);
|
||||
|
||||
@@ -26,7 +26,6 @@ export class SessionFactory {
|
||||
updatedAt: newDate(),
|
||||
userId: newUuid(),
|
||||
oauthSid: newUuid(),
|
||||
oauthBearerToken: 'oauth-bearer-token',
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ const authFactory = ({
|
||||
user,
|
||||
}: {
|
||||
apiKey?: Partial<AuthApiKey>;
|
||||
session?: { id?: string; hasElevatedPermission?: boolean; oauthBearerToken?: string | null };
|
||||
session?: { id?: string; hasElevatedPermission?: boolean };
|
||||
user?: Omit<
|
||||
Partial<UserAdmin>,
|
||||
'createdAt' | 'updatedAt' | 'deletedAt' | 'fileCreatedAt' | 'fileModifiedAt' | 'localDateTime' | 'profileChangedAt'
|
||||
|
||||
Reference in New Issue
Block a user