feat(server): remove inactive sessions (#9121)

* feat(server): remove inactive sessions

* add rudimentary unit test

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Jason Rasmussen 2024-04-27 16:45:16 -04:00 committed by GitHub
parent 953896a35a
commit 034c928d9e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 82 additions and 3 deletions

View File

@ -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 }

View File

@ -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<SessionEntity[]>;
create<T extends Partial<E>>(dto: T): Promise<T>;
update<T extends Partial<E>>(dto: T): Promise<T>;
delete(id: string): Promise<void>;

View File

@ -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"

View File

@ -26,6 +26,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[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,

View File

@ -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<SessionEntity>) {}
@GenerateSql({ params: [DummyValue.DATE] })
search(options: SessionSearchOptions): Promise<SessionEntity[]> {
return this.repository.find({ where: { updatedAt: LessThanOrEqual(options.updatedBefore) } });
}
@GenerateSql({ params: [DummyValue.STRING] })
getByToken(token: string): Promise<SessionEntity | null> {
return this.repository.findOne({ where: { token }, relations: { user: true } });

View File

@ -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 },
]);
});
});

View File

@ -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 },
]);
}

View File

@ -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(),

View File

@ -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]);

View File

@ -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<SessionResponseDto[]> {
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
return sessions.map((session) => mapSession(session, auth.session?.id));

View File

@ -79,6 +79,7 @@ class SqlGenerator {
imports: [
TypeOrmModule.forRoot({
...databaseConfig,
host: 'localhost',
entities,
logging: ['query'],
logger: this.sqlLogger,

View File

@ -3,6 +3,7 @@ import { Mocked, vitest } from 'vitest';
export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => {
return {
search: vitest.fn(),
create: vitest.fn() as any,
update: vitest.fn() as any,
delete: vitest.fn(),