From 9cb4ced59fb8a7f3c17e09765b851e0918ff1961 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Sun, 30 Oct 2022 03:53:49 +0000 Subject: [PATCH] HTTP servers --- .vscode/launch.json | 22 + src/cli/nso/http-server.ts | 1135 +++++++++++++++++------------------- src/cli/presence-server.ts | 530 +++++++++-------- src/common/users.ts | 8 + 4 files changed, 857 insertions(+), 838 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 7c8e631..85e3ca8 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -25,6 +25,28 @@ "DEBUG_COLORS": "1", "FORCE_COLOR": "3" }, + "envFile": "${workspaceFolder}/.env" + }, + { + "name": "Coral API proxy", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "program": "bin/nxapi.js", + "args": [ + "nso", + "http-server", + "--listen", + "[::1]:8080", + "--no-require-token" + ], + "outputCapture": "std", + "env": { + "DEBUG": "*,-express:*,-body-parser:*", + "DEBUG_COLORS": "1", + "FORCE_COLOR": "3" + }, + "envFile": "${workspaceFolder}/.env" } ] } diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 97da1ff..5b6d74b 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -1,35 +1,48 @@ import * as net from 'node:net'; import createDebug from 'debug'; import * as persist from 'node-persist'; -import express, { Request, Response } from 'express'; +import express, { NextFunction, Request, RequestHandler, Response } from 'express'; import bodyParser from 'body-parser'; import { v4 as uuidgen } from 'uuid'; -import { Announcement, CoralErrorResponse, CoralStatus, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, GetActiveEventResult, Presence, WebService } from '../../api/coral-types.js'; +import { Announcement, CoralStatus, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, Presence } from '../../api/coral-types.js'; import CoralApi from '../../api/coral.js'; import type { Arguments as ParentArguments } from '../nso.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; import { initStorage } from '../../util/storage.js'; -import { getToken, SavedToken } from '../../common/auth/coral.js'; +import { SavedToken } from '../../common/auth/coral.js'; import { NotificationManager, PresenceEvent, ZncNotifications } from '../../common/notify.js'; import { product } from '../../util/product.js'; import { parseListenAddress } from '../../util/net.js'; import { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/znc-proxy.js'; import { addCliFeatureUserAgent } from '../../util/useragent.js'; import { ErrorResponse } from '../../api/util.js'; +import Users, { CoralUser } from '../../common/users.js'; declare global { namespace Express { interface Request { - znc?: CoralApi; - zncAuth?: SavedToken; + coralUser?: CoralUser; + coral?: CoralApi; + coralAuthData?: SavedToken; - zncAuthPolicy?: AuthPolicy; - zncAuthPolicyUser?: string; - zncAuthPolicyToken?: string; + proxyAuthPolicy?: AuthPolicy; + proxyAuthPolicyUser?: string; + proxyAuthPolicyToken?: string; } } } +interface RequestData { + req: Request; + res: Response; + user?: CoralUser; + policy?: AuthPolicy; + token?: string; +} +interface RequestDataWithUser extends RequestData { + user: CoralUser; +} + const debug = createDebug('cli:nso:http-server'); export const command = 'http-server'; @@ -58,7 +71,12 @@ export async function handler(argv: ArgumentsCamelCase) { const storage = await initStorage(argv.dataPath); - const app = createApp(storage, argv.zncProxyUrl, argv.requireToken, argv.updateInterval * 1000); + const users = Users.coral(storage, argv.zncProxyUrl); + + const server = new Server(storage, users); + server.require_token = argv.requireToken; + server.update_interval = argv.updateInterval * 1000; + const app = server.app; for (const address of argv.listen) { const [host, port] = parseListenAddress(address); @@ -70,165 +88,296 @@ export async function handler(argv: ArgumentsCamelCase) { } } -function createApp( - storage: persist.LocalStorage, - znc_proxy_url?: string, - require_token = true, - update_interval = 30 * 1000 -) { - const app = express(); +const FRIEND_CODE = /^\d{4}-\d{4}-\d{4}$/; + +class Server { + require_token = true; + update_interval = 30 * 1000; // Friend codes won't change very frequently, but associated data in the response // (user name/image) might change - const friendcode_update_interval = 24 * 60 * 60 * 1000; // 24 hours + friendcode_update_interval = 24 * 60 * 60 * 1000; // 24 hours - app.use('/api/znc', (req, res, next) => { - console.log('[%s] %s %s HTTP/%s from %s, port %d%s, %s', - new Date(), req.method, req.path, req.httpVersion, - req.socket.remoteAddress, req.socket.remotePort, - req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '', - req.headers['user-agent']); + readonly app: express.Express; - res.setHeader('Server', product + ' znc-proxy'); + constructor( + readonly storage: persist.LocalStorage, + readonly users: Users, + ) { + const app = this.app = express(); - next(); - }); + app.use('/api/znc', (req, res, next) => { + console.log('[%s] %s %s HTTP/%s from %s, port %d%s, %s', + new Date(), req.method, req.path, req.httpVersion, + req.socket.remoteAddress, req.socket.remotePort, + req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '', + req.headers['user-agent']); - const localAuth: express.RequestHandler = async (req, res, next) => { - if (require_token || !req.query.user) return next(); + res.setHeader('Server', product + ' znc-proxy'); - const token = await storage.getItem('NintendoAccountToken.' + req.query.user); - if (!token) return next(); + next(); + }); + + app.get('/api/znc/auth', this.createApiRequestHandler(r => this.handleAuthRequest(r), true)); + + app.get('/api/znc/token', this.authTokenMiddleware, + this.createApiRequestHandler(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)); + app.post('/api/znc/tokens', bodyParser.json(), + this.createApiRequestHandler(r => this.handleCreateTokenRequest(r), true)); + + app.get('/api/znc/announcements', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleCreateTokenRequest(r), true)); + + app.get('/api/znc/user', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleCurrentUserRequest(r))); + app.get('/api/znc/user/presence', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleUserPresenceRequest(r))); + + app.get('/api/znc/friends', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleFriendsRequest(r))); + app.get('/api/znc/friends/favourites', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleFavouriteFriendsRequest(r))); + app.get('/api/znc/friends/presence', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleFriendsPresenceRequest(r))); + app.get('/api/znc/friends/favourites/presence', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleFavouriteFriendsPresenceRequest(r))); + + app.get('/api/znc/friend/:nsaid', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(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)); + app.get('/api/znc/friend/:nsaid/presence', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleFriendPresenceRequest(r, r.req.params.nsaid))); + + app.get('/api/znc/webservices', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleWebServicesRequest(r))); + app.get('/api/znc/webservice/:id/token', + this.createApiRequestHandler(r => this.handleWebServiceTokenRequest(r, r.req.params.id), true)); + app.get('/api/znc/activeevent', this.authTokenMiddleware, this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleActiveEventRequest(r))); + + app.get('/api/znc/event/:id', + this.createApiRequestHandler(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)); + + app.get('/api/znc/friendcode/:friendcode', this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleFriendCodeRequest(r, r.req.params.friendcode), true)); + app.get('/api/znc/friendcode', this.localAuthMiddleware, + this.createApiRequestHandler(r => this.handleFriendCodeUrlRequest(r), true)); + + app.get('/api/znc/presence/events', this.localAuthMiddleware, + this.createApiRequestHandler(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) { + return async (req: Request, res: Response) => { + try { + const user = req.coralUser ?? auth ? await this.getCoralUser(req) : undefined; + + const result = await callback.call(null, { + req, res, + user: user!, + policy: req.proxyAuthPolicy, + token: req.proxyAuthPolicyToken, + }); + + 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)); + } + + protected async _cache( + id: string, callback: () => Promise, + promises: Map>, + cache: Map, + update_interval = this.update_interval, + ) { + const data = cache.get(id); + + if (data && ((data[0] + update_interval) > Date.now())) { + debug('Using cached data for %s', id); + return data; + } + + debug('Updating data for %s', id); + + return this._update(id, () => + callback.call(null) + .then(result => [Date.now(), result] as const), promises, cache) + } + + protected _update( + id: string, callback: () => Promise, + promises: Map>, + cache: Map, + ) { + const promise = promises.get(id) ?? callback.call(null).then(result => { + cache.set(id, result); + return result; + }).finally(() => { + promises.delete(id); + }); + promises.set(id, promise); + return promise; + } + + private localAuthMiddleware = this.createApiMiddleware(async (req, res) => { + if (this.require_token || !req.query.user) return; + + const token = await this.storage.getItem('NintendoAccountToken.' + req.query.user); + if (!token) return; req.headers['authorization'] = 'na ' + token; + }); - next(); - }; - - const authToken: express.RequestHandler = async (req, res, next) => { + private authTokenMiddleware = this.createApiMiddleware(async (req, res) => { if (req.headers['authorization']?.startsWith('Bearer ')) { const token = req.headers['authorization'].substr(7); - const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + token); - if (!auth) return next(); + const auth: AuthToken | undefined = await this.storage.getItem('ZncProxyAuthPolicy.' + token); + if (!auth) return; - req.zncAuthPolicy = auth.policy; - req.zncAuthPolicyUser = auth.user; - req.zncAuthPolicyToken = token; + req.proxyAuthPolicy = auth.policy; + req.proxyAuthPolicyUser = auth.user; + req.proxyAuthPolicyToken = token; } else if (req.query.token) { - const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + req.query.token); - if (!auth) return next(); + const auth: AuthToken | undefined = await this.storage.getItem('ZncProxyAuthPolicy.' + req.query.token); + if (!auth) return; - req.zncAuthPolicy = auth.policy; - req.zncAuthPolicyUser = auth.user; - req.zncAuthPolicyToken = '' + req.query.token; + req.proxyAuthPolicy = auth.policy; + req.proxyAuthPolicyUser = auth.user; + req.proxyAuthPolicyToken = '' + req.query.token; + } + }); + + private coral_auth_promise = new Map>(); + private coral_auth_timeout = new Map(); + + async getCoralUser(req: Request) { + let na_session_token: string; + if (req.proxyAuthPolicyUser) { + const na_token = await this.storage.getItem('NintendoAccountToken.' + req.proxyAuthPolicyUser); + if (!na_token) throw new Error('Nintendo Account for this token must reauthenticate'); + na_session_token = na_token; + } else { + const auth = req.headers['authorization']; + if (!auth || !auth.startsWith('na ')) throw new Error('Requires Nintendo Account authentication'); + na_session_token = auth.substr(3); } - next(); - }; - function tokenUnauthorised(req: Request, res: Response) { - res.statusCode = 403; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: 'token_unauthorised', - })); - } + let user_naid: string | null = null; - const znc_auth_promise = new Map>(); - const znc_auth_timeout = new Map(); + const promise = this.coral_auth_promise.get(na_session_token) ?? (async () => { + const user = await this.users.get(na_session_token); - const nsoAuth: express.RequestHandler = async (req, res, next) => { - try { - let nintendoAccountSessionToken: string; - if (req.zncAuthPolicyUser) { - const na_token = await storage.getItem('NintendoAccountToken.' + req.zncAuthPolicyUser); - if (!na_token) throw new Error('Nintendo Account for this token must reauthenticate'); - nintendoAccountSessionToken = na_token; - } else { - const auth = req.headers['authorization']; - if (!auth || !auth.startsWith('na ')) throw new Error('Requires Nintendo Account authentication'); - nintendoAccountSessionToken = auth.substr(3); + const users = new Set(await this.storage.getItem('NintendoAccountIds') ?? []); + if (!users.has(user.data.user.id)) { + users.add(user.data.user.id); + await this.storage.setItem('NintendoAccountIds', [...users]); } - const promise = znc_auth_promise.get(nintendoAccountSessionToken) ?? (async () => { - const auth = await getToken(storage, nintendoAccountSessionToken, znc_proxy_url); + user_naid = user.data.user.id; - const users = new Set(await storage.getItem('NintendoAccountIds') ?? []); - users.add(auth.data.user.id); - await storage.setItem('NintendoAccountIds', [...users]); + return user; + })().catch(err => { + // Keep the resolved promise when successful instead of calling users.get again + this.coral_auth_promise.delete(na_session_token); + clearTimeout(this.coral_auth_timeout.get(na_session_token)); + this.coral_auth_timeout.delete(na_session_token); + throw err; + }); + this.coral_auth_promise.set(na_session_token, promise); - return auth; - })().catch(err => { - znc_auth_promise.delete(nintendoAccountSessionToken); - clearTimeout(znc_auth_timeout.get(nintendoAccountSessionToken)); - znc_auth_timeout.delete(nintendoAccountSessionToken); - throw err; - }); - znc_auth_promise.set(nintendoAccountSessionToken, promise); + this.resetAuthTimeout(na_session_token, () => user_naid); - let user_naid: string | null = null; + return promise; + } - // Remove the authenticated ZncApi 30 minutes after last use - clearTimeout(znc_auth_timeout.get(nintendoAccountSessionToken)); - znc_auth_timeout.set(nintendoAccountSessionToken, setTimeout(() => { - debug('Removing old CoralApi instance', user_naid); - znc_auth_promise.delete(nintendoAccountSessionToken); - znc_auth_timeout.delete(nintendoAccountSessionToken); - }, 30 * 60 * 1000).unref()); + protected resetAuthTimeout(na_session_token: string, debug_get_naid?: () => string | null) { + // Remove the authenticated CoralApi 30 minutes after last use + clearTimeout(this.coral_auth_timeout.get(na_session_token)); + this.coral_auth_timeout.set(na_session_token, setTimeout(() => { + debug('Removing old CoralApi instance', debug_get_naid?.call(null)); + this.coral_auth_promise.delete(na_session_token); + this.coral_auth_timeout.delete(na_session_token); + this.users.remove(na_session_token); + }, 30 * 60 * 1000).unref()); + } - const {nso, data} = await promise; - req.znc = nso; - req.zncAuth = data; - user_naid = data.user.id; + async handleAuthRequest({user}: RequestDataWithUser) { + return user.data; + } - next(); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); - } - }; - - app.get('/api/znc/auth', nsoAuth, (req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(req.zncAuth)); - }); - - app.get('/api/znc/token', authToken, (req, res) => { - if (!req.zncAuthPolicyToken) { - res.statusCode = 403; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({error: 'no_policy'})); - return; + async handleTokenRequest({policy, token}: RequestData) { + if (!token) { + throw new ResponseError(403, 'no_policy'); } - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(req.zncAuthPolicy)); - }); - app.delete('/api/znc/token', authToken, async (req, res) => { - if (!req.zncAuthPolicyToken) { - res.statusCode = 403; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({error: 'no_policy'})); - return; + return policy; + } + + async handleDeleteTokenRequest({req, res, token}: RequestData) { + if (!token) { + throw new ResponseError(403, 'no_policy'); } - await storage.removeItem('ZncProxyAuthPolicy.' + req.zncAuthPolicyToken!); + await this.storage.removeItem('ZncProxyAuthPolicy.' + token); - const tokens = new Set(await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuthPolicyUser) ?? []); - tokens.delete(req.zncAuthPolicyToken); - await storage.setItem('ZncProxyAuthPolicies.' + req.zncAuthPolicyUser, [...tokens]); + const tokens = new Set(await this.storage.getItem('ZncProxyAuthPolicies.' + req.proxyAuthPolicyUser) ?? []); + tokens.delete(token); + await this.storage.setItem('ZncProxyAuthPolicies.' + req.proxyAuthPolicyUser, [...tokens]); res.statusCode = 204; - res.end(); - }); - app.get('/api/znc/tokens', nsoAuth, async (req, res) => { - const token_ids: string[] | undefined = await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id); + } + + async handleTokensRequest({user}: RequestDataWithUser) { + const token_ids: string[] | undefined = await this.storage.getItem('ZncProxyAuthPolicies.' + user.data.user.id); const tokens = (await Promise.all(token_ids?.map(async id => { - const auth: AuthToken | undefined = await storage.getItem('ZncProxyAuthPolicy.' + id); + const auth: AuthToken | undefined = await this.storage.getItem('ZncProxyAuthPolicy.' + id); if (!auth) return; return { token: id, @@ -238,274 +387,166 @@ function createApp( }; }) ?? [])).filter(p => p); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({tokens})); - }); - app.post('/api/znc/tokens', nsoAuth, bodyParser.json(), async (req, res) => { + return {tokens}; + } + + async handleCreateTokenRequest({req, user}: RequestDataWithUser) { const token = uuidgen(); const auth: AuthToken = { - user: req.zncAuth!.user.id, + user: user.data.user.id, policy: req.body.policy, created_at: Math.floor(Date.now() / 1000), }; - await storage.setItem('ZncProxyAuthPolicy.' + token, auth); + await this.storage.setItem('ZncProxyAuthPolicy.' + token, auth); - const tokens = new Set(await storage.getItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id) ?? []); + const tokens = new Set(await this.storage.getItem('ZncProxyAuthPolicies.' + user.data.user.id) ?? []); tokens.add(token); - await storage.setItem('ZncProxyAuthPolicies.' + req.zncAuth!.user.id, [...tokens]); + await this.storage.setItem('ZncProxyAuthPolicies.' + user.data.user.id, [...tokens]); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ + return { token, ...auth, - })); - }); + }; + } // // Announcements // This is cached for all users. // - let cached_announcements: Announcement[] | null = null; - app.get('/api/znc/announcements', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.announcements) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, async (req, res) => { - if (cached_announcements) { - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - announcements: cached_announcements, - })); - return; + async handleAnnouncementsRequest({req, policy}: RequestData) { + if (policy && !policy.announcements) { + throw new ResponseError(403, 'token_unauthorised'); } - try { - const announcements = await req.znc!.getAnnouncements(); - cached_announcements = announcements; + const user = await this.getCoralUser(req); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({announcements})); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); - } - }); + const announcements: Announcement[] = user.announcements.result; + return {announcements}; + } // // Nintendo Switch user data // - const user_data_promise = new Map>(); - const cached_userdata = new Map(); + private user_data_promise = new Map>(); + private cached_userdata = new Map(); - const getUserData: express.RequestHandler = async (req, res, next) => { - const cache = cached_userdata.get(req.zncAuth!.user.id); + async getUserData(id: string, coral: CoralApi) { + return this._cache(id, () => coral.getCurrentUser(), + this.user_data_promise, this.cached_userdata); + } - if (cache && ((cache[1] + update_interval) > Date.now())) { - debug('Using cached user data for %s', req.zncAuth!.user.id); - next(); - return; + async handleCurrentUserRequest({req, res, policy}: RequestData) { + if (policy && !policy.current_user) { + throw new ResponseError(403, 'token_unauthorised'); } - try { - const promise = user_data_promise.get(req.zncAuth!.user.id) ?? req.znc!.getCurrentUser().then(user => { - cached_userdata.set(req.zncAuth!.user.id, [user, Date.now()]); - }).finally(() => { - user_data_promise.delete(req.zncAuth!.user.id); - }); - user_data_promise.set(req.zncAuth!.user.id, promise); - await promise; + const user = await this.getCoralUser(req); - next(); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); + const [updated, current_user] = await this.getUserData(user.data.user.id, user.nso); + + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return {user: current_user, updated}; + } + + async handleUserPresenceRequest({req, policy}: RequestData) { + if (policy && !policy.current_user_presence) { + throw new ResponseError(403, 'token_unauthorised'); } - }; - app.get('/api/znc/user', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.current_user) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getUserData, async (req, res) => { - const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!; + const user = await this.getCoralUser(req); - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({user, updated})); - }); + const [updated, current_user] = await this.getUserData(user.data.user.id, user.nso); - app.get('/api/znc/user/presence', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.current_user_presence) return tokenUnauthorised(req, res); - if (!('current_user_presence' in req.zncAuthPolicy) && !req.zncAuthPolicy.current_user) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getUserData, async (req, res) => { - const [user, updated] = cached_userdata.get(req.zncAuth!.user.id)!; - - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(user.presence)); - }); + return current_user.presence; + } // // Nintendo Switch friends, NSO app web services, events // - const friends_data_promise = new Map>(); - const cached_friendsdata = new Map(); - const app_data_promise = new Map>(); - const cached_appdata = new Map(); - - const getFriendsData: express.RequestHandler = async (req, res, next) => { - const cache = cached_friendsdata.get(req.zncAuth!.user.id); - - if (cache && ((cache[1] + update_interval) > Date.now())) { - debug('Using cached friends data for %s', req.zncAuth!.user.id); - next(); - return; + async handleFriendsRequest({req, res, policy}: RequestData) { + if (policy && !policy.list_friends) { + throw new ResponseError(403, 'token_unauthorised'); } - try { - const promise = friends_data_promise.get(req.zncAuth!.user.id) ?? req.znc!.getFriendList().then(friends => { - cached_friendsdata.set(req.zncAuth!.user.id, [friends.friends, Date.now()]); - }).finally(() => { - friends_data_promise.delete(req.zncAuth!.user.id); - }); - friends_data_promise.set(req.zncAuth!.user.id, promise); - await promise; + const user = await this.getCoralUser(req); - next(); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); - } - }; - const getAppData: express.RequestHandler = async (req, res, next) => { - const cache = cached_appdata.get(req.zncAuth!.user.id); + const friends = await user.getFriends(); + const updated = user.updated.friends; - if (cache && ((cache[2] + update_interval) > Date.now())) { - debug('Using cached app data for %s', req.zncAuth!.user.id); - next(); - return; - } + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); - try { - const friends_promise = friends_data_promise.get(req.zncAuth!.user.id) ?? req.znc!.getFriendList().then(friends => { - cached_friendsdata.set(req.zncAuth!.user.id, [friends.friends, Date.now()]); - }).finally(() => { - friends_data_promise.delete(req.zncAuth!.user.id); - }); - friends_data_promise.set(req.zncAuth!.user.id, friends_promise); - - const promise = app_data_promise.get(req.zncAuth!.user.id) ?? Promise.all([ - friends_promise, - req.znc!.getWebServices(), - req.znc!.getActiveEvent(), - ]).then(([friends, webservices, activeevent]) => { - // Friends list was already added to cache - cached_appdata.set(req.zncAuth!.user.id, [webservices, activeevent, Date.now()]); - }).finally(() => { - app_data_promise.delete(req.zncAuth!.user.id); - }); - app_data_promise.set(req.zncAuth!.user.id, promise); - await promise; - - next(); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); - } - }; - - app.get('/api/znc/friends', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.list_friends) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getFriendsData, async (req, res) => { - const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; - - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - friends: req.zncAuthPolicy?.friends ? - friends.filter(f => req.zncAuthPolicy!.friends!.includes(f.nsaId)) : friends, + return { + friends: policy?.friends ? + friends.filter(f => policy.friends!.includes(f.nsaId)) : friends, updated, - })); - }); + }; + } - app.get('/api/znc/friends/favourites', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.list_friends) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getFriendsData, async (req, res) => { - const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; + async handleFavouriteFriendsRequest({req, res, policy}: RequestData) { + if (policy && !policy.list_friends) { + throw new ResponseError(403, 'token_unauthorised'); + } - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ + const user = await this.getCoralUser(req); + + const friends = await user.getFriends(); + const updated = user.updated.friends; + + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + + return { friends: friends.filter(f => { - if (req.zncAuthPolicy?.friends && !req.zncAuthPolicy.friends.includes(f.nsaId)) return false; + if (policy?.friends && !policy.friends.includes(f.nsaId)) return false; return f.isFavoriteFriend; }), updated, - })); - }); + }; + } - app.get('/api/znc/friends/presence', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.list_friends_presence) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getFriendsData, async (req, res) => { - const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; + async handleFriendsPresenceRequest({req, res, policy}: RequestData) { + if (policy && !policy.list_friends_presence) { + throw new ResponseError(403, 'token_unauthorised'); + } + + const user = await this.getCoralUser(req); + + const friends = await user.getFriends(); + const updated = user.updated.friends; const presence: Record = {}; for (const friend of friends) { - if (req.zncAuthPolicy) { - const p = req.zncAuthPolicy; - if (p.friends_presence && !p.friends_presence.includes(friend.nsaId)) continue; - if (p.friends && !p.friends_presence && !p.friends.includes(friend.nsaId)) continue; + if (policy) { + if (policy.friends_presence && !policy.friends_presence.includes(friend.nsaId)) continue; + if (policy.friends && !policy.friends_presence && !policy.friends.includes(friend.nsaId)) continue; } presence[friend.nsaId] = friend.presence; } - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(presence)); - }); + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return presence; + } - app.get('/api/znc/friends/favourites/presence', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.list_friends_presence) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getFriendsData, async (req, res) => { - const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; + async handleFavouriteFriendsPresenceRequest({req, res, policy}: RequestData) { + if (policy && !policy.list_friends_presence) { + throw new ResponseError(403, 'token_unauthorised'); + } + + const user = await this.getCoralUser(req); + + const friends = await user.getFriends(); + const updated = user.updated.friends; const presence: Record = {}; for (const friend of friends) { - if (req.zncAuthPolicy) { - const p = req.zncAuthPolicy; - if (p.friends_presence && !p.friends_presence.includes(friend.nsaId)) continue; - if (p.friends && !p.friends_presence && !p.friends.includes(friend.nsaId)) continue; + if (policy) { + if (policy.friends_presence && !policy.friends_presence.includes(friend.nsaId)) continue; + if (policy.friends && !policy.friends_presence && !policy.friends.includes(friend.nsaId)) continue; } if (!friend.isFavoriteFriend) continue; @@ -513,338 +554,262 @@ function createApp( presence[friend.nsaId] = friend.presence; } - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(presence)); - }); + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return presence; + } - app.get('/api/znc/friend/:nsaid', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.friend) return tokenUnauthorised(req, res); - if (req.zncAuthPolicy.friends && !req.zncAuthPolicy.friends.includes(req.params.nsaid)) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getFriendsData, async (req, res) => { - const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; - const friend = friends.find(f => f.nsaId === req.params.nsaid); - - if (!friend) { - res.statusCode = 404; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: 'not_found', - error_message: 'The user is not friends with the authenticated user.', - })); - return; + async handleFriendRequest({req, res, policy}: RequestData, nsaid: string) { + if (policy && !policy.friend) { + throw new ResponseError(403, 'token_unauthorised'); + } + if (policy?.friends && !policy.friends.includes(nsaid)) { + throw new ResponseError(403, 'token_unauthorised'); } - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({friend, updated})); - }); + const user = await this.getCoralUser(req); - app.post('/api/znc/friend/:nsaid', nsoAuth, getFriendsData, bodyParser.json(), async (req, res) => { - const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; - const friend = friends.find(f => f.nsaId === req.params.nsaid); + const friends = await user.getFriends(); + const updated = user.updated.friends; + const friend = friends.find(f => f.nsaId === nsaid); if (!friend) { - res.statusCode = 404; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: 'not_found', - error_message: 'The user is not friends with the authenticated user.', - })); - return; + throw new ResponseError(404, 'not_found', 'The user is not friends with the authenticated user.'); + } + + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return {friend, updated}; + } + + async handleUpdateFriendRequest({req, res, user}: RequestDataWithUser, nsaid: string) { + const friends = await user.getFriends(); + const updated = user.updated.friends; + const friend = friends.find(f => f.nsaId === nsaid); + + if (!friend) { + throw new ResponseError(404, 'not_found', 'The user is not friends with the authenticated user.'); } if ('isFavoriteFriend' in req.body && req.body.isFavoriteFriend !== true && req.body.isFavoriteFriend !== false ) { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: 'invalid_request', - error_message: 'Invalid value for isFavoriteFriend', - })); - return; + throw new ResponseError(400, 'invalid_request', 'Invalid value for isFavoriteFriend.'); } if ('isFavoriteFriend' in req.body) { - try { - if (friend.isFavoriteFriend !== req.body.isFavoriteFriend) { - if (req.body.isFavoriteFriend) await req.znc!.addFavouriteFriend(friend.nsaId); - if (!req.body.isFavoriteFriend) await req.znc!.removeFavouriteFriend(friend.nsaId); - } else { - // No change - } - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); - return; + if (friend.isFavoriteFriend !== req.body.isFavoriteFriend) { + if (req.body.isFavoriteFriend) await user.nso.addFavouriteFriend(friend.nsaId); + if (!req.body.isFavoriteFriend) await user.nso.removeFavouriteFriend(friend.nsaId); + } else { + // No change } } res.statusCode = 204; - res.end(); - }); + } - app.get('/api/znc/friend/:nsaid/presence', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.friend_presence) return tokenUnauthorised(req, res); - if (req.zncAuthPolicy.friends_presence && !req.zncAuthPolicy.friends_presence.includes(req.params.nsaid)) return tokenUnauthorised(req, res); - if (req.zncAuthPolicy.friends && !req.zncAuthPolicy.friends_presence && !req.zncAuthPolicy.friends.includes(req.params.nsaid)) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getFriendsData, async (req, res) => { - const [friends, updated] = cached_friendsdata.get(req.zncAuth!.user.id)!; - const friend = friends.find(f => f.nsaId === req.params.nsaid); + async handleFriendPresenceRequest({req, res, policy}: RequestData, nsaid: string) { + if (policy && !policy.friend_presence) { + throw new ResponseError(403, 'token_unauthorised'); + } + if (!(policy?.friends_presence?.includes(nsaid) ?? policy?.friends?.includes(nsaid) ?? true)) { + throw new ResponseError(403, 'token_unauthorised'); + } + + const user = await this.getCoralUser(req); + + const friends = await user.getFriends(); + const updated = user.updated.friends; + const friend = friends.find(f => f.nsaId === nsaid); if (!friend) { - res.statusCode = 404; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: 'not_found', - error_message: 'The user is not friends with the authenticated user.', - })); - return; + throw new ResponseError(404, 'not_found', 'The user is not friends with the authenticated user.'); } - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(friend.presence)); - }); + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return friend.presence; + } - app.get('/api/znc/webservices', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.webservices) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getAppData, async (req, res) => { - const [webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; - - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({webservices, updated})); - }); - - app.get('/api/znc/webservice/:id/token', nsoAuth, async (req, res) => { - try { - const token = await req.znc!.getWebServiceToken(parseInt(req.params.id)); - - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({token})); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); + async handleWebServicesRequest({req, res, policy}: RequestData) { + if (policy && !policy.webservices) { + throw new ResponseError(403, 'token_unauthorised'); } - }); - app.get('/api/znc/activeevent', authToken, (req, res, next) => { - if (!req.zncAuthPolicy) return next(); - if (!req.zncAuthPolicy.activeevent) return tokenUnauthorised(req, res); - next(); - }, localAuth, nsoAuth, getAppData, async (req, res) => { - const [webservices, activeevent, updated] = cached_appdata.get(req.zncAuth!.user.id)!; + const user = await this.getCoralUser(req); - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({activeevent, updated})); - }); + const [friends, webservices, activeevent] = await Promise.all([ + user.getFriends(), + user.getWebServices(), + user.getActiveEvent(), + ]); + const updated = user.updated.webservices; - app.get('/api/znc/event/:id', nsoAuth, async (req, res) => { - try { - const event = await req.znc!.getEvent(parseInt(req.params.id)); + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return {webservices, updated}; + } - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({event})); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); + async handleWebServiceTokenRequest({user}: RequestDataWithUser, id: string) { + const token = await user.nso.getWebServiceToken(parseInt(id)); + + return {token}; + } + + async handleActiveEventRequest({req, res, policy}: RequestData) { + if (policy && !policy.activeevent) { + throw new ResponseError(403, 'token_unauthorised'); } - }); - app.get('/api/znc/user/:id', nsoAuth, async (req, res) => { - try { - if (!req.params.id.match(/^[0-9]{16}$/)) { - throw new Error('Invalid user ID'); - } + const user = await this.getCoralUser(req); - const user = await req.znc!.getUser(parseInt(req.params.id)); + const [friends, webservices, activeevent] = await Promise.all([ + user.getFriends(), + user.getWebServices(), + user.getActiveEvent(), + ]); + const updated = user.updated.webservices; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({user})); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + return {activeevent, updated}; + } + + async handleEventRequest({user}: RequestDataWithUser, id: string) { + const event = await user.nso.getEvent(parseInt(id)); + + return {event}; + } + + async handleUserRequest({user}: RequestDataWithUser, id: string) { + if (!id.match(/^[0-9]{16}$/)) { + throw new ResponseError(404, 'invalid_request', 'Invalid user ID'); } - }); + + const coral_user = await user.nso.getUser(parseInt(id)); + + return {user: coral_user}; + } // // Friend codes // + // This is cached for all users. + // - const friendcode_data_promise = new Map>>(); - const cached_friendcode_data = new Map>(); - const FRIEND_CODE = /^\d{4}-\d{4}-\d{4}$/; + private friendcode_data_promise = new Map>>(); + private cached_friendcode_data = new Map(); - const getFriendCodeData: express.RequestHandler = async (req, res, next) => { - if (!FRIEND_CODE.test(req.params.friendcode)) { - res.statusCode = 400; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: 'invalid_request', - error_message: 'Invalid friend code', - })); - return; + async getFriendCodeUser(id: string, coral: CoralApi, friendcode: string) { + if (!FRIEND_CODE.test(friendcode)) { + throw new ResponseError(400, 'invalid_request', 'Invalid friend code'); } - const cache = cached_friendcode_data.get(req.zncAuth!.user.id)?.get(req.params.friendcode); - - if (cache && ((cache[1] + friendcode_update_interval) > Date.now())) { - debug('Using cached friend code data for %s, %s', req.zncAuth!.user.id, req.params.friendcode); - next(); - return; - } + const promises = this.friendcode_data_promise.get(id) ?? + new Map>(); + this.friendcode_data_promise.set(id, promises); try { - const promise = friendcode_data_promise.get(req.zncAuth!.user.id)?.get(req.params.friendcode) ?? req.znc!.getUserByFriendCode(req.params.friendcode).catch((err: Error | ErrorResponse) => { - if ('response' in err && err.data?.status === CoralStatus.RESOURCE_NOT_FOUND) { - // A user with this friend code doesn't exist - // This should be cached - return null; + return await this._cache(friendcode, async (): Promise<[FriendCodeUser | null, string]> => { + try { + const user = await coral.getUserByFriendCode(friendcode); + return [user, id]; + } catch (err) { + if (err instanceof ErrorResponse && err.data?.status === CoralStatus.RESOURCE_NOT_FOUND) { + // A user with this friend code doesn't exist + // This should be cached + return [null, id]; + } + + throw err; } - - throw err; - }).then(user => { - const cache = cached_friendcode_data.get(req.zncAuth!.user.id) ?? new Map(); - cache.set(req.params.friendcode, [user ?? null, Date.now()]); - cached_friendcode_data.set(req.zncAuth!.user.id, cache); - }).finally(() => { - const promises = friendcode_data_promise.get(req.zncAuth!.user.id); - promises?.delete(req.params.friendcode); - if (!promises?.size) friendcode_data_promise.delete(req.zncAuth!.user.id); - }); - const promises = friendcode_data_promise.get(req.zncAuth!.user.id) ?? new Map>(); - promises.set(req.params.friendcode, promise); - friendcode_data_promise.set(req.zncAuth!.user.id, promises); - await promise; - - next(); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); + }, promises, this.cached_friendcode_data); + } finally { + if (!promises.size) this.friendcode_data_promise.delete(id); } - }; + } - app.get('/api/znc/friendcode/:friendcode', localAuth, nsoAuth, getFriendCodeData, async (req, res) => { - const [user, updated] = cached_friendcode_data.get(req.zncAuth!.user.id)!.get(req.params.friendcode)!; + async handleFriendCodeRequest({res, user}: RequestDataWithUser, friendcode: string) { + const [updated, [friend_code_user, lookup_auth_user_id]] = + await this.getFriendCodeUser(user.data.user.id, user.nso, friendcode); - if (!user) res.statusCode = 404; - res.setHeader('Cache-Control', 'immutable, max-age=' + cacheMaxAge(updated, friendcode_update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(user ? { - user, - updated, - } : { - error: 'not_found', - error_message: 'A user with this friend code was not found', - })); - }); + res.setHeader('Cache-Control', 'immutable, max-age=' + cacheMaxAge(updated, this.friendcode_update_interval)); - const user_friendcodeurl_promise = new Map>(); - const cached_friendcodeurl = new Map(); - - const getFriendCodeUrl: express.RequestHandler = async (req, res, next) => { - const cache = cached_friendcodeurl.get(req.zncAuth!.user.id); - - if (cache && ((cache[1] + update_interval) > Date.now())) { - debug('Using cached friend code URL for %s', req.zncAuth!.user.id); - next(); - return; + if (!friend_code_user) { + throw new ResponseError(404, 'not_found', 'A user with this friend code was not found'); } - try { - const promise = user_friendcodeurl_promise.get(req.zncAuth!.user.id) ?? req.znc!.getFriendCodeUrl().then(user => { - cached_friendcodeurl.set(req.zncAuth!.user.id, [user, Date.now()]); - }).finally(() => { - user_friendcodeurl_promise.delete(req.zncAuth!.user.id); - }); - user_friendcodeurl_promise.set(req.zncAuth!.user.id, promise); - await promise; + return {user: friend_code_user, updated}; + } - next(); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); - } - }; + private user_friendcodeurl_promise = new Map>(); + private cached_friendcodeurl = new Map(); - app.get('/api/znc/friendcode', localAuth, nsoAuth, getFriendCodeUrl, async (req, res) => { - const [friendcodeurl, updated] = cached_friendcodeurl.get(req.zncAuth!.user.id)!; + getFriendCodeUrl(id: string, coral: CoralApi) { + return this._cache(id, () => coral.getFriendCodeUrl(), + this.user_friendcodeurl_promise, this.cached_friendcodeurl); + } - res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, update_interval)); - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ + async handleFriendCodeUrlRequest({res, user}: RequestDataWithUser) { + const [updated, friendcodeurl] = await this.getFriendCodeUrl(user.data.user.id, user.nso); + + res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); + + return { friendcode: friendcodeurl, updated, - })); - }); + }; + } // // Event stream // - app.get('/api/znc/presence/events', localAuth, nsoAuth, async (req, res) => { + async handlePresenceEventStreamRequest({req, res, user}: RequestDataWithUser) { res.setHeader('Cache-Control', 'no-store'); res.setHeader('Content-Type', 'text/event-stream'); - const nintendoAccountSessionToken = req.headers['authorization']!.substr(3); - const i = new ZncNotifications(storage, nintendoAccountSessionToken, req.znc!, req.zncAuth!); + const na_session_token = req.headers['authorization']!.substr(3); + const i = new ZncNotifications(this.storage, na_session_token, user.nso, user.data, user); i.user_notifications = false; i.friend_notifications = true; - i.update_interval = update_interval / 1000; + i.update_interval = this.update_interval / 1000; const es = i.notifications = new EventStreamNotificationManager(req, res); try { await i.loop(true); - while (true) { + while (!res.closed) { await i.loop(); + + this.resetAuthTimeout(na_session_token, () => user.data.user.id); } } catch (err) { es.sendEvent('error', { error: (err as Error).name, error_message: (err as Error).message, }); - res.end(); } - }); + } +} - return app; +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) { @@ -853,8 +818,8 @@ function cacheMaxAge(updated_timestamp_ms: number, update_interval_ms: number) { class EventStreamNotificationManager extends NotificationManager { constructor( - public req: express.Request, - public res: express.Response + public req: Request, + public res: Response ) { super(); } diff --git a/src/cli/presence-server.ts b/src/cli/presence-server.ts index bec9766..85c0d99 100644 --- a/src/cli/presence-server.ts +++ b/src/cli/presence-server.ts @@ -1,8 +1,8 @@ import * as net from 'node:net'; import createDebug from 'debug'; -import express from 'express'; +import express, { Request, Response } from 'express'; import * as persist from 'node-persist'; -import { BankaraMatchMode, BankaraMatchSetting, CoopSetting, DetailVotingStatusResult, FestMatchSetting, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, Friend as SplatNetFriend, FriendListResult, FriendOnlineState, GraphQLSuccessResponse, LeagueMatchSetting, RegularMatchSetting, StageScheduleResult, XMatchSetting } from 'splatnet3-types/splatnet3'; +import { BankaraMatchMode, BankaraMatchSetting, CoopSetting, DetailVotingStatusResult, FestMatchSetting, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, Friend as SplatNetFriend, FriendListResult, FriendOnlineState, GraphQLSuccessResponse, LeagueMatchSetting, RegularMatchSetting, StageScheduleResult, VsMode, XMatchSetting } from 'splatnet3-types/splatnet3'; import type { Arguments as ParentArguments } from '../cli.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js'; import { initStorage } from '../util/storage.js'; @@ -16,6 +16,8 @@ import SplatNet3Api from '../api/splatnet3.js'; const debug = createDebug('cli:presence-server'); +type CoopSetting_schedule = Pick; + export const command = 'presence-server'; export const desc = 'Starts a HTTP server to fetch presence data from Coral and SplatNet 3'; @@ -73,10 +75,10 @@ export async function handler(argv: ArgumentsCamelCase) { return new SplatNet3User(splatnet, data, friends); }) : null; - const app = createApp( - storage, coral_users, splatnet3_users, user_naids, - argv.allowAllUsers, argv.updateInterval * 1000 - ); + const server = new Server(storage, coral_users, splatnet3_users, user_naids); + server.allow_all_users = argv.allowAllUsers; + server.update_interval = argv.updateInterval * 1000; + const app = server.app; for (const address of argv.listen) { const [host, port] = parseListenAddress(address); @@ -170,295 +172,317 @@ export class SplatNet3User { } } -function createApp( - storage: persist.LocalStorage, - coral_users: Users, - splatnet3_users: Users | null, - user_ids: string[], - allow_all_users = false, - update_interval = 30 * 1000 -) { - const app = express(); +class Server { + allow_all_users = false; + update_interval = 30 * 1000; - app.use('/api/presence', (req, res, next) => { - console.log('[%s] %s %s HTTP/%s from %s, port %d%s, %s', - new Date(), req.method, req.url, req.httpVersion, - req.socket.remoteAddress, req.socket.remotePort, - req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '', - req.headers['user-agent']); + app: express.Express; - res.setHeader('Server', product + ' presence-server'); + constructor( + readonly storage: persist.LocalStorage, + readonly coral_users: Users, + readonly splatnet3_users: Users | null, + readonly user_ids: string[], + ) { + const app = this.app = express(); - next(); - }); + app.use('/api/presence', (req, res, next) => { + console.log('[%s] %s %s HTTP/%s from %s, port %d%s, %s', + new Date(), req.method, req.url, req.httpVersion, + req.socket.remoteAddress, req.socket.remotePort, + req.headers['x-forwarded-for'] ? ' (' + req.headers['x-forwarded-for'] + ')' : '', + req.headers['user-agent']); - app.get('/api/presence', async (req, res) => { - if (!allow_all_users) { - res.statusCode = 403; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: 'forbidden', - })); - return; + res.setHeader('Server', product + ' presence-server'); + + next(); + }); + + app.get('/api/presence', this.createApiRequestHandler((req, res) => + this.handleAllUsersRequest(req, res))); + app.get('/api/presence/:user', this.createApiRequestHandler((req, res) => + 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) { + throw new ResponseError(403, 'forbidden'); } - try { - const include_splatnet3 = splatnet3_users && req.query['include-splatoon3'] === '1'; + const include_splatnet3 = this.splatnet3_users && req.query['include-splatoon3'] === '1'; - const result: (Friend & { - splatoon3?: SplatNetFriend | null; - splatoon3_fest_team?: FestTeam_votingStatus | null; - })[] = []; + const result: (Friend & { + splatoon3?: SplatNetFriend | null; + splatoon3_fest_team?: FestTeam_votingStatus | null; + })[] = []; - const users = await Promise.all(user_ids.map(async id => { - const token = await storage.getItem('NintendoAccountToken.' + id); - const user = await coral_users.get(token); - user.update_interval = update_interval; + const users = await Promise.all(this.user_ids.map(async id => { + const token = await this.storage.getItem('NintendoAccountToken.' + id); + const user = await this.coral_users.get(token); + user.update_interval = this.update_interval; + return user; + })); + + for (const user of users) { + const friends = await user.getFriends(); + + for (const friend of friends) { + const index = result.findIndex(f => f.nsaId === friend.nsaId); + if (index >= 0) { + const match = result[index]; + + if (match.presence.updatedAt && !friend.presence.updatedAt) continue; + if (match.presence.updatedAt >= friend.presence.updatedAt) continue; + + result.splice(index, 1); + } + + result.push(include_splatnet3 ? Object.assign(friend, {splatoon3: null}) : friend); + } + } + + if (this.splatnet3_users && include_splatnet3) { + const users = await Promise.all(this.user_ids.map(async id => { + const token = await this.storage.getItem('NintendoAccountToken.' + id); + const user = await this.splatnet3_users!.get(token); + user.update_interval = this.update_interval; return user; })); for (const user of users) { - const friends = await user.getFriends(); - - for (const friend of friends) { - const index = result.findIndex(f => f.nsaId === friend.nsaId); - if (index >= 0) { - const match = result[index]; - - if (match.presence.updatedAt && !friend.presence.updatedAt) continue; - if (match.presence.updatedAt >= friend.presence.updatedAt) continue; - - result.splice(index, 1); - } - - result.push(include_splatnet3 ? Object.assign(friend, {splatoon3: null}) : friend); - } - } - - if (splatnet3_users && include_splatnet3) { - const users = await Promise.all(user_ids.map(async id => { - const token = await storage.getItem('NintendoAccountToken.' + id); - const user = await splatnet3_users.get(token); - user.update_interval = update_interval; - return user; - })); - - for (const user of users) { - const friends = await user.getFriends(); - const fest_vote_status = await user.getCurrentFestVotes(); - - for (const friend of friends) { - const friend_nsaid = Buffer.from(friend.id, 'base64').toString() - .replace(/^Friend-([0-9a-f]{16})$/, '$1'); - const match = result.find(f => f.nsaId === friend_nsaid); - if (!match) continue; - - match.splatoon3 = friend; - - if (fest_vote_status) { - for (const team of fest_vote_status.teams) { - if (!team.votes || !team.preVotes) continue; - - for (const player of team.votes.nodes) { - if (player.userIcon.url !== friend.userIcon.url) continue; - - match.splatoon3_fest_team = createFestVoteTeam(team, FestVoteState.VOTED); - break; - } - - for (const player of team.preVotes.nodes) { - if (player.userIcon.url !== friend.userIcon.url) continue; - - match.splatoon3_fest_team = createFestVoteTeam(team, FestVoteState.PRE_VOTED); - break; - } - } - - if (!match.splatoon3_fest_team && fest_vote_status.undecidedVotes) { - match.splatoon3_fest_team = null; - } - } - } - } - } - - result.sort((a, b) => b.presence.updatedAt - a.presence.updatedAt); - - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({result}, replacer)); - } catch (err) { - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); - } - }); - - app.get('/api/presence/:user', async (req, res) => { - try { - const include_splatnet3 = splatnet3_users && req.query['include-splatoon3'] === '1'; - - let match_coral: Friend | null = null; - let match_user_id: string | null = null; - let match_splatnet3: SplatNetFriend | null = null; - let match_splatnet3_fest_team: FestTeam_schedule | null | undefined = undefined; - let match_splatnet3_fest_team_vote_status: FestTeam_votingStatus | null | undefined = undefined; - - const additional_response_data: { - splatoon3_vs_setting?: - RegularMatchSetting | BankaraMatchSetting | FestMatchSetting | - LeagueMatchSetting | XMatchSetting | null; - splatoon3_coop_setting?: Pick | null; - splatoon3_fest?: Fest_schedule | null; - } = {}; - - for (const user_naid of user_ids) { - const token = await storage.getItem('NintendoAccountToken.' + user_naid); - const user = await coral_users.get(token); - user.update_interval = update_interval; - - const has_friend = user.friends.result.friends.find(f => f.nsaId === req.params.user); - if (!has_friend) continue; - - const friends = await user.getFriends(); - const friend = friends.find(f => f.nsaId === req.params.user); - if (!friend) continue; - - match_coral = friend; - match_user_id = user_naid; - - // Keep searching if the authenticated user doesn't have permission to view this user's presence - if (friend.presence.updatedAt) break; - } - - if (!match_coral) { - res.statusCode = 404; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({error: 'not_found'})); - return; - } - - if (splatnet3_users && include_splatnet3) { - const token = await storage.getItem('NintendoAccountToken.' + match_user_id); - const user = await splatnet3_users.get(token); - user.update_interval = update_interval; - const friends = await user.getFriends(); const fest_vote_status = await user.getCurrentFestVotes(); for (const friend of friends) { const friend_nsaid = Buffer.from(friend.id, 'base64').toString() .replace(/^Friend-([0-9a-f]{16})$/, '$1'); - if (match_coral.nsaId !== friend_nsaid) continue; + const match = result.find(f => f.nsaId === friend_nsaid); + if (!match) continue; - match_splatnet3 = friend; + match.splatoon3 = friend; if (fest_vote_status) { - const schedules = await user.getSchedules(); - for (const team of fest_vote_status.teams) { - const schedule_team = schedules.currentFest?.teams.find(t => t.id === team.id); - if (!schedule_team || !team.votes || !team.preVotes) continue; // Shouldn't ever happen + if (!team.votes || !team.preVotes) continue; for (const player of team.votes.nodes) { if (player.userIcon.url !== friend.userIcon.url) continue; - match_splatnet3_fest_team = createFestScheduleTeam(schedule_team, FestVoteState.VOTED); - match_splatnet3_fest_team_vote_status = createFestVoteTeam(team, FestVoteState.VOTED); + match.splatoon3_fest_team = createFestVoteTeam(team, FestVoteState.VOTED); break; } for (const player of team.preVotes.nodes) { if (player.userIcon.url !== friend.userIcon.url) continue; - match_splatnet3_fest_team = createFestScheduleTeam(schedule_team, FestVoteState.VOTED); - match_splatnet3_fest_team_vote_status = createFestVoteTeam(team, FestVoteState.PRE_VOTED); + match.splatoon3_fest_team = createFestVoteTeam(team, FestVoteState.PRE_VOTED); break; } } - if (!match_splatnet3_fest_team && fest_vote_status.undecidedVotes) { - match_splatnet3_fest_team = null; + if (!match.splatoon3_fest_team && fest_vote_status.undecidedVotes) { + match.splatoon3_fest_team = null; } } - - if ((friend.onlineState === FriendOnlineState.VS_MODE_MATCHING || - friend.onlineState === FriendOnlineState.VS_MODE_FIGHTING) && friend.vsMode - ) { - const schedules = await user.getSchedules(); - - const vs_setting = - friend.vsMode.mode === 'REGULAR' ? getSchedule(schedules.regularSchedules)?.regularMatchSetting : - friend.vsMode.mode === 'BANKARA' ? - friend.vsMode.id === 'VnNNb2RlLTI=' ? - getSchedule(schedules.bankaraSchedules)?.bankaraMatchSettings - ?.find(s => s.mode === BankaraMatchMode.CHALLENGE) : - friend.vsMode.id === 'VnNNb2RlLTUx' ? - getSchedule(schedules.bankaraSchedules)?.bankaraMatchSettings - ?.find(s => s.mode === BankaraMatchMode.OPEN) : - null : - friend.vsMode.mode === 'FEST' ? getSchedule(schedules.festSchedules)?.festMatchSetting : - friend.vsMode.mode === 'LEAGUE' ? getSchedule(schedules.leagueSchedules)?.leagueMatchSetting : - friend.vsMode.mode === 'X_MATCH' ? getSchedule(schedules.xSchedules)?.xMatchSetting : - null; - - additional_response_data.splatoon3_vs_setting = vs_setting ?? null; - - if (friend.vsMode.mode === 'FEST') { - additional_response_data.splatoon3_fest = schedules.currentFest ? - createScheduleFest(schedules.currentFest, - match_splatnet3_fest_team?.id, match_splatnet3_fest_team?.myVoteState) : null; - } - } - - if (friend.onlineState === FriendOnlineState.COOP_MODE_MATCHING || - friend.onlineState === FriendOnlineState.COOP_MODE_FIGHTING - ) { - const schedules = await user.getSchedules(); - - const coop_setting = - getSchedule(schedules.coopGroupingSchedule.regularSchedules)?.setting; - - additional_response_data.splatoon3_coop_setting = coop_setting ?? null; - } - - break; } } - - const response = { - friend: match_coral, - splatoon3: include_splatnet3 ? match_splatnet3 : undefined, - splatoon3_fest_team: include_splatnet3 ? match_splatnet3_fest_team ? { - ...match_splatnet3_fest_team, - ...match_splatnet3_fest_team_vote_status, - } : match_splatnet3_fest_team : undefined, - ...additional_response_data, - }; - - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify(response, replacer)); - } catch (err) { - if (err && 'type' in err && 'code' in err && (err as any).type === 'system') { - const code: string = (err as any).code; - - if (code === 'ETIMEDOUT' || code === 'ENOTFOUND' || code === 'EAI_AGAIN') { - res.setHeader('Retry-After', '60'); - } - } - - res.statusCode = 500; - res.setHeader('Content-Type', 'application/json'); - res.end(JSON.stringify({ - error: err, - error_message: (err as Error).message, - })); } - }); - return app; + result.sort((a, b) => b.presence.updatedAt - a.presence.updatedAt); + + return {result}; + } + + async handlePresenceRequest(req: Request, res: Response, presence_user_nsaid: string) { + const include_splatnet3 = this.splatnet3_users && req.query['include-splatoon3'] === '1'; + + let match_coral: Friend | null = null; + let match_user_id: string | null = null; + + for (const user_naid of this.user_ids) { + const token = await this.storage.getItem('NintendoAccountToken.' + user_naid); + const user = await this.coral_users.get(token); + user.update_interval = this.update_interval; + + const has_friend = user.friends.result.friends.find(f => f.nsaId === presence_user_nsaid); + if (!has_friend) continue; + + const friends = await user.getFriends(); + const friend = friends.find(f => f.nsaId === presence_user_nsaid); + if (!friend) continue; + + match_coral = friend; + match_user_id = user_naid; + + // Keep searching if the authenticated user doesn't have permission to view this user's presence + if (friend.presence.updatedAt) break; + } + + if (!match_coral) { + throw new ResponseError(404, 'not_found'); + } + + const response: { + friend: Friend; + splatoon3?: SplatNetFriend | null; + splatoon3_fest_team?: (FestTeam_schedule & FestTeam_votingStatus) | null; + splatoon3_vs_setting?: + RegularMatchSetting | BankaraMatchSetting | FestMatchSetting | + LeagueMatchSetting | XMatchSetting | null; + splatoon3_coop_setting?: CoopSetting_schedule | null; + splatoon3_fest?: Fest_schedule | null; + } = { + friend: match_coral, + }; + + if (this.splatnet3_users && include_splatnet3) { + const token = await this.storage.getItem('NintendoAccountToken.' + match_user_id); + const user = await this.splatnet3_users.get(token); + user.update_interval = this.update_interval; + + const friends = await user.getFriends(); + const fest_vote_status = await user.getCurrentFestVotes(); + + for (const friend of friends) { + const friend_nsaid = Buffer.from(friend.id, 'base64').toString() + .replace(/^Friend-([0-9a-f]{16})$/, '$1'); + if (match_coral.nsaId !== friend_nsaid) continue; + + response.splatoon3 = friend; + + if (fest_vote_status) { + const schedules = await user.getSchedules(); + + for (const team of fest_vote_status.teams) { + const schedule_team = schedules.currentFest?.teams.find(t => t.id === team.id); + if (!schedule_team || !team.votes || !team.preVotes) continue; // Shouldn't ever happen + + for (const player of team.votes.nodes) { + if (player.userIcon.url !== friend.userIcon.url) continue; + + response.splatoon3_fest_team = { + ...createFestScheduleTeam(schedule_team, FestVoteState.VOTED), + ...createFestVoteTeam(team, FestVoteState.VOTED), + }; + break; + } + + for (const player of team.preVotes.nodes) { + if (player.userIcon.url !== friend.userIcon.url) continue; + + response.splatoon3_fest_team = { + ...createFestScheduleTeam(schedule_team, FestVoteState.PRE_VOTED), + ...createFestVoteTeam(team, FestVoteState.PRE_VOTED), + }; + break; + } + } + + if (!response.splatoon3_fest_team && fest_vote_status.undecidedVotes) { + response.splatoon3_fest_team = null; + } + } + + if ((friend.onlineState === FriendOnlineState.VS_MODE_MATCHING || + friend.onlineState === FriendOnlineState.VS_MODE_FIGHTING) && friend.vsMode + ) { + const schedules = await user.getSchedules(); + const vs_setting = this.getSettingForVsMode(schedules, friend.vsMode); + + response.splatoon3_vs_setting = vs_setting ?? null; + + if (friend.vsMode.mode === 'FEST') { + response.splatoon3_fest = schedules.currentFest ? + createScheduleFest(schedules.currentFest, + response.splatoon3_fest_team?.id, response.splatoon3_fest_team?.myVoteState) : null; + } + } + + if (friend.onlineState === FriendOnlineState.COOP_MODE_MATCHING || + friend.onlineState === FriendOnlineState.COOP_MODE_FIGHTING + ) { + const schedules = await user.getSchedules(); + const coop_setting = getSchedule(schedules.coopGroupingSchedule.regularSchedules)?.setting; + + response.splatoon3_coop_setting = coop_setting ?? null; + } + + break; + } + } + + return response; + } + + getSettingForVsMode(schedules: StageScheduleResult, vs_mode: Pick) { + if (vs_mode.mode === 'REGULAR') { + return getSchedule(schedules.regularSchedules)?.regularMatchSetting; + } + if (vs_mode.mode === 'BANKARA') { + const settings = getSchedule(schedules.bankaraSchedules)?.bankaraMatchSettings; + if (vs_mode.id === 'VnNNb2RlLTI=') { + return settings?.find(s => s.mode === BankaraMatchMode.CHALLENGE); + } + if (vs_mode.id === 'VnNNb2RlLTUx') { + return settings?.find(s => s.mode === BankaraMatchMode.OPEN); + } + } + if (vs_mode.mode === 'FEST') { + return getSchedule(schedules.festSchedules)?.festMatchSetting; + } + if (vs_mode.mode === 'LEAGUE') { + return getSchedule(schedules.leagueSchedules)?.leagueMatchSetting; + } + if (vs_mode.mode === 'X_MATCH') { + return getSchedule(schedules.xSchedules)?.xMatchSetting; + } + return null; + } +} + +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( diff --git a/src/common/users.ts b/src/common/users.ts index 2d193ac..9cd5174 100644 --- a/src/common/users.ts +++ b/src/common/users.ts @@ -43,6 +43,14 @@ export default class Users { return promise; } + async remove(token: string) { + const promise = this.promise.get(token); + this.promise.delete(token); + + await promise; + this.users.delete(token); + } + static coral(store: Store | persist.LocalStorage, znc_proxy_url: string, ratelimit?: boolean): Users> static coral(store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users static coral(_store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean) {