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
|
// cleanup
|
||||||
DELETE_FILES = 'delete-files',
|
DELETE_FILES = 'delete-files',
|
||||||
CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs',
|
CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs',
|
||||||
|
CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens',
|
||||||
|
|
||||||
// smart search
|
// smart search
|
||||||
QUEUE_SMART_SEARCH = 'queue-smart-search',
|
QUEUE_SMART_SEARCH = 'queue-smart-search',
|
||||||
|
@ -202,8 +203,9 @@ export type JobItem =
|
||||||
// Filesystem
|
// Filesystem
|
||||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||||
|
|
||||||
// Audit Log Cleanup
|
// Cleanup
|
||||||
| { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob }
|
| { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob }
|
||||||
|
| { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob }
|
||||||
|
|
||||||
// Asset Deletion
|
// Asset Deletion
|
||||||
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
|
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
|
||||||
|
|
|
@ -3,8 +3,10 @@ import { SessionEntity } from 'src/entities/session.entity';
|
||||||
export const ISessionRepository = 'ISessionRepository';
|
export const ISessionRepository = 'ISessionRepository';
|
||||||
|
|
||||||
type E = SessionEntity;
|
type E = SessionEntity;
|
||||||
|
export type SessionSearchOptions = { updatedBefore: Date };
|
||||||
|
|
||||||
export interface ISessionRepository {
|
export interface ISessionRepository {
|
||||||
|
search(options: SessionSearchOptions): Promise<SessionEntity[]>;
|
||||||
create<T extends Partial<E>>(dto: T): Promise<T>;
|
create<T extends Partial<E>>(dto: T): Promise<T>;
|
||||||
update<T extends Partial<E>>(dto: T): Promise<T>;
|
update<T extends Partial<E>>(dto: T): Promise<T>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
|
|
|
@ -1,5 +1,18 @@
|
||||||
-- NOTE: This file is auto generated by ./sql-generator
|
-- 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
|
-- SessionRepository.getByToken
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
"distinctAlias"."SessionEntity_id" AS "ids_SessionEntity_id"
|
"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.USER_DELETION]: QueueName.BACKGROUND_TASK,
|
||||||
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
|
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
|
||||||
[JobName.CLEAN_OLD_AUDIT_LOGS]: 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.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK,
|
||||||
[JobName.USER_SYNC_USAGE]: 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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { SessionEntity } from 'src/entities/session.entity';
|
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 { Instrumentation } from 'src/utils/instrumentation';
|
||||||
import { Repository } from 'typeorm';
|
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||||
|
|
||||||
@Instrumentation()
|
@Instrumentation()
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionRepository implements ISessionRepository {
|
export class SessionRepository implements ISessionRepository {
|
||||||
constructor(@InjectRepository(SessionEntity) private repository: Repository<SessionEntity>) {}
|
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] })
|
@GenerateSql({ params: [DummyValue.STRING] })
|
||||||
getByToken(token: string): Promise<SessionEntity | null> {
|
getByToken(token: string): Promise<SessionEntity | null> {
|
||||||
return this.repository.findOne({ where: { token }, relations: { user: true } });
|
return this.repository.findOne({ where: { token }, relations: { user: true } });
|
||||||
|
|
|
@ -72,6 +72,7 @@ describe(JobService.name, () => {
|
||||||
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
|
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
|
||||||
{ name: JobName.USER_SYNC_USAGE },
|
{ name: JobName.USER_SYNC_USAGE },
|
||||||
{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } },
|
{ 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.CLEAN_OLD_AUDIT_LOGS },
|
||||||
{ name: JobName.USER_SYNC_USAGE },
|
{ name: JobName.USER_SYNC_USAGE },
|
||||||
{ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } },
|
{ 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 { MediaService } from 'src/services/media.service';
|
||||||
import { MetadataService } from 'src/services/metadata.service';
|
import { MetadataService } from 'src/services/metadata.service';
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
|
import { SessionService } from 'src/services/session.service';
|
||||||
import { SmartInfoService } from 'src/services/smart-info.service';
|
import { SmartInfoService } from 'src/services/smart-info.service';
|
||||||
import { StorageTemplateService } from 'src/services/storage-template.service';
|
import { StorageTemplateService } from 'src/services/storage-template.service';
|
||||||
import { StorageService } from 'src/services/storage.service';
|
import { StorageService } from 'src/services/storage.service';
|
||||||
|
@ -27,6 +28,7 @@ export class MicroservicesService {
|
||||||
private metadataService: MetadataService,
|
private metadataService: MetadataService,
|
||||||
private personService: PersonService,
|
private personService: PersonService,
|
||||||
private smartInfoService: SmartInfoService,
|
private smartInfoService: SmartInfoService,
|
||||||
|
private sessionService: SessionService,
|
||||||
private storageTemplateService: StorageTemplateService,
|
private storageTemplateService: StorageTemplateService,
|
||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
|
@ -42,6 +44,7 @@ export class MicroservicesService {
|
||||||
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
|
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
|
||||||
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
|
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
|
||||||
[JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(),
|
[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_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
|
||||||
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
|
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
|
||||||
[JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(),
|
[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 { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||||
import { SessionService } from 'src/services/session.service';
|
import { SessionService } from 'src/services/session.service';
|
||||||
|
@ -26,6 +28,32 @@ describe('SessionService', () => {
|
||||||
expect(sut).toBeDefined();
|
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', () => {
|
describe('getAll', () => {
|
||||||
it('should get the devices', async () => {
|
it('should get the devices', async () => {
|
||||||
sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]);
|
sessionMock.getByUserId.mockResolvedValue([sessionStub.valid, sessionStub.inactive]);
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
|
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
|
||||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||||
|
import { JobStatus } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||||
|
|
||||||
|
@ -19,6 +21,25 @@ export class SessionService {
|
||||||
this.access = AccessCore.create(accessRepository);
|
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[]> {
|
async getAll(auth: AuthDto): Promise<SessionResponseDto[]> {
|
||||||
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
|
const sessions = await this.sessionRepository.getByUserId(auth.user.id);
|
||||||
return sessions.map((session) => mapSession(session, auth.session?.id));
|
return sessions.map((session) => mapSession(session, auth.session?.id));
|
||||||
|
|
|
@ -79,6 +79,7 @@ class SqlGenerator {
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forRoot({
|
TypeOrmModule.forRoot({
|
||||||
...databaseConfig,
|
...databaseConfig,
|
||||||
|
host: 'localhost',
|
||||||
entities,
|
entities,
|
||||||
logging: ['query'],
|
logging: ['query'],
|
||||||
logger: this.sqlLogger,
|
logger: this.sqlLogger,
|
||||||
|
|
|
@ -3,6 +3,7 @@ import { Mocked, vitest } from 'vitest';
|
||||||
|
|
||||||
export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => {
|
export const newSessionRepositoryMock = (): Mocked<ISessionRepository> => {
|
||||||
return {
|
return {
|
||||||
|
search: vitest.fn(),
|
||||||
create: vitest.fn() as any,
|
create: vitest.fn() as any,
|
||||||
update: vitest.fn() as any,
|
update: vitest.fn() as any,
|
||||||
delete: vitest.fn(),
|
delete: vitest.fn(),
|
||||||
|
|
Loading…
Reference in a new issue