diff --git a/backend/src/config.ts b/backend/src/config.ts index d121c0f..bd7213f 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -169,6 +169,11 @@ export const config = { homeRecentHours: parseInt(process.env.HOME_RECENT_HOURS || '', 10) || 24, + // Session manager that streams games which can't run in a browser, such as + // Java applets (flashpoint-ultimate-server). Streaming is off unless both are set. + streamingApiUrl: process.env.STREAMING_API_URL || null, + streamingApiToken: process.env.STREAMING_API_TOKEN || null, + // When enabled, copies flashpoint.sqlite to local storage for faster network access enableLocalDbCopy: process.env.ENABLE_LOCAL_DB_COPY === 'true', localDbPath: '/app/data/flashpoint.sqlite', diff --git a/backend/src/routes/games.test.ts b/backend/src/routes/games.test.ts index 86bf034..2247d55 100644 --- a/backend/src/routes/games.test.ts +++ b/backend/src/routes/games.test.ts @@ -32,6 +32,12 @@ vi.mock('../services/GameDataService', () => ({ gameDataService: { mountGameZip }, })); +const isStreamable = vi.hoisted(() => vi.fn()); +const createSession = vi.hoisted(() => vi.fn()); +vi.mock('../services/StreamingService', () => ({ + StreamingService: { isStreamable, createSession }, +})); + import gamesRouter from './games'; import { errorHandler } from '../middleware/errorHandler'; import { AuthService } from '../services/AuthService'; @@ -100,3 +106,71 @@ describe('GET /api/games/:id/launch', () => { expect(mountGameZip).toHaveBeenCalledOnce(); }); }); + +describe('streaming games that cannot run in a browser', () => { + beforeEach(() => { + vi.clearAllMocks(); + getGameById.mockResolvedValue({ + id: GAME_ID, + title: 'Java Game', + platformName: 'Java', + launchCommand: 'http://example.com/index.html', + }); + mountGameZip.mockResolvedValue({ mounted: true, downloading: false }); + isStreamable.mockResolvedValue(true); + createSession.mockResolvedValue({ path: '/stream/abc/', width: 472, height: 354 }); + }); + + it('reports a streamable game on its details and launch data', async () => { + signInAs(['games.read', 'games.play']); + + const details = await request(createApp()) + .get(`/api/games/${GAME_ID}`) + .set('Authorization', 'Bearer token'); + const launch = await request(createApp()) + .get(`/api/games/${GAME_ID}/launch`) + .set('Authorization', 'Bearer token'); + + expect(details.body).toMatchObject({ id: GAME_ID, streamable: true }); + expect(launch.body).toMatchObject({ canPlayInBrowser: false, streamable: true }); + }); + + it('does not ask the session manager about browser-playable games', async () => { + signInAs(['games.read', 'games.play']); + getGameById.mockResolvedValue({ + id: GAME_ID, + platformName: 'Flash', + launchCommand: 'http://a/b.swf', + }); + + const details = await request(createApp()) + .get(`/api/games/${GAME_ID}`) + .set('Authorization', 'Bearer token'); + + expect(details.body.streamable).toBe(false); + expect(isStreamable).not.toHaveBeenCalled(); + }); + + it('creates a session for users with games.play', async () => { + signInAs(['games.read', 'games.play']); + + const response = await request(createApp()) + .post(`/api/games/${GAME_ID}/stream-session`) + .set('Authorization', 'Bearer token'); + + expect(response.status).toBe(201); + expect(response.body).toEqual({ path: '/stream/abc/', width: 472, height: 354 }); + expect(createSession).toHaveBeenCalledWith(GAME_ID); + }); + + it('rejects session creation without games.play', async () => { + signInAs(['games.read']); + + const response = await request(createApp()) + .post(`/api/games/${GAME_ID}/stream-session`) + .set('Authorization', 'Bearer token'); + + expect(response.status).toBe(403); + expect(createSession).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/routes/games.ts b/backend/src/routes/games.ts index 3d73969..d332b8f 100644 --- a/backend/src/routes/games.ts +++ b/backend/src/routes/games.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { GameService } from '../services/GameService'; import { GameSearchCache } from '../services/GameSearchCache'; import { gameDataService } from '../services/GameDataService'; +import { StreamingService } from '../services/StreamingService'; import { AppError } from '../middleware/errorHandler'; import { asyncHandler } from '../middleware/asyncHandler'; import { sharedAccessAuth, validateSharedGameAccess } from '../middleware/auth'; @@ -235,7 +236,10 @@ router.get( res.locals.game = game; - res.json(game); + const streamable = + !isWebPlayablePlatform(game.platformName) && (await StreamingService.isStreamable(game.id)); + + res.json({ ...game, streamable }); }) ); @@ -311,8 +315,8 @@ router.get( const hasContent = launchCommand.trim().length > 0; - const isWebPlayablePlatform = game.platformName === 'Flash' || game.platformName === 'HTML5'; - const canPlayInBrowser = isWebPlayablePlatform && hasContent; + const canPlayInBrowser = isWebPlayablePlatform(game.platformName) && hasContent; + const streamable = !canPlayInBrowser && (await StreamingService.isStreamable(game.id)); res.locals.platform = game.platformName; res.locals.canPlayInBrowser = canPlayInBrowser; @@ -327,9 +331,25 @@ router.get( applicationPath: game.applicationPath, playMode: game.playMode, canPlayInBrowser, + streamable, downloading, }); }) ); +router.post( + '/:id/stream-session', + requirePermission('games.play'), + validateSharedGameAccess('id'), + logActivity('games.stream.start', 'games'), + asyncHandler(async (req, res) => { + const id = gameIdSchema.parse(req.params.id); + res.status(201).json(await StreamingService.createSession(id)); + }) +); + +function isWebPlayablePlatform(platformName?: string): boolean { + return platformName === 'Flash' || platformName === 'HTML5'; +} + export default router; diff --git a/backend/src/services/StreamingService.test.ts b/backend/src/services/StreamingService.test.ts new file mode 100644 index 0000000..8c9cc63 --- /dev/null +++ b/backend/src/services/StreamingService.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../utils/logger', () => ({ + logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); + +const config = vi.hoisted(() => ({ + streamingApiUrl: 'http://manager:8080' as string | null, + streamingApiToken: 'secret' as string | null, +})); +vi.mock('../config', () => ({ config })); + +import { StreamingService } from './StreamingService'; + +const GAME_ID = '11111111-2222-4333-8444-555555555555'; +const fetchMock = vi.fn(); + +function respond(status: number, body: unknown = {}): void { + fetchMock.mockResolvedValue(new Response(JSON.stringify(body), { status })); +} + +describe('StreamingService', () => { + beforeEach(() => { + config.streamingApiUrl = 'http://manager:8080'; + config.streamingApiToken = 'secret'; + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('is disabled unless both the URL and token are set', async () => { + config.streamingApiToken = null; + + expect(StreamingService.isEnabled()).toBe(false); + expect(await StreamingService.isStreamable(GAME_ID)).toBe(false); + await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({ + statusCode: 404, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('asks the session manager whether a game is streamable', async () => { + respond(200, { width: 472, height: 354 }); + expect(await StreamingService.isStreamable(GAME_ID)).toBe(true); + expect(fetchMock).toHaveBeenCalledWith( + `http://manager:8080/games/${GAME_ID}`, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer secret' }), + }) + ); + + respond(422); + expect(await StreamingService.isStreamable(GAME_ID)).toBe(false); + + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + expect(await StreamingService.isStreamable(GAME_ID)).toBe(false); + }); + + it('creates sessions and maps session manager errors', async () => { + respond(201, { id: 'abc', path: '/stream/abc/', width: 472, height: 354 }); + expect(await StreamingService.createSession(GAME_ID)).toEqual({ + path: '/stream/abc/', + width: 472, + height: 354, + }); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ gameId: GAME_ID }); + + respond(422); + await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({ + statusCode: 422, + }); + respond(503); + await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({ + statusCode: 503, + }); + respond(500); + await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({ + statusCode: 502, + }); + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + await expect(StreamingService.createSession(GAME_ID)).rejects.toMatchObject({ + statusCode: 502, + }); + }); +}); diff --git a/backend/src/services/StreamingService.ts b/backend/src/services/StreamingService.ts new file mode 100644 index 0000000..e602d7c --- /dev/null +++ b/backend/src/services/StreamingService.ts @@ -0,0 +1,76 @@ +import { config } from '../config'; +import { AppError } from '../middleware/errorHandler'; +import { logger } from '../utils/logger'; + +export interface StreamSession { + /** Same-origin path the stream is served under, ending in a slash */ + path: string; + width: number; + height: number; +} + +const LOOKUP_TIMEOUT_MS = 2000; +const CREATE_TIMEOUT_MS = 15000; + +/** Client for the session manager that streams games which can't run in a browser. */ +export class StreamingService { + static isEnabled(): boolean { + return Boolean(config.streamingApiUrl && config.streamingApiToken); + } + + /** Whether the session manager can stream a game. False when streaming is off or unreachable. */ + static async isStreamable(gameId: string): Promise { + if (!this.isEnabled()) return false; + try { + const res = await this.request(`/games/${gameId}`, { method: 'GET' }, LOOKUP_TIMEOUT_MS); + return res.ok; + } catch (error) { + logger.warn('[Streaming] Session manager unreachable:', error); + return false; + } + } + + static async createSession(gameId: string): Promise { + if (!this.isEnabled()) { + throw new AppError(404, 'Streaming is not enabled'); + } + let res: Response; + try { + res = await this.request( + '/sessions', + { method: 'POST', body: JSON.stringify({ gameId }) }, + CREATE_TIMEOUT_MS + ); + } catch (error) { + logger.error('[Streaming] Session manager unreachable:', error); + throw new AppError(502, 'Failed to start a streaming session'); + } + if (res.status === 422) { + throw new AppError(422, 'This game cannot be streamed'); + } + if (res.status === 503) { + throw new AppError(503, 'All streaming slots are in use. Try again later.'); + } + if (!res.ok) { + logger.error(`[Streaming] Session manager returned ${res.status}`); + throw new AppError(502, 'Failed to start a streaming session'); + } + const { path, width, height } = (await res.json()) as StreamSession; + return { path, width, height }; + } + + private static request( + pathname: string, + init: RequestInit, + timeoutMs: number + ): Promise { + return fetch(`${config.streamingApiUrl}${pathname}`, { + ...init, + headers: { + Authorization: `Bearer ${config.streamingApiToken}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(timeoutMs), + }); + } +}