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 { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
import { EnvironmentService } from '../../integrations/environment/environment.service';
@@ -10,8 +11,13 @@ import {
} from '../../common/middlewares/audit-context.middleware';
import * as Bowser from 'bowser';
const MAX_SESSIONS_PER_USER = 25;
const RETENTION_DAYS = 7;
@Injectable()
export class SessionService {
private readonly logger = new Logger(SessionService.name);
constructor(
private readonly tokenService: TokenService,
private readonly userSessionRepo: UserSessionRepo,
@@ -19,6 +25,17 @@ export class SessionService {
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> {
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
const ipAddress = auditContext?.ipAddress ?? null;
@@ -27,17 +27,19 @@ export async function up(db: Kysely<any>): Promise<void> {
)
.execute();
await db.schema
.createIndex('idx_user_sessions_user_workspace')
.on('user_sessions')
.columns(['user_id', 'workspace_id'])
.execute();
// Partial index for active session queries (list, validate)
await sql`
CREATE INDEX idx_user_sessions_active
ON user_sessions (user_id, workspace_id, last_active_at DESC)
WHERE revoked_at IS NULL
`.execute(db);
await db.schema
.createIndex('idx_user_sessions_expires_at')
.on('user_sessions')
.column('expires_at')
.execute();
// For session cleanup
await sql`
CREATE INDEX idx_user_sessions_cleanup
ON user_sessions (revoked_at, expires_at)
WHERE revoked_at IS NOT NULL OR expires_at < now()
`.execute(db);
}
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 { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
@Injectable()
export class UserSessionRepo {
@@ -124,4 +125,38 @@ export class UserSessionRepo {
.where('id', '!=', currentSessionId)
.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);
}
}
}