Files
bookorbit/server/src/modules/scanner/scan.gateway.ts
T
neon 54cb7dbda0 fix(auth): recover live sessions when an access token expires
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
2026-08-09 03:24:24 +00:00

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);
}
}