mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-10-09 02:57:40 -05:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
110
src/common/auth/na.ts
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
361
src/common/presence-embed.ts
Normal file
361
src/common/presence-embed.ts
Normal 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');
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user