diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 5b6d74b..7d7e3ee 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -17,6 +17,7 @@ import { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/zn import { addCliFeatureUserAgent } from '../../util/useragent.js'; import { ErrorResponse } from '../../api/util.js'; import Users, { CoralUser } from '../../common/users.js'; +import { EventStreamResponse, HttpServer, ResponseError } from '../util/http-server.js'; declare global { namespace Express { @@ -90,7 +91,7 @@ export async function handler(argv: ArgumentsCamelCase) { const FRIEND_CODE = /^\d{4}-\d{4}-\d{4}$/; -class Server { +class Server extends HttpServer { require_token = true; update_interval = 30 * 1000; @@ -104,6 +105,8 @@ class Server { readonly storage: persist.LocalStorage, readonly users: Users, ) { + super(); + const app = this.app = express(); app.use('/api/znc', (req, res, next) => { @@ -118,64 +121,64 @@ class Server { next(); }); - app.get('/api/znc/auth', this.createApiRequestHandler(r => this.handleAuthRequest(r), true)); + app.get('/api/znc/auth', this.createProxyRequestHandler(r => this.handleAuthRequest(r), true)); app.get('/api/znc/token', this.authTokenMiddleware, - this.createApiRequestHandler(r => this.handleTokenRequest(r))); + this.createProxyRequestHandler(r => this.handleTokenRequest(r))); app.delete('/api/znc/token', this.authTokenMiddleware, - this.createApiRequestHandler(r => this.handleDeleteTokenRequest(r))); - app.get('/api/znc/tokens', this.createApiRequestHandler(r => this.handleTokensRequest(r), true)); + this.createProxyRequestHandler(r => this.handleDeleteTokenRequest(r))); + app.get('/api/znc/tokens', this.createProxyRequestHandler(r => this.handleTokensRequest(r), true)); app.post('/api/znc/tokens', bodyParser.json(), - this.createApiRequestHandler(r => this.handleCreateTokenRequest(r), true)); + this.createProxyRequestHandler(r => this.handleCreateTokenRequest(r), true)); app.get('/api/znc/announcements', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleCreateTokenRequest(r), true)); + this.createProxyRequestHandler(r => this.handleCreateTokenRequest(r), true)); app.get('/api/znc/user', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleCurrentUserRequest(r))); + this.createProxyRequestHandler(r => this.handleCurrentUserRequest(r))); app.get('/api/znc/user/presence', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleUserPresenceRequest(r))); + this.createProxyRequestHandler(r => this.handleUserPresenceRequest(r))); app.get('/api/znc/friends', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleFriendsRequest(r))); + this.createProxyRequestHandler(r => this.handleFriendsRequest(r))); app.get('/api/znc/friends/favourites', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleFavouriteFriendsRequest(r))); + this.createProxyRequestHandler(r => this.handleFavouriteFriendsRequest(r))); app.get('/api/znc/friends/presence', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleFriendsPresenceRequest(r))); + this.createProxyRequestHandler(r => this.handleFriendsPresenceRequest(r))); app.get('/api/znc/friends/favourites/presence', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleFavouriteFriendsPresenceRequest(r))); + this.createProxyRequestHandler(r => this.handleFavouriteFriendsPresenceRequest(r))); app.get('/api/znc/friend/:nsaid', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleFriendRequest(r, r.req.params.nsaid))); + this.createProxyRequestHandler(r => this.handleFriendRequest(r, r.req.params.nsaid))); app.post('/api/znc/friend/:nsaid', bodyParser.json(), - this.createApiRequestHandler(r => this.handleUpdateFriendRequest(r, r.req.params.nsaid), true)); + this.createProxyRequestHandler(r => this.handleUpdateFriendRequest(r, r.req.params.nsaid), true)); app.get('/api/znc/friend/:nsaid/presence', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleFriendPresenceRequest(r, r.req.params.nsaid))); + this.createProxyRequestHandler(r => this.handleFriendPresenceRequest(r, r.req.params.nsaid))); app.get('/api/znc/webservices', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleWebServicesRequest(r))); + this.createProxyRequestHandler(r => this.handleWebServicesRequest(r))); app.get('/api/znc/webservice/:id/token', - this.createApiRequestHandler(r => this.handleWebServiceTokenRequest(r, r.req.params.id), true)); + this.createProxyRequestHandler(r => this.handleWebServiceTokenRequest(r, r.req.params.id), true)); app.get('/api/znc/activeevent', this.authTokenMiddleware, this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleActiveEventRequest(r))); + this.createProxyRequestHandler(r => this.handleActiveEventRequest(r))); app.get('/api/znc/event/:id', - this.createApiRequestHandler(r => this.handleEventRequest(r, r.req.params.id), true)); + this.createProxyRequestHandler(r => this.handleEventRequest(r, r.req.params.id), true)); app.get('/api/znc/user/:id', - this.createApiRequestHandler(r => this.handleUserRequest(r, r.req.params.id), true)); + this.createProxyRequestHandler(r => this.handleUserRequest(r, r.req.params.id), true)); app.get('/api/znc/friendcode/:friendcode', this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleFriendCodeRequest(r, r.req.params.friendcode), true)); + this.createProxyRequestHandler(r => this.handleFriendCodeRequest(r, r.req.params.friendcode), true)); app.get('/api/znc/friendcode', this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handleFriendCodeUrlRequest(r), true)); + this.createProxyRequestHandler(r => this.handleFriendCodeUrlRequest(r), true)); app.get('/api/znc/presence/events', this.localAuthMiddleware, - this.createApiRequestHandler(r => this.handlePresenceEventStreamRequest(r), true)); + this.createProxyRequestHandler(r => this.handlePresenceEventStreamRequest(r), true)); } - protected createApiRequestHandler(callback: (data: RequestDataWithUser) => Promise<{} | void>, auth: true): RequestHandler - protected createApiRequestHandler(callback: (data: RequestData) => Promise<{} | void>, auth?: boolean): RequestHandler - protected createApiRequestHandler(callback: (data: RequestDataWithUser) => Promise<{} | void>, auth = false) { + protected createProxyRequestHandler(callback: (data: RequestDataWithUser) => Promise<{} | void>, auth: true): RequestHandler + protected createProxyRequestHandler(callback: (data: RequestData) => Promise<{} | void>, auth?: boolean): RequestHandler + protected createProxyRequestHandler(callback: (data: RequestDataWithUser) => Promise<{} | void>, auth = false) { return async (req: Request, res: Response) => { try { const user = req.coralUser ?? auth ? await this.getCoralUser(req) : undefined; @@ -202,34 +205,6 @@ class Server { }; } - protected createApiMiddleware( - callback: (req: Request, res: Response) => Promise - ): RequestHandler { - return async (req: Request, res: Response, next: NextFunction) => { - try { - await callback.call(null, req, res); - - next(); - } catch (err) { - if (err instanceof ResponseError) { - err.sendResponse(req, res); - } else { - this.sendJsonResponse(res, { - error: err, - error_message: (err as Error).message, - }, 500); - } - } - }; - } - - protected sendJsonResponse(res: Response, data: {}, status?: number) { - if (status) res.statusCode = status; - res.setHeader('Content-Type', 'application/json'); - res.end(res.req.headers['accept']?.match(/\/html\b/i) ? - JSON.stringify(data, null, 4) : JSON.stringify(data)); - } - protected async _cache( id: string, callback: () => Promise, promises: Map>, @@ -765,9 +740,6 @@ class Server { // async handlePresenceEventStreamRequest({req, res, user}: RequestDataWithUser) { - res.setHeader('Cache-Control', 'no-store'); - res.setHeader('Content-Type', 'text/event-stream'); - const na_session_token = req.headers['authorization']!.substr(3); const i = new ZncNotifications(this.storage, na_session_token, user.nso, user.data, user); @@ -775,7 +747,8 @@ class Server { i.friend_notifications = true; i.update_interval = this.update_interval / 1000; - const es = i.notifications = new EventStreamNotificationManager(req, res); + const stream = new EventStreamResponse(req, res); + i.notifications = new EventStreamNotificationManager(stream); try { await i.loop(true); @@ -786,7 +759,7 @@ class Server { this.resetAuthTimeout(na_session_token, () => user.data.user.id); } } catch (err) { - es.sendEvent('error', { + stream.sendEvent('error', { error: (err as Error).name, error_message: (err as Error).message, }); @@ -794,71 +767,44 @@ class Server { } } -class ResponseError extends Error { - constructor(readonly status: number, readonly code: string, message?: string) { - super(message); - } - - sendResponse(req: Request, res: Response) { - const data = { - error: this.code, - error_message: this.message, - }; - - res.statusCode = this.status; - res.setHeader('Content-Type', 'application/json'); - res.end(req.headers['accept']?.match(/\/html\b/i) ? - JSON.stringify(data, null, 4) : JSON.stringify(data)); - } -} - function cacheMaxAge(updated_timestamp_ms: number, update_interval_ms: number) { return Math.floor(((updated_timestamp_ms + update_interval_ms) - Date.now()) / 1000); } class EventStreamNotificationManager extends NotificationManager { - constructor( - public req: Request, - public res: Response - ) { + constructor(readonly stream: EventStreamResponse) { super(); } - sendEvent(event: string | null, ...data: unknown[]) { - if (event) this.res.write('event: ' + event + '\n'); - for (const d of data) this.res.write('data: ' + JSON.stringify(d) + '\n'); - this.res.write('\n'); - } - onPresenceUpdated( friend: CurrentUser | Friend, prev?: CurrentUser | Friend, type?: PresenceEvent, naid?: string, ir?: boolean ) { - this.sendEvent(ZncPresenceEventStreamEvent.PRESENCE_UPDATED, { + this.stream.sendEvent(ZncPresenceEventStreamEvent.PRESENCE_UPDATED, { id: friend.nsaId, presence: friend.presence, prev: prev?.presence, }); } onFriendOnline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) { - this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_ONLINE, { + this.stream.sendEvent(ZncPresenceEventStreamEvent.FRIEND_ONLINE, { id: friend.nsaId, presence: friend.presence, prev: prev?.presence, }); } onFriendOffline(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) { - this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_OFFLINE, { + this.stream.sendEvent(ZncPresenceEventStreamEvent.FRIEND_OFFLINE, { id: friend.nsaId, presence: friend.presence, prev: prev?.presence, }); } onFriendPlayingChangeTitle(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) { - this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_TITLE_CHANGE, { + this.stream.sendEvent(ZncPresenceEventStreamEvent.FRIEND_TITLE_CHANGE, { id: friend.nsaId, presence: friend.presence, prev: prev?.presence, }); } onFriendTitleStateChange(friend: CurrentUser | Friend, prev?: CurrentUser | Friend, naid?: string, ir?: boolean) { - this.sendEvent(ZncPresenceEventStreamEvent.FRIEND_TITLE_STATECHANGE, { + this.stream.sendEvent(ZncPresenceEventStreamEvent.FRIEND_TITLE_STATECHANGE, { id: friend.nsaId, presence: friend.presence, prev: prev?.presence, }); } diff --git a/src/cli/presence-server.ts b/src/cli/presence-server.ts index 85c0d99..192e66a 100644 --- a/src/cli/presence-server.ts +++ b/src/cli/presence-server.ts @@ -13,6 +13,7 @@ import Users, { CoralUser } from '../common/users.js'; import { Friend } from '../api/coral-types.js'; import { getBulletToken, SavedBulletToken } from '../common/auth/splatnet3.js'; import SplatNet3Api from '../api/splatnet3.js'; +import { HttpServer, ResponseError } from './util/http-server.js'; const debug = createDebug('cli:presence-server'); @@ -172,7 +173,7 @@ export class SplatNet3User { } } -class Server { +class Server extends HttpServer { allow_all_users = false; update_interval = 30 * 1000; @@ -184,6 +185,8 @@ class Server { readonly splatnet3_users: Users | null, readonly user_ids: string[], ) { + super(); + const app = this.app = express(); app.use('/api/presence', (req, res, next) => { @@ -204,32 +207,6 @@ class Server { this.handlePresenceRequest(req, res, req.params.user))); } - sendJsonResponse(res: Response, data: {}, status?: number) { - if (status) res.statusCode = status; - res.setHeader('Content-Type', 'application/json'); - res.end(res.req.headers['accept']?.match(/\/html\b/i) ? - JSON.stringify(data, replacer, 4) : JSON.stringify(data, replacer)); - } - - createApiRequestHandler(callback: (req: Request, res: Response) => Promise<{} | void>) { - return async (req: Request, res: Response) => { - try { - const result = await callback.call(null, req, res); - - if (result) this.sendJsonResponse(res, result); - else res.end(); - } catch (err) { - if (err instanceof ResponseError) { - err.sendResponse(req, res); - } else { - this.sendJsonResponse(res, { - error: err, - error_message: (err as Error).message, - }, 500); - } - } - }; - } async handleAllUsersRequest(req: Request, res: Response) { if (!this.allow_all_users) { @@ -467,24 +444,6 @@ class Server { } } -class ResponseError extends Error { - constructor(readonly status: number, readonly code: string, message?: string) { - super(message); - } - - sendResponse(req: Request, res: Response) { - const data = { - error: this.code, - error_message: this.message, - }; - - res.statusCode = this.status; - res.setHeader('Content-Type', 'application/json'); - res.end(req.headers['accept']?.match(/\/html\b/i) ? - JSON.stringify(data, null, 4) : JSON.stringify(data)); - } -} - function createScheduleFest( fest: Fest_schedule, vote_team?: string, state?: FestVoteState | null ): Fest_schedule { diff --git a/src/cli/util/http-server.ts b/src/cli/util/http-server.ts new file mode 100644 index 0000000..7494746 --- /dev/null +++ b/src/cli/util/http-server.ts @@ -0,0 +1,89 @@ +import createDebug from 'debug'; +import { NextFunction, Request, RequestHandler, Response } from 'express'; +import { ErrorResponse } from '../../api/util.js'; + +const debug = createDebug('cli:util:http-server'); + +export class HttpServer { + protected createApiRequestHandler(callback: (req: Request, res: Response) => Promise<{} | void>, auth = false) { + return async (req: Request, res: Response) => { + try { + const result = await callback.call(null, req, res); + + if (result) this.sendJsonResponse(res, result); + else res.end(); + } catch (err) { + if (err instanceof ResponseError) { + err.sendResponse(req, res); + } else { + this.sendJsonResponse(res, { + error: err, + error_message: (err as Error).message, + }, 500); + } + } + }; + } + + protected createApiMiddleware( + callback: (req: Request, res: Response) => Promise + ): RequestHandler { + return async (req: Request, res: Response, next: NextFunction) => { + try { + await callback.call(null, req, res); + + next(); + } catch (err) { + if (err instanceof ResponseError) { + err.sendResponse(req, res); + } else { + this.sendJsonResponse(res, { + error: err, + error_message: (err as Error).message, + }, 500); + } + } + }; + } + + protected sendJsonResponse(res: Response, data: {}, status?: number) { + if (status) res.statusCode = status; + res.setHeader('Content-Type', 'application/json'); + res.end(res.req.headers['accept']?.match(/\/html\b/i) ? + JSON.stringify(data, null, 4) : JSON.stringify(data)); + } +} + +export class ResponseError extends Error { + constructor(readonly status: number, readonly code: string, message?: string) { + super(message); + } + + sendResponse(req: Request, res: Response) { + const data = { + error: this.code, + error_message: this.message, + }; + + res.statusCode = this.status; + res.setHeader('Content-Type', 'application/json'); + res.end(req.headers['accept']?.match(/\/html\b/i) ? + JSON.stringify(data, null, 4) : JSON.stringify(data)); + } +} + +export class EventStreamResponse { + constructor( + readonly req: Request, + readonly res: Response, + ) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Type', 'text/event-stream'); + } + + sendEvent(event: string | null, ...data: unknown[]) { + if (event) this.res.write('event: ' + event + '\n'); + for (const d of data) this.res.write('data: ' + JSON.stringify(d) + '\n'); + this.res.write('\n'); + } +}