mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
54cb7dbda0
A tab that slept past the 15 minute access-token lifetime woke up holding a token every consumer then handed to the server independently. socket.io reconnected all seven namespaces with it, each gateway rejected the handshake and closed the socket, and because a server-initiated close reports `io server disconnect` the client never reconnects from it, so live updates stayed dead until the page was reloaded. The dashboard widgets fetch once on mount with no retry, so whatever failed in that window sat on "Failed to load" for the life of the page. Make a valid token the thing consumers ask for rather than something they hope is still good. Decode `exp` on the client and renew inside a 30 second skew before the token is used, so websocket handshakes, uploads and `api()` requests no longer discover the expiry one 401 at a time. Route every authenticated namespace through a shared factory that resolves the token per handshake and, on an auth rejection, refreshes and reconnects with bounded exponential backoff. Let the server say which rejections are worth retrying. Gateways now answer an authentication failure with an `unauthorized` event before closing, and stay silent on a permission denial, which a fresh token would not fix. Classifying that needed the gateways to stop throwing bare `Error` for both cases, so the handshake paths now throw `UnauthorizedException` or `ForbiddenException` as the project's conventions already require, and jsonwebtoken's own `TokenExpiredError` counts as an authentication failure since it is the single most common way a slept-through handshake fails. Collapse the twelve identical widget composables onto one loader that reloads a failed widget once a rejected request is followed by a successful refresh. Closes #942
155 lines
6.2 KiB
TypeScript
155 lines
6.2 KiB
TypeScript
import { Logger, OnModuleDestroy, OnModuleInit, UnauthorizedException } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, SubscribeMessage, WebSocketGateway, WebSocketServer } from '@nestjs/websockets';
|
|
import { Server, Socket } from 'socket.io';
|
|
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
|
|
|
import type {
|
|
BookMissingEvent,
|
|
BookMovedEvent,
|
|
BookProgressChangedEvent,
|
|
BookRestoredEvent,
|
|
BookTransferredEvent,
|
|
CoverRefreshedEvent,
|
|
CoverRefreshProgressEvent,
|
|
ScanBooksAddedEvent,
|
|
ScanProgressEvent,
|
|
} from '@bookorbit/types';
|
|
import { AuthService } from '../auth/auth.service';
|
|
import {
|
|
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
|
|
AchievementEventsService,
|
|
type BookProgressChangedPayload,
|
|
} from '../achievement/achievement-events.service';
|
|
import { ScanJobStore } from './scan-job-store.service';
|
|
import { rejectSocketConnection } from '../../common/utils/ws-auth.utils';
|
|
|
|
@WebSocketGateway({ namespace: '/scan', cors: { credentials: true } })
|
|
export class ScanGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect, OnModuleInit, OnModuleDestroy {
|
|
@WebSocketServer() server: Server;
|
|
private readonly logger = new Logger(ScanGateway.name);
|
|
private readonly clientOrigin: string;
|
|
private readonly handleBookProgressChanged = (payload: BookProgressChangedPayload): void => {
|
|
this.emitBookProgressChanged(payload);
|
|
};
|
|
|
|
constructor(
|
|
private readonly jwtService: JwtService,
|
|
private readonly authService: AuthService,
|
|
private readonly scanJobStore: ScanJobStore,
|
|
private readonly achievementEvents: AchievementEventsService,
|
|
config: ConfigService,
|
|
) {
|
|
this.clientOrigin = config.get<string>('app.appUrl') ?? 'http://localhost:5173';
|
|
}
|
|
|
|
onModuleInit(): void {
|
|
this.achievementEvents.on(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, this.handleBookProgressChanged);
|
|
}
|
|
|
|
onModuleDestroy(): void {
|
|
this.achievementEvents.removeListener(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, this.handleBookProgressChanged);
|
|
}
|
|
|
|
afterInit(server: Server): void {
|
|
if (!server.engine?.opts) return;
|
|
server.engine.opts.cors = {
|
|
...(server.engine.opts.cors ?? {}),
|
|
origin: this.clientOrigin,
|
|
credentials: true,
|
|
};
|
|
}
|
|
|
|
async handleConnection(client: Socket): Promise<void> {
|
|
try {
|
|
const token = client.handshake.auth?.token as string | undefined;
|
|
if (!token) throw new UnauthorizedException('No token provided');
|
|
const payload = this.jwtService.verify<{ sub: number; ver: number }>(token, { algorithms: ['HS256'] });
|
|
const user = await this.authService.validateUser(payload.sub, payload.ver);
|
|
if (!user) throw new UnauthorizedException('User not found or token revoked');
|
|
(client.data as Record<string, unknown>).user = user;
|
|
await client.join(`user:${user.id}`);
|
|
this.logger.debug(`[scanner.ws_connection] [start] userId=${user.id} socketId=${client.id} - websocket connected`);
|
|
} catch (err) {
|
|
this.logger.warn(
|
|
`[scanner.ws_connection] [fail] socketId=${client.id} errorClass=${err instanceof Error ? err.name : 'Error'} error="${sanitizeLogValue(err instanceof Error ? err.message : String(err))}" - websocket rejected`,
|
|
);
|
|
rejectSocketConnection(client, err);
|
|
}
|
|
}
|
|
|
|
handleDisconnect(client: Socket): void {
|
|
this.logger.debug(`[scanner.ws_connection] [end] socketId=${client.id} - websocket disconnected`);
|
|
}
|
|
|
|
@SubscribeMessage('subscribe:library')
|
|
handleSubscribeLibrary(client: Socket, libraryId: number): void {
|
|
void client.join(`library:${libraryId}`);
|
|
// Send current snapshot immediately so reconnecting clients catch up.
|
|
const entry = this.scanJobStore.get(libraryId);
|
|
if (entry) {
|
|
const event: ScanProgressEvent = {
|
|
jobId: entry.jobId,
|
|
libraryId,
|
|
status: 'running',
|
|
processed: entry.processed,
|
|
total: entry.total,
|
|
added: entry.added,
|
|
updated: entry.updated,
|
|
missing: entry.missing,
|
|
};
|
|
client.emit('scan:progress', event);
|
|
}
|
|
}
|
|
|
|
// Called by services — not WS message handlers. Guards against script/test
|
|
// contexts where no HTTP adapter starts and this.server is never populated.
|
|
emitProgress(event: ScanProgressEvent): void {
|
|
this.server?.to(`library:${event.libraryId}`).emit('scan:progress', event);
|
|
}
|
|
|
|
emitCoverRefreshProgress(event: CoverRefreshProgressEvent): void {
|
|
this.server?.to(`library:${event.libraryId}`).emit('cover:refresh:progress', event);
|
|
}
|
|
|
|
emitCoverRefreshed(event: CoverRefreshedEvent): void {
|
|
this.server?.to(`library:${event.libraryId}`).emit('cover:refreshed', event);
|
|
}
|
|
|
|
emitBookMissing(event: BookMissingEvent): void {
|
|
this.logger.debug(`[scanner.ws_emit] [book:missing] libraryId=${event.libraryId} bookCount=${event.bookIds.length}`);
|
|
this.server?.to(`library:${event.libraryId}`).emit('book:missing', event);
|
|
}
|
|
|
|
emitBookRestored(event: BookRestoredEvent): void {
|
|
this.logger.debug(`[scanner.ws_emit] [book:restored] libraryId=${event.libraryId} bookCount=${event.bookIds.length}`);
|
|
this.server?.to(`library:${event.libraryId}`).emit('book:restored', event);
|
|
}
|
|
|
|
emitBookMoved(event: BookMovedEvent): void {
|
|
this.logger.debug(`[scanner.ws_emit] [book:moved] libraryId=${event.libraryId} bookCount=${event.bookIds.length}`);
|
|
this.server?.to(`library:${event.libraryId}`).emit('book:moved', event);
|
|
}
|
|
|
|
emitBookTransferred(event: BookTransferredEvent): void {
|
|
this.logger.debug(
|
|
`[scanner.ws_emit] [book:transferred] fromLibraryId=${event.fromLibraryId} toLibraryId=${event.toLibraryId} bookCount=${event.bookIds.length}`,
|
|
);
|
|
this.server?.to([`library:${event.fromLibraryId}`, `library:${event.toLibraryId}`]).emit('book:transferred', event);
|
|
}
|
|
|
|
emitBookProgressChanged(payload: BookProgressChangedPayload): void {
|
|
const event: BookProgressChangedEvent = {
|
|
bookId: payload.bookId,
|
|
progress: payload.progress,
|
|
source: payload.source,
|
|
};
|
|
this.server?.to(`user:${payload.userId}`).emit('book:progress-changed', event);
|
|
}
|
|
|
|
emitBooksAdded(event: ScanBooksAddedEvent): void {
|
|
this.server?.to(`library:${event.libraryId}`).emit('scan:books:added', event);
|
|
}
|
|
}
|