From 034c928d9e39fa10347dccf7a7c3fbf259aaf7ea Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 27 Apr 2024 16:45:16 -0400 Subject: [PATCH] feat(server): remove inactive sessions (#9121) * feat(server): remove inactive sessions * add rudimentary unit test --------- Co-authored-by: Daniel Dietzler --- server/src/interfaces/job.interface.ts | 4 ++- server/src/interfaces/session.interface.ts | 2 ++ server/src/queries/session.repository.sql | 13 +++++++++ server/src/repositories/job.repository.ts | 1 + server/src/repositories/session.repository.ts | 9 ++++-- server/src/services/job.service.spec.ts | 1 + server/src/services/job.service.ts | 1 + server/src/services/microservices.service.ts | 3 ++ server/src/services/session.service.spec.ts | 28 +++++++++++++++++++ server/src/services/session.service.ts | 21 ++++++++++++++ server/src/utils/sql.ts | 1 + .../repositories/session.repository.mock.ts | 1 + 12 files changed, 82 insertions(+), 3 deletions(-) diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index eddaefcf38..c837919617 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -79,6 +79,7 @@ export enum JobName { // cleanup DELETE_FILES = 'delete-files', CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', + CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens', // smart search QUEUE_SMART_SEARCH = 'queue-smart-search', @@ -202,8 +203,9 @@ export type JobItem = // Filesystem | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } - // Audit Log Cleanup + // Cleanup | { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob } + | { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob } // Asset Deletion | { name: JobName.PERSON_CLEANUP; data?: IBaseJob } diff --git a/server/src/interfaces/session.interface.ts b/server/src/interfaces/session.interface.ts index 62c2ecec7b..33b48045a2 100644 --- a/server/src/interfaces/session.interface.ts +++ b/server/src/interfaces/session.interface.ts @@ -3,8 +3,10 @@ import { SessionEntity } from 'src/entities/session.entity'; export const ISessionRepository = 'ISessionRepository'; type E = SessionEntity; +export type SessionSearchOptions = { updatedBefore: Date }; export interface ISessionRepository { + search(options: SessionSearchOptions): Promise; create>(dto: T): Promise; update>(dto: T): Promise; delete(id: string): Promise; diff --git a/server/src/queries/session.repository.sql b/server/src/queries/session.repository.sql index e712c8a160..87c1b55c9b 100644 --- a/server/src/queries/session.repository.sql +++ b/server/src/queries/session.repository.sql @@ -1,5 +1,18 @@ -- NOTE: This file is auto generated by ./sql-generator +-- SessionRepository.search +SELECT + "SessionEntity"."id" AS "SessionEntity_id", + "SessionEntity"."userId" AS "SessionEntity_userId", + "SessionEntity"."createdAt" AS "SessionEntity_createdAt", + "SessionEntity"."updatedAt" AS "SessionEntity_updatedAt", + "SessionEntity"."deviceType" AS "SessionEntity_deviceType", + "SessionEntity"."deviceOS" AS "SessionEntity_deviceOS" +FROM + "sessions" "SessionEntity" +WHERE + (("SessionEntity"."updatedAt" <= $1)) + -- SessionRepository.getByToken SELECT DISTINCT "distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id" diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 858798b88d..09c4d54751 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -26,6 +26,7 @@ export const JOBS_TO_QUEUE: Record = { [JobName.USER_DELETION]: QueueName.BACKGROUND_TASK, [JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK, [JobName.CLEAN_OLD_AUDIT_LOGS]: QueueName.BACKGROUND_TASK, + [JobName.CLEAN_OLD_SESSION_TOKENS]: QueueName.BACKGROUND_TASK, [JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK, [JobName.USER_SYNC_USAGE]: QueueName.BACKGROUND_TASK, diff --git a/server/src/repositories/session.repository.ts b/server/src/repositories/session.repository.ts index ed2da7a05f..97b8750510 100644 --- a/server/src/repositories/session.repository.ts +++ b/server/src/repositories/session.repository.ts @@ -2,15 +2,20 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { SessionEntity } from 'src/entities/session.entity'; -import { ISessionRepository } from 'src/interfaces/session.interface'; +import { ISessionRepository, SessionSearchOptions } from 'src/interfaces/session.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Repository } from 'typeorm'; +import { LessThanOrEqual, Repository } from 'typeorm'; @Instrumentation() @Injectable() export class SessionRepository implements ISessionRepository { constructor(@InjectRepository(SessionEntity) private repository: Repository) {} + @GenerateSql({ params: [DummyValue.DATE] }) + search(options: SessionSearchOptions): Promise { + return this.repository.find({ where: { updatedAt: LessThanOrEqual(options.updatedBefore) } }); + } + @GenerateSql({ params: [DummyValue.STRING] }) getByToken(token: string): Promise { return this.repository.findOne({ where: { token }, relations: { user: true } }); diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index ce7d2c00e4..2e91ca41ef 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -72,6 +72,7 @@ describe(JobService.name, () => { { name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.USER_SYNC_USAGE }, { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, + { name: JobName.CLEAN_OLD_SESSION_TOKENS }, ]); }); }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 13d367994b..7a56cd61dd 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -207,6 +207,7 @@ export class JobService { { name: JobName.CLEAN_OLD_AUDIT_LOGS }, { name: JobName.USER_SYNC_USAGE }, { name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } }, + { name: JobName.CLEAN_OLD_SESSION_TOKENS }, ]); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 7bea8c3770..a2782b6d9c 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -8,6 +8,7 @@ import { LibraryService } from 'src/services/library.service'; import { MediaService } from 'src/services/media.service'; import { MetadataService } from 'src/services/metadata.service'; import { PersonService } from 'src/services/person.service'; +import { SessionService } from 'src/services/session.service'; import { SmartInfoService } from 'src/services/smart-info.service'; import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; @@ -27,6 +28,7 @@ export class MicroservicesService { private metadataService: MetadataService, private personService: PersonService, private smartInfoService: SmartInfoService, + private sessionService: SessionService, private storageTemplateService: StorageTemplateService, private storageService: StorageService, private userService: UserService, @@ -42,6 +44,7 @@ export class MicroservicesService { [JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(), [JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data), [JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(), + [JobName.CLEAN_OLD_SESSION_TOKENS]: () => this.sessionService.handleCleanup(), [JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(), [JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data), [JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(), diff --git a/server/src/services/session.service.spec.ts b/server/src/services/session.service.spec.ts index 0b54564da6..ca3d2fd858 100644 --- a/server/src/services/session.service.spec.ts +++ b/server/src/services/session.service.spec.ts @@ -1,3 +1,5 @@ +import { UserEntity } from 'src/entities/user.entity'; +import { JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; import { SessionService } from 'src/services/session.service'; @@ -26,6 +28,32 @@ describe('SessionService', () => { expect(sut).toBeDefined(); }); + describe('handleCleanup', () => { + it('should return skipped if nothing is to be deleted', async () => { + sessionMock.search.mockResolvedValue([]); + await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SKIPPED); + expect(sessionMock.search).toHaveBeenCalled(); + }); + + it('should delete sessions', async () => { + sessionMock.search.mockResolvedValue([ + { + createdAt: new Date('1970-01-01T00:00:00.00Z'), + updatedAt: new Date('1970-01-02T00:00:00.00Z'), + deviceOS: '', + deviceType: '', + id: '123', + token: '420', + user: {} as UserEntity, + userId: '42', + }, + ]); + + await expect(sut.handleCleanup()).resolves.toEqual(JobStatus.SUCCESS); + expect(sessionMock.delete).toHaveBeenCalledWith('123'); + }); + }); + describe('getAll', () => { it('should get the devices', async () => { sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]); diff --git a/server/src/services/session.service.ts b/server/src/services/session.service.ts index 7ee454d7b4..f72bf194c1 100644 --- a/server/src/services/session.service.ts +++ b/server/src/services/session.service.ts @@ -1,8 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; +import { DateTime } from 'luxon'; import { AccessCore, Permission } from 'src/cores/access.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { IAccessRepository } from 'src/interfaces/access.interface'; +import { JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; @@ -19,6 +21,25 @@ export class SessionService { this.access = AccessCore.create(accessRepository); } + async handleCleanup() { + const sessions = await this.sessionRepository.search({ + updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(), + }); + + if (sessions.length === 0) { + return JobStatus.SKIPPED; + } + + for (const session of sessions) { + await this.sessionRepository.delete(session.id); + this.logger.verbose(`Deleted expired session token: ${session.deviceOS}/${session.deviceType}`); + } + + this.logger.log(`Deleted ${sessions.length} expired session tokens`); + + return JobStatus.SUCCESS; + } + async getAll(auth: AuthDto): Promise { const sessions = await this.sessionRepository.getByUserId(auth.user.id); return sessions.map((session) => mapSession(session, auth.session?.id)); diff --git a/server/src/utils/sql.ts b/server/src/utils/sql.ts index 36b485b88a..6bf85d1553 100644 --- a/server/src/utils/sql.ts +++ b/server/src/utils/sql.ts @@ -79,6 +79,7 @@ class SqlGenerator { imports: [ TypeOrmModule.forRoot({ ...databaseConfig, + host: 'localhost', entities, logging: ['query'], logger: this.sqlLogger, diff --git a/server/test/repositories/session.repository.mock.ts b/server/test/repositories/session.repository.mock.ts index d510eb53f7..e24d4c87dd 100644 --- a/server/test/repositories/session.repository.mock.ts +++ b/server/test/repositories/session.repository.mock.ts @@ -3,6 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newSessionRepositoryMock = (): Mocked => { return { + search: vitest.fn(), create: vitest.fn() as any, update: vitest.fn() as any, delete: vitest.fn(),