Add presence server SplatNet 3 proxy

This commit is contained in:
Samuel Elliott 2022-12-18 12:08:03 +00:00
parent 7035fb6b19
commit fb9cd6d76f
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
3 changed files with 196 additions and 27 deletions

View File

@ -1,7 +1,8 @@
import * as net from 'node:net';
import * as os from 'node:os';
import createDebug from 'debug';
import * as persist from 'node-persist';
import express, { NextFunction, Request, RequestHandler, Response } from 'express';
import express, { Request, RequestHandler, Response } from 'express';
import bodyParser from 'body-parser';
import { v4 as uuidgen } from 'uuid';
import { Announcement, CoralStatus, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, Presence } from '../../api/coral-types.js';
@ -117,6 +118,8 @@ class Server extends HttpServer {
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();
});

View File

@ -1,12 +1,14 @@
import * as net from 'node:net';
import * as os from 'node:os';
import createDebug from 'debug';
import express, { Request, Response } from 'express';
import fetch from 'node-fetch';
import * as persist from 'node-persist';
import { BankaraMatchMode, BankaraMatchSetting_schedule, CoopSetting_schedule, DetailVotingStatusResult, FestMatchSetting_schedule, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, FriendListResult, FriendOnlineState, Friend_friendList, GraphQLSuccessResponse, LeagueMatchSetting_schedule, RegularMatchSetting_schedule, StageScheduleResult, VsMode, XMatchSetting_schedule } 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';
import { addCliFeatureUserAgent } from '../util/useragent.js';
import { addCliFeatureUserAgent, getUserAgent } from '../util/useragent.js';
import { parseListenAddress } from '../util/net.js';
import { product } from '../util/product.js';
import Users, { CoralUser } from '../common/users.js';
@ -18,6 +20,7 @@ import { EventStreamResponse, HttpServer, ResponseError } from './util/http-serv
import { getTitleIdFromEcUrl } from '../util/misc.js';
const debug = createDebug('cli:presence-server');
const debugSplatnet3Proxy = createDebug('cli:presence-server:splatnet3-proxy');
interface AllUsersResult extends Friend {
splatoon3?: Friend_friendList | null;
@ -54,6 +57,13 @@ export function builder(yargs: Argv<ParentArguments>) {
describe: 'Enable returning all users',
type: 'boolean',
default: false,
}).option('splatnet3-proxy', {
describe: 'Enable SplatNet 3 proxy',
type: 'boolean',
default: false,
}).option('splatnet3-proxy-url', {
describe: 'SplatNet 3 proxy URL',
type: 'string',
}).option('update-interval', {
describe: 'Max. update interval in seconds',
type: 'number',
@ -77,29 +87,21 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
debug('user', user_naids);
if (!user_naids.length) {
if (!user_naids.length && !argv.splatnet3Proxy) {
throw new Error('No user selected');
}
const coral_users = Users.coral(storage, argv.zncProxyUrl);
const splatnet3_users = argv.splatnet3 ? new Users(async token => {
const {splatnet, data} = await getBulletToken(storage, token, argv.zncProxyUrl, true);
const friends = await splatnet.getFriends();
Promise.all([
splatnet.getCurrentFest(),
splatnet.getConfigureAnalytics(),
]).catch(err => {
debug('Error in useCurrentFest/ConfigureAnalyticsQuery', err);
});
return new SplatNet3User(splatnet, data, friends);
return argv.splatnet3ProxyUrl ?
SplatNet3ProxyUser.create(argv.splatnet3ProxyUrl, token) :
SplatNet3ApiUser.create(storage, token, argv.zncProxyUrl);
}) : null;
const server = new Server(storage, coral_users, splatnet3_users, user_naids);
server.allow_all_users = argv.allowAllUsers;
server.enable_splatnet3_proxy = argv.splatnet3Proxy;
server.update_interval = argv.updateInterval * 1000;
const app = server.app;
@ -113,7 +115,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
}
}
export class SplatNet3User {
abstract class SplatNet3User {
created_at = Date.now();
expires_at = Infinity;
@ -132,12 +134,10 @@ export class SplatNet3User {
update_interval_fest_voting_status: number | null = null; // 10 seconds
constructor(
public splatnet: SplatNet3Api,
public data: SavedBulletToken,
public friends: GraphQLSuccessResponse<FriendListResult>,
) {}
private async update(key: keyof SplatNet3User['updated'], callback: () => Promise<void>, ttl: number) {
protected async update(key: keyof SplatNet3User['updated'], callback: () => Promise<void>, ttl: number) {
if (((this.updated[key] ?? 0) + ttl) < Date.now()) {
const promise = this.promise.get(key) ?? callback.call(null).then(() => {
this.updated[key] = Date.now();
@ -157,12 +157,14 @@ export class SplatNet3User {
async getFriends(): Promise<Friend_friendList[]> {
await this.update('friends', async () => {
this.friends = await this.splatnet.getFriendsRefetch();
this.friends = await this.getFriendsData();
}, this.update_interval);
return this.friends.data.friends.nodes;
}
abstract getFriendsData(): Promise<GraphQLSuccessResponse<FriendListResult>>;
async getSchedules(): Promise<StageScheduleResult> {
let update_interval = this.update_interval_schedules;
@ -175,28 +177,125 @@ export class SplatNet3User {
}
await this.update('schedules', async () => {
this.schedules = await this.splatnet.getSchedules();
this.schedules = await this.getSchedulesData();
}, update_interval);
return this.schedules!.data;
}
abstract getSchedulesData(): Promise<GraphQLSuccessResponse<StageScheduleResult>>;
async getCurrentFestVotes(): Promise<DetailVotingStatusResult['fest'] | null> {
await this.update('fest_vote_status', async () => {
const schedules = await this.getSchedules();
this.fest_vote_status =
!schedules.currentFest || new Date(schedules.currentFest.endTime).getTime() <= Date.now() ? null :
this.fest_vote_status?.data.fest?.id === schedules.currentFest.id ?
await this.splatnet.getFestVotingStatusRefetch(schedules.currentFest.id) :
await this.splatnet.getFestVotingStatus(schedules.currentFest.id);
this.fest_vote_status = await this.getCurrentFestVotingStatusData();
}, this.update_interval_fest_voting_status ?? this.update_interval);
return this.fest_vote_status?.data.fest ?? null;
}
abstract getCurrentFestVotingStatusData(): Promise<GraphQLSuccessResponse<DetailVotingStatusResult> | null>;
}
class SplatNet3ApiUser extends SplatNet3User {
constructor(
public splatnet: SplatNet3Api,
public data: SavedBulletToken,
public friends: GraphQLSuccessResponse<FriendListResult>,
) {
super(friends);
}
async getFriendsData() {
return this.splatnet.getFriendsRefetch();
}
async getSchedulesData() {
return this.splatnet.getSchedules();
}
async getCurrentFestVotingStatusData() {
const schedules = await this.getSchedules();
return !schedules.currentFest || new Date(schedules.currentFest.endTime).getTime() <= Date.now() ? null :
await this.getFestVotingStatusData(schedules.currentFest.id);
}
async getFestVotingStatusData(id: string) {
return this.fest_vote_status?.data.fest?.id === id ?
await this.splatnet.getFestVotingStatusRefetch(id) :
await this.splatnet.getFestVotingStatus(id);
}
static async create(storage: persist.LocalStorage, token: string, znc_proxy_url?: string) {
const {splatnet, data} = await getBulletToken(storage, token, znc_proxy_url, true);
const friends = await splatnet.getFriends();
splatnet.getCurrentFest().catch(err => {
debug('Error in useCurrentFest request', err);
});
splatnet.getConfigureAnalytics().catch(err => {
debug('Error in ConfigureAnalyticsQuery request', err);
});
return new SplatNet3ApiUser(splatnet, data, friends);
}
}
class SplatNet3ProxyUser extends SplatNet3User {
constructor(
readonly url: string,
private readonly token: string,
public friends: GraphQLSuccessResponse<FriendListResult>,
) {
super(friends);
}
async fetch(url: string) {
return SplatNet3ProxyUser.fetch(this.url, url, this.token);
}
async getFriendsData() {
return this.fetch('/friends');
}
async getSchedulesData() {
return this.fetch('/schedules');
}
async getCurrentFestVotingStatusData() {
return this.fetch('/fest/current/voting-status');
}
static async fetch(base_url: string, url: string, token: string) {
const response = await fetch(base_url + url, {
method: 'GET',
headers: {
'User-Agent': getUserAgent(),
'Authorization': 'na ' + token,
},
});
debugSplatnet3Proxy('fetch %s %s, response %s', 'GET', url, response.status);
if (response.status !== 200) {
throw new ErrorResponse('[splatnet3] Non-200 status code', response, await response.text());
}
const data: any = await response.json();
return data.result;
}
static async create(url: string, token: string) {
const friends = await this.fetch(url, '/friends', token);
return new SplatNet3ProxyUser(url, token, friends);
}
}
class Server extends HttpServer {
allow_all_users = false;
enable_splatnet3_proxy = false;
update_interval = 30 * 1000;
/** Interval coral friends data should be updated if the requested user isn't friends with the authenticated user */
update_interval_unknown_friends = 10 * 60 * 1000; // 10 minutes
@ -221,6 +320,8 @@ class Server extends HttpServer {
req.headers['user-agent']);
res.setHeader('Server', product + ' presence-server');
res.setHeader('X-Server', product + ' presence-server');
res.setHeader('X-Served-By', os.hostname());
next();
});
@ -231,6 +332,27 @@ class Server extends HttpServer {
this.handlePresenceRequest(req, res, req.params.user)));
app.get('/api/presence/:user/events', this.createApiRequestHandler((req, res) =>
this.handlePresenceStreamRequest(req, res, req.params.user)));
app.use('/api/splatnet3-presence', (req, res, next) => {
console.log('[%s] [splatnet3 proxy] %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']);
res.setHeader('Server', product + ' presence-server splatnet3-proxy');
res.setHeader('X-Server', product + ' presence-server splatnet3-proxy');
res.setHeader('X-Served-By', os.hostname());
next();
});
app.get('/api/splatnet3-presence/friends', this.createApiRequestHandler((req, res) =>
this.handleSplatNet3ProxyFriends(req, res)));
app.get('/api/splatnet3-presence/schedules', this.createApiRequestHandler((req, res) =>
this.handleSplatNet3ProxySchedules(req, res)));
app.get('/api/splatnet3-presence/fest/current/voting-status', this.createApiRequestHandler((req, res) =>
this.handleSplatNet3ProxyCurrentFestVotingStatus(req, res)));
}
protected encodeJsonForResponse(data: unknown, space?: number) {
@ -591,6 +713,48 @@ class Server extends HttpServer {
}
}
}
async handleSplatNet3ProxyFriends(req: Request, res: Response) {
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
const token = req.headers.authorization?.substr(0, 3) === 'na ' ?
req.headers.authorization.substr(3) : null;
if (!token) throw new ResponseError(401, 'unauthorised');
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
await user.getFriends();
return {result: user.friends};
}
async handleSplatNet3ProxySchedules(req: Request, res: Response) {
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
const token = req.headers.authorization?.substr(0, 3) === 'na ' ?
req.headers.authorization.substr(3) : null;
if (!token) throw new ResponseError(401, 'unauthorised');
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
await user.getSchedules();
return {result: user.schedules!};
}
async handleSplatNet3ProxyCurrentFestVotingStatus(req: Request, res: Response) {
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
const token = req.headers.authorization?.substr(0, 3) === 'na ' ?
req.headers.authorization.substr(3) : null;
if (!token) throw new ResponseError(401, 'unauthorised');
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
await user.getCurrentFestVotes();
return {result: user.fest_vote_status};
}
}
function createScheduleFest(

View File

@ -43,6 +43,8 @@ export class HttpServer {
}
protected handleRequestError(req: Request, res: Response, err: unknown) {
debug('Error in request %s %s', req.method, req.url, err);
if (err instanceof ErrorResponse) {
const retry_after = err.response.headers.get('Retry-After');