mirror of
https://github.com/samuelthomas2774/nxapi.git
synced 2026-03-22 02:14:10 -05:00
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import fetch, { Response } from 'node-fetch';
|
|
import createDebug from 'debug';
|
|
import { WebServiceToken } from './coral-types.js';
|
|
import { NintendoAccountUser } from './na.js';
|
|
import { defineResponse, ErrorResponse, HasResponse } from './util.js';
|
|
import CoralApi from './coral.js';
|
|
import { WebServiceError, Users, AuthToken, UserProfile, Newspapers, Newspaper, Emoticons, Reaction, IslandProfile } from './nooklink-types.js';
|
|
import { timeoutSignal } from '../util/misc.js';
|
|
|
|
const debug = createDebug('nxapi:api:nooklink');
|
|
|
|
export const NOOKLINK_WEBSERVICE_ID = 4953919198265344;
|
|
export const NOOKLINK_WEBSERVICE_URL = 'https://web.sd.lp1.acbaa.srv.nintendo.net';
|
|
export const NOOKLINK_WEBSERVICE_USERAGENT = 'Mozilla/5.0 (Linux; Android 8.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/58.0.3029.125 Mobile Safari/537.36';
|
|
|
|
const NOOKLINK_URL = NOOKLINK_WEBSERVICE_URL + '/api';
|
|
const BLANCO_VERSION = '2.1.1';
|
|
|
|
export default class NooklinkApi {
|
|
onTokenExpired: ((data: WebServiceError, res: Response) => Promise<NooklinkAuthData | void>) | null = null;
|
|
/** @internal */
|
|
_renewToken: Promise<void> | null = null;
|
|
|
|
protected constructor(
|
|
public gtoken: string,
|
|
public useragent: string,
|
|
readonly client_version = BLANCO_VERSION,
|
|
) {}
|
|
|
|
async fetch<T extends object>(
|
|
url: string, method = 'GET', body?: string | FormData, headers?: object,
|
|
/** @internal */ _autoRenewToken = true,
|
|
/** @internal */ _attempt = 0
|
|
): Promise<HasResponse<T, Response>> {
|
|
if (this._renewToken && _autoRenewToken) {
|
|
await this._renewToken;
|
|
}
|
|
|
|
const [signal, cancel] = timeoutSignal();
|
|
const response = await fetch(NOOKLINK_URL + url, {
|
|
method,
|
|
headers: Object.assign({
|
|
'Upgrade-Insecure-Requests': '1',
|
|
'User-Agent': this.useragent,
|
|
'Cookie': '_gtoken=' + encodeURIComponent(this.gtoken),
|
|
'dnt': '1',
|
|
'Accept': 'application/json, text/plain, */*',
|
|
'Accept-Language': 'en-GB,en-US;q=0.8',
|
|
'Origin': 'https://web.sd.lp1.acbaa.srv.nintendo.net',
|
|
'Content-Type': 'application/json',
|
|
'X-Blanco-Version': this.client_version,
|
|
}, headers),
|
|
body,
|
|
signal,
|
|
}).finally(cancel);
|
|
|
|
debug('fetch %s %s, response %s', method, url, response.status);
|
|
|
|
if (response.status === 401 && _autoRenewToken && !_attempt && this.onTokenExpired) {
|
|
const data = await response.json() as WebServiceError;
|
|
|
|
// _renewToken will be awaited when calling fetch
|
|
this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, data, response).then(data => {
|
|
if (data) this.setTokenWithSavedToken(data);
|
|
}).finally(() => {
|
|
this._renewToken = null;
|
|
});
|
|
return this.fetch(url, method, body, headers, _autoRenewToken, _attempt + 1);
|
|
}
|
|
|
|
if (response.status !== 200 && response.status !== 201) {
|
|
throw new ErrorResponse('[nooklink] Non-200/201 status code', response, await response.text());
|
|
}
|
|
|
|
const data = await response.json() as T | WebServiceError;
|
|
|
|
if ('code' in data) {
|
|
throw new ErrorResponse<WebServiceError>('[nooklink] Error ' + data.code, response, data);
|
|
}
|
|
|
|
return defineResponse(data, response);
|
|
}
|
|
|
|
async getUsers() {
|
|
return this.fetch<Users>('/sd/v1/users');
|
|
}
|
|
|
|
async getAuthToken(user_id: string) {
|
|
return this.fetch<AuthToken>('/sd/v1/auth_token', 'POST', JSON.stringify({
|
|
userId: user_id,
|
|
}));
|
|
}
|
|
|
|
async createUserClient(user_id: string) {
|
|
return NooklinkUserApi._createWithNooklinkApi(this, user_id);
|
|
}
|
|
|
|
async renewTokenWithCoral(nso: CoralApi, user: NintendoAccountUser) {
|
|
const data = await NooklinkApi.loginWithCoral(nso, user);
|
|
this.setTokenWithSavedToken(data);
|
|
return data;
|
|
}
|
|
|
|
async renewTokenWithWebServiceToken(webserviceToken: WebServiceToken, user: NintendoAccountUser) {
|
|
const data = await NooklinkApi.loginWithWebServiceToken(webserviceToken, user);
|
|
this.setTokenWithSavedToken(data);
|
|
return data;
|
|
}
|
|
|
|
private setTokenWithSavedToken(data: NooklinkAuthData) {
|
|
this.gtoken = data.gtoken;
|
|
}
|
|
|
|
static async createWithCoral(nso: CoralApi, user: NintendoAccountUser) {
|
|
const data = await this.loginWithCoral(nso, user);
|
|
return {nooklink: this.createWithSavedToken(data), data};
|
|
}
|
|
|
|
static createWithSavedToken(data: NooklinkAuthData) {
|
|
return new this(data.gtoken, data.useragent);
|
|
}
|
|
|
|
static async loginWithCoral(nso: CoralApi, user: NintendoAccountUser) {
|
|
const { default: { coral_gws_nooklink: config } } = await import('../common/remote-config.js');
|
|
if (!config) throw new Error('Remote configuration prevents NookLink authentication');
|
|
|
|
const webserviceToken = await nso.getWebServiceToken(NOOKLINK_WEBSERVICE_ID);
|
|
|
|
return this.loginWithWebServiceToken(webserviceToken, user);
|
|
}
|
|
|
|
static async loginWithWebServiceToken(
|
|
webserviceToken: WebServiceToken, user: NintendoAccountUser
|
|
): Promise<NooklinkAuthData> {
|
|
const { default: { coral_gws_nooklink: config } } = await import('../common/remote-config.js');
|
|
if (!config) throw new Error('Remote configuration prevents NookLink authentication');
|
|
|
|
const url = new URL(NOOKLINK_WEBSERVICE_URL);
|
|
url.search = new URLSearchParams({
|
|
lang: user.language,
|
|
na_country: user.country,
|
|
na_lang: user.language,
|
|
}).toString();
|
|
|
|
const [signal, cancel] = timeoutSignal();
|
|
const response = await fetch(url.toString(), {
|
|
headers: {
|
|
'Upgrade-Insecure-Requests': '1',
|
|
'User-Agent': NOOKLINK_WEBSERVICE_USERAGENT,
|
|
'x-appcolorscheme': 'DARK',
|
|
'x-gamewebtoken': webserviceToken.accessToken,
|
|
'dnt': '1',
|
|
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
|
'Accept-Language': 'en-GB,en-US;q=0.8',
|
|
'X-Requested-With': 'com.nintendo.znca',
|
|
},
|
|
signal,
|
|
}).finally(cancel);
|
|
|
|
debug('fetch %s %s, response %s', 'GET', url, response.status);
|
|
|
|
const body = await response.text();
|
|
|
|
if (response.status !== 200) {
|
|
throw new ErrorResponse('[nooklink] Non-200 status code', response, body);
|
|
}
|
|
|
|
const cookies = response.headers.get('Set-Cookie');
|
|
const match = cookies?.match(/\b_gtoken=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i);
|
|
|
|
if (!match) {
|
|
throw new ErrorResponse('[nooklink] Response didn\'t include _gtoken cookie', response, body);
|
|
}
|
|
|
|
const gtoken = decodeURIComponent(match[1]);
|
|
const expires = decodeURIComponent(match[8] || '')
|
|
.replace(/(\b)(\d{1,2})-([a-z]{3})-(\d{4})(\b)/gi, '$1$2 $3 $4$5');
|
|
|
|
debug('_gtoken %s, expires %s', gtoken, expires);
|
|
|
|
const expires_at = expires ? Date.parse(expires) : Date.now() + webserviceToken.expiresIn * 1000;
|
|
|
|
return {
|
|
webserviceToken,
|
|
url: url.toString(),
|
|
cookies: cookies!,
|
|
body,
|
|
|
|
gtoken,
|
|
expires_at,
|
|
useragent: NOOKLINK_WEBSERVICE_USERAGENT,
|
|
version: config.blanco_version,
|
|
};
|
|
}
|
|
}
|
|
|
|
export class NooklinkUserApi {
|
|
onTokenExpired: ((data: WebServiceError, res: Response) => Promise<NooklinkUserAuthData | PartialNooklinkUserAuthData | void>) | null = null;
|
|
/** @internal */
|
|
_renewToken: Promise<void> | null = null;
|
|
|
|
protected constructor(
|
|
public user_id: string,
|
|
public auth_token: string,
|
|
public gtoken: string,
|
|
public useragent: string,
|
|
public language = 'en-GB',
|
|
readonly client_version = BLANCO_VERSION,
|
|
) {}
|
|
|
|
async fetch<T extends object>(
|
|
url: string, method = 'GET', body?: string | FormData, headers?: object,
|
|
/** @internal */ _autoRenewToken = true,
|
|
/** @internal */ _attempt = 0
|
|
): Promise<HasResponse<T, Response>> {
|
|
if (this._renewToken && _autoRenewToken) {
|
|
await this._renewToken;
|
|
}
|
|
|
|
const [signal, cancel] = timeoutSignal();
|
|
const response = await fetch(NOOKLINK_URL + url, {
|
|
method,
|
|
headers: Object.assign({
|
|
'Upgrade-Insecure-Requests': '1',
|
|
'User-Agent': this.useragent,
|
|
'Cookie': '_gtoken=' + encodeURIComponent(this.gtoken),
|
|
'dnt': '1',
|
|
'Accept': 'application/json, text/plain,*/*',
|
|
'Accept-Language': 'en-GB,en-US;q=0.8',
|
|
'Origin': 'https://web.sd.lp1.acbaa.srv.nintendo.net',
|
|
'Content-Type': 'application/json',
|
|
'Authorization': 'Bearer ' + this.auth_token,
|
|
'X-Blanco-Version': this.client_version,
|
|
}, headers),
|
|
body,
|
|
signal,
|
|
}).finally(cancel);
|
|
|
|
debug('fetch %s %s, response %s', method, url, response.status);
|
|
|
|
if (response.status === 401 && _autoRenewToken && !_attempt && this.onTokenExpired) {
|
|
const data = await response.json() as WebServiceError;
|
|
|
|
// _renewToken will be awaited when calling fetch
|
|
this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, data, response).then(data => {
|
|
if (data) this.setTokenWithSavedToken(data);
|
|
}).finally(() => {
|
|
this._renewToken = null;
|
|
});
|
|
return this.fetch(url, method, body, headers, _autoRenewToken, _attempt + 1);
|
|
}
|
|
|
|
if (response.status !== 200 && response.status !== 201) {
|
|
throw new ErrorResponse('[nooklink] Non-200/201 status code', response, await response.text());
|
|
}
|
|
|
|
const data = await response.json() as T | WebServiceError;
|
|
|
|
if ('code' in data) {
|
|
throw new ErrorResponse<WebServiceError>('[nooklink] Error ' + data.code, response, data);
|
|
}
|
|
|
|
return defineResponse(data, response);
|
|
}
|
|
|
|
async getUserProfile(id?: string) {
|
|
return this.fetch<UserProfile>('/sd/v1/users/' + (id ?? this.user_id) + '/profile?language=' + this.language);
|
|
}
|
|
|
|
async getIslandProfile(id: string) {
|
|
return this.fetch<IslandProfile>('/sd/v1/lands/' + id + '/profile?language=' + this.language);
|
|
}
|
|
|
|
async getNewspapers() {
|
|
return this.fetch<Newspapers>('/sd/v1/newspapers');
|
|
}
|
|
|
|
async getNewspaper(key: string) {
|
|
const requestedAt = formatDateTime(new Date());
|
|
|
|
return this.fetch<Newspaper>('/sd/v1/newspapers/' + key + '?requestedAt=' + requestedAt + '&language=' + this.language);
|
|
}
|
|
|
|
async getLatestNewspaper() {
|
|
const requestedAt = formatDateTime(new Date());
|
|
|
|
return this.fetch<Newspaper>('/sd/v1/newspapers/latest?requestedAt=' + requestedAt + '&language=' + this.language);
|
|
}
|
|
|
|
async postMessage(body: string, type: MessageType, destination_user_id?: string) {
|
|
return this.fetch('/sd/v1/messages', 'POST', JSON.stringify({
|
|
type,
|
|
body,
|
|
userId: destination_user_id,
|
|
}));
|
|
}
|
|
|
|
async keyboard(message: string) {
|
|
return this.postMessage(message, MessageType.KEYBOARD);
|
|
}
|
|
|
|
async getEmoticons() {
|
|
return this.fetch<Emoticons>('/sd/v1/emoticons?language=' + this.language);
|
|
}
|
|
|
|
async reaction(reaction: Reaction) {
|
|
return this.postMessage(reaction.label, MessageType.EMOTICON);
|
|
}
|
|
|
|
async getToken(client: NooklinkApi): Promise<PartialNooklinkUserAuthData> {
|
|
const token = await client.getAuthToken(this.user_id);
|
|
|
|
return {
|
|
gtoken: client.gtoken,
|
|
user_id: this.user_id,
|
|
token,
|
|
};
|
|
}
|
|
|
|
async renewToken(client: NooklinkApi) {
|
|
const data = await this.getToken(client);
|
|
this.setTokenWithSavedToken(data);
|
|
return data;
|
|
}
|
|
|
|
private setTokenWithSavedToken(data: NooklinkUserAuthData | PartialNooklinkUserAuthData) {
|
|
this.user_id = data.user_id;
|
|
this.auth_token = data.token.token;
|
|
this.gtoken = data.gtoken;
|
|
}
|
|
|
|
/** @internal */
|
|
static async _loginWithNooklinkApi(client: NooklinkApi, user_id: string): Promise<NooklinkUserAuthData> {
|
|
const token = await client.getAuthToken(user_id);
|
|
|
|
return {
|
|
gtoken: client.gtoken,
|
|
useragent: client.useragent,
|
|
version: client.client_version,
|
|
|
|
user_id,
|
|
token,
|
|
language: 'en-GB',
|
|
};
|
|
}
|
|
|
|
/** @internal */
|
|
static async _createWithNooklinkApi(client: NooklinkApi, user_id: string) {
|
|
const data = await this._loginWithNooklinkApi(client, user_id);
|
|
return {nooklinkuser: this.createWithSavedToken(data), data};
|
|
}
|
|
|
|
static createWithSavedToken(data: NooklinkUserAuthData) {
|
|
return new NooklinkUserApi(
|
|
data.user_id, data.token.token,
|
|
data.gtoken, data.useragent, data.language, data.version
|
|
);
|
|
}
|
|
|
|
static createWithCliTokenData(data: NooklinkUserCliTokenData) {
|
|
return new NooklinkUserApi(
|
|
data.user_id, data.auth_token,
|
|
data.gtoken, NOOKLINK_WEBSERVICE_USERAGENT, data.language, data.version
|
|
);
|
|
}
|
|
}
|
|
|
|
export interface NooklinkAuthData {
|
|
webserviceToken: WebServiceToken;
|
|
url: string;
|
|
cookies: string;
|
|
body: string;
|
|
|
|
gtoken: string;
|
|
expires_at: number;
|
|
useragent: string;
|
|
version: string;
|
|
}
|
|
|
|
export interface NooklinkUserAuthData {
|
|
gtoken: string;
|
|
useragent: string;
|
|
version: string;
|
|
|
|
user_id: string;
|
|
token: AuthToken;
|
|
language: string;
|
|
}
|
|
export type PartialNooklinkUserAuthData =
|
|
Pick<NooklinkUserAuthData, 'gtoken' | 'user_id' | 'token'>;
|
|
|
|
export interface NooklinkUserCliTokenData {
|
|
gtoken: string;
|
|
version: string;
|
|
|
|
auth_token: string;
|
|
expires_at: number;
|
|
user_id: string;
|
|
language: string;
|
|
}
|
|
|
|
function formatDateTime(date: Date) {
|
|
return date.getFullYear().toString().padStart(4, '0') + '-' +
|
|
(date.getMonth() + 1).toString().padStart(2, '0') + '-' +
|
|
date.getDate().toString().padStart(2, '0') + ' ' +
|
|
date.getHours().toString().padStart(2, '0') + ':' +
|
|
date.getMinutes().toString().padStart(2, '0');
|
|
}
|
|
|
|
export enum MessageType {
|
|
KEYBOARD = 'keyboard',
|
|
EMOTICON = 'emoticon',
|
|
}
|