diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts index bb2b0d9ab4..ec6e776f59 100644 --- a/server/src/interfaces/event.interface.ts +++ b/server/src/interfaces/event.interface.ts @@ -21,6 +21,9 @@ type EmitEventMap = { 'asset.tag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }]; + // session events + 'session.delete': [{ sessionId: string }]; + // user events 'user.signup': [{ notify: boolean; id: string; tempPassword?: string }]; }; @@ -43,6 +46,7 @@ export enum ClientEvent { SERVER_VERSION = 'on_server_version', CONFIG_UPDATE = 'on_config_update', NEW_RELEASE = 'on_new_release', + SESSION_DELETE = 'on_session_delete', } export interface ClientEventMap { @@ -58,6 +62,7 @@ export interface ClientEventMap { [ClientEvent.SERVER_VERSION]: ServerVersionResponseDto; [ClientEvent.CONFIG_UPDATE]: Record; [ClientEvent.NEW_RELEASE]: ReleaseNotification; + [ClientEvent.SESSION_DELETE]: string; } export enum ServerEvent { @@ -77,7 +82,7 @@ export interface IEventRepository { /** * Send to connected clients for a specific user */ - clientSend(event: E, userId: string, data: ClientEventMap[E]): void; + clientSend(event: E, room: string, data: ClientEventMap[E]): void; /** * Send to all connected clients */ diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts index 668eac48d9..9aa12e15dd 100644 --- a/server/src/repositories/event.repository.ts +++ b/server/src/repositories/event.repository.ts @@ -1,4 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { OnGatewayConnection, @@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect private server?: Server; constructor( - private authService: AuthService, + private moduleRef: ModuleRef, private eventEmitter: EventEmitter2, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { @@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect async handleConnection(client: Socket) { try { this.logger.log(`Websocket Connect: ${client.id}`); - const auth = await this.authService.authenticate({ + const auth = await this.moduleRef.get(AuthService).authenticate({ headers: client.request.headers, queryParams: {}, metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' }, }); await client.join(auth.user.id); + if (auth.session) { + await client.join(auth.session.id); + } this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id }); } catch (error: Error | any) { this.logger.error(`Websocket connection error: ${error}`, error?.stack); @@ -96,8 +100,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect } } - clientSend(event: E, userId: string, data: ClientEventMap[E]) { - this.server?.to(userId).emit(event, data); + clientSend(event: E, room: string, data: ClientEventMap[E]) { + this.server?.to(room).emit(event, data); } clientBroadcast(event: E, data: ClientEventMap[E]) { diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index f2fa0c520a..acc2d3459c 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -20,6 +21,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock'; import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock'; import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock'; @@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = { describe('AuthService', () => { let sut: AuthService; let cryptoMock: Mocked; + let eventMock: Mocked; let userMock: Mocked; let loggerMock: Mocked; let systemMock: Mocked; @@ -87,6 +90,7 @@ describe('AuthService', () => { } as any); cryptoMock = newCryptoRepositoryMock(); + eventMock = newEventRepositoryMock(); userMock = newUserRepositoryMock(); loggerMock = newLoggerRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); @@ -94,7 +98,7 @@ describe('AuthService', () => { shareMock = newSharedLinkRepositoryMock(); keyMock = newKeyRepositoryMock(); - sut = new AuthService(cryptoMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock); + sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock); }); it('should be defined', () => { @@ -208,6 +212,7 @@ describe('AuthService', () => { }); expect(sessionMock.delete).toHaveBeenCalledWith('token123'); + expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' }); }); it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => { diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 2b25decc07..6eaf755d0e 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -34,6 +34,7 @@ import { UserEntity } from 'src/entities/user.entity'; import { Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface'; @@ -75,6 +76,7 @@ export class AuthService { constructor( @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -114,6 +116,7 @@ export class AuthService { async logout(auth: AuthDto, authType: AuthType): Promise { if (auth.session) { await this.sessionRepository.delete(auth.session.id); + await this.eventRepository.emit('session.delete', { sessionId: auth.session.id }); } return { diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index 5bcead0ff3..9d9f8f5fcf 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { AssetFileType, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; +import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface'; @@ -17,6 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub'; import { userStub } from 'test/fixtures/user.stub'; import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; +import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock'; @@ -64,6 +66,7 @@ const configs = { describe(NotificationService.name, () => { let albumMock: Mocked; let assetMock: Mocked; + let eventMock: Mocked; let jobMock: Mocked; let loggerMock: Mocked; let notificationMock: Mocked; @@ -74,13 +77,23 @@ describe(NotificationService.name, () => { beforeEach(() => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); + eventMock = newEventRepositoryMock(); jobMock = newJobRepositoryMock(); loggerMock = newLoggerRepositoryMock(); notificationMock = newNotificationRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock); + sut = new NotificationService( + eventMock, + systemMock, + notificationMock, + userMock, + jobMock, + loggerMock, + assetMock, + albumMock, + ); }); it('should work', () => { diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index 274c91661c..d450f8dc75 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { AlbumEntity } from 'src/entities/album.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; -import { ArgOf } from 'src/interfaces/event.interface'; +import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IEmailJob, IJobRepository, @@ -30,6 +30,7 @@ export class NotificationService { private configCore: SystemConfigCore; constructor( + @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository, @Inject(INotificationRepository) private notificationRepository: INotificationRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @@ -74,6 +75,12 @@ export class NotificationService { await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } }); } + @OnEmit({ event: 'session.delete' }) + onSessionDelete({ sessionId }: ArgOf<'session.delete'>) { + // after the response is sent + setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500); + } + async sendTestEmail(id: string, dto: SystemConfigSmtpDto) { const user = await this.userRepository.get(id, { withDeleted: false }); if (!user) { diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 044a81b222..ad8801ff3f 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -1,15 +1,17 @@ @@ -153,7 +145,7 @@ {/if} {#if shouldShowAccountInfoPanel} - + {/if} diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index 6422983d94..d398ca52a9 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -1,3 +1,5 @@ +import { AppRoute } from '$lib/constants'; +import { handleLogout } from '$lib/utils/auth'; import { createEventEmitter } from '$lib/utils/eventemitter'; import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk'; import { io, type Socket } from 'socket.io-client'; @@ -24,6 +26,7 @@ export interface Events { on_server_version: (serverVersion: ServerVersionResponseDto) => void; on_config_update: () => void; on_new_release: (newRelase: ReleaseEvent) => void; + on_session_delete: (sessionId: string) => void; } const websocket: Socket = io({ @@ -47,6 +50,7 @@ websocket .on('disconnect', () => websocketStore.connected.set(false)) .on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion)) .on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion)) + .on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN)) .on('connect_error', (e) => console.log('Websocket Connect Error', e)); export const openWebsocketConnection = () => { diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts index d37f1bb960..0ac1658948 100644 --- a/web/src/lib/utils/auth.ts +++ b/web/src/lib/utils/auth.ts @@ -1,7 +1,9 @@ import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { foldersStore } from '$lib/stores/folders.store'; import { purchaseStore } from '$lib/stores/purchase.store'; import { serverInfo } from '$lib/stores/server-info.store'; -import { preferences as preferences$, user as user$ } from '$lib/stores/user.store'; +import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store'; import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk'; import { redirect } from '@sveltejs/kit'; import { DateTime } from 'luxon'; @@ -87,3 +89,16 @@ export const getAccountAge = (): number => { return Number(accountAge); }; + +export const handleLogout = async (redirectUri: string) => { + try { + if (redirectUri.startsWith('/')) { + await goto(redirectUri); + } else { + window.location.href = redirectUri; + } + } finally { + resetSavedUser(); + foldersStore.clearCache(); + } +};