mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-07-14 07:10:55 -05:00
300 lines
9.9 KiB
TypeScript
300 lines
9.9 KiB
TypeScript
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import * as child_process from 'child_process';
|
|
import * as yargs from 'yargs';
|
|
import type * as yargstypes from '../node_modules/@types/yargs/index.js';
|
|
import createDebug from 'debug';
|
|
import persist from 'node-persist';
|
|
import getPaths from 'env-paths';
|
|
import fetch from 'node-fetch';
|
|
import { FlapgApiResponse } from './api/f.js';
|
|
import { NintendoAccountSessionTokenJwtPayload, NintendoAccountToken, NintendoAccountUser } from './api/na.js';
|
|
import { AccountLogin } from './api/znc-types.js';
|
|
import ZncApi, { ZNCA_CLIENT_ID } from './api/znc.js';
|
|
import ZncProxyApi from './api/znc-proxy.js';
|
|
import MoonApi, { ZNMA_CLIENT_ID } from './api/moon.js';
|
|
import { Jwks, Jwt } from './api/util.js';
|
|
|
|
const debug = createDebug('cli');
|
|
|
|
export const paths = getPaths('nxapi');
|
|
|
|
export const dir = path.resolve(import.meta.url.substr(7), '..', '..');
|
|
export const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
|
|
export const version = pkg.version;
|
|
export const dev = (() => {
|
|
try {
|
|
fs.statSync(path.join(dir, '.git'));
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
|
|
const revision = child_process.execSync('git rev-parse HEAD').toString().trim();
|
|
const branch = child_process.execSync('git rev-parse --abbrev-ref HEAD').toString().trim();
|
|
const changed_files = child_process.execSync('git diff --name-only HEAD').toString().trim();
|
|
|
|
return {
|
|
revision,
|
|
branch: branch && branch !== 'HEAD' ? branch : null,
|
|
changed_files: changed_files.length ? changed_files.split('\n') : [],
|
|
};
|
|
})();
|
|
|
|
export type YargsArguments<T extends yargs.Argv> = T extends yargs.Argv<infer R> ? R : any;
|
|
export type Argv<T = {}> = yargs.Argv<T>;
|
|
export type ArgumentsCamelCase<T = {}> = yargstypes.ArgumentsCamelCase<T>;
|
|
|
|
export interface SavedToken {
|
|
uuid: string;
|
|
timestamp: string;
|
|
nintendoAccountToken: NintendoAccountToken;
|
|
user: NintendoAccountUser;
|
|
flapg: FlapgApiResponse['result'];
|
|
nsoAccount: AccountLogin;
|
|
credential: AccountLogin['webApiServerCredential'];
|
|
|
|
expires_at: number;
|
|
proxy_url?: string;
|
|
}
|
|
|
|
export interface SavedMoonToken {
|
|
nintendoAccountToken: NintendoAccountToken;
|
|
user: NintendoAccountUser;
|
|
|
|
expires_at: number;
|
|
}
|
|
|
|
export async function initStorage(dir: string) {
|
|
const storage = persist.create({
|
|
dir: path.join(dir, 'persist'),
|
|
stringify: data => JSON.stringify(data, null, 4) + '\n',
|
|
});
|
|
await storage.init();
|
|
return storage;
|
|
}
|
|
|
|
export async function getToken(storage: persist.LocalStorage, token: string, proxy_url: string): Promise<{
|
|
nso: ZncProxyApi;
|
|
data: SavedToken;
|
|
}>
|
|
export async function getToken(storage: persist.LocalStorage, token: string, proxy_url?: string): Promise<{
|
|
nso: ZncApi;
|
|
data: SavedToken;
|
|
}>
|
|
export async function getToken(storage: persist.LocalStorage, token: string, proxy_url?: string) {
|
|
if (!token) {
|
|
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi nso token`.');
|
|
throw new Error('Invalid token');
|
|
}
|
|
|
|
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
|
|
|
|
if (jwt.payload.iss !== 'https://accounts.nintendo.com') {
|
|
throw new Error('Invalid Nintendo Account session token issuer');
|
|
}
|
|
if (jwt.payload.typ !== 'session_token') {
|
|
throw new Error('Invalid Nintendo Account session token type');
|
|
}
|
|
if (jwt.payload.aud !== ZNCA_CLIENT_ID) {
|
|
throw new Error('Invalid Nintendo Account session token audience');
|
|
}
|
|
if (jwt.payload.exp <= (Date.now() / 1000)) {
|
|
throw new Error('Nintendo Account session token expired');
|
|
}
|
|
|
|
// Nintendo Account session tokens use a HMAC SHA256 signature, so we can't verify this is valid
|
|
|
|
const existingToken: SavedToken | undefined = await storage.getItem('NsoToken.' + token);
|
|
|
|
if (!existingToken || existingToken.expires_at <= Date.now()) {
|
|
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 ZncApi.createWithSessionToken(token);
|
|
|
|
const existingToken: SavedToken = {
|
|
...data,
|
|
expires_at: Date.now() + (data.credential.expiresIn * 1000),
|
|
};
|
|
|
|
await storage.setItem('NsoToken.' + token, existingToken);
|
|
await storage.setItem('NintendoAccountToken.' + data.user.id, token);
|
|
|
|
return {nso, data: existingToken};
|
|
}
|
|
|
|
debug('Using existing token');
|
|
await storage.setItem('NintendoAccountToken.' + existingToken.user.id, token);
|
|
|
|
return {
|
|
nso: proxy_url ?
|
|
new ZncProxyApi(proxy_url, token) :
|
|
new ZncApi(existingToken.credential.accessToken),
|
|
data: existingToken,
|
|
};
|
|
}
|
|
|
|
export async function getPctlToken(storage: persist.LocalStorage, token: string) {
|
|
if (!token) {
|
|
console.error('No token set. Set a Nintendo Account session token using the `--token` option or by running `nxapi pctl auth`.');
|
|
throw new Error('Invalid token');
|
|
}
|
|
|
|
const [jwt, sig] = Jwt.decode<NintendoAccountSessionTokenJwtPayload>(token);
|
|
|
|
if (jwt.payload.iss !== 'https://accounts.nintendo.com') {
|
|
throw new Error('Invalid Nintendo Account session token issuer');
|
|
}
|
|
if (jwt.payload.typ !== 'session_token') {
|
|
throw new Error('Invalid Nintendo Account session token type');
|
|
}
|
|
if (jwt.payload.aud !== ZNMA_CLIENT_ID) {
|
|
throw new Error('Invalid Nintendo Account session token audience');
|
|
}
|
|
if (jwt.payload.exp <= (Date.now() / 1000)) {
|
|
throw new Error('Nintendo Account session token expired');
|
|
}
|
|
|
|
// Nintendo Account session tokens use a HMAC SHA256 signature, so we can't verify this is valid
|
|
|
|
const existingToken: SavedMoonToken | undefined = await storage.getItem('MoonToken.' + token);
|
|
|
|
if (!existingToken || existingToken.expires_at <= Date.now()) {
|
|
console.warn('Authenticating to Nintendo Switch Parental Controls app');
|
|
debug('Authenticating to pctl with session token');
|
|
|
|
const {moon, data} = await MoonApi.createWithSessionToken(token);
|
|
|
|
const existingToken: SavedMoonToken = {
|
|
...data,
|
|
expires_at: Date.now() + (data.nintendoAccountToken.expires_in * 1000),
|
|
};
|
|
|
|
await storage.setItem('MoonToken.' + token, existingToken);
|
|
await storage.setItem('NintendoAccountToken-pctl.' + data.user.id, token);
|
|
|
|
return {moon, data: existingToken};
|
|
}
|
|
|
|
debug('Using existing token');
|
|
await storage.setItem('NintendoAccountToken-pctl.' + existingToken.user.id, token);
|
|
|
|
return {
|
|
moon: new MoonApi(existingToken.nintendoAccountToken.access_token!, existingToken.user.id),
|
|
data: existingToken,
|
|
};
|
|
}
|
|
|
|
export function getTitleIdFromEcUrl(url: string) {
|
|
const match = url.match(/^https:\/\/ec\.nintendo\.com\/apps\/([0-9a-f]{16})\//);
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
export function hrduration(duration: number, short = false) {
|
|
const hours = Math.floor(duration / 60);
|
|
const minutes = duration - (hours * 60);
|
|
|
|
const hour_str = short ? 'hr' : 'hour';
|
|
const minute_str = short ? 'min' : 'minute';
|
|
|
|
if (hours >= 1) {
|
|
return hours + ' ' + hour_str + (hours === 1 ? '' : 's') +
|
|
(minutes ? ', ' + minutes + ' ' + minute_str + (minutes === 1 ? '' : 's') : '');
|
|
} else {
|
|
return minutes + ' ' + minute_str + (minutes === 1 ? '' : 's');
|
|
}
|
|
}
|
|
|
|
export abstract class Loop {
|
|
update_interval = 60;
|
|
|
|
init(): void | Promise<LoopResult | void> {}
|
|
|
|
abstract update(): void | Promise<LoopResult | void>;
|
|
|
|
protected async loopRun(init = false): Promise<LoopResult> {
|
|
try {
|
|
const result = init ? await this.init() : await this.update();
|
|
|
|
return result ?? (init ? LoopResult.OK_SKIP_INTERVAL : LoopResult.OK);
|
|
} catch (err) {
|
|
return this.handleError(err as any);
|
|
}
|
|
}
|
|
|
|
async handleError(err: Error): Promise<LoopResult> {
|
|
throw err;
|
|
}
|
|
|
|
private is_loop_active = 0;
|
|
|
|
async loop(init = false) {
|
|
try {
|
|
this.is_loop_active++;
|
|
|
|
const result = await this.loopRun(init);
|
|
|
|
if (result === LoopResult.OK) {
|
|
if (this.skip_interval_once) {
|
|
this.skip_interval_once = false;
|
|
} else {
|
|
await new Promise(rs => setTimeout(this.timeout_resolve = rs, this.update_interval * 1000));
|
|
}
|
|
}
|
|
} finally {
|
|
this.is_loop_active--;
|
|
this.skip_interval_once = false;
|
|
this.timeout_resolve = null;
|
|
}
|
|
}
|
|
|
|
private skip_interval_once = false;
|
|
private timeout_resolve: ((value: void) => void) | null = null;
|
|
|
|
skipIntervalInCurrentLoop() {
|
|
debug('Skip update interval', this.is_loop_active);
|
|
if (!this.is_loop_active) return;
|
|
|
|
this.skip_interval_once = true;
|
|
this.timeout_resolve?.call(null);
|
|
}
|
|
}
|
|
|
|
const LoopRunOk = Symbol('LoopRunOk');
|
|
const LoopRunOkSkipInterval = Symbol('LoopRunOkSkipInterval');
|
|
|
|
export enum LoopResult {
|
|
OK = LoopRunOk as any,
|
|
OK_SKIP_INTERVAL = LoopRunOkSkipInterval as any,
|
|
}
|
|
|
|
interface SavedJwks {
|
|
jwks: Jwks;
|
|
expires_at: number;
|
|
}
|
|
|
|
export async function getJwks(url: string, storage?: persist.LocalStorage) {
|
|
const cached_keyset: SavedJwks | undefined = await storage?.getItem('Jwks.' + url);
|
|
|
|
if (!cached_keyset || cached_keyset.expires_at <= Date.now()) {
|
|
debug('Downloading JSON Web Key Set from %s', url);
|
|
|
|
const response = await fetch(url);
|
|
|
|
const jwks = await response.json() as Jwks;
|
|
|
|
const cached_keyset: SavedJwks = {
|
|
jwks,
|
|
expires_at: Date.now() + (1 * 60 * 60 * 1000), // 1 hour
|
|
};
|
|
|
|
await storage?.setItem('Jwks.' + url, cached_keyset);
|
|
|
|
return jwks;
|
|
}
|
|
|
|
return cached_keyset.jwks;
|
|
}
|