Merge branch 'main' into i18n

# Conflicts:
#	package-lock.json
#	package.json
#	src/app/browser/main/discord-setup.tsx
#	src/app/browser/main/discord.tsx
#	src/app/main/app-menu.ts
#	src/app/main/index.ts
#	src/app/main/ipc.ts
#	src/app/main/menu.ts
#	src/app/main/monitor.ts
#	src/app/main/na-auth.ts
#	src/app/main/util.ts
#	src/app/main/webservices.ts
#	src/app/main/windows.ts
This commit is contained in:
Samuel Elliott
2024-10-15 23:31:28 +01:00
179 changed files with 13955 additions and 6527 deletions

View File

@@ -1,12 +1,13 @@
import * as persist from 'node-persist';
import { Response } from 'node-fetch';
import { Response } from 'undici';
import CoralApi, { CoralAuthData, ZNCA_CLIENT_ID } from '../../api/coral.js';
import { CoralErrorResponse } from '../../api/coral-types.js';
import { CoralError } from '../../api/coral-types.js';
import ZncProxyApi from '../../api/znc-proxy.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
import { getNintendoAccountUser, NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
import createDebug from '../../util/debug.js';
import { Jwt } from '../../util/jwt.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import { getNaToken } from './na.js';
const debug = createDebug('nxapi:auth:coral');
@@ -26,11 +27,17 @@ export async function getToken(
data: SavedToken;
}>
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, ratelimit?: boolean
storage: persist.LocalStorage, token: string, proxy_url?: undefined, ratelimit?: boolean
): Promise<{
nso: CoralApi;
data: SavedToken;
}>
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, ratelimit?: boolean
): Promise<{
nso: CoralApi | ZncProxyApi;
data: SavedToken;
}>
export async function getToken(
storage: persist.LocalStorage, token: string, proxy_url?: string, ratelimit = SHOULD_LIMIT_USE
) {
@@ -59,28 +66,35 @@ export async function getToken(
const existingToken: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
const attempt = await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
console.warn('Authenticating to Nintendo Switch Online app');
debug('Authenticating to znc with session token');
const {nso, data} = proxy_url ?
await ZncProxyApi.createWithSessionToken(proxy_url, token) :
await CoralApi.createWithSessionToken(token);
try {
const {nso, data} = proxy_url ?
await ZncProxyApi.createWithSessionToken(proxy_url, token) :
await createWithSessionToken(storage, token, ratelimit);
const existingToken: SavedToken = {
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
const existingToken: SavedToken = {
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
if (nso instanceof CoralApi) {
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
}
await storage.setItem('NsoToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken.' + data.user.id, token);
await storage.setItem('NsoToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken.' + data.user.id, token);
existingToken[Login] = true;
existingToken[Login] = true;
return {nso, data: existingToken};
return {nso, data: existingToken};
} catch (err) {
await attempt.recordError(err);
throw err;
}
}
debug('Using existing token');
@@ -90,38 +104,64 @@ export async function getToken(
new ZncProxyApi(proxy_url, token) :
CoralApi.createWithSavedToken(existingToken);
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
if (nso instanceof CoralApi) {
nso.onTokenExpired = createTokenExpiredHandler(storage, token, nso, {existingToken});
}
return {nso, data: existingToken};
}
async function createWithSessionToken(
storage: persist.LocalStorage, na_session_token: string, ratelimit = true
) {
const na_token = await getNaToken(storage, na_session_token, ZNCA_CLIENT_ID, ratelimit);
const user = await getNintendoAccountUser(na_token.token);
debug('Authenticating to coral');
return CoralApi.createWithNintendoAccountToken(na_token.token, user);
}
function createTokenExpiredHandler(
storage: persist.LocalStorage, token: string, nso: CoralApi,
renew_token_data: {existingToken: SavedToken}, ratelimit = true
) {
return (data?: CoralErrorResponse, response?: Response) => {
return (data?: CoralError, response?: Response) => {
debug('Token expired', renew_token_data.existingToken.user.id, data);
return renewToken(storage, token, nso, renew_token_data, ratelimit);
};
}
async function renewToken(
storage: persist.LocalStorage, token: string, nso: CoralApi,
storage: persist.LocalStorage, na_session_token: string, nso: CoralApi,
renew_token_data: {existingToken: SavedToken}, ratelimit = true
) {
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(na_session_token);
attempt = await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
}
const data = await nso.renewToken(token, renew_token_data.existingToken.user);
try {
const na_token = await getNaToken(storage, na_session_token, ZNCA_CLIENT_ID, ratelimit);
const existingToken: SavedToken = {
...renew_token_data.existingToken,
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
debug('Reauthenticating to coral');
await storage.setItem('NsoToken.' + token, existingToken);
renew_token_data.existingToken = existingToken;
const data = await nso.renewTokenWithNintendoAccountToken(na_token.token, renew_token_data.existingToken.user);
const existingToken: SavedToken = {
...renew_token_data.existingToken,
...data,
expires_at: Date.now() + (data.credential.expiresIn * 1000),
};
await storage.setItem('NsoToken.' + na_session_token, existingToken);
renew_token_data.existingToken = existingToken;
return existingToken;
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}

View File

@@ -1,5 +1,5 @@
import * as persist from 'node-persist';
import { Response } from 'node-fetch';
import { Response } from 'undici';
import { MoonAuthData, ZNMA_CLIENT_ID } from '../../api/moon.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
import createDebug from '../../util/debug.js';
@@ -43,29 +43,36 @@ export async function getPctlToken(storage: persist.LocalStorage, token: string,
const existingToken: SavedMoonToken | undefined = await storage.getItem('MoonToken.' + token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
await checkUseLimit(storage, 'moon', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
const attempt = await checkUseLimit(storage, 'moon', jwt.payload.sub, ratelimit,
[LIMIT_REQUESTS, LIMIT_PERIOD]);
console.warn('Authenticating to Nintendo Switch Parental Controls app');
debug('Authenticating to pctl with session token');
try {
console.warn('Authenticating to Nintendo Switch Parental Controls app');
debug('Authenticating to pctl with session token');
const {moon, data} = await MoonApi.createWithSessionToken(token);
const {moon, data} = await MoonApi.createWithSessionToken(token);
const existingToken: SavedMoonToken = {
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
const existingToken: SavedMoonToken = {
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
moon.onTokenExpired = createTokenExpiredHandler(storage, token, moon, {existingToken});
moon.onTokenExpired = createTokenExpiredHandler(storage, token, moon, {existingToken});
await storage.setItem('MoonToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token);
await storage.setItem('MoonToken.' + token, existingToken);
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token);
return {moon, data: existingToken};
return {moon, data: existingToken};
} catch (err) {
await attempt.recordError(err);
throw err;
}
}
debug('Using existing token');
await storage.setItem('NintendoAccountToken-pctl.' + existingToken.user.id, token);
const moon = MoonApi.createWithSavedToken(existingToken);
moon.onTokenExpired = createTokenExpiredHandler(storage, token, moon, {existingToken});
@@ -86,19 +93,26 @@ async function renewToken(
storage: persist.LocalStorage, token: string, moon: MoonApi,
renew_token_data: {existingToken: SavedMoonToken}, ratelimit = true
) {
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'moon', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
attempt = await checkUseLimit(storage, 'moon', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
}
const data = await moon.renewToken(token);
try {
const data = await moon.renewToken(token);
const existingToken: SavedMoonToken = {
...renew_token_data.existingToken,
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
const existingToken: SavedMoonToken = {
...renew_token_data.existingToken,
...data,
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
};
await storage.setItem('MoonToken.' + token, existingToken);
renew_token_data.existingToken = existingToken;
await storage.setItem('MoonToken.' + token, existingToken);
renew_token_data.existingToken = existingToken;
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}

110
src/common/auth/na.ts Normal file
View File

@@ -0,0 +1,110 @@
import * as persist from 'node-persist';
import { getNintendoAccountToken, NintendoAccountAuthError, NintendoAccountAuthErrorResponse, NintendoAccountSessionTokenJwtPayload, NintendoAccountToken } from '../../api/na.js';
import createDebug from '../../util/debug.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../../util/errors.js';
import { Jwt } from '../../util/jwt.js';
import { checkUseLimit, LIMIT_REQUESTS, SHOULD_LIMIT_USE } from './util.js';
const debug = createDebug('nxapi:auth:na');
// Higher rate limit for Nintendo Accounts, as the token expires sooner
const LIMIT_PERIOD = 15 * 60 * 1000; // 15 minutes
export interface SavedNintendoAccountToken {
token: NintendoAccountToken;
expires_at: number;
}
export interface SavedNintendoAccountTokenError {
data: NintendoAccountAuthError;
created_at: number;
}
export async function getNaToken(
storage: persist.LocalStorage, na_session_token: string, client_id: string, ratelimit = SHOULD_LIMIT_USE,
) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(na_session_token);
if (jwt.payload.iss !== 'https://accounts.nintendo.com') {
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token issuer');
}
if (jwt.payload.typ !== 'session_token') {
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token type');
}
if (jwt.payload.aud !== client_id) {
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token audience');
}
if (jwt.payload.exp <= (Date.now() / 1000)) {
throw new NintendoAccountSessionTokenExpiredError('Nintendo Account session token expired');
}
// Nintendo Account session tokens use a HMAC SHA256 signature, so we can't verify this is valid
const existingToken: SavedNintendoAccountToken | undefined =
await storage.getItem('NaToken.' + na_session_token);
if (!existingToken || existingToken.expires_at <= Date.now()) {
const error: SavedNintendoAccountTokenError | undefined =
await storage.getItem('NaTokenError.' + na_session_token);
if (error) {
throw new NintendoAccountSessionTokenInvalidError('Invalid Nintendo Account session token', error);
}
const attempt = await checkUseLimit(storage, 'na', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
console.warn('Authenticating to Nintendo Accounts');
debug('Authenticating with session token');
try {
const token = await getNintendoAccountToken(na_session_token, client_id);
const existingToken: SavedNintendoAccountToken = {
token,
expires_at: Date.now() + (token.expires_in * 1000),
};
await storage.setItem('NaToken.' + na_session_token, existingToken);
return existingToken;
} catch (err) {
if (err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant') {
const data: SavedNintendoAccountTokenError = {
data: err.data,
created_at: Date.now(),
};
await storage.setItem('NaTokenError.' + na_session_token, data);
}
await attempt.recordError(err);
throw err;
}
}
debug('Using existing token');
return existingToken;
}
export class InvalidNintendoAccountTokenError extends Error {}
export class NintendoAccountSessionTokenExpiredError extends InvalidNintendoAccountTokenError implements HasErrorDescription {
get [ErrorDescriptionSymbol]() {
return new ErrorDescription('na.session_token_expired', 'Your Nintendo Account session token has expired.\n\nYou need to sign in again.');
}
}
export class NintendoAccountSessionTokenInvalidError extends InvalidNintendoAccountTokenError implements HasErrorDescription {
constructor(
message: string,
readonly data: SavedNintendoAccountTokenError,
) {
super(message);
}
get [ErrorDescriptionSymbol]() {
return new ErrorDescription('na.session_token_invalid_cached', 'Your Nintendo Account session token has expired or was revoked.\n\nYou need to sign in again.');
}
}

View File

@@ -1,9 +1,9 @@
import persist from 'node-persist';
import { Response } from 'node-fetch';
import { Response } from 'undici';
import { getToken, Login } from './coral.js';
import NooklinkApi, { NooklinkAuthData, NooklinkUserApi, NooklinkUserAuthData } from '../../api/nooklink.js';
import { Users, WebServiceError } from '../../api/nooklink-types.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import { checkMembershipActive, checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import createDebug from '../../util/debug.js';
import { Jwt } from '../../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
@@ -31,14 +31,6 @@ export async function getWebServiceToken(
const { default: { coral_gws_nooklink: config } } = await import('../remote-config.js');
if (!config) throw new Error('Remote configuration prevents NookLink authentication');
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'nooklink', jwt.payload.sub);
}
console.warn('Authenticating to NookLink');
debug('Authenticating to NookLink');
const {nso, data} = await getToken(storage, token, proxy_url);
if (data[Login]) {
@@ -48,18 +40,35 @@ export async function getWebServiceToken(
const activeevent = await nso.getActiveEvent();
}
const existingToken: SavedToken = await NooklinkApi.loginWithCoral(nso, data.user);
checkMembershipActive(data);
await storage.setItem('NookToken.' + token, existingToken);
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
attempt = await checkUseLimit(storage, 'nooklink', jwt.payload.sub);
}
const nooklink = NooklinkApi.createWithSavedToken(existingToken);
try {
console.warn('Authenticating to NookLink');
debug('Authenticating to NookLink');
nooklink.onTokenExpired = createTokenExpiredHandler(storage, token, nooklink, {
existingToken,
znc_proxy_url: proxy_url,
});
const existingToken: SavedToken = await NooklinkApi.loginWithCoral(nso, data.user);
return {nooklink, data: existingToken};
await storage.setItem('NookToken.' + token, existingToken);
const nooklink = NooklinkApi.createWithSavedToken(existingToken);
nooklink.onTokenExpired = createTokenExpiredHandler(storage, token, nooklink, {
existingToken,
znc_proxy_url: proxy_url,
});
return {nooklink, data: existingToken};
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}
debug('Using existing web service token');

View File

@@ -34,14 +34,6 @@ export async function getIksmToken(
throw new Error('No valid iksm_session cookie');
}
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'splatnet2', jwt.payload.sub);
}
console.warn('Authenticating to SplatNet 2');
debug('Authenticating to SplatNet 2');
const {nso, data} = await getToken(storage, token, proxy_url);
if (data[Login]) {
@@ -51,18 +43,33 @@ export async function getIksmToken(
const activeevent = await nso.getActiveEvent();
}
const existingToken: SavedIksmSessionToken = await SplatNet2Api.loginWithCoral(nso, data.user);
await storage.setItem('IksmToken.' + token, existingToken);
if (!iksm_sessions.has(existingToken.iksm_session)) {
iksm_sessions.set(existingToken.iksm_session, [storage, token, null, null]);
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
attempt = await checkUseLimit(storage, 'splatnet2', jwt.payload.sub);
}
return {
splatnet: SplatNet2Api.createWithSavedToken(existingToken),
data: existingToken,
};
try {
console.warn('Authenticating to SplatNet 2');
debug('Authenticating to SplatNet 2');
const existingToken: SavedIksmSessionToken = await SplatNet2Api.loginWithCoral(nso, data.user);
await storage.setItem('IksmToken.' + token, existingToken);
if (!iksm_sessions.has(existingToken.iksm_session)) {
iksm_sessions.set(existingToken.iksm_session, [storage, token, null, null]);
}
return {
splatnet: SplatNet2Api.createWithSavedToken(existingToken),
data: existingToken,
};
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}
debug('Using existing token');

View File

@@ -1,12 +1,11 @@
import persist from 'node-persist';
import { Response } from 'node-fetch';
import { Response } from 'undici';
import { getToken, Login, SavedToken } from './coral.js';
import SplatNet3Api, { SplatNet3AuthData } from '../../api/splatnet3.js';
import { checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import SplatNet3Api, { SplatNet3AuthData, SplatNet3AuthErrorCode, SplatNet3AuthErrorResponse } from '../../api/splatnet3.js';
import { checkMembershipActive, checkUseLimit, SHOULD_LIMIT_USE } from './util.js';
import createDebug from '../../util/debug.js';
import { Jwt } from '../../util/jwt.js';
import { NintendoAccountSessionTokenJwtPayload } from '../../api/na.js';
import { ErrorResponse } from '../../api/util.js';
const debug = createDebug('nxapi:auth:splatnet3');
@@ -31,14 +30,6 @@ export async function getBulletToken(
const { default: { coral_gws_splatnet3: config } } = await import('../remote-config.js');
if (!config) throw new Error('Remote configuration prevents SplatNet 3 authentication');
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
await checkUseLimit(storage, 'splatnet3', jwt.payload.sub);
}
console.warn('Authenticating to SplatNet 3');
debug('Authenticating to SplatNet 3');
const {nso, data} = await getToken(storage, token, proxy_url);
if (data[Login]) {
@@ -48,17 +39,34 @@ export async function getBulletToken(
const activeevent = await nso.getActiveEvent();
}
const existingToken: SavedBulletToken = await SplatNet3Api.loginWithCoral(nso, data.user);
checkMembershipActive(data);
await storage.setItem('BulletToken.' + token, existingToken);
let attempt;
if (ratelimit) {
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
attempt = await checkUseLimit(storage, 'splatnet3', jwt.payload.sub);
}
const splatnet = SplatNet3Api.createWithSavedToken(existingToken);
try {
console.warn('Authenticating to SplatNet 3');
debug('Authenticating to SplatNet 3');
const renew_token_data = {existingToken, znc_proxy_url: proxy_url};
splatnet.onTokenExpired = createTokenExpiredHandler(storage, token, splatnet, renew_token_data);
splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(storage, token, splatnet, renew_token_data);
const existingToken: SavedBulletToken = await SplatNet3Api.loginWithCoral(nso, data.user);
return {splatnet, data: existingToken};
await storage.setItem('BulletToken.' + token, existingToken);
const splatnet = SplatNet3Api.createWithSavedToken(existingToken);
const renew_token_data = {existingToken, znc_proxy_url: proxy_url};
splatnet.onTokenExpired = createTokenExpiredHandler(storage, token, splatnet, renew_token_data);
splatnet.onTokenShouldRenew = createTokenShouldRenewHandler(storage, token, splatnet, renew_token_data);
return {splatnet, data: existingToken};
} catch (err) {
await attempt?.recordError(err);
throw err;
}
}
debug('Using existing token');
@@ -121,7 +129,7 @@ async function renewToken(
debug('Unable to renew bullet token with saved web services token - cached data for this session token doesn\'t exist??');
}
} catch (err) {
if (err instanceof ErrorResponse && err.response.status === 401) {
if (err instanceof SplatNet3AuthErrorResponse && err.code === SplatNet3AuthErrorCode.ERROR_INVALID_GAME_WEB_TOKEN) {
// Web service token invalid/expired...
debug('Web service token expired, authenticating with new token', err);
} else {

View File

@@ -1,5 +1,8 @@
import * as util from 'node:util';
import * as persist from 'node-persist';
import createDebug from '../../util/debug.js';
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../../util/errors.js';
import { SavedToken } from './coral.js';
const debug = createDebug('nxapi:auth:util');
@@ -13,21 +16,116 @@ export const SHOULD_LIMIT_USE = !process.stdin.isTTY || !process.stderr.isTTY;
export const LIMIT_REQUESTS = 4;
export const LIMIT_PERIOD = 60 * 60 * 1000; // 60 minutes
type RateLimitAttempts = number[];
export async function checkUseLimit(
storage: persist.LocalStorage,
key: string, user: string,
/** Set to false to count the attempt but ignore the limit */ ratelimit = true,
/** [requests, period_ms] */ limits: [number, number] = [LIMIT_REQUESTS, LIMIT_PERIOD]
limits: [requests: number, period_ms: number] = [LIMIT_REQUESTS, LIMIT_PERIOD]
) {
let attempts: RateLimitAttempts = await storage.getItem('RateLimitAttempts-' + key + '.' + user) ?? [];
attempts = attempts.filter(a => a >= Date.now() - limits[1]);
let attempts: SavedRateLimitAttempt[] = await storage.getItem('RateLimitAttempts-' + key + '.' + user) ?? [];
if (typeof attempts[0] === 'number') attempts = attempts.map((a: SavedRateLimitAttempt | number) =>
typeof a === 'number' ? {time: a} : a);
attempts = attempts.filter(a => a.time >= Date.now() - limits[1]);
if (ratelimit && attempts.length >= limits[0]) {
throw new Error('Too many attempts to authenticate');
for (const attempt of attempts) decorateRateLimitAttempt(attempt);
throw new RateLimitError('Too many attempts to authenticate (' + key + ')', key, attempts);
}
attempts.unshift(Date.now());
const attempt = new RateLimitAttempt(storage, key, user);
debug('rl attempt', attempt, attempts.length, limits[0]);
attempts.unshift({time: attempt.time});
await storage.setItem('RateLimitAttempts-' + key + '.' + user, attempts);
return attempt;
}
class RateLimitAttempt {
constructor(
readonly storage: persist.LocalStorage,
readonly key: string, readonly user: string,
readonly time = Date.now(),
) {
Object.defineProperty(this, 'storage', {configurable: true, enumerable: false, value: storage});
}
async recordError(err: Error | unknown) {
const error_description = ErrorDescription.getErrorDescription(err);
const error_description_data = err instanceof HasErrorDescription ? err[ErrorDescriptionSymbol] : null;
await this.recordErrorData(error_description, err, error_description_data);
}
async recordErrorData(error_description: string, data: unknown, error_description_data?: ErrorDescription | null) {
const key = 'RateLimitAttempts-' + this.key + '.' + this.user;
let attempts: SavedRateLimitAttempt[] = await this.storage.getItem(key) ?? [];
const attempt = attempts.find(a => a.time === this.time);
if (!attempt) return;
attempt.error_description = error_description;
attempt.error_description_data = error_description_data ?? undefined;
attempt.error_data = data;
await this.storage.setItem(key, attempts);
}
}
interface SavedRateLimitAttempt {
time: number;
error_description?: string;
error_description_data?: {
type: string;
message: string;
};
error_data?: unknown;
}
function decorateRateLimitAttempt(attempt: SavedRateLimitAttempt) {
Object.defineProperty(attempt, util.inspect.custom, {value: inspectRateLimitAttempt});
}
function inspectRateLimitAttempt(
this: SavedRateLimitAttempt,
depth: number, options: util.InspectOptionsStylized, inspect: typeof util.inspect,
) {
const time = options.stylize('RateLimitAttempt', 'special') + ' ' + new Date(this.time);
if (!this.error_description) {
return time + ' ' + options.stylize('[no error]', 'undefined');
} else if (depth < 0) {
return time + ' ' + options.stylize('[error hidden]', 'undefined');
} else {
return time + '\n' + this.error_description + ' ' + inspect(this.error_data, {
...options,
depth: options.depth ? options.depth - 1 : null,
});
}
}
export class RateLimitError extends Error implements HasErrorDescription {
constructor(
message: string,
readonly key: string,
readonly attempts: SavedRateLimitAttempt[],
) {
super(message);
}
get [ErrorDescriptionSymbol]() {
return new ErrorDescription('auth.use_limit_exceeded', 'Too many attempts to authenticate.');
}
}
export function checkMembershipActive(data: SavedToken) {
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
if (!active) throw new MembershipRequiredError('Nintendo Switch Online membership required');
}
export class MembershipRequiredError extends Error implements HasErrorDescription {
get [ErrorDescriptionSymbol]() {
return new ErrorDescription('auth.nso_membership_required', 'Nintendo Switch Online membership required.\n\nMake sure your account has an active Nintendo Switch Online membership. It may take up to two hours for your membership status to update.');
}
}

View File

@@ -6,7 +6,7 @@ export const USER_AGENT_INFO_URL = 'https://gitlab.fancy.org.uk/samuel/nxapi#use
export const CONFIG_URL = 'https://fancy.org.uk/api/nxapi/config';
export const LICENCE_NOTICE = `
Copyright (c) 2022 Samuel Elliott
Copyright (c) 2023 Samuel Elliott
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View File

@@ -1,6 +1,6 @@
import persist from 'node-persist';
import CoralApi from '../api/coral.js';
import { ActiveEvent, Announcements, CurrentUser, Friend, Game, Presence, PresenceState, WebServices, CoralErrorResponse, GetActiveEventResult } from '../api/coral-types.js';
import { CoralApiInterface } from '../api/coral.js';
import { ActiveEvent, Announcements, CurrentUser, Friend, Game, Presence, PresenceState, WebServices, CoralError, GetActiveEventResult } from '../api/coral-types.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { ErrorResponse } from '../api/util.js';
import { SavedToken } from './auth/coral.js';
@@ -26,9 +26,9 @@ export class ZncNotifications extends Loop {
constructor(
public storage: persist.LocalStorage,
public token: string,
public nso: CoralApi,
public nso: CoralApiInterface,
public data: Omit<SavedToken, 'expires_at'>,
public user?: CoralUser,
public user?: CoralUser<CoralApiInterface>,
) {
super();
}
@@ -179,7 +179,7 @@ export class ZncNotifications extends Loop {
if (user) await this.updatePresenceForSplatNet2Monitors([user]);
}
async handleError(err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
async handleError(err: ErrorResponse<CoralError> | NodeJS.ErrnoException): Promise<LoopResult> {
return handleError(err, this);
}
}

View File

@@ -0,0 +1,361 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Request } from 'express';
import sharp from 'sharp';
import { CoopRule, FestVoteState, FriendOnlineState, StageScheduleResult } from 'splatnet3-types/splatnet3';
import { dir } from '../util/product.js';
import createDebug from '../util/debug.js';
import { Game, PresenceState } from '../api/coral-types.js';
import { RawValueSymbol, htmlentities } from '../util/misc.js';
import { PresenceResponse } from '../cli/presence-server.js';
const debug = createDebug('nxapi:common:presence-embed');
type VsSchedule_event = StageScheduleResult['eventSchedules']['nodes'][0];
type LeagueMatchSetting_schedule = VsSchedule_event['leagueMatchSetting'];
export enum PresenceEmbedFormat {
SVG,
PNG,
JPEG,
WEBP,
}
export enum PresenceEmbedTheme {
LIGHT,
DARK,
}
interface PresenceEmbedThemeColours {
background: string;
separator: string;
text: string;
online: string;
online_border: string;
}
const embed_themes: Record<PresenceEmbedTheme, PresenceEmbedThemeColours> = {
[PresenceEmbedTheme.LIGHT]: {
background: '#ebebeb',
separator: '#7b7b7b',
text: '#000000',
online: '#2db742',
online_border: '#0eb728',
},
[PresenceEmbedTheme.DARK]: {
background: '#2d2d2d',
separator: '#7e7e7e',
text: '#ffffff',
online: '#47e85f',
online_border: '#19e838',
},
};
interface UserEmbedOptions {
show_splatoon3_fest_team?: boolean;
}
const embed_titles: Partial<Record<string, (
result: PresenceResponse,
url_map: Record<string, string | readonly [url: string, data: Uint8Array, type: string]>,
image: (url: string) => string | undefined,
theme?: PresenceEmbedTheme,
options?: UserEmbedOptions,
) => readonly [svg: string, height: number, override_description: string | null]>> = {
'0100c2500fc20000': renderUserSplatoon3EmbedPartialSvg,
};
export function getUserEmbedOptionsFromRequest(req: Request) {
const url = new URL(req.url, 'https://localhost');
const theme = url.searchParams.get('theme') === 'dark' ? PresenceEmbedTheme.DARK : PresenceEmbedTheme.LIGHT;
const friend_code = url.searchParams.getAll('friend-code').find(c => c.match(/^\d{4}-\d{4}-\d{4}$/));
const transparent = url.searchParams.get('transparent') === '1';
let width = url.searchParams.getAll('width')
.map(w => parseInt(w))
.map(w => transparent ? w + 60 : w)
.find(w => !isNaN(w) && w >= 500);
if (!width) width = 500;
if (width > 1500) width = 1500;
let scale = url.searchParams.getAll('scale')
.map(s => parseInt(s))
.find(s => !isNaN(s) && s >= 1 && s <= 4);
const options: UserEmbedOptions = {
show_splatoon3_fest_team: url.searchParams.get('show-splatoon3-fest-team') === '1',
};
return {theme, friend_code, transparent, width, scale, options};
}
export async function renderUserEmbedImage(
svg: string,
format: PresenceEmbedFormat,
): Promise<[data: Buffer, type: string]> {
if (format === PresenceEmbedFormat.SVG) {
return [Buffer.from(svg), 'image/svg+xml'];
}
const start = Date.now();
debug('generating image, format %s', PresenceEmbedFormat[format]);
let image = sharp(Buffer.from(svg)).withMetadata({
density: 72 * 2,
});
if (format === PresenceEmbedFormat.PNG) image = image.png();
if (format === PresenceEmbedFormat.JPEG) image = image.jpeg();
if (format === PresenceEmbedFormat.WEBP) image = image.webp();
const data = await image.toBuffer();
debug('generated %s in %d ms', PresenceEmbedFormat[format], Date.now() - start);
if (format === PresenceEmbedFormat.PNG) return [data, 'image/png'];
if (format === PresenceEmbedFormat.JPEG) return [data, 'image/jpeg'];
if (format === PresenceEmbedFormat.WEBP) return [data, 'image/webp'];
throw new TypeError('Invalid format');
}
export function renderUserEmbedSvg(
result: PresenceResponse,
url_map: Record<string, string | readonly [url: string, data: Uint8Array, type: string]>,
theme = PresenceEmbedTheme.LIGHT,
friend_code?: string,
options?: UserEmbedOptions,
scale = 1,
transparent = false,
width = 500,
) {
if (width < 500) width = 500;
let height = 180;
if (friend_code) height += 40;
const colours = embed_themes[theme];
const font_family = '\'Open Sans\', -apple-system, BlinkMacSystemFont, Arial, sans-serif';
const state = result.friend.presence.state;
const game = 'name' in result.friend.presence.game ? result.friend.presence.game : null;
const image = (url: string) =>
url_map[url] instanceof Array ?
'data:' + url_map[url][2] + ';base64,' +
Buffer.from(url_map[url][1]).toString('base64') :
url_map[url] as string | undefined;
const show_splatoon3_fest_team = options?.show_splatoon3_fest_team &&
result.splatoon3_fest_team?.myVoteState === FestVoteState.VOTED ? result.splatoon3_fest_team : null;
const title_extra = result.title ? embed_titles[result.title.id]?.call(null, result, url_map, image, theme, options) : null;
if (title_extra) height += title_extra[1];
return htmlentities`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="${(width + (transparent ? -60 : 0)) * scale}"
height="${(height + (transparent ? -60 : 0)) * scale}"
viewBox="${transparent ? '30 30' : '0 0'} ${width + (transparent ? -60 : 0)} ${height + (transparent ? -60 : 0)}"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<style>${embed_style}</style>
<defs>
<linearGradient id="gradient-out">
<stop offset="0%" stop-opacity="1" stop-color="#ffffff"></stop>
<stop offset="100%" stop-opacity="0" stop-color="#ffffff"></stop>
</linearGradient>
<mask id="mask-out">
<rect x="0" y="0" width="${width - 50}" height="${height}" fill="#ffffff"></rect>
<rect x="${width - 50}" y="0" width="20" height="${height}" fill="url(#gradient-out)"></rect>
</mask>
<mask id="mask-out-title">
<rect x="0" y="0" width="${width - 50 - (show_splatoon3_fest_team ? 40 : 0)}" height="${height}"
fill="#ffffff"></rect>
<rect x="${width - 50 - (show_splatoon3_fest_team ? 40 : 0)}" y="0" width="20" height="${height}"
fill="url(#gradient-out)"></rect>
</mask>
</defs>
${{[RawValueSymbol]: transparent ? '' : htmlentities`
<rect x="0" y="0" width="${width}" height="${height}" fill="${colours.background}" />
`}}
<image x="30" y="30" width="120" height="120"
href="${image(result.friend.imageUri) ?? result.friend.imageUri}" />
<text x="180" y="57" fill="${colours.text}" font-size="26" font-family="${font_family}" font-weight="500" mask="url(#mask-out-title)">${result.friend.name}</text>
<line x1="180" y1="73" x2="${width - 30}" y2="73" stroke="${colours.separator}" />
${{[RawValueSymbol]: game && (state === PresenceState.ONLINE || state === PresenceState.PLAYING) ? htmlentities`
<image x="180" y="87" width="60" height="60"
href="${image(game.imageUri) ?? game.imageUri}" />
${{[RawValueSymbol]: renderUserTitleEmbedPartialSvg(game, title_extra?.[2], colours, font_family)}}
` : htmlentities`
<text x="180" y="97" fill="${colours.text}" font-size="14" font-family="${font_family}" font-weight="400">Offline</text>
`}}
${{[RawValueSymbol]: friend_code ? htmlentities`
<text x="30" y="${186 + (title_extra?.[1] ?? 0)}" fill="${colours.text}" font-size="14" font-family="${font_family}" font-weight="400">Friend code: SW-${friend_code}</text>
` : ''}}
${{[RawValueSymbol]: show_splatoon3_fest_team ? htmlentities`
<image x="${width - 60}" y="33" width="30" height="30"
href="${image(show_splatoon3_fest_team.image.url) ?? show_splatoon3_fest_team.image.url}" />
` : ''}}
${{[RawValueSymbol]: title_extra?.[0] ?? ''}}
</svg>
`;
}
function renderUserTitleEmbedPartialSvg(
game: Game, description: string | null | undefined,
colours: PresenceEmbedThemeColours, font_family: string,
) {
if (typeof description !== 'string') description = game.sysDescription;
const playing_text_offset = description ? 92 : 97;
const title_name_text_offset = description ? 122 : 133;
return htmlentities`
<rect x="255" y="${playing_text_offset}" width="10" height="10" fill="${colours.online}"
stroke="${colours.online_border}" stroke-width="1" rx="1" ry="1" stroke-linejoin="round"
/>
<text x="272" y="${playing_text_offset + 10}" fill="${colours.online}" font-size="14" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">Online</text>
<text x="255" y="${title_name_text_offset}" fill="${colours.text}" font-size="14" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">${game.name}</text>
` + (description ? htmlentities`
<text x="255" y="142" fill="${colours.text}" font-size="14" font-family="${font_family}" font-weight="300" mask="url(#mask-out)">${description ?? ''}</text>
` : '');
}
function renderUserSplatoon3EmbedPartialSvg(
result: PresenceResponse,
url_map: Record<string, string | readonly [url: string, data: Uint8Array, type: string]>,
image: (url: string) => string | undefined,
theme = PresenceEmbedTheme.LIGHT,
options?: UserEmbedOptions,
) {
const x = 180;
const y = 165;
const colours = embed_themes[theme];
const font_family = '\'Open Sans\', -apple-system, BlinkMacSystemFont, Arial, sans-serif';
if (result.splatoon3?.vsMode && (
result.splatoon3.onlineState === FriendOnlineState.VS_MODE_FIGHTING ||
result.splatoon3.onlineState === FriendOnlineState.VS_MODE_MATCHING
)) {
const mode_name =
result.splatoon3.vsMode.mode === 'REGULAR' ? 'Regular Battle' :
result.splatoon3.vsMode.id === 'VnNNb2RlLTI=' ? 'Anarchy Battle (Series)' : // VsMode-2
result.splatoon3.vsMode.id === 'VnNNb2RlLTUx' ? 'Anarchy Battle (Open)' : // VsMode-51
result.splatoon3.vsMode.mode === 'BANKARA' ? 'Anarchy Battle' :
result.splatoon3.vsMode.id === 'VnNNb2RlLTY=' ? 'Splatfest Battle (Open)' : // VsMode-6
result.splatoon3.vsMode.id === 'VnNNb2RlLTc=' ? 'Splatfest Battle (Pro)' : // VsMode-7
result.splatoon3.vsMode.id === 'VnNNb2RlLTg=' ? 'Tricolour Battle' : // VsMode-8
result.splatoon3.vsMode.mode === 'FEST' ? 'Splatfest Battle' :
result.splatoon3.vsMode.id === 'VnNNb2RlLTQ=' ? 'Challenge' : // VsMode-4
result.splatoon3.vsMode.mode === 'LEAGUE' ? 'Challenge' :
result.splatoon3.vsMode.mode === 'X_MATCH' ? 'X Battle' : // VsMode-3
undefined;
const setting = result.splatoon3_vs_setting;
const fest_team = result.splatoon3_fest_team;
const description =
(mode_name ?? result.splatoon3.vsMode.name) +
(result.splatoon3.vsMode.mode === 'FEST' && fest_team ?
' - Team ' + fest_team.teamName : '') +
(result.splatoon3.vsMode.mode === 'LEAGUE' && setting && 'leagueMatchEvent' in setting ?
': ' + (setting as LeagueMatchSetting_schedule).leagueMatchEvent.name : '') +
(result.splatoon3.vsMode.mode !== 'FEST' && result.splatoon3.vsMode.mode !== 'LEAGUE' && setting ?
' - ' + setting.vsRule.name : '') +
(result.splatoon3.onlineState === FriendOnlineState.VS_MODE_MATCHING ? ' (matching)' : '');
if (result.splatoon3.vsMode.id === 'VnNNb2RlLTg=' && result.splatoon3_fest) {
const stage = result.splatoon3_fest.tricolorStage;
return [htmlentities`
<image x="${x}" y="${y}" width="80" height="40"
href="${image(stage.image.url) ?? stage.image.url}" />
<text x="${x + 95}" y="${y + 25}" fill="${colours.text}" font-size="12" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">${stage.name}</text>
`, 55, description] as const;
}
if (setting?.vsStages.length) {
return [htmlentities`
${{[RawValueSymbol]: setting.vsStages.map((stage, i) => htmlentities`
<image x="${x}" y="${y + (i * 50)}" width="80" height="40"
href="${image(stage.image.url) ?? stage.image.url}" />
<text x="${x + 95}" y="${y + (i * 50) + 25}" fill="${colours.text}" font-size="12" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">${stage.name}</text>
`).join('')}}
`, (setting.vsStages.length * 50) + 5, description] as const;
}
return ['', 0, description] as const;
}
if (result.splatoon3?.onlineState === FriendOnlineState.COOP_MODE_FIGHTING ||
result.splatoon3?.onlineState === FriendOnlineState.COOP_MODE_MATCHING
) {
const rule_name =
result.splatoon3.coopRule === CoopRule.REGULAR ? 'Salmon Run' :
result.splatoon3.coopRule === CoopRule.BIG_RUN ? 'Big Run' :
result.splatoon3.coopRule === CoopRule.TEAM_CONTEST ? 'Eggstra Work' : null;
const description = (rule_name ?? 'Salmon Run') +
(result.splatoon3.onlineState === FriendOnlineState.COOP_MODE_MATCHING ? ' (matching)' : '');
const setting = result.splatoon3_coop_setting;
if (setting) {
return [htmlentities`
<image x="${x}" y="${y}" width="80" height="40"
href="${image(setting.coopStage.thumbnailImage.url) ?? setting.coopStage.thumbnailImage.url}" />
<text x="${x + 95}" y="${y + 13}" fill="${colours.text}" font-size="12" font-family="${font_family}" font-weight="400" mask="url(#mask-out)">${setting.coopStage.name}</text>
${{[RawValueSymbol]: setting.weapons.map((weapon, i) => htmlentities`
<image x="${x + 95 + (i * 22)}" y="${y + 21}" width="18" height="18"
href="${image(weapon.image.url) ?? weapon.image.url}" />
`).join('')}}
`, 55, description] as const;
}
return ['', 0, description] as const;
}
if (result.splatoon3?.onlineState === FriendOnlineState.MINI_GAME_PLAYING) {
const description = 'Tableturf Battle';
return ['', 0, description] as const;
}
return ['', 0, null] as const;
}
const embed_fonts: [name: string, style: string, weight: string, files: [format: string, type: string, path: string][]][] = [
['Open Sans', 'normal', '400', [['opentype', 'font/ttf', 'opensans-normal-400.ttf']]],
['Open Sans', 'normal', '500', [['opentype', 'font/ttf', 'opensans-normal-500.ttf']]],
];
const embed_style = `
text {
-webkit-user-select: none;
user-select: none;
}
` + (await Promise.all(embed_fonts.map(async ([name, style, weight, files]) => `@font-face {
font-family: '${name}';
font-style: ${style};
font-weight: ${weight};
src: ${(await Promise.all(files.map(async ([format, type, file]) => `url('data:${type};base64,${
(await fs.readFile(path.join(dir, 'resources', 'cli', 'fonts', file))).toString('base64')
}') format('${format}')`))).join(',')};
}`))).join('\n');

View File

@@ -1,9 +1,10 @@
import EventSource from 'eventsource';
import { errors } from 'undici';
import EventSource, { ErrorEvent } from '../util/eventsource.js';
import { DiscordRpcClient, findDiscordRpcClient } from '../discord/rpc.js';
import { getDiscordPresence, getInactiveDiscordPresence } from '../discord/util.js';
import { DiscordPresencePlayTime, DiscordPresenceContext, DiscordPresence, ExternalMonitorConstructor, ExternalMonitor, ErrorResult } from '../discord/types.js';
import { EmbeddedSplatNet2Monitor, ZncNotifications } from './notify.js';
import { ActiveEvent, CurrentUser, Friend, Game, Presence, PresenceState, CoralErrorResponse } from '../api/coral-types.js';
import { ActiveEvent, CurrentUser, Friend, Game, Presence, PresenceState, CoralError } from '../api/coral-types.js';
import { getPresenceFromUrl } from '../api/znc-proxy.js';
import createDebug from '../util/debug.js';
import { ErrorResponse, ResponseSymbol } from '../api/util.js';
@@ -30,6 +31,7 @@ interface SavedPresence {
class ZncDiscordPresenceClient {
rpc: {client: DiscordRpcClient, id: string} | null = null;
connecting: string | null = null;
title: {id: string; since: number} | null = null;
monitors = new Map<ExternalMonitorConstructor<any>, ExternalMonitor>();
protected i = 0;
@@ -47,6 +49,11 @@ class ZncDiscordPresenceClient {
ErrorResult | Promise<ErrorResult>) | null = null;
update_presence_errors = 0;
last_update_error: Error | null = null;
last_update_error_at: Date | null = null;
onUpdateError: ((error: Error | null) => void) | null = null;
onUpdateSuccess: (() => void) | null = null;
onUpdate: (() => void) | null = null;
constructor(
readonly m: ZncDiscordPresence | ZncProxyDiscordPresence,
@@ -63,6 +70,13 @@ class ZncDiscordPresenceClient {
this.last_friendcode = friendcode;
this.last_event = activeevent;
this.onUpdate?.call(null);
if (this.update_presence_errors) {
this.update_presence_errors = 0;
this.onUpdateSuccess?.call(null);
}
const online = presence?.state === PresenceState.ONLINE || presence?.state === PresenceState.PLAYING;
const show_presence =
@@ -233,7 +247,10 @@ class ZncDiscordPresenceClient {
}
if (!this.rpc) {
this.connect(client_id, this.m.discord_client_filter);
if (this.connecting !== client_id) {
this.connect(client_id, this.m.discord_client_filter).finally(() => this.connecting = null);
this.connecting = client_id;
}
} else {
if (typeof activity === 'string') this.rpc.client.clearActivity();
else this.rpc.client.setActivity(activity.activity);
@@ -253,7 +270,7 @@ class ZncDiscordPresenceClient {
let i = ++this.i;
while (attempts < MAX_CONNECT_ATTEMPTS) {
if (this.i !== i || client!) return;
if (this.i !== i || client! || this.rpc) return;
if (attempts === 0) debugDiscord('RPC connecting', client_id, i);
else debugDiscord('RPC connecting, attempt %d', attempts + 1, client_id, i);
@@ -328,15 +345,15 @@ class ZncDiscordPresenceClient {
async onError(err: Error) {
this.update_presence_errors++;
this.last_update_error = err;
this.last_update_error_at = new Date();
if (this.update_presence_errors > 2) {
this.onUpdateError?.call(null, err);
if (this.update_presence_errors > 2 && this.rpc) {
// Disconnect from Discord if the last two attempts to update presence failed
// This prevents the user's activity on Discord being stuck
if (this.rpc) {
const client = this.rpc.client;
this.rpc = null;
await client.destroy();
}
this.setActivity(this.m.discord_preconnect ? this.rpc.id : null);
}
if (this.update_presence_errors > 10) {
@@ -499,7 +516,7 @@ export class ZncDiscordPresence extends ZncNotifications {
this.discord.title = {id: title_id, since: saved_presence.title_since};
}
async handleError(err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
async handleError(err: ErrorResponse<CoralError> | NodeJS.ErrnoException): Promise<LoopResult> {
this.discord.onError(err);
return super.handleError(err);
@@ -512,7 +529,7 @@ export class ZncProxyDiscordPresence extends Loop {
readonly user_notifications = false;
readonly friend_notifications = false;
update_interval = 30;
upgrade_to_sse = process.env.NXAPI_PRESENCE_SSE === '1';
upgrade_to_sse = process.env.NXAPI_PRESENCE_SSE !== '0';
presence_user: null = null;
discord_preconnect = false;
@@ -549,11 +566,11 @@ export class ZncProxyDiscordPresence extends Loop {
protected proxy_temporary_errors = 0;
async update() {
if (this.is_sse) {
return await this.useEventStream();
}
try {
if (this.is_sse) {
return await this.useEventStream();
}
const result = await getPresenceFromUrl(this.presence_url);
const [presence, user, data] = result;
this.last_data = data;
@@ -581,7 +598,7 @@ export class ZncProxyDiscordPresence extends Loop {
}
} catch (err) {
if (err instanceof ErrorResponse) {
if (err.response.headers.get('Content-Type')?.match(/^text\/event-stream(;|$)/)) {
if (!this.is_sse && err.response.headers.get('Content-Type')?.match(/^text\/event-stream(;|$)/)) {
this.is_sse = true;
debug('Presence URL responded with an event stream');
return LoopResult.OK_SKIP_INTERVAL;
@@ -614,9 +631,7 @@ export class ZncProxyDiscordPresence extends Loop {
if (this.events) this.events.close();
const events = new EventSource(this.eventstream_url ?? this.presence_url, {
headers: {
'User-Agent': getUserAgent(),
},
useragent: getUserAgent(),
});
this.events = events;
@@ -624,11 +639,10 @@ export class ZncProxyDiscordPresence extends Loop {
let timeout: NodeJS.Timeout;
let timeout_interval = 90000;
const ontimeout = () => {
const err = new Error('Timeout') as any;
err.type = 'error';
err[TemporaryErrorSymbol] = true;
Object.defineProperty(err, 'detail', {enumerable: false, value: err});
events.dispatchEvent(err);
const event = new ErrorEvent(new errors.RequestAbortedError('Timeout'));
// @ts-expect-error
event[TemporaryErrorSymbol] = true;
events.dispatchEvent(event);
};
events.onopen = event => {
@@ -638,11 +652,10 @@ export class ZncProxyDiscordPresence extends Loop {
let user: CurrentUser | Friend | undefined = undefined;
let presence: Presence | null = null;
let supported_events: readonly string[] = ['friend'];
this.last_data = {};
const onmessage = (event: MessageEvent) => {
events.onAnyMessage = (event: MessageEvent) => {
clearTimeout(timeout);
timeout = setTimeout(ontimeout, timeout_interval);
@@ -660,15 +673,6 @@ export class ZncProxyDiscordPresence extends Loop {
e !== 'message' && e !== 'update' &&
e !== 'supported_events');
debugEventStream('Received supported events message', new_supported_events);
for (const type of supported_events) {
events.removeEventListener(type, onmessage);
}
for (const type of new_supported_events) {
events.addEventListener(type, onmessage);
}
supported_events = new_supported_events;
return;
}
@@ -691,11 +695,6 @@ export class ZncProxyDiscordPresence extends Loop {
}
};
events.onmessage = onmessage;
events.addEventListener('supported_events', onmessage);
events.addEventListener('update', onmessage);
events.addEventListener('friend', onmessage);
return new Promise<void>((rs, rj) => {
this.timeout_resolve = () => {
debugEventStream('Update interval cancelled, closing event stream');
@@ -703,19 +702,14 @@ export class ZncProxyDiscordPresence extends Loop {
rs();
};
events.onerror = event => {
events.onerror = (event: ErrorEvent | MessageEvent) => {
debugEventStream('EventSource error', event);
events.close();
if (event instanceof Error) {
rj(event);
} else if ((event as any).message) {
const err = new Error((event as any).message);
Object.assign(err, event);
rj(err);
if (event instanceof MessageEvent) {
rj(new ErrorResponse('Received error in event stream', events.response!, event.data));
} else {
// No error message
rs();
rj(event.error);
}
};
});
@@ -759,7 +753,7 @@ export class ZncProxyDiscordPresence extends Loop {
}
}
async handleError(err: ErrorResponse<CoralErrorResponse> | NodeJS.ErrnoException): Promise<LoopResult> {
async handleError(err: ErrorResponse<CoralError> | NodeJS.ErrnoException): Promise<LoopResult> {
this.discord.onError(err);
return handleError(err, this);

View File

@@ -1,8 +1,7 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import fetch from 'node-fetch';
import mkdirp from 'mkdirp';
import { fetch } from 'undici';
import { ErrorResponse, ResponseSymbol } from '../api/util.js';
import createDebug from '../util/debug.js';
import { timeoutSignal } from '../util/misc.js';
@@ -29,7 +28,7 @@ const default_config: NxapiRemoteConfig = {
};
async function loadRemoteConfig() {
await mkdirp(paths.cache);
await fs.mkdir(paths.cache, {recursive: true});
const config_cache_path = path.resolve(paths.cache, 'config.json');
const url = process.env.NXAPI_CONFIG_URL ?? CONFIG_URL;
@@ -124,7 +123,7 @@ async function loadRemoteConfig() {
version,
revision: git?.revision ?? null,
url: response.url,
headers: response.headers.raw(),
headers: Object.fromEntries(response.headers.entries()),
data: config,
};
@@ -241,7 +240,7 @@ export interface RemoteConfigCacheData {
version: string;
revision: string | null;
url: string;
headers: Record<string, string[]>;
headers: Record<string, string> | Record<string, string[]>;
data: NxapiRemoteConfig;
}

View File

@@ -2,7 +2,7 @@ import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import * as crypto from 'node:crypto';
import { Buffer } from 'node:buffer';
import fetch from 'node-fetch';
import { fetch } from 'undici';
import SplatNet2Api, { ShareColour } from '../../api/splatnet2.js';
import { Challenge, NicknameAndIcon, Records, Stages } from '../../api/splatnet2-types.js';
import createDebug from '../../util/debug.js';

View File

@@ -1,7 +1,7 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
import fetch from 'node-fetch';
import { fetch } from 'undici';
import SplatNet2Api from '../../api/splatnet2.js';
import { NicknameAndIcon } from '../../api/splatnet2-types.js';
import createDebug from '../../util/debug.js';

View File

@@ -1,13 +1,11 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import persist from 'node-persist';
import mkdirp from 'mkdirp';
import SplatNet2Api from '../../api/splatnet2.js';
import SplatNet2Api, { SplatNet2ErrorResponse } from '../../api/splatnet2.js';
import { renewIksmToken } from '../auth/splatnet2.js';
import { Records, Stages, WebServiceError } from '../../api/splatnet2-types.js';
import { Records, Stages } from '../../api/splatnet2-types.js';
import { dumpCoopResults, dumpResults } from './dump-results.js';
import { dumpProfileImage, dumpRecords } from './dump-records.js';
import { ErrorResponse } from '../../api/util.js';
import createDebug from '../../util/debug.js';
import Loop, { LoopResult } from '../../util/loop.js';
@@ -42,7 +40,7 @@ export class SplatNet2RecordsMonitor extends Loop {
}
async init() {
await mkdirp(this.directory);
await fs.mkdir(this.directory, {recursive: true});
}
async hasChanged(records: Records) {
@@ -90,8 +88,8 @@ export class SplatNet2RecordsMonitor extends Loop {
}
}
async handleError(err: Error | ErrorResponse<WebServiceError>): Promise<LoopResult> {
if ('response' in err && err.data?.code === 'AUTHENTICATION_ERROR') {
async handleError(err: Error): Promise<LoopResult> {
if (err instanceof SplatNet2ErrorResponse && err.data?.code === 'AUTHENTICATION_ERROR') {
// Token expired
debug('Renewing iksm_session cookie');

View File

@@ -1,7 +1,6 @@
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import fetch from 'node-fetch';
import mkdirp from 'mkdirp';
import { fetch } from 'undici';
import createDebug from '../util/debug.js';
import { dir, docker, version } from '../util/product.js';
import { paths } from '../util/storage.js';
@@ -26,7 +25,7 @@ export async function checkUpdates() {
}
} catch (err) {}
await mkdirp(paths.cache);
await fs.mkdir(paths.cache, {recursive: true});
const update_cache_path = path.resolve(paths.cache, 'update.json');
try {

View File

@@ -1,9 +1,10 @@
import * as crypto from 'node:crypto';
import * as persist from 'node-persist';
import { Response } from 'undici';
import createDebug from '../util/debug.js';
import CoralApi, { Result } from '../api/coral.js';
import CoralApi, { CoralApiInterface, Result } from '../api/coral.js';
import ZncProxyApi from '../api/znc-proxy.js';
import { Announcements, Friends, Friend, GetActiveEventResult, CoralSuccessResponse, WebService, WebServices } from '../api/coral-types.js';
import { Announcements, Friends, Friend, GetActiveEventResult, CoralSuccessResponse, WebService, WebServices, CoralError } from '../api/coral-types.js';
import { getToken, SavedToken } from './auth/coral.js';
import type { Store } from '../app/main/index.js';
import { NintendoAccountUser } from '../api/na.js';
@@ -52,7 +53,8 @@ export default class Users<T extends UserData> {
}
static coral(store: Store | persist.LocalStorage, znc_proxy_url: string, ratelimit?: boolean): Users<CoralUser<ZncProxyApi>>
static coral(store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users<CoralUser>
static coral(store: Store | persist.LocalStorage, znc_proxy_url?: undefined, ratelimit?: boolean): Users<CoralUser<CoralApi>>
static coral(store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean): Users<CoralUser<CoralApiInterface>>
static coral(_store: Store | persist.LocalStorage, znc_proxy_url?: string, ratelimit?: boolean) {
const store = 'storage' in _store ? _store : null;
const storage = 'storage' in _store ? _store.storage : _store;
@@ -71,6 +73,16 @@ export default class Users<T extends UserData> {
const user = new CoralUser(nso, data, announcements, friends, webservices, active_event);
if (nso instanceof CoralApi && nso.onTokenExpired) {
const renewToken = nso.onTokenExpired;
nso.onTokenExpired = async (error?: CoralError, response?: Response) => {
const auth_data = await renewToken(error, response) as SavedToken;
user.data = auth_data;
return auth_data;
};
}
if (store) {
await maybeUpdateWebServicesListCache(cached_webservices, store, data.user, webservices);
user.onUpdatedWebServices = webservices => {
@@ -83,7 +95,7 @@ export default class Users<T extends UserData> {
}
}
export interface CoralUserData<T extends CoralApi = CoralApi> extends UserData {
export interface CoralUserData<T extends CoralApiInterface = CoralApi> extends UserData {
nso: T;
data: SavedToken;
announcements: CoralSuccessResponse<Announcements>;
@@ -92,11 +104,12 @@ export interface CoralUserData<T extends CoralApi = CoralApi> extends UserData {
active_event: CoralSuccessResponse<GetActiveEventResult>;
}
export class CoralUser<T extends CoralApi = CoralApi> implements CoralUserData<T> {
export class CoralUser<T extends CoralApiInterface = CoralApi> implements CoralUserData<T> {
created_at = Date.now();
expires_at = Infinity;
promise = new Map<string, Promise<void>>();
delay_retry_after_error_until: number | null = null;
updated = {
announcements: Date.now(),
@@ -104,6 +117,8 @@ export class CoralUser<T extends CoralApi = CoralApi> implements CoralUserData<T
webservices: Date.now(),
active_event: Date.now(),
};
delay_retry_after_error = 5 * 1000; // 5 seconds
update_interval = 10 * 1000; // 10 seconds
update_interval_announcements = 30 * 60 * 1000; // 30 minutes
@@ -120,10 +135,16 @@ export class CoralUser<T extends CoralApi = CoralApi> implements CoralUserData<T
private async update(key: keyof CoralUser['updated'], callback: () => Promise<void>, ttl: number) {
if ((this.updated[key] + ttl) < Date.now()) {
const promise = this.promise.get(key) ?? callback.call(null).then(() => {
const promise = this.promise.get(key) ?? Promise.resolve().then(() => {
const delay_retry = (this.delay_retry_after_error_until ?? 0) - Date.now();
return delay_retry > 0 ? new Promise(rs => setTimeout(rs, delay_retry)) : null;
}).then(() => callback.call(null)).then(() => {
this.updated[key] = Date.now();
this.delay_retry_after_error_until = null;
this.promise.delete(key);
}).catch(err => {
this.delay_retry_after_error_until = Date.now() + this.delay_retry_after_error;
this.promise.delete(key);
throw err;
});
@@ -171,6 +192,10 @@ export class CoralUser<T extends CoralApi = CoralApi> implements CoralUserData<T
}
async addFriend(nsa_id: string) {
if (!(this.nso instanceof CoralApi)) {
throw new Error('Cannot send friend requests using Coral API proxy');
}
if (nsa_id === this.data.nsoAccount.user.nsaId) {
throw new Error('Cannot add self as a friend');
}