Merge branch 'main' into i18n

# Conflicts:
#	src/app/browser/main/discord.tsx
This commit is contained in:
Samuel Elliott 2023-05-23 00:08:26 +01:00
commit cde3f4c7a3
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
21 changed files with 921 additions and 191 deletions

View File

@ -254,13 +254,19 @@ When using nxapi as a TypeScript/JavaScript library, the `addUserAgent` function
import { addUserAgent } from 'nxapi';
addUserAgent('your-script/1.0.0 (+https://github.com/...)');
```
// This could also be read from a package.json file
import { fileURLToPath } from 'node:url';
import { resolve } from 'node:path';
import { readFile } from 'node:fs/promises':
const pkg = JSON.parse(await readFile(resolve(fileURLToPath(import.meta.url), '..', 'package.json'), 'utf-8'));
addUserAgent(pkg.name + '/' + pkg.version + ' (+' + pkg.repository.url + ')');
The `addUserAgentFromPackageJson` function can be used to add data from a package.json file.
```ts
import { addUserAgentFromPackageJson } from 'nxapi';
await addUserAgentFromPackageJson(new URL('../package.json', import.meta.url));
await addUserAgentFromPackageJson(path.resolve(fileURLToString(import.meta.url), '..', 'package.json'));
// adds "test-package/0.1.0 (+https://github.com/ghost/example.git)"
await addUserAgentFromPackageJson(new URL('../package.json', import.meta.url), 'additional information');
// adds "test-package/0.1.0 (+https://github.com/ghost/example.git; additional information)"
```
### Usage as a TypeScript/JavaScript library

View File

@ -40,18 +40,18 @@ services:
presence-server:
build: .
command: presence-server --listen \[::]:80 --splatnet3
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-fest-votes
restart: unless-stopped
profiles:
- presence-server
labels:
traefik.enable: true
traefik.http.routers.nxapi-presence.entrypoints: websecure
traefik.http.routers.nxapi-presence.rule: Host(`${TRAEFIK_HOST:-nxapi.ta.fancy.org.uk}`) && (Path(`/api/presence`) || PathPrefix(`/api/presence/`))
traefik.http.routers.nxapi-presence.rule: Host(`${TRAEFIK_HOST:-nxapi.ta.fancy.org.uk}`) && (Path(`/api/presence`) || PathPrefix(`/api/presence/`) || PathPrefix(`/api/splatnet3/resources/`))
traefik.http.routers.nxapi-presence.tls: true
traefik.http.services.nxapi-presence.loadbalancer.server.port: 80
environment:
DEBUG: '*,-express:*'
DEBUG: '*,-express:*,-send'
ZNC_PROXY_URL: http://znc-proxy/api/znc
NXAPI_PRESENCE_SERVER_USER: ${NXAPI_PRESENCE_SERVER_USER:-}
NXAPI_PRESENCE_SERVER_SPLATNET3_PROXY_URL: http://presence-splatnet3-proxy/api/splatnet3-presence
@ -60,7 +60,7 @@ services:
presence-splatnet3-proxy:
build: .
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-proxy
command: presence-server --listen \[::]:80 --splatnet3 --splatnet3-proxy --splatnet3-record-fest-votes
restart: unless-stopped
profiles:
- presence-server

View File

@ -151,6 +151,10 @@ try {
This function is used to set the user agent string to use for non-Nintendo API requests. Any project using nxapi (including as a dependency of another project) must call this function with an appropriate user agent string segment. See [user agent strings](../../README.md#user-agent-strings).
#### `addUserAgentFromPackageJson`
This function is used to set the user agent string to use for non-Nintendo API requests using data from a package.json file. A string, URL object or the package.json data can be provided, as well as optional additional data. If a string/URL is provided this will return a Promise that will be resolved once the user agent is updated. See [user agent strings](../../README.md#user-agent-strings).
#### `version`
nxapi's version number.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"body-parser": "^1.20.1",

View File

@ -1,6 +1,6 @@
{
"name": "nxapi",
"version": "1.6.0",
"version": "1.6.1",
"description": "Nintendo Switch app APIs",
"license": "AGPL-3.0-or-later",
"author": "Samuel Elliott <samuel+nxapi@fancy.org.uk>",

View File

@ -1,7 +1,7 @@
{
"require_version": [],
"coral": {
"znca_version": "2.4.0"
"znca_version": "2.5.0"
},
"coral_auth": {
"default": "imink",
@ -10,15 +10,15 @@
"imink": {}
},
"moon": {
"znma_version": "1.17.0",
"znma_build": "261"
"znma_version": "1.18.0",
"znma_build": "275"
},
"coral_gws_nooklink": {
"blanco_version": "2.1.1"
},
"coral_gws_splatnet3": {
"app_ver": "3.0.0-2857bc50",
"app_ver": "3.0.0-0742bda0",
"version": "3.0.0",
"revision": "2857bc50653d316cb69f017b2eef24d2ae56a1b7"
"revision": "0742bda0f28edfcda33ec743b8afc4a95700e27d"
}
}

View File

@ -210,7 +210,11 @@ export default class CoralApi {
async getWebServiceToken(id: number, /** @internal */ _attempt = 0): Promise<Result<WebServiceToken>> {
await this._renewToken;
const data = await f(this.token, HashMethod.WEB_SERVICE, this.useragent ?? getAdditionalUserAgents());
const data = await f(this.token, HashMethod.WEB_SERVICE, {
platform: ZNCA_PLATFORM,
version: this.znca_version,
useragent: this.useragent ?? getAdditionalUserAgents(),
});
const req: WebServiceTokenParameter = {
id,
@ -242,8 +246,11 @@ export default class CoralApi {
// Nintendo Account token
const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID);
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL,
this.useragent ?? getAdditionalUserAgents());
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, {
platform: ZNCA_PLATFORM,
version: this.znca_version,
useragent: this.useragent ?? getAdditionalUserAgents(),
});
const req: AccountTokenParameter = {
naBirthday: user.birthday,
@ -320,7 +327,11 @@ export default class CoralApi {
if (!config) throw new Error('Remote configuration prevents Coral authentication');
const znca_useragent = `com.nintendo.znca/${config.znca_version}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`;
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, useragent);
const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, {
platform: ZNCA_PLATFORM,
version: config.znca_version,
useragent,
});
debug('Getting Nintendo Switch Online app token');

View File

@ -1,5 +1,5 @@
import process from 'node:process';
import fetch from 'node-fetch';
import fetch, { Headers } from 'node-fetch';
import createDebug from 'debug';
import { v4 as uuidgen } from 'uuid';
import { defineResponse, ErrorResponse } from './util.js';
@ -239,10 +239,11 @@ export class ZncaApiImink extends ZncaApi {
export async function genf(
url: string, hash_method: HashMethod,
token: string, timestamp?: number, request_id?: string,
useragent?: string
app?: {platform?: string; version?: string;}, useragent?: string
) {
debugZncaApi('Getting f parameter', {
url, hash_method, token, timestamp, request_id,
znca_platform: app?.platform, znca_version: app?.version,
});
const req: AndroidZncaFRequest = {
@ -252,13 +253,17 @@ export async function genf(
request_id,
};
const headers = new Headers({
'Content-Type': 'application/json',
'User-Agent': getUserAgent(useragent),
});
if (app?.platform) headers.append('X-znca-Platform', app.platform);
if (app?.version) headers.append('X-znca-Version', app.version);
const [signal, cancel] = timeoutSignal();
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'User-Agent': getUserAgent(useragent),
},
headers,
body: JSON.stringify(req),
signal,
}).finally(cancel);
@ -296,14 +301,14 @@ export interface AndroidZncaFError {
}
export class ZncaApiNxapi extends ZncaApi {
constructor(readonly url: string, useragent?: string) {
constructor(readonly url: string, readonly app?: {platform?: string; version?: string;}, useragent?: string) {
super(useragent);
}
async genf(token: string, hash_method: HashMethod) {
const request_id = uuidgen();
const result = await genf(this.url + '/f', hash_method, token, undefined, request_id, this.useragent);
const result = await genf(this.url + '/f', hash_method, token, undefined, request_id, this.app, this.useragent);
return {
provider: 'nxapi' as const,
@ -316,10 +321,13 @@ export class ZncaApiNxapi extends ZncaApi {
}
}
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, useragent?: string): Promise<FResult> {
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, options?: ZncaApiOptions): Promise<FResult>;
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, useragent?: string): Promise<FResult>;
export async function f(token: string, hash_method: HashMethod | `${HashMethod}`, options?: ZncaApiOptions | string): Promise<FResult> {
if (typeof options === 'string') options = {useragent: options};
if (typeof hash_method === 'string') hash_method = parseInt(hash_method);
const provider = getPreferredZncaApiFromEnvironment(useragent) ?? await getDefaultZncaApi(useragent);
const provider = getPreferredZncaApiFromEnvironment(options) ?? await getDefaultZncaApi(options);
return provider.genf(token, hash_method);
}
@ -344,37 +352,51 @@ export type FResult = {
result: AndroidZncaFResponse;
});
export function getPreferredZncaApiFromEnvironment(useragent?: string): ZncaApi | null {
interface ZncaApiOptions {
useragent?: string;
platform?: string;
version?: string;
}
export function getPreferredZncaApiFromEnvironment(options?: ZncaApiOptions): ZncaApi | null;
export function getPreferredZncaApiFromEnvironment(useragent?: string): ZncaApi | null;
export function getPreferredZncaApiFromEnvironment(options?: ZncaApiOptions | string): ZncaApi | null {
if (typeof options === 'string') options = {useragent: options};
if (process.env.NXAPI_ZNCA_API) {
if (process.env.NXAPI_ZNCA_API === 'flapg') {
return new ZncaApiFlapg(useragent);
return new ZncaApiFlapg(options?.useragent);
}
if (process.env.NXAPI_ZNCA_API === 'imink') {
return new ZncaApiImink(useragent);
return new ZncaApiImink(options?.useragent);
}
throw new Error('Unknown znca API provider');
}
if (process.env.ZNCA_API_URL) {
return new ZncaApiNxapi(process.env.ZNCA_API_URL, useragent);
return new ZncaApiNxapi(process.env.ZNCA_API_URL, options, options?.useragent);
}
return null;
}
export async function getDefaultZncaApi(useragent?: string) {
export async function getDefaultZncaApi(options?: ZncaApiOptions): Promise<ZncaApi>;
export async function getDefaultZncaApi(useragent?: string): Promise<ZncaApi>;
export async function getDefaultZncaApi(options?: ZncaApiOptions | string) {
if (typeof options === 'string') options = {useragent: options};
const { default: { coral_auth: { default: provider } } } = await import('../common/remote-config.js');
if (provider === 'flapg') {
return new ZncaApiFlapg(useragent);
return new ZncaApiFlapg(options?.useragent);
}
if (provider === 'imink') {
return new ZncaApiImink(useragent);
return new ZncaApiImink(options?.useragent);
}
if (provider[0] === 'nxapi') {
return new ZncaApiNxapi(provider[1], useragent);
return new ZncaApiNxapi(provider[1], options, options?.useragent);
}
throw new Error('Invalid znca API provider');

View File

@ -14,8 +14,7 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Platform': 'Android',
'X-ProductVersion': '2.0.0',
'Accept': 'application/json',
'User-Agent': 'NASDKAPI; Android',
},
body: new URLSearchParams({

View File

@ -19,7 +19,7 @@ export default function DiscordPresenceSource(props: {
return <TouchableOpacity onPress={() => ipc.showDiscordModal()}>
<View style={[styles.discord, !props.source ? styles.discordInactive : null]}>
{renderDiscordPresenceSource(props.source)}
{props.presence && props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
{props.presence || props.user ? <DiscordPresence presence={props.presence} user={props.user} /> : null}
</View>
</TouchableOpacity>;
}
@ -86,28 +86,37 @@ function DiscordPresenceInactive() {
}
function DiscordPresence(props: {
presence: DiscordPresence;
user: User;
presence: DiscordPresence | null;
user: User | null;
}) {
const { t, i18n } = useTranslation('main_window', { keyPrefix: 'sidebar' });
const large_image_url = props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
const large_image_url = props.presence ? props.presence.activity.largeImageKey?.match(/^\d{16}$/) ?
'https://cdn.discordapp.com/app-assets/' + props.presence.id + '/' +
props.presence.activity.largeImageKey + '.png' :
props.presence.activity.largeImageKey;
const user_image_url = 'https://cdn.discordapp.com/avatars/' + props.user.id + '/' + props.user.avatar + '.png';
props.presence.activity.largeImageKey : undefined;
return <>
<View style={styles.discordPresence}>
const user_image_url = props.user ?
props.user.avatar ? 'https://cdn.discordapp.com/avatars/' + props.user.id + '/' + props.user.avatar + '.png' :
!props.user.discriminator || props.user.discriminator === '0' ?
'https://cdn.discordapp.com/embed/avatars/' + ((parseInt(props.user.id) >> 22) % 5) + '.png' :
'https://cdn.discordapp.com/embed/avatars/' + (parseInt(props.user.discriminator) % 5) + '.png' : undefined;
return <View style={styles.discordPresenceContainer}>
{props.presence ? <View style={styles.discordPresence}>
<Image source={{uri: large_image_url, width: 18, height: 18}} style={styles.discordPresenceImage} />
<Text style={styles.discordPresenceText} numberOfLines={1} ellipsizeMode="tail">{t('discord_playing')}</Text>
</View>
</View> : null}
<View style={styles.discordUser}>
{props.user ? <View style={styles.discordUser}>
<Image source={{uri: user_image_url, width: 18, height: 18}} style={styles.discordUserImage} />
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">{props.user.username}#{props.user.discriminator}</Text>
</View>
</>;
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">
{props.user.username}<Text style={styles.discordUserDiscriminator}>#{props.user.discriminator}</Text>
</Text>
</View> : <View style={styles.discordUser}>
<Text style={styles.discordUserText} numberOfLines={1} ellipsizeMode="tail">Not connected to Discord</Text>
</View>}
</View>;
}
const styles = StyleSheet.create({
@ -131,8 +140,12 @@ const styles = StyleSheet.create({
userSelect: 'all',
},
discordPresenceContainer: {
marginTop: 2,
},
discordPresence: {
marginTop: 12,
marginTop: 10,
flexDirection: 'row',
alignItems: 'center',
},
@ -156,4 +169,7 @@ const styles = StyleSheet.create({
discordUserText: {
color: TEXT_COLOUR_DARK,
},
discordUserDiscriminator: {
opacity: 0.7,
},
});

View File

@ -155,8 +155,12 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
const users: User[] = [];
for (const client of await getDiscordRpcClients()) {
await client.connect(defaultTitle.client);
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
try {
await client.connect(defaultTitle.client);
if (client.user && !users.find(u => u.id === client.user!.id)) users.push(client.user);
} finally {
await client.destroy();
}
}
return users;

View File

@ -45,6 +45,7 @@ export class PresenceMonitorManager {
i.presence_user = null;
i.user_notifications = false;
i.friend_notifications = false;
i.discord_preconnect = true;
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null);
@ -91,6 +92,7 @@ export class PresenceMonitorManager {
const i = new EmbeddedProxyPresenceMonitor(presence_url);
i.notifications = this.notifications;
i.discord_preconnect = true;
i.discord.onUpdateActivity = (presence: DiscordPresence | null) => {
this.app.store.emit('update-discord-presence', presence ? {...presence, config: undefined} : null);

View File

@ -68,6 +68,8 @@ export default async function openWebService(
if (!isWebServiceUrlAllowed(webservice, url)) {
debug('Web service attempted to navigate to a URL not allowed by it\'s `whiteList`', webservice, url);
debug('open', url);
shell.openExternal(url);
event.preventDefault();
}
});

View File

@ -755,10 +755,7 @@ class Server extends HttpServer {
this.resetAuthTimeout(na_session_token, () => user.data.user.id);
}
} catch (err) {
stream.sendEvent('error', {
error: (err as Error).name,
error_message: (err as Error).message,
});
stream.sendErrorEvent(err);
}
}
}

View File

@ -1,10 +1,13 @@
import * as net from 'node:net';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
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 mkdirp from 'mkdirp';
import { BankaraMatchMode, BankaraMatchSetting_schedule, CoopRule, CoopSetting_schedule, DetailFestRecordDetailResult, DetailVotingStatusResult, FestMatchSetting_schedule, FestRecordResult, FestState, FestTeam_schedule, FestTeam_votingStatus, FestVoteState, Fest_schedule, FriendListResult, FriendOnlineState, Friend_friendList, GraphQLSuccessResponse, KnownRequestId, 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';
@ -14,8 +17,8 @@ import { product, version } from '../util/product.js';
import Users, { CoralUser } from '../common/users.js';
import { Friend } from '../api/coral-types.js';
import { getBulletToken, SavedBulletToken } from '../common/auth/splatnet3.js';
import SplatNet3Api from '../api/splatnet3.js';
import { ErrorResponse } from '../api/util.js';
import SplatNet3Api, { PersistedQueryResult, RequestIdSymbol } from '../api/splatnet3.js';
import { ErrorResponse, ResponseSymbol } from '../api/util.js';
import { EventStreamResponse, HttpServer, ResponseError } from './util/http-server.js';
import { getTitleIdFromEcUrl } from '../util/misc.js';
import StageScheduleQuery_730cd98 from 'splatnet3-types/graphql/730cd98e84f1030d3e9ac86b6f1aae13';
@ -47,6 +50,21 @@ interface TitleResult {
since: string;
}
interface FestVotingStatusRecord {
result: Exclude<DetailVotingStatusResult['fest'], null>;
query: KnownRequestId;
app_version: string;
be_version: string | null;
friends: {
result: FriendListResult['friends'];
query: KnownRequestId;
be_version: string | null;
};
fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null;
}
export const command = 'presence-server';
export const desc = 'Starts a HTTP server to fetch presence data from Coral and SplatNet 3';
@ -78,6 +96,14 @@ export function builder(yargs: Argv<ParentArguments>) {
describe: 'SplatNet 3 proxy URL',
type: 'string',
default: process.env.NXAPI_PRESENCE_SERVER_SPLATNET3_PROXY_URL,
}).option('splatnet3-fest-votes', {
describe: 'Record Splatoon 3 fest vote history',
type: 'boolean',
default: false,
}).option('splatnet3-record-fest-votes', {
describe: 'Record Splatoon 3 fest vote history',
type: 'boolean',
default: false,
}).option('update-interval', {
describe: 'Max. update interval in seconds',
type: 'number',
@ -91,6 +117,8 @@ export function builder(yargs: Argv<ParentArguments>) {
type Arguments = YargsArguments<ReturnType<typeof builder>>;
const ResourceUrlMapSymbol = Symbol('ResourceUrls');
export async function handler(argv: ArgumentsCamelCase<Arguments>) {
addCliFeatureUserAgent('presence-server');
@ -113,10 +141,23 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
SplatNet3ApiUser.create(storage, token, argv.zncProxyUrl);
}) : null;
const server = new Server(storage, coral_users, splatnet3_users, user_naids);
const image_proxy_path = {
baas: path.join(argv.dataPath, 'presence-server-resources', 'baas'),
atum: path.join(argv.dataPath, 'presence-server-resources', 'atum'),
splatnet3: path.join(argv.dataPath, 'presence-server-resources', 'splatnet3'),
};
const server = new Server(storage, coral_users, splatnet3_users, user_naids, image_proxy_path);
server.allow_all_users = argv.allowAllUsers;
server.enable_splatnet3_proxy = argv.splatnet3Proxy;
server.record_fest_votes = argv.splatnet3FestVotes || argv.splatnet3RecordFestVotes ? {
path: path.join(argv.dataPath, 'presence-server'),
read: argv.splatnet3FestVotes,
write: argv.splatnet3RecordFestVotes,
} : null;
server.update_interval = argv.updateInterval * 1000;
const app = server.app;
for (const address of argv.listen) {
@ -127,13 +168,62 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
console.log('Listening on %s, port %d', address.address, address.port);
});
}
if (argv.splatnet3RecordFestVotes) {
const update_interval_fest_voting_status_record = 60 * 60 * 1000; // 60 minutes
const recordFestVotes = async (is_force_early = false) => {
const users = await Promise.all(user_naids.map(id => server.getSplatNet3User(id)));
debug('Checking for new fest votes to record', is_force_early);
let fest_ending: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null = null;
for (const user of users) {
try {
const fest = await user.getCurrentFest();
if (is_force_early) user.updated.fest_vote_status = null;
// Fetching current fest vote data will record any new data
await user.getCurrentFestVotes();
if (fest && (!fest_ending ||
new Date(fest_ending.endTime).getTime() > new Date(fest.endTime).getTime()
)) {
fest_ending = fest;
}
} catch (err) {
debug('Error fetching current fest voting status for recording');
}
}
const time_until_fest_ends_ms = fest_ending ? new Date(fest_ending.endTime).getTime() - Date.now() : null;
const update_interval = time_until_fest_ends_ms && time_until_fest_ends_ms > 60 * 1000 ?
Math.min(time_until_fest_ends_ms - 60 * 1000, update_interval_fest_voting_status_record) :
update_interval_fest_voting_status_record;
setTimeout(() => recordFestVotes(update_interval !== update_interval_fest_voting_status_record),
update_interval);
};
recordFestVotes();
}
}
abstract class SplatNet3User {
created_at = Date.now();
expires_at = Infinity;
record_fest_votes: {
path: string;
read: boolean;
write: boolean;
} | null = null;
schedules: GraphQLSuccessResponse<StageScheduleResult> | null = null;
fest_records: GraphQLSuccessResponse<FestRecordResult> | null = null;
current_fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null = null;
fest_vote_status: GraphQLSuccessResponse<DetailVotingStatusResult> | null = null;
promise = new Map<string, Promise<void>>();
@ -141,6 +231,8 @@ abstract class SplatNet3User {
updated = {
friends: Date.now(),
schedules: null as number | null,
fest_records: null as number | null,
current_fest: null as number | null,
fest_vote_status: null as number | null,
};
update_interval = 10 * 1000; // 10 seconds
@ -199,15 +291,55 @@ abstract class SplatNet3User {
abstract getSchedulesData(): Promise<GraphQLSuccessResponse<StageScheduleResult>>;
async getCurrentFest(): Promise<StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null> {
let update_interval = this.update_interval_schedules;
if (this.schedules && this.schedules.data.currentFest) {
const tricolour_open = new Date(this.schedules.data.currentFest.midtermTime).getTime() <= Date.now();
const should_refresh_fest = tricolour_open &&
![FestState.SECOND_HALF, FestState.CLOSED].includes(this.schedules.data.currentFest.state as FestState);
if (should_refresh_fest) update_interval = this.update_interval;
}
await this.update('current_fest', async () => {
this.current_fest = await this.getCurrentFestData();
}, update_interval);
return this.current_fest;
}
abstract getCurrentFestData(): Promise<StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null>;
async getCurrentFestVotes(): Promise<DetailVotingStatusResult['fest'] | null> {
await this.update('fest_vote_status', async () => {
this.fest_vote_status = await this.getCurrentFestVotingStatusData();
const fest_vote_status = await this.getCurrentFestVotingStatusData();
if (fest_vote_status) this.tryRecordFestVotes(fest_vote_status);
this.fest_vote_status = fest_vote_status;
}, this.update_interval_fest_voting_status ?? this.update_interval);
return this.fest_vote_status?.data.fest ?? null;
}
abstract getCurrentFestVotingStatusData(): Promise<GraphQLSuccessResponse<DetailVotingStatusResult> | null>;
async tryRecordFestVotes(fest_vote_status: GraphQLSuccessResponse<DetailVotingStatusResult>) {
if (this.record_fest_votes?.write && fest_vote_status.data.fest &&
JSON.stringify(fest_vote_status?.data) !== JSON.stringify(this.fest_vote_status?.data)
) {
try {
await this.recordFestVotes(fest_vote_status as PersistedQueryResult<DetailVotingStatusResult>);
} catch (err) {
debug('Error recording updated fest vote data', fest_vote_status.data.fest.id, err);
}
}
}
async recordFestVotes(fest_vote_status: PersistedQueryResult<DetailVotingStatusResult>) {
throw new Error('Cannot record fest vote status when using SplatNet 3 API proxy');
}
}
class SplatNet3ApiUser extends SplatNet3User {
@ -227,10 +359,36 @@ class SplatNet3ApiUser extends SplatNet3User {
return this.splatnet.getSchedules();
}
async getCurrentFestVotingStatusData() {
async getCurrentFestData() {
const schedules = await this.getSchedules();
return !schedules.currentFest || new Date(schedules.currentFest.endTime).getTime() <= Date.now() ? null :
await this.getFestVotingStatusData(schedules.currentFest.id);
if (schedules.currentFest) {
return new Date(schedules.currentFest.endTime).getTime() <= Date.now() ? null : schedules.currentFest;
}
await this.update('fest_records', async () => {
this.fest_records = await this.splatnet.getFestRecords();
}, this.update_interval_schedules);
const current_or_upcoming_fest = this.fest_records!.data.festRecords.nodes.find(fest =>
new Date(fest.endTime).getTime() >= Date.now());
if (!current_or_upcoming_fest) return null;
const fest_detail = await this.getFestDetailData(current_or_upcoming_fest.id);
return fest_detail.data.fest;
}
async getFestDetailData(id: string) {
return this.current_fest?.id === id ?
await this.splatnet.getFestDetailRefetch(id) :
await this.splatnet.getFestDetail(id);
}
async getCurrentFestVotingStatusData() {
const fest = await this.getCurrentFest();
return !fest || new Date(fest.endTime).getTime() <= Date.now() ? null :
await this.getFestVotingStatusData(fest.id);
}
async getFestVotingStatusData(id: string) {
@ -239,6 +397,37 @@ class SplatNet3ApiUser extends SplatNet3User {
await this.splatnet.getFestVotingStatus(id);
}
async recordFestVotes(result: PersistedQueryResult<DetailVotingStatusResult>) {
if (!result.data.fest) return;
const id_str = Buffer.from(result.data.fest.id, 'base64').toString() || result.data.fest.id;
const match = id_str.match(/^Fest-([A-Z]{2}):(([A-Z]+)-(\d+))$/);
const id = match ? match[1] + '-' + match[2] : id_str;
debug('Recording updated fest vote data', id);
await this.getFriends();
const friends = this.friends as PersistedQueryResult<FriendListResult>;
const record: FestVotingStatusRecord = {
result: result.data.fest,
query: result[RequestIdSymbol],
app_version: this.splatnet.version,
be_version: result[ResponseSymbol].headers.get('x-be-version'),
friends: {
result: friends.data.friends,
query: friends[RequestIdSymbol],
be_version: friends[ResponseSymbol].headers.get('x-be-version'),
},
fest: await this.getCurrentFest(),
};
await mkdirp(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id));
await fs.writeFile(path.join(this.record_fest_votes!.path, 'splatnet3-fest-votes-' + id, Date.now() + '.json'), JSON.stringify(record, null, 4) + '\n');
}
static async create(storage: persist.LocalStorage, token: string, znc_proxy_url?: string) {
const {splatnet, data} = await getBulletToken(storage, token, znc_proxy_url, true);
@ -276,6 +465,10 @@ class SplatNet3ProxyUser extends SplatNet3User {
return this.fetch('/schedules');
}
async getCurrentFestData() {
return this.fetch('/fest/current');
}
async getCurrentFestVotingStatusData() {
return this.fetch('/fest/current/voting-status');
}
@ -310,6 +503,16 @@ class Server extends HttpServer {
allow_all_users = false;
enable_splatnet3_proxy = false;
record_fest_votes: {
path: string;
read: boolean;
write: boolean;
} | null = null;
readonly image_proxy_path_baas: string | null = null;
readonly image_proxy_path_atum: string | null = null;
readonly image_proxy_path_splatnet3: string | null = null;
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
@ -317,12 +520,14 @@ class Server extends HttpServer {
app: express.Express;
titles = new Map</** NSA ID */ string, [TitleResult | null, /** updated */ number]>();
readonly promise_image = new Map<string, Promise<string>>();
constructor(
readonly storage: persist.LocalStorage,
readonly coral_users: Users<CoralUser>,
readonly splatnet3_users: Users<SplatNet3User> | null,
readonly user_ids: string[],
image_proxy_path?: {baas?: string; atum?: string; splatnet3?: string;},
) {
super();
@ -346,9 +551,20 @@ class Server extends HttpServer {
this.handleAllUsersRequest(req, res)));
app.get('/api/presence/:user', this.createApiRequestHandler((req, res) =>
this.handlePresenceRequest(req, res, req.params.user)));
app.get('/api/presence/:user/splatoon3-fest-votes', this.createApiRequestHandler((req, res) =>
this.handleUserFestVotingStatusHistoryRequest(req, res, req.params.user)));
app.get('/api/presence/:user/events', this.createApiRequestHandler((req, res) =>
this.handlePresenceStreamRequest(req, res, req.params.user)));
if (image_proxy_path?.baas) {
this.image_proxy_path_baas = image_proxy_path.baas;
app.use('/api/presence/resources/baas', express.static(this.image_proxy_path_baas, {redirect: false}));
}
if (image_proxy_path?.atum) {
this.image_proxy_path_atum = image_proxy_path.atum;
app.use('/api/presence/resources/atum', express.static(this.image_proxy_path_atum, {redirect: false}));
}
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,
@ -367,12 +583,52 @@ class Server extends HttpServer {
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', this.createApiRequestHandler((req, res) =>
this.handleSplatNet3ProxyCurrentFest(req, res)));
app.get('/api/splatnet3-presence/fest/current/voting-status', this.createApiRequestHandler((req, res) =>
this.handleSplatNet3ProxyCurrentFestVotingStatus(req, res)));
app.use('/api/splatnet3', (req, res, next) => {
console.log('[%s] [splatnet3] %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();
});
if (image_proxy_path?.splatnet3) {
this.image_proxy_path_splatnet3 = image_proxy_path.splatnet3;
app.use('/api/splatnet3/resources', express.static(this.image_proxy_path_splatnet3, {redirect: false}));
}
}
protected encodeJsonForResponse(data: unknown, space?: number) {
return JSON.stringify(data, replacer, space);
return JSON.stringify(data, (key: string, value: unknown) => replacer(key, value, data), space);
}
async getCoralUser(naid: string) {
const token = await this.storage.getItem('NintendoAccountToken.' + naid);
const user = await this.coral_users.get(token);
user.update_interval = this.update_interval;
return user;
}
async getSplatNet3User(naid: string) {
const token = await this.storage.getItem('NintendoAccountToken.' + naid);
return this.getSplatNet3UserBySessionToken(token);
}
async getSplatNet3UserBySessionToken(token: string) {
const user = await this.splatnet3_users!.get(token);
user.record_fest_votes = this.record_fest_votes;
user.update_interval = this.update_interval;
return user;
}
async handleAllUsersRequest(req: Request, res: Response) {
@ -384,12 +640,7 @@ class Server extends HttpServer {
const result: AllUsersResult[] = [];
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;
}));
const users = await Promise.all(this.user_ids.map(id => this.getCoralUser(id)));
for (const user of users) {
const friends = await user.getFriends();
@ -415,12 +666,7 @@ class Server extends HttpServer {
}
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;
}));
const users = await Promise.all(this.user_ids.map(id => this.getSplatNet3User(id)));
for (const user of users) {
const friends = await user.getFriends();
@ -473,7 +719,9 @@ class Server extends HttpServer {
result.sort((a, b) => b.presence.updatedAt - a.presence.updatedAt);
return {result};
const images = await this.downloadImages(result, this.getResourceBaseUrls(req));
return {result, [ResourceUrlMapSymbol]: images};
}
async handlePresenceRequest(req: Request, res: Response | null, presence_user_nsaid: string, is_stream = false) {
@ -523,14 +771,14 @@ class Server extends HttpServer {
};
if (this.splatnet3_users && include_splatnet3) {
const token = await this.storage.getItem('NintendoAccountToken.' + user_naid);
const user = await this.splatnet3_users!.get(token);
user.update_interval = this.update_interval;
const user = await this.getSplatNet3User(user_naid);
await this.handleSplatoon3Presence(friend, user, response);
}
return response;
const images = await this.downloadImages(response, this.getResourceBaseUrls(req));
return {...response, [ResourceUrlMapSymbol]: images};
}
getTitleResult(friend: Friend, updated: number, req: Request) {
@ -546,7 +794,7 @@ class Server extends HttpServer {
name: game.name,
image_url: game.imageUri,
url: 'https://fancy.org.uk/api/nxapi/title/' + encodeURIComponent(id) + '/redirect?source=' +
encodeURIComponent('nxapi-' + version + '-presenceserver-' + req.headers.host),
encodeURIComponent('nxapi-' + version + '-presenceserver/' + req.headers.host),
since: new Date(Math.min(Date.now(), friend.presence.updatedAt * 1000)).toISOString(),
} : null;
@ -576,38 +824,13 @@ class Server extends HttpServer {
response.splatoon3 = friend;
if (fest_vote_status) {
const schedules = await user.getSchedules();
const fest = await user.getCurrentFest();
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
const fest_team = this.getFestTeamVotingStatus(fest_vote_status, fest, friend);
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;
}
if (response.splatoon3_fest_team) 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) break;
}
if (!response.splatoon3_fest_team && fest_vote_status.undecidedVotes) {
if (fest_team) {
response.splatoon3_fest_team = fest_team;
} else if (fest_vote_status.undecidedVotes) {
response.splatoon3_fest_team = null;
}
}
@ -635,8 +858,9 @@ class Server extends HttpServer {
friend.onlineState === FriendOnlineState.COOP_MODE_FIGHTING
) {
const schedules = await user.getSchedules();
const coop_schedules = friend.coopRule === 'BIG_RUN' ?
schedules.coopGroupingSchedule.bigRunSchedules :
const coop_schedules =
friend.coopRule === CoopRule.BIG_RUN ? schedules.coopGroupingSchedule.bigRunSchedules :
friend.coopRule === CoopRule.TEAM_CONTEST ? schedules.coopGroupingSchedule.teamContestSchedules :
schedules.coopGroupingSchedule.regularSchedules;
const coop_setting = getSchedule(coop_schedules)?.setting;
@ -644,6 +868,37 @@ class Server extends HttpServer {
}
}
getFestTeamVotingStatus(
fest_vote_status: Exclude<DetailVotingStatusResult['fest'], null>,
fest: StageScheduleResult['currentFest'] | DetailFestRecordDetailResult['fest'] | null,
friend: Friend_friendList,
) {
for (const team of fest_vote_status.teams) {
const schedule_or_detail_team = fest?.teams.find(t => t.id === team.id);
if (!schedule_or_detail_team || !team.votes || !team.preVotes) continue;
for (const player of team.votes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
return {
...createFestScheduleTeam(schedule_or_detail_team, FestVoteState.VOTED),
...createFestVoteTeam(team, FestVoteState.VOTED),
};
}
for (const player of team.preVotes.nodes) {
if (player.userIcon.url !== friend.userIcon.url) continue;
return {
...createFestScheduleTeam(schedule_or_detail_team, FestVoteState.PRE_VOTED),
...createFestVoteTeam(team, FestVoteState.PRE_VOTED),
};
}
}
return null;
}
getSettingForVsMode(schedules: StageScheduleResult, vs_mode: Pick<VsMode, 'id' | 'mode'>) {
if (vs_mode.mode === 'REGULAR') {
return getSchedule(schedules.regularSchedules)?.regularMatchSetting;
@ -669,6 +924,111 @@ class Server extends HttpServer {
return null;
}
async handleUserFestVotingStatusHistoryRequest(req: Request, res: Response, presence_user_nsaid: string) {
if (!this.record_fest_votes?.read) {
throw new ResponseError(404, 'not_found', 'Not recording fest voting status history');
}
// Attempt to fetch the user's current presence to make sure they are
// still friends with the presence server user
await this.handlePresenceRequest(req, null, presence_user_nsaid);
const TimestampSymbol = Symbol('Timestamp');
const VoteKeySymbol = Symbol('VoteKey');
const response: {
result: {
id: string;
fest_id: string;
fest_team_id: string;
fest_team: FestTeam_votingStatus;
updated_at: string;
[TimestampSymbol]: number;
[VoteKeySymbol]: string;
}[];
} = {
result: [],
};
const latest = new Map<string, [timestamp: Date, data: FestTeam_votingStatus]>();
const all = req.query['include-all'] === '1';
for await (const dirent of await fs.opendir(this.record_fest_votes.path)) {
if (!dirent.isDirectory() || !dirent.name.startsWith('splatnet3-fest-votes-')) continue;
const id = dirent.name.substr(21);
const fest_votes_dir = path.join(this.record_fest_votes.path, dirent.name);
for await (const dirent of await fs.opendir(fest_votes_dir)) {
const match = dirent.name.match(/^(\d+)\.json$/);
if (!dirent.isFile() || !match) continue;
const timestamp = new Date(parseInt(match[1]));
const is_latest = (latest.get(id)?.[0].getTime() ?? 0) <= timestamp.getTime();
if (!all && !is_latest) continue;
try {
const data: FestVotingStatusRecord =
JSON.parse(await fs.readFile(path.join(fest_votes_dir, dirent.name), 'utf-8'));
const friend = data.friends.result.nodes.find(f => Buffer.from(f.id, 'base64').toString()
.match(/^Friend-([0-9a-f]{16})$/)?.[1] === presence_user_nsaid);
if (!friend) continue;
const fest_team = this.getFestTeamVotingStatus(data.result, data.fest, friend);
if (!fest_team) continue;
const fest_id = data.fest ?
Buffer.from(data.fest.id, 'base64').toString()
.match(/^Fest-([A-Z]{2}):(([A-Z]+)-(\d+))$/)?.[2] || data.fest.id :
null;
if (!fest_id) continue;
const fest_team_id =
Buffer.from(fest_team.id, 'base64').toString()
.match(/^FestTeam-([A-Z]{2}):((([A-Z]+)-(\d+)):([A-Za-z]+))$/)?.[2] || fest_team.id;
if (is_latest) latest.set(id, [timestamp, fest_team]);
if (!all) {
let index;
while ((index = response.result.findIndex(r => r.id === id)) >= 0) {
response.result.splice(index, 1);
}
}
response.result.push({
id,
fest_id,
fest_team_id,
fest_team,
updated_at: timestamp.toISOString(),
[TimestampSymbol]: timestamp.getTime(),
[VoteKeySymbol]: fest_id + '/' + fest_team_id + '/' + fest_team.myVoteState,
});
} catch (err) {
debug('Error reading fest voting status records', id, match[1], err);
}
}
}
if (!response.result.length) throw new ResponseError(404, 'not_found', 'No fest voting status history for this user');
response.result.sort((a, b) => a[TimestampSymbol] - b[TimestampSymbol]);
response.result = response.result.filter((result, index, results) => {
const prev_result = results[index - 1];
return !prev_result || result[VoteKeySymbol] !== prev_result[VoteKeySymbol];
});
response.result.reverse();
const images = await this.downloadImages(response.result, this.getResourceBaseUrls(req));
return {...response, [ResourceUrlMapSymbol]: images};
}
presence_streams = new Set<EventStreamResponse>();
async handlePresenceStreamRequest(req: Request, res: Response, presence_user_nsaid: string) {
@ -704,7 +1064,8 @@ class Server extends HttpServer {
for (const [key, value] of Object.entries(result) as
[keyof typeof result, typeof result[keyof typeof result]][]
) {
stream.sendEvent(key, value);
if (typeof key !== 'string') continue;
stream.sendEvent(key, {...value, [ResourceUrlMapSymbol]: result[ResourceUrlMapSymbol]});
}
await new Promise(rs => setTimeout(rs, this.update_interval));
@ -721,9 +1082,9 @@ class Server extends HttpServer {
for (const [key, value] of Object.entries(result) as
[keyof typeof result, typeof result[keyof typeof result]][]
) {
if (typeof key !== 'string') continue;
if (JSON.stringify(value) === JSON.stringify(last_result[key])) continue;
stream.sendEvent(key, value);
stream.sendEvent(key, {...value, [ResourceUrlMapSymbol]: result[ResourceUrlMapSymbol]});
}
last_result = result;
@ -735,8 +1096,9 @@ class Server extends HttpServer {
if (retry_after && /^\d+$/.test(retry_after)) {
stream.sendEvent(null, 'debug: timestamp ' + new Date().toISOString(), {
error: err,
error: 'unknown_error',
error_message: (err as Error).message,
...err,
});
await new Promise(rs => setTimeout(rs, parseInt(retry_after) * 1000));
@ -745,17 +1107,7 @@ class Server extends HttpServer {
}
}
if (err instanceof ResponseError) {
stream.sendEvent('error', {
error: err.code,
error_message: err.message,
});
} else {
stream.sendEvent('error', {
error: err,
error_message: (err as Error).message,
});
}
stream.sendErrorEvent(err);
debug('Error in event stream %d', stream.id, err);
@ -772,8 +1124,7 @@ class Server extends HttpServer {
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;
const user = await this.getSplatNet3UserBySessionToken(token);
await user.getFriends();
return {result: user.friends};
@ -786,13 +1137,25 @@ class Server extends HttpServer {
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;
const user = await this.getSplatNet3UserBySessionToken(token);
await user.getSchedules();
return {result: user.schedules!};
}
async handleSplatNet3ProxyCurrentFest(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.getSplatNet3UserBySessionToken(token);
await user.getCurrentFest();
return {result: user.current_fest};
}
async handleSplatNet3ProxyCurrentFestVotingStatus(req: Request, res: Response) {
if (!this.enable_splatnet3_proxy) throw new ResponseError(403, 'forbidden');
@ -800,12 +1163,106 @@ class Server extends HttpServer {
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;
const user = await this.getSplatNet3UserBySessionToken(token);
await user.getCurrentFestVotes();
return {result: user.fest_vote_status};
}
async downloadImages(data: unknown, base_url: {
baas: string | null;
atum: string | null;
splatnet3: string | null;
}): Promise<Record<string, string>> {
const image_urls: [url: string, dir: string, base_url: string][] = [];
// Use JSON.stringify to iterate over everything in the response
JSON.stringify(data, (key: string, value: unknown) => {
if (this.image_proxy_path_baas && base_url.baas) {
if (typeof value === 'string' &&
value.startsWith('https://cdn-image-e0d67c509fb203858ebcb2fe3f88c2aa.baas.nintendo.com/')
) {
image_urls.push([value, this.image_proxy_path_baas, base_url.baas]);
}
}
if (this.image_proxy_path_atum && base_url.atum) {
if (typeof value === 'string' &&
value.startsWith('https://atum-img-lp1.cdn.nintendo.net/')
) {
image_urls.push([value, this.image_proxy_path_atum, base_url.atum]);
}
}
if (this.image_proxy_path_splatnet3 && base_url.splatnet3) {
if (typeof value === 'object' && value && 'url' in value && typeof value.url === 'string') {
if (value.url.toLowerCase().startsWith('https://api.lp1.av5ja.srv.nintendo.net/')) {
image_urls.push([value.url, this.image_proxy_path_splatnet3, base_url.splatnet3]);
}
}
}
return value;
});
const url_map: Record<string, string> = {};
await Promise.all(image_urls.map(async ([url, dir, base_url]) => {
url_map[url] = new URL(await this.downloadImage(url, dir), base_url).toString();
}));
return url_map;
}
getResourceBaseUrls(req: Request) {
const base_url = process.env.BASE_URL ??
(req.headers['x-forwarded-proto'] === 'https' ? 'https://' : 'http://') +
req.headers.host;
return {
baas: this.image_proxy_path_baas ? base_url + '/api/presence/resources/baas/' : null,
atum: this.image_proxy_path_atum ? base_url + '/api/presence/resources/atum/' : null,
splatnet3: this.image_proxy_path_splatnet3 ? base_url + '/api/splatnet3/resources/' : null,
};
}
downloadImage(url: string, dir: string) {
const pathname = new URL(url).pathname;
const name = pathname.substr(1).toLowerCase()
.replace(/^resources\//g, '')
.replace(/(\/|^)\.\.(\/|$)/g, '$1...$2') +
(path.extname(pathname) ? '' : '.jpeg');
const promise = this.promise_image.get(dir + '/' + name) ?? Promise.resolve().then(async () => {
try {
await fs.stat(path.join(dir, name));
return name;
} catch (err) {}
debug('Fetching image %s', name);
const response = await fetch(url);
const data = new Uint8Array(await response.arrayBuffer());
if (!response.ok) throw new ErrorResponse('Unable to download resource ' + name, response, data.toString());
await mkdirp(path.dirname(path.join(dir, name)));
await fs.writeFile(path.join(dir, name), data);
debug('Downloaded image %s', name);
return name;
}).then(result => {
this.promise_image.delete(dir + '/' + name);
return result;
}).catch(err => {
this.promise_image.delete(dir + '/' + name);
throw err;
});
this.promise_image.set(dir + '/' + name, promise);
return promise;
}
}
function createScheduleFest(
@ -834,7 +1291,7 @@ function createFestVoteTeam(
id: team.id,
teamName: team.teamName,
image: {
url: getSplatoon3inkUrl(team.image.url),
url: team.image.url,
},
color: team.color,
votes: {nodes: []},
@ -842,11 +1299,19 @@ function createFestVoteTeam(
};
}
function replacer(key: string, value: any) {
if ((key === 'image' || key.endsWith('Image')) && value && typeof value === 'object' && 'url' in value) {
function replacer(key: string, value: any, data: unknown) {
const url_map = data && typeof data === 'object' && ResourceUrlMapSymbol in data &&
data[ResourceUrlMapSymbol] && typeof data[ResourceUrlMapSymbol] === 'object' ?
data[ResourceUrlMapSymbol] as Partial<Record<string, string>> : null;
if (typeof value === 'string') {
return url_map?.[value] ?? value;
}
if (typeof value === 'object' && value && 'url' in value && typeof value.url === 'string') {
return {
...value,
url: getSplatoon3inkUrl(value.url),
url: url_map?.[value.url] ?? value.url,
};
}

View File

@ -71,10 +71,7 @@ export class HttpServer {
if (err instanceof ResponseError) {
err.sendResponse(req, res);
} else {
this.sendJsonResponse(res, {
error: err,
error_message: (err as Error).message,
}, 500);
this.sendJsonResponse(res, getErrorObject(err), 500);
}
}
}
@ -85,20 +82,28 @@ export class ResponseError extends Error {
}
sendResponse(req: Request, res: Response) {
const data = {
error: this.code,
error_message: this.message,
};
const data = this.toJSON();
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));
}
sendEventStreamEvent(events: EventStreamResponse) {
events.sendEvent('error', this.toJSON());
}
toJSON() {
return {
error: this.code,
error_message: this.message,
};
}
}
export class EventStreamResponse {
json_replacer: ((key: string, value: unknown) => any) | null = null;
json_replacer: ((key: string, value: unknown, data: unknown) => any) | null = null;
private static id = 0;
readonly id = EventStreamResponse.id++;
@ -123,7 +128,96 @@ export class EventStreamResponse {
sendEvent(event: string | null, ...data: unknown[]) {
if (event) this.res.write('event: ' + event + '\n');
for (const d of data) this.res.write('data: ' + JSON.stringify(d, this.json_replacer ?? undefined) + '\n');
for (const d of data) {
if (d instanceof EventStreamField) d.write(this.res);
else this.res.write('data: ' + JSON.stringify(d,
this.json_replacer ? (k, v) => this.json_replacer?.call(null, k, v, d) : undefined) + '\n');
}
this.res.write('\n');
}
sendErrorEvent(err: unknown) {
if (err instanceof ResponseError) {
err.sendEventStreamEvent(this);
} else {
this.sendEvent('error', getErrorObject(err));
}
}
}
abstract class EventStreamField {
abstract write(res: Response): void;
}
export class EventStreamLastEventId extends EventStreamField {
constructor(
readonly id: string,
) {
super();
if (!/^[0-9a-z-_\.:;]+$/i.test(id)) {
throw new TypeError('Invalid event ID');
}
}
write(res: Response) {
res.write('id: ' + this.id + '\n');
}
}
export class EventStreamRetryTime extends EventStreamField {
constructor(
readonly retry_ms: number,
) {
super();
}
write(res: Response) {
res.write('retry: ' + this.retry_ms + '\n');
}
}
export class EventStreamRawData extends EventStreamField {
constructor(
readonly data: string,
) {
super();
if (/[\0\n\r]/.test(data)) {
throw new TypeError('Invalid data');
}
}
write(res: Response) {
res.write('data: ' + this.data + '\n');
}
}
function getErrorObject(err: unknown) {
if (err instanceof ResponseError) {
return err.toJSON();
}
if (err && typeof err === 'object' && 'type' in err && 'code' in err && 'message' in err && err.type === 'system') {
return {
error: 'unknown_error',
error_message: err.message,
error_code: err.code,
...err,
};
}
if (err instanceof Error) {
return {
error: 'unknown_error',
error_message: err.message,
...err,
};
}
return {
error: 'unknown_error',
error_message: (err as Error)?.message,
...(err as object),
};
}

View File

@ -79,8 +79,9 @@ class ZncDiscordPresenceClient {
if (this.m.presence_enabled && this.m.discord_preconnect) {
if (this.rpc) {
debugDiscord('No presence but Discord preconnect enabled - clearing Discord activity');
if (this.title) debugDiscord('No presence but Discord preconnect enabled - clearing Discord activity');
this.setActivity(this.rpc.id);
this.title = null;
} else {
debugDiscord('No presence but Discord preconnect enabled - connecting');
const discordpresence = getInactiveDiscordPresence(PresenceState.OFFLINE, 0);
@ -235,7 +236,8 @@ class ZncDiscordPresenceClient {
if (!this.rpc) {
this.connect(client_id, this.m.discord_client_filter);
} else {
this.rpc.client.setActivity(typeof activity === 'string' ? undefined : activity.activity);
if (typeof activity === 'string') this.rpc.client.clearActivity();
else this.rpc.client.setActivity(activity.activity);
}
}

View File

@ -570,14 +570,14 @@ export const titles: Title[] = [
showPlayingOnline: true,
showActiveEvent: true,
},
// {
// // Pokémon Mystery Dungeon: Rescue Team DX Demo
// id: '', // TODO
// client: '966387876520685668',
// largeImageText: 'Demo',
// showPlayingOnline: true,
// showActiveEvent: true,
// },
{
// Pokémon Mystery Dungeon: Rescue Team DX Demo
id: '010040800fb54000',
client: '966387876520685668',
largeImageText: 'Demo',
showPlayingOnline: true,
showActiveEvent: true,
},
{
// Super Mario Party
@ -666,14 +666,14 @@ export const titles: Title[] = [
showPlayingOnline: true,
showActiveEvent: true,
},
// {
// // Cadence of Hyrule - Crypt of the NecroDancer Featuring The Legend of Zelda Demo
// id: '', // TODO
// client: '966441763973763162',
// largeImageText: 'Demo',
// showPlayingOnline: true,
// showActiveEvent: true,
// },
{
// Cadence of Hyrule - Crypt of the NecroDancer Featuring The Legend of Zelda Demo
id: '010065700ee06000',
client: '966441763973763162',
largeImageText: 'Demo',
showPlayingOnline: true,
showActiveEvent: true,
},
{
// Dragon Quest XI S: Echoes of an Elusive Age - Definitive Edition
@ -682,14 +682,14 @@ export const titles: Title[] = [
showPlayingOnline: true,
showActiveEvent: true,
},
// {
// // Dragon Quest XI S: Echoes of an Elusive Age - Definitive Edition Demo
// id: '', // TODO
// client: '966441876267864084',
// largeImageText: 'Demo',
// showPlayingOnline: true,
// showActiveEvent: true,
// },
{
// Dragon Quest XI S: Echoes of an Elusive Age - Definitive Edition [Demo version]
id: '010026800ea0a000',
client: '966441876267864084',
largeImageText: 'Demo',
showPlayingOnline: true,
showActiveEvent: true,
},
{
// Super Kirby Clash
@ -826,14 +826,14 @@ export const titles: Title[] = [
showPlayingOnline: true,
showActiveEvent: true,
},
// {
// // Hyrule Warriors: Age of Calamity Demo
// id: '', // TODO
// client: '966479236762325013',
// largeImageText: 'Demo',
// showPlayingOnline: true,
// showActiveEvent: true,
// },
{
// Hyrule Warriors: Age of Calamity - Demo Version
id: '0100a2c01320e000',
client: '966479236762325013',
largeImageText: 'Demo',
showPlayingOnline: true,
showActiveEvent: true,
},
{
// Fitness Boxing 2: Rhythm & Exercise
@ -1176,4 +1176,46 @@ export const titles: Title[] = [
showPlayingOnline: true,
showActiveEvent: true,
},
{
// Bayonetta Origins: Cereza and the Lost Demon
id: '0100cf5010fec000',
client: '1107032391496761375',
titleName: 'Bayonetta Origins: Cereza and the Lost Demon',
showPlayingOnline: true,
showActiveEvent: true,
},
{
// Bayonetta Origins: Cereza and the Lost Demon Demo
id: '010002801a3fa000',
client: '1107032391496761375',
largeImageText: 'Bayonetta Origins: Cereza and the Lost Demon Demo',
showPlayingOnline: true,
showActiveEvent: true,
},
{
// The Legend of Zelda: Tears of the Kingdom
id: '0100f2c0115b6000',
client: '1107033191912579183',
largeImageText: 'The Legend of Zelda: Tears of the Kingdom',
showPlayingOnline: true,
showActiveEvent: true,
},
// {
// // Pikmin 4
// id: '',
// client: '1107033455755264010',
// showPlayingOnline: true,
// showActiveEvent: true,
// },
{
// Metroid Prime Remastered
id: '010012101468c000',
client: '1107120929953284150',
showPlayingOnline: true,
showActiveEvent: true,
},
];

View File

@ -1,6 +1,6 @@
export { getTitleIdFromEcUrl } from '../util/misc.js';
export { ErrorResponse, ResponseSymbol } from '../api/util.js';
export { addUserAgent } from '../util/useragent.js';
export { addUserAgent, addUserAgentFromPackageJson } from '../util/useragent.js';
export { version } from '../util/product.js';

View File

@ -12,6 +12,7 @@ export const temporary_system_errors = {
'ENOTFOUND': null,
'EAI_AGAIN': 'name resolution failed',
'ECONNRESET': 'connection reset',
'ENETUNREACH': 'network unreachable',
};
export const temporary_http_errors = [
502, // Bad Gateway

View File

@ -1,5 +1,6 @@
import * as process from 'node:process';
import * as os from 'node:os';
import * as fs from 'node:fs/promises';
import { docker, git, release, version } from '../util/product.js';
const default_useragent = 'nxapi/' + version + ' (' +
@ -19,6 +20,68 @@ export function addUserAgent(...useragent: string[]) {
additional_useragents.push(...useragent);
}
export function addUserAgentFromPackageJson(pkg: string | URL, additional?: string): Promise<void>;
export function addUserAgentFromPackageJson(pkg: object, additional?: string): void;
export function addUserAgentFromPackageJson(pkg: string | URL | object, additional?: string) {
if (typeof pkg === 'string' || pkg instanceof URL) {
return fs.readFile(pkg, 'utf-8').then(pkg => addUserAgentFromPackageJson(JSON.parse(pkg)));
}
const name = 'name' in pkg && typeof pkg.name === 'string' ? pkg.name : null;
const version = 'version' in pkg && typeof pkg.version === 'string' ? pkg.version : null;
if (!name || !version) throw new Error('package.json does not contain valid name and version fields');
const homepage = 'homepage' in pkg ? pkg.homepage : null;
if (homepage != null && typeof homepage !== 'string') throw new Error('package.json contains an invalid homepage field');
const repository = 'repository' in pkg && pkg.repository != null ?
getPackageJsonRepository(pkg.repository) : null;
const end =
(homepage ? '+' + homepage : repository ? '+' + repository.url : '') +
(repository && additional ? '; ' : '') +
additional;
const useragent = name + '/' + version + (end ? ' (' + end + ')' : '');
addUserAgent(useragent);
}
function getPackageJsonRepository(repository: unknown): {
type: string; url: string; directory?: string | null;
} {
if (typeof repository === 'string') {
if (repository.startsWith('github:')) {
return {type: 'git', url: 'https://github.com/' + repository.substr(7)};
}
if (repository.startsWith('gist:')) {
return {type: 'git', url: 'https://gist.github.com/' + repository.substr(5)};
}
if (repository.startsWith('bitbucket:')) {
return {type: 'git', url: 'https://bitbucket.org/' + repository.substr(10)};
}
if (repository.startsWith('gitlab:')) {
return {type: 'git', url: 'https://gitlab.com/' + repository.substr(7)};
}
if (repository.match(/^[0-9a-z-.]+\/[0-9a-z-.]+$/i)) {
return {type: 'git', url: 'https://github.com/' + repository};
}
}
if (typeof repository === 'object' && repository) {
if ('type' in repository && typeof repository.type === 'string' &&
'url' in repository && typeof repository.url === 'string' &&
(!('directory' in repository) || repository.directory == null || typeof repository.directory === 'string')
) {
return {
type: repository.type, url: repository.url,
directory: 'directory' in repository ? repository.directory as string : null,
};
}
}
throw new Error('package.json contains an invalid repository field');
}
/**
* Only used by cli/nso/http-server.ts.
* This command is intended to be run automatically and doesn't make any requests itself, so this function removes