diff --git a/apps/server/src/core/session/session.service.ts b/apps/server/src/core/session/session.service.ts index 513037d1..cd60ee3b 100644 --- a/apps/server/src/core/session/session.service.ts +++ b/apps/server/src/core/session/session.service.ts @@ -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 { const auditContext = this.cls.get(AUDIT_CONTEXT_KEY); const ipAddress = auditContext?.ipAddress ?? null; diff --git a/apps/server/src/database/migrations/20260326T121350-user-sessions.ts b/apps/server/src/database/migrations/20260326T121350-user-sessions.ts index cd14872c..2b6d7152 100644 --- a/apps/server/src/database/migrations/20260326T121350-user-sessions.ts +++ b/apps/server/src/database/migrations/20260326T121350-user-sessions.ts @@ -27,17 +27,19 @@ export async function up(db: Kysely): Promise { ) .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): Promise { diff --git a/apps/server/src/database/repos/session/user-session.repo.ts b/apps/server/src/database/repos/session/user-session.repo.ts index 01dedccd..f3da384b 100644 --- a/apps/server/src/database/repos/session/user-session.repo.ts +++ b/apps/server/src/database/repos/session/user-session.repo.ts @@ -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 { + 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 { + 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); + } + } } diff --git a/apps/server/src/ee b/apps/server/src/ee index 759e1097..02911b3b 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 759e10970832579d39358c2a35286f2142cc0417 +Subproject commit 02911b3b46fbca9a1f97736e75c73d74bafc3a08