mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
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:
parent
953896a35a
commit
034c928d9e
12 changed files with 82 additions and 3 deletions
|
@ -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 }
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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 } });
|
||||
|
|
|
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 },
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -79,6 +79,7 @@ class SqlGenerator {
|
|||
imports: [
|
||||
TypeOrmModule.forRoot({
|
||||
...databaseConfig,
|
||||
host: 'localhost',
|
||||
entities,
|
||||
logging: ['query'],
|
||||
logger: this.sqlLogger,
|
||||
|
|
|
@ -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(),
|
||||
|
|
Loading…
Reference in a new issue