This commit is contained in:
Philipinho
2026-03-26 17:57:06 +00:00
parent da7bb9a07f
commit 32446d1320
4 changed files with 66 additions and 12 deletions
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { TokenService } from '../auth/services/token.service'; import { TokenService } from '../auth/services/token.service';
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo'; import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
@@ -10,8 +11,13 @@ import {
} from '../../common/middlewares/audit-context.middleware'; } from '../../common/middlewares/audit-context.middleware';
import * as Bowser from 'bowser'; import * as Bowser from 'bowser';
const MAX_SESSIONS_PER_USER = 25;
const RETENTION_DAYS = 7;
@Injectable() @Injectable()
export class SessionService { export class SessionService {
private readonly logger = new Logger(SessionService.name);
constructor( constructor(
private readonly tokenService: TokenService, private readonly tokenService: TokenService,
private readonly userSessionRepo: UserSessionRepo, private readonly userSessionRepo: UserSessionRepo,
@@ -19,6 +25,17 @@ export class SessionService {
private readonly cls: ClsService, private readonly cls: ClsService,
) {} ) {}
@Interval('session-cleanup', 24 * 60 * 60 * 1000)
async cleanupSessions() {
try {
await this.userSessionRepo.deleteStale(RETENTION_DAYS);
await this.userSessionRepo.trimExcessSessions(MAX_SESSIONS_PER_USER);
this.logger.debug('Session cleanup completed');
} catch (err) {
this.logger.error('Session cleanup failed', err);
}
}
async createSessionAndToken(user: User): Promise<string> { async createSessionAndToken(user: User): Promise<string> {
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY); const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
const ipAddress = auditContext?.ipAddress ?? null; const ipAddress = auditContext?.ipAddress ?? null;
@@ -27,17 +27,19 @@ export async function up(db: Kysely<any>): Promise<void> {
) )
.execute(); .execute();
await db.schema // Partial index for active session queries (list, validate)
.createIndex('idx_user_sessions_user_workspace') await sql`
.on('user_sessions') CREATE INDEX idx_user_sessions_active
.columns(['user_id', 'workspace_id']) ON user_sessions (user_id, workspace_id, last_active_at DESC)
.execute(); WHERE revoked_at IS NULL
`.execute(db);
await db.schema // For session cleanup
.createIndex('idx_user_sessions_expires_at') await sql`
.on('user_sessions') CREATE INDEX idx_user_sessions_cleanup
.column('expires_at') ON user_sessions (revoked_at, expires_at)
.execute(); WHERE revoked_at IS NOT NULL OR expires_at < now()
`.execute(db);
} }
export async function down(db: Kysely<any>): Promise<void> { export async function down(db: Kysely<any>): Promise<void> {
@@ -6,6 +6,7 @@ import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils'; import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
@Injectable() @Injectable()
export class UserSessionRepo { export class UserSessionRepo {
@@ -124,4 +125,38 @@ export class UserSessionRepo {
.where('id', '!=', currentSessionId) .where('id', '!=', currentSessionId)
.execute(); .execute();
} }
async deleteStale(retentionDays: number): Promise<void> {
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
await this.db
.deleteFrom('userSessions')
.where((eb) =>
eb.or([
eb('revokedAt', '<', cutoff),
eb('expiresAt', '<', cutoff),
]),
)
.execute();
}
async trimExcessSessions(maxPerUser: number): Promise<void> {
const overflowed = await this.db
.selectFrom('userSessions')
.select(['userId', 'workspaceId'])
.groupBy(['userId', 'workspaceId'])
.having(sql`COUNT(*)`, '>', maxPerUser)
.execute();
for (const { userId, workspaceId } of overflowed) {
await sql`
DELETE FROM user_sessions
WHERE id IN (
SELECT id FROM user_sessions
WHERE user_id = ${userId} AND workspace_id = ${workspaceId}
ORDER BY last_active_at DESC
OFFSET ${maxPerUser}
)
`.execute(this.db);
}
}
} }