nxapi/src/cli/nso/http-server.ts
2023-07-04 21:01:25 +01:00

805 lines
31 KiB
TypeScript

import * as net from 'node:net';
import * as os from 'node:os';
import * as persist from 'node-persist';
import express, { Request, RequestHandler, Response } from 'express';
import bodyParser from 'body-parser';
import { v4 as uuidgen } from 'uuid';
import type { Arguments as ParentArguments } from '../nso.js';
import CoralApi, { CoralApiInterface } from '../../api/coral.js';
import { Announcement, CoralStatus, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, Presence } from '../../api/coral-types.js';
import { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/znc-proxy.js';
import { ErrorResponse } from '../../api/util.js';
import createDebug from '../../util/debug.js';
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
import { initStorage } from '../../util/storage.js';
import { product } from '../../util/product.js';
import { parseListenAddress } from '../../util/net.js';
import { addCliFeatureUserAgent } from '../../util/useragent.js';
import { EventStreamResponse, HttpServer, ResponseError } from '../util/http-server.js';
import { SavedToken } from '../../common/auth/coral.js';
import { NotificationManager, PresenceEvent, ZncNotifications } from '../../common/notify.js';
import Users, { CoralUser } from '../../common/users.js';
declare global {
namespace Express {
interface Request {
coralUser?: CoralUser;
coral?: CoralApi;
coralAuthData?: SavedToken;
proxyAuthPolicy?: AuthPolicy;
proxyAuthPolicyUser?: string;
proxyAuthPolicyToken?: string;
}
}
}
interface RequestData {
req: Request;
res: Response;
user?: CoralUser<CoralApiInterface>;
policy?: AuthPolicy;
token?: string;
}
interface RequestDataWithUser extends RequestData {
user: CoralUser<CoralApiInterface>;
}
const debug = createDebug('cli:nso:http-server');
export const command = 'http-server';
export const desc = 'Starts a HTTP server to access the Nintendo Switch Online app API';
export function builder(yargs: Argv<ParentArguments>) {
return yargs.option('listen', {
describe: 'Server address and port',
type: 'array',
default: ['[::]:0'],
}).option('require-token', {
describe: 'Require Nintendo Account session token for all requests (if disabled the user query string parameter can be used to use the last token for that user)',
type: 'boolean',
default: true,
}).option('update-interval', {
describe: 'Max. update interval in seconds',
type: 'number',
default: 30,
});
}
type Arguments = YargsArguments<ReturnType<typeof builder>>;
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
addCliFeatureUserAgent('http-server');
const storage = await initStorage(argv.dataPath);
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);
const server = app.listen(port, host ?? '::');
server.on('listening', () => {
const address = server.address() as net.AddressInfo;
console.log('Listening on %s, port %d', address.address, address.port);
});
}
}
const FRIEND_CODE = /^\d{4}-\d{4}-\d{4}$/;
class Server extends HttpServer {
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
friendcode_update_interval = 24 * 60 * 60 * 1000; // 24 hours
readonly app: express.Express;
constructor(
readonly storage: persist.LocalStorage,
readonly users: Users<CoralUser<CoralApiInterface>>,
) {
super();
const app = this.app = express();
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']);
res.setHeader('Server', product + ' znc-proxy');
res.setHeader('X-Server', product + ' znc-proxy');
res.setHeader('X-Served-By', os.hostname());
next();
});
app.get('/api/znc/auth', this.createProxyRequestHandler(r => this.handleAuthRequest(r), true));
app.get('/api/znc/token', this.authTokenMiddleware,
this.createProxyRequestHandler(r => this.handleTokenRequest(r)));
app.delete('/api/znc/token', this.authTokenMiddleware,
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.createProxyRequestHandler(r => this.handleCreateTokenRequest(r), true));
app.get('/api/znc/announcements', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleAnnouncementsRequest(r), true));
app.get('/api/znc/user', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleCurrentUserRequest(r)));
app.get('/api/znc/user/presence', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleUserPresenceRequest(r)));
app.get('/api/znc/friends', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleFriendsRequest(r)));
app.get('/api/znc/friends/favourites', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleFavouriteFriendsRequest(r)));
app.get('/api/znc/friends/presence', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleFriendsPresenceRequest(r)));
app.get('/api/znc/friends/favourites/presence', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleFavouriteFriendsPresenceRequest(r)));
app.get('/api/znc/friend/:nsaid', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleFriendRequest(r, r.req.params.nsaid)));
app.post('/api/znc/friend/:nsaid', bodyParser.json(),
this.createProxyRequestHandler(r => this.handleUpdateFriendRequest(r, r.req.params.nsaid), true));
app.get('/api/znc/friend/:nsaid/presence', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleFriendPresenceRequest(r, r.req.params.nsaid)));
app.get('/api/znc/webservices', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleWebServicesRequest(r)));
app.get('/api/znc/webservice/:id/token',
this.createProxyRequestHandler(r => this.handleWebServiceTokenRequest(r, r.req.params.id), true));
app.get('/api/znc/activeevent', this.authTokenMiddleware, this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleActiveEventRequest(r)));
app.get('/api/znc/event/:id',
this.createProxyRequestHandler(r => this.handleEventRequest(r, r.req.params.id), true));
app.get('/api/znc/user/:id',
this.createProxyRequestHandler(r => this.handleUserRequest(r, r.req.params.id), true));
app.get('/api/znc/friendcode/:friendcode', this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleFriendCodeRequest(r, r.req.params.friendcode), true));
app.get('/api/znc/friendcode', this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handleFriendCodeUrlRequest(r), true));
app.get('/api/znc/presence/events', this.localAuthMiddleware,
this.createProxyRequestHandler(r => this.handlePresenceEventStreamRequest(r), true));
}
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;
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) {
this.handleRequestError(req, res, err);
}
};
}
protected async _cache<T>(
id: string, callback: () => Promise<T>,
promises: Map<string, Promise<[number, T]>>,
cache: Map<string, [number, T]>,
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<T>(
id: string, callback: () => Promise<T>,
promises: Map<string, Promise<T>>,
cache: Map<string, T>,
) {
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;
});
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 this.storage.getItem('ZncProxyAuthPolicy.' + token);
if (!auth) return;
req.proxyAuthPolicy = auth.policy;
req.proxyAuthPolicyUser = auth.user;
req.proxyAuthPolicyToken = token;
} else if (req.query.token) {
const auth: AuthToken | undefined = await this.storage.getItem('ZncProxyAuthPolicy.' + req.query.token);
if (!auth) return;
req.proxyAuthPolicy = auth.policy;
req.proxyAuthPolicyUser = auth.user;
req.proxyAuthPolicyToken = '' + req.query.token;
}
});
private coral_auth_promise = new Map</** session token */ string, Promise<CoralUser<CoralApiInterface>>>();
private coral_auth_timeout = new Map</** session token */ string, NodeJS.Timeout>();
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);
}
let user_naid: string | null = null;
const promise = this.coral_auth_promise.get(na_session_token) ?? (async () => {
const user = await this.users.get(na_session_token);
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]);
}
user_naid = user.data.user.id;
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);
this.resetAuthTimeout(na_session_token, () => user_naid);
return promise;
}
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());
}
async handleAuthRequest({user}: RequestDataWithUser) {
return user.data;
}
async handleTokenRequest({policy, token}: RequestData) {
if (!token) {
throw new ResponseError(403, 'no_policy');
}
return policy;
}
async handleDeleteTokenRequest({req, res, token}: RequestData) {
if (!token) {
throw new ResponseError(403, 'no_policy');
}
await this.storage.removeItem('ZncProxyAuthPolicy.' + token);
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;
}
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 this.storage.getItem('ZncProxyAuthPolicy.' + id);
if (!auth) return;
return {
token: id,
user: auth.user,
policy: auth.policy,
created_at: auth.created_at,
};
}) ?? [])).filter(p => p);
return {tokens};
}
async handleCreateTokenRequest({req, user}: RequestDataWithUser) {
const token = uuidgen();
const auth: AuthToken = {
user: user.data.user.id,
policy: req.body.policy,
created_at: Math.floor(Date.now() / 1000),
};
await this.storage.setItem('ZncProxyAuthPolicy.' + token, auth);
const tokens = new Set(await this.storage.getItem('ZncProxyAuthPolicies.' + user.data.user.id) ?? []);
tokens.add(token);
await this.storage.setItem('ZncProxyAuthPolicies.' + user.data.user.id, [...tokens]);
return {
token,
...auth,
};
}
//
// Announcements
// This is cached for all users.
//
async handleAnnouncementsRequest({req, policy}: RequestData) {
if (policy && !policy.announcements) {
throw new ResponseError(403, 'token_unauthorised');
}
const user = await this.getCoralUser(req);
const announcements: Announcement[] = user.announcements.result;
return {announcements};
}
//
// Nintendo Switch user data
//
private user_data_promise = new Map</** NA ID */ string, Promise<[number, CurrentUser]>>();
private cached_userdata = new Map</** NA ID */ string, [number, CurrentUser]>();
async getUserData(id: string, coral: CoralApiInterface) {
return this._cache(id, () => coral.getCurrentUser(),
this.user_data_promise, this.cached_userdata);
}
async handleCurrentUserRequest({req, res, policy}: RequestData) {
if (policy && !policy.current_user) {
throw new ResponseError(403, 'token_unauthorised');
}
const user = await this.getCoralUser(req);
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');
}
const user = await this.getCoralUser(req);
const [updated, current_user] = await this.getUserData(user.data.user.id, user.nso);
return current_user.presence;
}
//
// Nintendo Switch friends, NSO app web services, events
//
async handleFriendsRequest({req, res, policy}: RequestData) {
if (policy && !policy.list_friends) {
throw new ResponseError(403, 'token_unauthorised');
}
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: policy?.friends ?
friends.filter(f => policy.friends!.includes(f.nsaId)) : friends,
updated,
};
}
async handleFavouriteFriendsRequest({req, res, policy}: RequestData) {
if (policy && !policy.list_friends) {
throw new ResponseError(403, 'token_unauthorised');
}
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 (policy?.friends && !policy.friends.includes(f.nsaId)) return false;
return f.isFavoriteFriend;
}),
updated,
};
}
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<string, Presence> = {};
for (const friend of friends) {
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, this.update_interval));
return presence;
}
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<string, Presence> = {};
for (const friend of friends) {
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;
presence[friend.nsaId] = friend.presence;
}
res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval));
return presence;
}
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');
}
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) {
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
) {
throw new ResponseError(400, 'invalid_request', 'Invalid value for isFavoriteFriend.');
}
if ('isFavoriteFriend' in req.body) {
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;
}
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) {
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.presence;
}
async handleWebServicesRequest({req, res, policy}: RequestData) {
if (policy && !policy.webservices) {
throw new ResponseError(403, 'token_unauthorised');
}
const user = await this.getCoralUser(req);
const [friends, webservices, activeevent] = await Promise.all([
user.getFriends(),
user.getWebServices(),
user.getActiveEvent(),
]);
const updated = user.updated.webservices;
res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval));
return {webservices, updated};
}
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');
}
const user = await this.getCoralUser(req);
const [friends, webservices, activeevent] = await Promise.all([
user.getFriends(),
user.getWebServices(),
user.getActiveEvent(),
]);
const updated = user.updated.webservices;
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.
//
private friendcode_data_promise = new Map</** NA ID */ string, Map</** FC ID */ string,
Promise<[number, [FriendCodeUser | null, /** NA ID */ string]]>>>();
private cached_friendcode_data = new Map</** FC ID */ string,
[number, [FriendCodeUser | null, /** NA ID */ string]]>();
async getFriendCodeUser(id: string, coral: CoralApiInterface, friendcode: string) {
if (!FRIEND_CODE.test(friendcode)) {
throw new ResponseError(400, 'invalid_request', 'Invalid friend code');
}
const promises = this.friendcode_data_promise.get(id) ??
new Map<string, Promise<[number, [FriendCodeUser | null, string]]>>();
this.friendcode_data_promise.set(id, promises);
try {
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;
}
}, promises, this.cached_friendcode_data, this.friendcode_update_interval);
} finally {
if (!promises.size) this.friendcode_data_promise.delete(id);
}
}
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);
res.setHeader('Cache-Control', 'immutable, max-age=' + cacheMaxAge(updated, this.friendcode_update_interval));
if (!friend_code_user) {
throw new ResponseError(404, 'not_found', 'A user with this friend code was not found');
}
return {user: friend_code_user, updated};
}
private user_friendcodeurl_promise = new Map</** NA ID */ string, Promise<[number, FriendCodeUrl]>>();
private cached_friendcodeurl = new Map</** NA ID */ string, [number, FriendCodeUrl]>();
getFriendCodeUrl(id: string, coral: CoralApiInterface) {
return this._cache(id, () => coral.getFriendCodeUrl(),
this.user_friendcodeurl_promise, this.cached_friendcodeurl);
}
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
//
async handlePresenceEventStreamRequest({req, res, user}: RequestDataWithUser) {
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 = this.update_interval / 1000;
const stream = new EventStreamResponse(req, res);
i.notifications = new EventStreamNotificationManager(stream);
try {
await i.loop(true);
while (!res.closed) {
await i.loop();
this.resetAuthTimeout(na_session_token, () => user.data.user.id);
}
} catch (err) {
stream.sendErrorEvent(err);
}
}
}
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(readonly stream: EventStreamResponse) {
super();
}
onPresenceUpdated(
friend: CurrentUser | Friend, prev?: CurrentUser | Friend, type?: PresenceEvent,
naid?: string, ir?: boolean
) {
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.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.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.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.stream.sendEvent(ZncPresenceEventStreamEvent.FRIEND_TITLE_STATECHANGE, {
id: friend.nsaId, presence: friend.presence, prev: prev?.presence,
});
}
}