kt-template-online-api/src/apps/napcat-webui-gateway/application/napcat-webui-gateway-session.service.ts

309 lines
10 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ForbiddenException,
GoneException,
Inject,
Injectable,
} from '@nestjs/common';
import { NapcatWebuiGatewayConfigService } from '../config/napcat-webui-gateway-config.service';
import {
NAPCAT_WEBUI_GATEWAY_SESSION_STORE,
type NapcatWebuiGatewayCreateSessionInput,
type NapcatWebuiGatewayLifecycleInput,
type NapcatWebuiGatewaySession,
type NapcatWebuiGatewaySessionStore,
} from '../domain/napcat-webui-gateway.types';
const TERMINAL_SESSION_STATUSES = ['expired', 'failed', 'revoked'];
@Injectable()
export class NapcatWebuiGatewaySessionService {
/**
* Creates the Gateway session lifecycle service.
* @param store - Session store abstraction backed by Redis in production.
* @param config - Gateway runtime config and time source.
*/
constructor(
@Inject(NAPCAT_WEBUI_GATEWAY_SESSION_STORE)
private readonly store: NapcatWebuiGatewaySessionStore,
private readonly config: NapcatWebuiGatewayConfigService,
) {}
/**
* Creates a new Gateway session and revokes an older same-user same-account session.
* @param input - Internal API payload with server-only WebUI target metadata.
* @returns Created session persisted in the store.
*/
async create(input: NapcatWebuiGatewayCreateSessionInput) {
const normalizedInput = this.validateCreateInput(input);
const existing = await this.store.findActiveByUserAndAccount(
normalizedInput.adminUserId,
normalizedInput.accountId,
);
if (existing) {
await this.updateSession(existing.sessionId, {
revokedAt: this.config.now(),
status: 'revoked',
});
}
const now = this.config.now();
const session: NapcatWebuiGatewaySession = {
accountId: normalizedInput.accountId,
adminUserId: normalizedInput.adminUserId,
clientIp: this.toOptionalText(normalizedInput.clientIp),
containerId: normalizedInput.containerId,
containerName: normalizedInput.containerName,
createdAt: now,
expiresAt: now + this.config.ttlMs(),
selfId: normalizedInput.selfId,
sessionId: randomUUID(),
status: 'created',
upstreamBaseUrl: normalizedInput.upstreamBaseUrl,
userAgent: this.toOptionalText(normalizedInput.userAgent),
webuiToken: normalizedInput.webuiToken,
};
return this.store.create(session);
}
/**
* Marks a bootstrap-created session active before proxy traffic is allowed.
* @param sessionId - Gateway session id being bootstrapped.
* @returns Updated active session.
*/
async markActive(sessionId: string) {
const session = await this.requireBootstrapSession(sessionId);
const now = this.config.now();
return this.updateSession(sessionId, {
activeAt: session.activeAt || now,
expiresAt: now + this.config.ttlMs(),
lastSeenAt: now,
status: 'active',
});
}
/**
* Extends an Admin-owned active Gateway session.
* @param input - Session id plus Admin ownership and request evidence.
* @returns Browser-safe lifecycle result.
*/
async heartbeat(input: NapcatWebuiGatewayLifecycleInput) {
const adminUserId = this.requireLifecycleAdminUserId(input.adminUserId);
const session = await this.requireUsableSession(input.sessionId);
this.assertOwner(session, adminUserId);
const now = this.config.now();
const expiresAt = now + this.config.ttlMs();
const updated = await this.updateSession(input.sessionId, {
clientIp: this.toOptionalText(input.clientIp) || session.clientIp,
expiresAt,
lastSeenAt: now,
status: 'active',
userAgent: this.toOptionalText(input.userAgent) || session.userAgent,
});
return {
expiresAt: updated.expiresAt,
sessionId: input.sessionId,
status: 'active' as const,
};
}
/**
* Revokes an Admin-owned Gateway session.
* @param input - Session id plus Admin ownership and request evidence.
* @returns Browser-safe lifecycle result.
*/
async revoke(input: NapcatWebuiGatewayLifecycleInput) {
const adminUserId = this.requireLifecycleAdminUserId(input.adminUserId);
const session = await this.requireUsableSession(input.sessionId);
this.assertOwner(session, adminUserId);
const updated = await this.updateSession(input.sessionId, {
clientIp: this.toOptionalText(input.clientIp) || session.clientIp,
revokedAt: this.config.now(),
status: 'revoked',
userAgent: this.toOptionalText(input.userAgent) || session.userAgent,
});
return {
expiresAt: updated.expiresAt,
sessionId: input.sessionId,
status: 'revoked' as const,
};
}
/**
* Loads a session only when it is eligible for one-time bootstrap.
* @param sessionId - Gateway session id redeemed from a bootstrap ticket.
* @returns Indexed non-terminal session that may be marked active.
*/
async requireBootstrapSession(sessionId: string) {
return this.requireUsableSession(sessionId);
}
/**
* Loads a session only when it is active and usable for proxy handling.
* @param sessionId - Gateway session id from a public Gateway route.
* @returns Stored server-only session metadata for proxy setup.
*/
async requireProxySession(sessionId: string) {
const session = await this.requireUsableSession(sessionId);
if (session.status !== 'active') {
throw new GoneException('Gateway session is not active');
}
return session;
}
/**
* Loads and validates a non-terminal, non-expired, currently indexed session.
* @param sessionId - Gateway session id.
* @returns Usable Gateway session.
*/
private async requireUsableSession(sessionId: string) {
const session = await this.store.find(sessionId);
if (!session || TERMINAL_SESSION_STATUSES.includes(session.status)) {
throw new GoneException('Gateway session is not active');
}
if (session.expiresAt <= this.config.now()) {
await this.updateSession(sessionId, { status: 'expired' });
throw new GoneException('Gateway session is not active');
}
const indexed = await this.store.findActiveByUserAndAccount(
session.adminUserId,
session.accountId,
);
if (!indexed || indexed.sessionId !== session.sessionId) {
throw new GoneException('Gateway session is not active');
}
return session;
}
/**
* Ensures lifecycle calls can only mutate sessions owned by the same Admin user.
* @param session - Stored Gateway session.
* @param adminUserId - Admin actor id from the internal API payload.
*/
private assertOwner(
session: NapcatWebuiGatewaySession,
adminUserId: string,
) {
if (session.adminUserId !== adminUserId) {
throw new ForbiddenException('Gateway session owner mismatch');
}
}
/**
* Applies a store update and maps expected stale lifecycle rejections to 410.
* @param sessionId - Gateway session id to update.
* @param patch - Session fields to merge in the store.
* @returns Updated session from the backing store.
*/
private async updateSession(
sessionId: string,
patch: Partial<NapcatWebuiGatewaySession>,
) {
try {
return await this.store.update(sessionId, patch);
} catch (error) {
if (this.isInactiveStoreError(error)) {
throw new GoneException('Gateway session is not active');
}
throw error;
}
}
/**
* Detects stale or inactive store errors that should not become HTTP 500.
* @param error - Error thrown by the session store.
* @returns Whether the error represents an expected inactive session race.
*/
private isInactiveStoreError(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return (
message.includes('Gateway session is not active') ||
message.includes('Gateway terminal session cannot become active')
);
}
/**
* Requires a lifecycle Admin actor before owner comparison.
* @param adminUserId - Candidate Admin actor id from heartbeat or revoke body.
* @returns Trimmed Admin actor id.
*/
private requireLifecycleAdminUserId(adminUserId: string) {
return this.requireText(adminUserId, 'adminUserId');
}
/**
* Validates and normalizes the internal create-session payload before persistence.
* @param input - Internal API payload supplied by the main API process.
* @returns Normalized create payload with required fields trimmed.
*/
private validateCreateInput(input: NapcatWebuiGatewayCreateSessionInput) {
const normalized = {
...input,
accountId: this.requireText(input.accountId, 'accountId'),
adminUserId: this.requireText(input.adminUserId, 'adminUserId'),
containerId: this.requireText(input.containerId, 'containerId'),
containerName: this.requireText(input.containerName, 'containerName'),
selfId: this.requireText(input.selfId, 'selfId'),
upstreamBaseUrl: this.requireUpstreamBaseUrl(input.upstreamBaseUrl),
webuiToken: this.requireText(input.webuiToken, 'webuiToken'),
};
return normalized;
}
/**
* Requires a non-empty text field from the internal create-session payload.
* @param value - Candidate field value.
* @param fieldName - Payload field name used in the error message.
* @returns Trimmed field text.
*/
private requireText(value: string, fieldName: string) {
const text = this.toOptionalText(value);
if (!text) {
throw new BadRequestException(
`Gateway session field ${fieldName} is required`,
);
}
return text;
}
/**
* Validates the upstream WebUI base URL without restricting Docker host shape.
* @param value - Candidate upstream URL.
* @returns Trimmed http or https URL.
*/
private requireUpstreamBaseUrl(value: string) {
const text = this.requireText(value, 'upstreamBaseUrl');
try {
const url = new URL(text);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error('Unsupported protocol');
}
return text;
} catch {
throw new BadRequestException('Gateway session upstream URL is invalid');
}
}
/**
* Normalizes optional evidence fields before storing them.
* @param value - Optional string value.
* @returns Trimmed text or undefined.
*/
private toOptionalText(value?: string) {
const text = String(value || '').trim();
return text || undefined;
}
}