mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-03-21 18:04:10 -05:00
Improve error messages, record error history for use limits
This commit is contained in:
parent
826ee51a68
commit
3e1bd2621a
|
|
@ -9,6 +9,7 @@ import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParamete
|
|||
import { f, FResult, HashMethod } from './f.js';
|
||||
import { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountSessionAuthorisation, NintendoAccountToken, NintendoAccountUser } from './na.js';
|
||||
import { ErrorResponse, ResponseSymbol } from './util.js';
|
||||
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../util/errors.js';
|
||||
|
||||
const debug = createDebug('nxapi:api:coral');
|
||||
|
||||
|
|
@ -474,10 +475,21 @@ export default class CoralApi implements CoralApiInterface {
|
|||
}
|
||||
}
|
||||
|
||||
export class CoralErrorResponse extends ErrorResponse<CoralError> {
|
||||
export class CoralErrorResponse extends ErrorResponse<CoralError> implements HasErrorDescription {
|
||||
get status(): CoralStatus | null {
|
||||
return this.data?.status ?? null;
|
||||
}
|
||||
|
||||
get [ErrorDescriptionSymbol]() {
|
||||
if (this.status === CoralStatus.NSA_NOT_LINKED) {
|
||||
return new ErrorDescription('coral.nsa_not_linked', 'Your Nintendo Account is not linked to a Network Service Account (Nintendo Switch user).\n\nMake sure you are using the Nintendo Account linked to your Nintendo Switch console.');
|
||||
}
|
||||
if (this.status === CoralStatus.UPGRADE_REQUIRED) {
|
||||
return new ErrorDescription('coral.upgrade_required', 'The Coral (Nintendo Switch Online app) version used by nxapi is no longer supported by the Coral API.\n\nTry restarting nxapi and make sure nxapi is up to date.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const na_client_settings = {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { defineResponse, ErrorResponse, HasResponse } from './util.js';
|
|||
import createDebug from '../util/debug.js';
|
||||
import { JwtPayload } from '../util/jwt.js';
|
||||
import { timeoutSignal } from '../util/misc.js';
|
||||
import { ErrorDescription, ErrorDescriptionSymbol, HasErrorDescription } from '../util/errors.js';
|
||||
|
||||
const debug = createDebug('nxapi:api:na');
|
||||
|
||||
|
|
@ -408,6 +409,14 @@ export interface NintendoAccountError {
|
|||
type: string;
|
||||
}
|
||||
|
||||
export class NintendoAccountAuthErrorResponse extends ErrorResponse<NintendoAccountAuthError> {}
|
||||
export class NintendoAccountAuthErrorResponse extends ErrorResponse<NintendoAccountAuthError> implements HasErrorDescription {
|
||||
get [ErrorDescriptionSymbol]() {
|
||||
if (this.data?.error === 'invalid_grant') {
|
||||
return new ErrorDescription('na.invalid_grant', 'Your Nintendo Account session token has expired or was revoked.\n\nYou need to sign in again.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class NintendoAccountErrorResponse extends ErrorResponse<NintendoAccountError> {}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { app, BrowserWindow, dialog, ipcMain, LoginItemSettingsOptions } from './electron.js';
|
||||
import { app, BrowserWindow, ipcMain, LoginItemSettingsOptions } from './electron.js';
|
||||
import process from 'node:process';
|
||||
import * as path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
|
@ -8,7 +8,7 @@ import { handleOpenWebServiceUri } from './webservices.js';
|
|||
import { EmbeddedPresenceMonitor, PresenceMonitorManager } from './monitor.js';
|
||||
import { createModalWindow, createWindow } from './windows.js';
|
||||
import { setupIpc } from './ipc.js';
|
||||
import { askUserForUri } from './util.js';
|
||||
import { askUserForUri, showErrorDialog } from './util.js';
|
||||
import { setAppInstance } from './app-menu.js';
|
||||
import { handleAuthUri } from './na-auth.js';
|
||||
import { DiscordPresenceConfiguration, LoginItem, LoginItemOptions, WindowType } from '../common/types.js';
|
||||
|
|
@ -20,6 +20,7 @@ import createDebug from '../../util/debug.js';
|
|||
import { dev, dir, git, release, version } from '../../util/product.js';
|
||||
import { addUserAgent } from '../../util/useragent.js';
|
||||
import { initStorage, paths } from '../../util/storage.js';
|
||||
import { CoralApiInterface } from '../../api/coral.js';
|
||||
|
||||
const debug = createDebug('app:main');
|
||||
|
||||
|
|
@ -271,7 +272,7 @@ interface SavedMonitorState {
|
|||
}
|
||||
|
||||
export class Store extends EventEmitter {
|
||||
readonly users: Users<CoralUser>;
|
||||
readonly users: Users<CoralUser<CoralApiInterface>>;
|
||||
|
||||
constructor(
|
||||
readonly app: App,
|
||||
|
|
@ -423,10 +424,9 @@ export class Store extends EventEmitter {
|
|||
} catch (err) {
|
||||
debug('Error restoring monitor for user %s', user.id, err);
|
||||
|
||||
const {response} = await dialog.showMessageBox({
|
||||
const {response} = await showErrorDialog({
|
||||
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for user ' + user.id,
|
||||
detail: err instanceof Error ? err.stack ?? err.message : err as any,
|
||||
type: 'error',
|
||||
error: err,
|
||||
buttons: ['OK', 'Retry'],
|
||||
defaultId: 1,
|
||||
});
|
||||
|
|
@ -452,10 +452,10 @@ export class Store extends EventEmitter {
|
|||
} catch (err) {
|
||||
debug('Error restoring monitor for presence URL %s', state.discord_presence.source.url, err);
|
||||
|
||||
const {response} = await dialog.showMessageBox({
|
||||
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for presence URL ' + state.discord_presence.source.url,
|
||||
detail: err instanceof Error ? err.stack ?? err.message : err as any,
|
||||
type: 'error',
|
||||
const {response} = await showErrorDialog({
|
||||
message: (err instanceof Error ? err.name : 'Error') + ' restoring monitor for presence URL ' +
|
||||
state.discord_presence.source.url,
|
||||
error: err,
|
||||
buttons: ['OK', 'Retry'],
|
||||
defaultId: 1,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { defaultTitle } from '../../discord/titles.js';
|
|||
import type { FriendProps } from '../browser/friend/index.js';
|
||||
import type { DiscordSetupProps } from '../browser/discord/index.js';
|
||||
import type { AddFriendProps } from '../browser/add-friend/index.js';
|
||||
import { MembershipRequiredError } from '../../common/auth/util.js';
|
||||
|
||||
const debug = createDebug('app:main:ipc');
|
||||
|
||||
|
|
@ -59,7 +60,7 @@ export function setupIpc(appinstance: App, ipcMain: IpcMain) {
|
|||
ipcMain.handle('nxapi:coral:webservices', (e, token: string) => store.users.get(token).then(u => u.getWebServices()));
|
||||
ipcMain.handle('nxapi:coral:openwebservice', (e, webservice: WebService, token: string, qs?: string) =>
|
||||
store.users.get(token).then(u => openWebService(store, token, u.nso, u.data, webservice, qs)
|
||||
.catch(err => err instanceof WebServiceValidationError ?
|
||||
.catch(err => err instanceof WebServiceValidationError || err instanceof MembershipRequiredError ?
|
||||
handleOpenWebServiceError(err, webservice, qs, u.data, BrowserWindow.fromWebContents(e.sender)!) :
|
||||
null)));
|
||||
ipcMain.handle('nxapi:coral:activeevent', (e, token: string) => store.users.get(token).then(u => u.getActiveEvent()));
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { SavedMoonToken } from '../../common/auth/moon.js';
|
|||
import { CachedWebServicesList } from '../../common/users.js';
|
||||
import createDebug from '../../util/debug.js';
|
||||
import { dev, dir } from '../../util/product.js';
|
||||
import { MembershipRequiredError } from '../../common/auth/util.js';
|
||||
|
||||
const debug = createDebug('app:main:menu');
|
||||
|
||||
|
|
@ -167,7 +168,7 @@ export default class MenuApp {
|
|||
try {
|
||||
await openWebService(this.app.store, token, coral, data, webservice);
|
||||
} catch (err) {
|
||||
if (!(err instanceof WebServiceValidationError)) return;
|
||||
if (!(err instanceof WebServiceValidationError) && !(err instanceof MembershipRequiredError)) return;
|
||||
|
||||
handleOpenWebServiceError(err, webservice, undefined, data);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { dialog, Notification } from './electron.js';
|
||||
import { Notification } from './electron.js';
|
||||
import { App } from './index.js';
|
||||
import { tryGetNativeImageFromUrl } from './util.js';
|
||||
import { showErrorDialog, tryGetNativeImageFromUrl } from './util.js';
|
||||
import { DiscordPresenceConfiguration, DiscordPresenceExternalMonitorsConfiguration, DiscordPresenceSource } from '../common/types.js';
|
||||
import { CurrentUser, Friend, Game, CoralError } from '../../api/coral-types.js';
|
||||
import { ErrorResponse } from '../../api/util.js';
|
||||
|
|
@ -51,10 +51,9 @@ export class PresenceMonitorManager {
|
|||
this.app.store.emit('update-discord-user', client?.user ?? null);
|
||||
};
|
||||
i.discord.onMonitorError = async (monitor, instance, err) => {
|
||||
const {response} = await dialog.showMessageBox({
|
||||
const {response} = await showErrorDialog({
|
||||
message: err.name + ' in external monitor ' + monitor.name,
|
||||
detail: err.stack ?? err.message,
|
||||
type: 'error',
|
||||
error: err,
|
||||
buttons: ['OK', 'Retry', 'Stop'],
|
||||
defaultId: 0,
|
||||
});
|
||||
|
|
@ -348,10 +347,9 @@ export class PresenceMonitorManager {
|
|||
monitor: EmbeddedPresenceMonitor | EmbeddedProxyPresenceMonitor,
|
||||
err: ErrorResponse<CoralError> | NodeJS.ErrnoException
|
||||
): Promise<LoopResult> {
|
||||
const {response} = await dialog.showMessageBox({
|
||||
const {response} = await showErrorDialog({
|
||||
message: err.name + ' updating presence monitor',
|
||||
detail: err.stack ?? err.message,
|
||||
type: 'error',
|
||||
error: err,
|
||||
buttons: ['OK', 'Retry'],
|
||||
defaultId: 0,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,15 +5,15 @@ import { protocol_registration_options } from './index.js';
|
|||
import { createModalWindow } from './windows.js';
|
||||
import { tryGetNativeImageFromUrl } from './util.js';
|
||||
import { WindowType } from '../common/types.js';
|
||||
import { NintendoAccountAuthError, NintendoAccountAuthErrorResponse, NintendoAccountSessionAuthorisation, NintendoAccountSessionAuthorisationError, NintendoAccountSessionToken } from '../../api/na.js';
|
||||
import { NintendoAccountAuthErrorResponse, NintendoAccountSessionAuthorisation, NintendoAccountSessionAuthorisationError, NintendoAccountSessionToken } from '../../api/na.js';
|
||||
import { NintendoAccountSessionAuthorisationCoral } from '../../api/coral.js';
|
||||
import { NintendoAccountSessionAuthorisationMoon } from '../../api/moon.js';
|
||||
import { ErrorResponse } from '../../api/util.js';
|
||||
import { getToken } from '../../common/auth/coral.js';
|
||||
import { getPctlToken } from '../../common/auth/moon.js';
|
||||
import createDebug from '../../util/debug.js';
|
||||
import { Jwt } from '../../util/jwt.js';
|
||||
import { ZNCA_API_USE_URL } from '../../common/constants.js';
|
||||
import { InvalidNintendoAccountTokenError } from '../../common/auth/na.js';
|
||||
|
||||
const debug = createDebug('app:main:na-auth');
|
||||
|
||||
|
|
@ -329,7 +329,10 @@ export async function addNsoAccount(storage: persist.LocalStorage, use_in_app_br
|
|||
|
||||
return {nso, data};
|
||||
} catch (err) {
|
||||
if (err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant') {
|
||||
if (
|
||||
(err instanceof InvalidNintendoAccountTokenError) ||
|
||||
(err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant')
|
||||
) {
|
||||
// The session token has expired/was revoked
|
||||
return authenticateCoralSessionToken(storage, authenticator, code, true);
|
||||
}
|
||||
|
|
@ -490,7 +493,10 @@ export async function addPctlAccount(storage: persist.LocalStorage, use_in_app_b
|
|||
|
||||
return {moon, data};
|
||||
} catch (err) {
|
||||
if (err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant') {
|
||||
if (
|
||||
(err instanceof InvalidNintendoAccountTokenError) ||
|
||||
(err instanceof NintendoAccountAuthErrorResponse && err.data?.error === 'invalid_grant')
|
||||
) {
|
||||
// The session token has expired/was revoked
|
||||
return authenticateMoonSessionToken(storage, authenticator, code, true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { BrowserWindow, Menu, MenuItem, nativeImage } from './electron.js';
|
||||
import { BrowserWindow, dialog, Menu, MenuItem, MessageBoxOptions, nativeImage } from './electron.js';
|
||||
import path from 'node:path';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import fetch from 'node-fetch';
|
||||
import { dir } from '../../util/product.js';
|
||||
import { Store } from './index.js';
|
||||
import { App, Store } from './index.js';
|
||||
import { SavedToken } from '../../common/auth/coral.js';
|
||||
import { ErrorDescription } from '../../util/errors.js';
|
||||
|
||||
export const bundlepath = path.resolve(dir, 'dist', 'app', 'bundle');
|
||||
|
||||
|
|
@ -65,3 +66,24 @@ export async function askUserForUri(store: Store, uri: string, prompt: string):
|
|||
|
||||
return selected_user;
|
||||
}
|
||||
|
||||
interface ErrorBoxOptions extends MessageBoxOptions {
|
||||
error: Error | unknown;
|
||||
app?: App;
|
||||
window?: BrowserWindow;
|
||||
}
|
||||
|
||||
export function showErrorDialog(options: ErrorBoxOptions) {
|
||||
const {error, app, window, ...message_box_options} = options;
|
||||
const detail = ErrorDescription.getErrorDescription(error);
|
||||
|
||||
message_box_options.detail = message_box_options.detail ?
|
||||
detail + '\n\n' + message_box_options.detail :
|
||||
detail;
|
||||
|
||||
if (!message_box_options.type) message_box_options.type = 'error';
|
||||
|
||||
return window ?
|
||||
dialog.showMessageBox(window, message_box_options) :
|
||||
dialog.showMessageBox(message_box_options);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ import * as util from 'node:util';
|
|||
import fetch from 'node-fetch';
|
||||
import { Store } from './index.js';
|
||||
import { createWebServiceWindow } from './windows.js';
|
||||
import { askUserForUri } from './util.js';
|
||||
import { askUserForUri, showErrorDialog } from './util.js';
|
||||
import type { DownloadImagesRequest, NativeShareRequest, NativeShareUrlRequest, QrCodeReaderCameraOptions, QrCodeReaderCheckinOptions, QrCodeReaderCheckinResult, QrCodeReaderPhotoLibraryOptions, SendMessageOptions } from '../preload-webservice/znca-js-api.js';
|
||||
import createDebug from '../../util/debug.js';
|
||||
import { CoralApiInterface, CoralAuthData } from '../../api/coral.js';
|
||||
import { CurrentUser, WebService, WebServiceToken } from '../../api/coral-types.js';
|
||||
import { NintendoAccountUser } from '../../api/na.js';
|
||||
import { WebService, WebServiceToken } from '../../api/coral-types.js';
|
||||
import { SavedToken } from '../../common/auth/coral.js';
|
||||
import { checkMembershipActive } from '../../common/auth/util.js';
|
||||
|
||||
const debug = createDebug('app:main:webservices');
|
||||
|
||||
|
|
@ -41,12 +41,7 @@ export default async function openWebService(
|
|||
}
|
||||
|
||||
const verifymembership = webservice.customAttributes.find(a => a.attrKey === 'verifyMembership');
|
||||
|
||||
if (verifymembership?.attrValue === 'true') {
|
||||
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
|
||||
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
|
||||
if (!active) throw new WebServiceValidationError('Nintendo Switch Online membership required');
|
||||
}
|
||||
if (verifymembership?.attrValue === 'true') checkMembershipActive(data);
|
||||
|
||||
const user_title_prefix = '[' + data.user.nickname +
|
||||
(data.nsoAccount.user.name !== data.user.nickname ? '/' + data.nsoAccount.user.name : '') + '] ';
|
||||
|
|
@ -202,19 +197,13 @@ export async function handleOpenWebServiceError(
|
|||
user_coral_id: auth_data?.nsoAccount.user.id,
|
||||
};
|
||||
|
||||
const options: MessageBoxOptions = {
|
||||
type: 'error',
|
||||
return showErrorDialog({
|
||||
message: (err instanceof Error ? err.name : 'Error') + ' opening web service',
|
||||
detail: (err instanceof Error ? err.stack ?? err.message : err) +
|
||||
'\n\n' + util.inspect(data, {compact: true}),
|
||||
error: err,
|
||||
detail: util.inspect(data, {compact: true}),
|
||||
buttons,
|
||||
};
|
||||
|
||||
const result = window ?
|
||||
await dialog.showMessageBox(window, options) :
|
||||
await dialog.showMessageBox(options);
|
||||
|
||||
return result;
|
||||
window,
|
||||
});
|
||||
}
|
||||
|
||||
export interface WebServiceData {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import createDebug from '../../util/debug.js';
|
|||
import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js';
|
||||
import { initStorage } from '../../util/storage.js';
|
||||
import { getToken, Login } from '../../common/auth/coral.js';
|
||||
import { checkMembershipActive } from '../../common/auth/util.js';
|
||||
|
||||
const debug = createDebug('cli:nso:webservicetoken');
|
||||
|
||||
|
|
@ -54,12 +55,7 @@ export async function handler(argv: ArgumentsCamelCase<Arguments>) {
|
|||
}
|
||||
|
||||
const verifymembership = webservice.customAttributes.find(a => a.attrKey === 'verifyMembership');
|
||||
|
||||
if (verifymembership?.attrValue === 'true') {
|
||||
const membership = data.nsoAccount.user.links.nintendoAccount.membership;
|
||||
const active = typeof membership.active === 'object' ? membership.active.active : membership.active;
|
||||
if (!active) throw new Error('Nintendo Switch Online membership required');
|
||||
}
|
||||
if (verifymembership?.attrValue === 'true') checkMembershipActive(data);
|
||||
|
||||
const webserviceToken = await nso.getWebServiceToken(webservice.id);
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { EventStreamResponse, HttpServer, ResponseError } from './util/http-serv
|
|||
import { ArgumentsCamelCase, Argv, YargsArguments } from '../util/yargs.js';
|
||||
import { getTitleIdFromEcUrl } from '../util/misc.js';
|
||||
import { getSettingForCoopRule, getSettingForVsMode } from '../discord/monitor/splatoon3.js';
|
||||
import { CoralApiInterface } from '../api/coral.js';
|
||||
|
||||
const debug = createDebug('cli:presence-server');
|
||||
const debugSplatnet3Proxy = createDebug('cli:presence-server:splatnet3-proxy');
|
||||
|
|
@ -524,7 +525,7 @@ class Server extends HttpServer {
|
|||
|
||||
constructor(
|
||||
readonly storage: persist.LocalStorage,
|
||||
readonly coral_users: Users<CoralUser>,
|
||||
readonly coral_users: Users<CoralUser<CoralApiInterface>>,
|
||||
readonly splatnet3_users: Users<SplatNet3User> | null,
|
||||
readonly user_ids: string[],
|
||||
image_proxy_path?: {baas?: string; atum?: string; splatnet3?: string;},
|
||||
|
|
@ -736,7 +737,7 @@ class Server extends HttpServer {
|
|||
|
||||
const include_splatnet3 = this.splatnet3_users && req.query['include-splatoon3'] === '1';
|
||||
|
||||
let match: [CoralUser, Friend, string] | null = null;
|
||||
let match: [CoralUser<CoralApiInterface>, Friend, string] | null = null;
|
||||
|
||||
for (const user_naid of this.user_ids) {
|
||||
const token = await this.storage.getItem('NintendoAccountToken.' + user_naid);
|
||||
|
|
|
|||
|
|
@ -66,29 +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');
|
||||
|
||||
const {nso, data} = proxy_url ?
|
||||
await ZncProxyApi.createWithSessionToken(proxy_url, token) :
|
||||
await createWithSessionToken(storage, token, ratelimit);
|
||||
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),
|
||||
};
|
||||
|
||||
if (nso instanceof CoralApi) {
|
||||
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);
|
||||
|
||||
existingToken[Login] = true;
|
||||
|
||||
return {nso, data: existingToken};
|
||||
} catch (err) {
|
||||
await attempt.recordError(err);
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
await storage.setItem('NsoToken.' + token, existingToken);
|
||||
await storage.setItem('NintendoAccountToken.' + data.user.id, token);
|
||||
|
||||
existingToken[Login] = true;
|
||||
|
||||
return {nso, data: existingToken};
|
||||
}
|
||||
|
||||
debug('Using existing token');
|
||||
|
|
@ -130,23 +136,30 @@ async function renewToken(
|
|||
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>(na_session_token);
|
||||
await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
|
||||
attempt = await checkUseLimit(storage, 'coral', jwt.payload.sub, ratelimit);
|
||||
}
|
||||
|
||||
const na_token = await getNaToken(storage, na_session_token, ZNCA_CLIENT_ID, ratelimit);
|
||||
try {
|
||||
const na_token = await getNaToken(storage, na_session_token, ZNCA_CLIENT_ID, ratelimit);
|
||||
|
||||
debug('Reauthenticating to coral');
|
||||
debug('Reauthenticating to coral');
|
||||
|
||||
const data = await nso.renewTokenWithNintendoAccountToken(na_token.token, renew_token_data.existingToken.user);
|
||||
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),
|
||||
};
|
||||
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;
|
||||
await storage.setItem('NsoToken.' + na_session_token, existingToken);
|
||||
renew_token_data.existingToken = existingToken;
|
||||
} catch (err) {
|
||||
await attempt?.recordError(err);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,24 +43,31 @@ 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');
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as persist from 'node-persist';
|
||||
import { getNintendoAccountToken, NintendoAccountSessionTokenJwtPayload, NintendoAccountToken } from '../../api/na.js';
|
||||
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';
|
||||
|
||||
|
|
@ -14,22 +15,27 @@ export interface SavedNintendoAccountToken {
|
|||
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 Error('Invalid Nintendo Account session token issuer');
|
||||
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token issuer');
|
||||
}
|
||||
if (jwt.payload.typ !== 'session_token') {
|
||||
throw new Error('Invalid Nintendo Account session token type');
|
||||
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token type');
|
||||
}
|
||||
if (jwt.payload.aud !== client_id) {
|
||||
throw new Error('Invalid Nintendo Account session token audience');
|
||||
throw new InvalidNintendoAccountTokenError('Invalid Nintendo Account session token audience');
|
||||
}
|
||||
if (jwt.payload.exp <= (Date.now() / 1000)) {
|
||||
throw new Error('Nintendo Account session token expired');
|
||||
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
|
||||
|
|
@ -38,24 +44,67 @@ export async function getNaToken(
|
|||
await storage.getItem('NaToken.' + na_session_token);
|
||||
|
||||
if (!existingToken || existingToken.expires_at <= Date.now()) {
|
||||
await checkUseLimit(storage, 'na', jwt.payload.sub, ratelimit, [LIMIT_REQUESTS, LIMIT_PERIOD]);
|
||||
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');
|
||||
|
||||
const token = await getNintendoAccountToken(na_session_token, client_id);
|
||||
try {
|
||||
const token = await getNintendoAccountToken(na_session_token, client_id);
|
||||
|
||||
const existingToken: SavedNintendoAccountToken = {
|
||||
token,
|
||||
expires_at: Date.now() + (token.expires_in * 1000),
|
||||
};
|
||||
const existingToken: SavedNintendoAccountToken = {
|
||||
token,
|
||||
expires_at: Date.now() + (token.expires_in * 1000),
|
||||
};
|
||||
|
||||
await storage.setItem('NaToken.' + na_session_token, existingToken);
|
||||
await storage.setItem('NaToken.' + na_session_token, existingToken);
|
||||
|
||||
return 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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Response } from 'node-fetch';
|
|||
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');
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import persist from 'node-persist';
|
|||
import { Response } from 'node-fetch';
|
||||
import { getToken, Login, SavedToken } from './coral.js';
|
||||
import SplatNet3Api, { SplatNet3AuthData, SplatNet3AuthErrorCode, SplatNet3AuthErrorResponse } from '../../api/splatnet3.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';
|
||||
|
|
@ -30,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]) {
|
||||
|
|
@ -47,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');
|
||||
|
|
|
|||
|
|
@ -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,114 @@ 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(),
|
||||
) {}
|
||||
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import * as util from 'node:util';
|
||||
import { AbortError } from 'node-fetch';
|
||||
import createDebug from './debug.js';
|
||||
import Loop, { LoopResult } from './loop.js';
|
||||
|
|
@ -6,6 +7,43 @@ import { ErrorResponse } from '../api/util.js';
|
|||
|
||||
const debug = createDebug('nxapi:util:errors');
|
||||
|
||||
export class ErrorDescription {
|
||||
constructor(
|
||||
readonly type: string,
|
||||
readonly message: string,
|
||||
) {}
|
||||
|
||||
static getErrorDescription(err: Error | unknown) {
|
||||
if (err instanceof HasErrorDescription) {
|
||||
const description = err[ErrorDescriptionSymbol];
|
||||
|
||||
if (description) {
|
||||
return description.message +
|
||||
(err instanceof Error ? '\n\n--\n\n' + (err.stack ?? err.message) : '');
|
||||
}
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
return err.stack || err.message;
|
||||
}
|
||||
|
||||
return util.inspect(err, {compact: true});
|
||||
}
|
||||
}
|
||||
|
||||
export const ErrorDescriptionSymbol = Symbol('ErrorDescription');
|
||||
|
||||
export abstract class HasErrorDescription {
|
||||
abstract get [ErrorDescriptionSymbol](): ErrorDescription | null;
|
||||
}
|
||||
|
||||
Object.defineProperty(HasErrorDescription, Symbol.hasInstance, {
|
||||
configurable: true,
|
||||
value: (instance: HasErrorDescription) => {
|
||||
return instance && ErrorDescriptionSymbol in instance;
|
||||
},
|
||||
});
|
||||
|
||||
export const temporary_system_errors = {
|
||||
'ETIMEDOUT': 'request timed out',
|
||||
'ENOTFOUND': null,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user