From 57ce3c21dec1ddb7efb97c8bd03f7da5aff1bed3 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Tue, 22 Jul 2025 01:33:03 +0100 Subject: [PATCH] Coral 3.0.3 --- src/api/coral.ts | 1162 +++++++++++++++++++++---------- src/api/f.ts | 10 + src/api/moon.ts | 4 +- src/api/na.ts | 178 +++-- src/api/util.ts | 4 +- src/api/znc-proxy.ts | 105 ++- src/app/browser/util.tsx | 9 +- src/cli/nso/http-server.ts | 66 +- src/cli/nso/znc-proxy-tokens.ts | 4 +- src/common/notify.ts | 4 +- src/common/users.ts | 106 ++- 11 files changed, 1181 insertions(+), 471 deletions(-) diff --git a/src/api/coral.ts b/src/api/coral.ts index db99de6..cab1f96 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -5,17 +5,17 @@ import { JwtPayload } from '../util/jwt.js'; import { timeoutSignal } from '../util/misc.js'; import { getAdditionalUserAgents } from '../util/useragent.js'; import type { CoralRemoteConfig } from '../common/remote-config.js'; -import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParameter, Announcements, CoralError, CoralResponse, CoralStatus, CoralSuccessResponse, CurrentUser, CurrentUserPermissions, Event, FriendCodeUrl, FriendCodeUser, Friends, GetActiveEventResult, PresencePermissions, User, WebServices, WebServiceToken, WebServiceTokenParameter } from './coral-types.js'; -import { f, FResult, HashMethod } from './f.js'; -import { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountSessionAuthorisation, NintendoAccountToken, NintendoAccountUser } from './na.js'; +import { AccountLogin, AccountLoginParameter, AccountToken, AccountTokenParameter, Announcements, Announcements_4, BlockingUsers, CoralError, CoralResponse, CoralStatus, CoralSuccessResponse, CurrentUser, CurrentUserPermissions, Event, Friend_4, FriendCodeUrl, FriendCodeUser, Friends, Friends_4, GetActiveEventResult, ListChat, ListMedia, ListMediaParameter, Media, PlayLogPermissions, PresencePermissions, PushNotificationPlayInvitationScope, ReceivedFriendRequest, ReceivedFriendRequests, SentFriendRequests, ShowUserLogin, UpdatePushNotificationSettingsParameter, UpdatePushNotificationSettingsParameterItem, User, UserPlayLog, WebServices, WebServices_4, WebServiceToken, WebServiceTokenParameter } from './coral-types.js'; +import { createZncaApi, FResult, getDefaultZncaApi, getPreferredZncaApiFromEnvironment, HashMethod, RequestEncryptionProvider, ZncaApi, ZncaApiNxapi } from './f.js'; +import { generateAuthData, getNintendoAccountToken, getNintendoAccountUser, NintendoAccountScope, 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'); const ZNCA_PLATFORM = 'Android'; -const ZNCA_PLATFORM_VERSION = '8.0.0'; -export const ZNCA_VERSION = '2.12.0'; +const ZNCA_PLATFORM_VERSION = '12'; +export const ZNCA_VERSION = '3.0.2'; const ZNCA_USER_AGENT = `com.nintendo.znca/${ZNCA_VERSION}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`; const ZNC_URL = 'https://api-lp1.znc.srv.nintendo.net'; @@ -43,11 +43,13 @@ export interface ResultData { } export interface CoralApiInterface { - getAnnouncements(): Promise>; - getFriendList(): Promise>; + getAnnouncements(): Promise>; + getFriendList(): Promise>; addFavouriteFriend(nsa_id: string): Promise>; removeFavouriteFriend(nsa_id: string): Promise>; - getWebServices(): Promise>; + getWebServices(): Promise>; + getChats(): Promise>; + getMedia(): Promise>; getActiveEvent(): Promise>; getEvent(id: number): Promise>; getUser(id: number): Promise>; @@ -58,6 +60,262 @@ export interface CoralApiInterface { getWebServiceToken(id: number): Promise>; } +export abstract class AbstractCoralApi { + abstract call( + url: string, parameter?: R & Partial, + ): Promise>; + + async getAnnouncements() { + return this.call('/v4/Announcement/List', { + [RequestFlagAddPlatformSymbol]: true, + }); + } + + async getWebServices() { + return this.call('/v4/GameWebService/List', { + [RequestFlagRequestIdSymbol]: RequestFlagRequestId.AFTER, + }); + } + + async getChats() { + return this.call('/v5/Chat/List'); + } + + async getMedia() { + return this.call('/v4/Media/List', { + count: 100, + }); + } + + async getActiveEvent() { + return this.call('/v1/Event/GetActiveEvent'); + } + + async getEvent(id: number) { + return this.call('/v1/Event/Show', { + id, + }); + } + + async getUser(id: number) { + return this.call('/v3/User/Show', { + id, + }); + } + + async getFriendList() { + return this.call('/v4/Friend/List', { + [RequestFlagAddPlatformSymbol]: true, + }); + } + + async addFavouriteFriend(nsa_id: string) { + return this.call('/v3/Friend/Favorite/Create', { + nsaId: nsa_id, + + [RequestFlagAddPlatformSymbol]: true, + }); + } + + async removeFavouriteFriend(nsa_id: string) { + return this.call('/v3/Friend/Favorite/Delete', { + nsaId: nsa_id, + + [RequestFlagAddPlatformSymbol]: true, + }); + } + + /** unused */ + async getFriend(nsa_id: string) { + return this.call('/v4/Friend/Show', { + nsaId: nsa_id, + }); + } + + async getPlayLog(nsa_id: string) { + return this.call('/v4/User/PlayLog/Show', { + nsaId: nsa_id, + }); + } + + async deleteFriend(nsa_id: string) { + return this.call<{}, {nsaId: string}>('/v3/Friend/Delete', { + nsaId: nsa_id, + }); + } + + async getUserByFriendCode(friend_code: string, hash?: string) { + if (!FRIEND_CODE.test(friend_code)) throw new Error('Invalid friend code'); + if (hash && !FRIEND_CODE_HASH.test(hash)) throw new Error('Invalid friend code hash'); + + return hash ? this.call('/v3/Friend/GetUserByFriendCodeHash', { + friendCode: friend_code, + friendCodeHash: hash, + }) : this.call('/v3/Friend/GetUserByFriendCode', { + friendCode: friend_code, + }); + } + + async sendFriendRequest(nsa_id: string) { + return this.call('/v3/FriendRequest/Create', { + nsaId: nsa_id, + }); + } + + async getReceivedFriendRequests() { + return this.call('/v4/FriendRequest/Received/List'); + } + + async getReceivedFriendRequest(friend_request_id: string) { + return this.call('/v4/FriendRequest/Received/Show', { + id: friend_request_id, + }); + } + + async setReceivedFriendRequestRead(friend_request_id: string) { + return this.call('/v4/FriendRequest/Received/MarkAsRead', { + id: friend_request_id, + }); + } + + async acceptFriendRequest(friend_request_id: string) { + return this.call('/v3/FriendRequest/Accept', { + id: friend_request_id, + }); + } + + async rejectFriendRequest(friend_request_id: string) { + return this.call('/v3/FriendRequest/Reject', { + id: friend_request_id, + }); + } + + async getSentFriendRequests() { + return this.call('/v3/FriendRequest/Sent/List'); + } + + async cancelFriendRequest(friend_request_id: string) { + return this.call('/v3/FriendRequest/Cancel', { + id: friend_request_id, + }); + } + + async getBlockedUsers() { + return this.call('/v3/User/Block/List'); + } + + async addBlockedUser(nsa_id: string) { + return this.call('/v3/User/Block/Create', { + nsaId: nsa_id, + }); + } + + async removeBlockedUser(nsa_id: string) { + return this.call('/v3/User/Block/Delete', { + nsaId: nsa_id, + }); + } + + async setAnnouncementRead(id: string) { + return this.call('/v4/Announcement/MarkAsRead', { + id, + + [RequestFlagAddPlatformSymbol]: true, + }); + } + + abstract getCurrentUser(): Promise>; + + async getFriendCodeUrl() { + return this.call('/v3/Friend/CreateFriendCodeUrl', { + [RequestFlagNoParameterSymbol]: true, + }); + } + + async getCurrentUserPermissions() { + return this.call('/v3/User/Permissions/ShowSelf', { + [RequestFlagNoParameterSymbol]: true, + [RequestFlagRequestIdSymbol]: RequestFlagRequestId.AFTER, + }); + } + + /** @deprecated Use updateUserPresencePermissions */ + updateCurrentUserPermissions(to: PresencePermissions, from: PresencePermissions, etag: string): Promise> { + return this.updateUserPresencePermissions(to); + } + + async updateUserPresencePermissions(value: PresencePermissions) { + return this.call('/v4/User/Permissions/UpdateSelf', { + permissions: { + presence: value, + }, + }); + } + + async updateUserPlayLogPermissions(value: PlayLogPermissions) { + return this.call('/v4/User/Permissions/UpdateSelf', { + permissions: { + playLog: value, + }, + }); + } + + async updateUserFriendRequestPermissions(value: boolean) { + return this.call('/v4/User/Permissions/UpdateSelf', { + permissions: { + friendRequestReception: value, + }, + }); + } + + async updateNotificationSetting(item: UpdatePushNotificationSettingsParameterItem) { + return this.call<{}, UpdatePushNotificationSettingsParameter>('/v5/PushNotification/Settings/Update', [ + item, + ]); + } + + async updateFriendRequestNotificationSettings(value: boolean) { + return this.updateNotificationSetting({ + type: 'friendRequest', + value, + }); + } + + async updateChatInvitationNotificationSettings(value: boolean) { + return this.updateNotificationSetting({ + type: 'chatInvitation', + value, + }); + } + + async updatePlayInvitationNotificationSettings(scope: PushNotificationPlayInvitationScope) { + return this.updateNotificationSetting({ + type: 'playInvitation', + scope, + }); + } + + async updateWebServiceNotificationSettings(id: number, value: boolean) { + return this.updateNotificationSetting({ + type: 'gws', + gwsId: id, + value, + }); + } + + async updateFriendOnlineNotificationSettings(nsa_id: string, value: boolean) { + return this.updateNotificationSetting({ + type: 'friendOnline', + value, + friendId: nsa_id, + }); + } + + async getUserLoginFactor() { + return this.call('/v4/NA/User/LoginFactor/Show'); + } +} + export interface ClientInfo { platform: string; version: string; @@ -68,12 +326,47 @@ const RemoteConfigSymbol = Symbol('RemoteConfigSymbol'); const ClientInfoSymbol = Symbol('CoralClientInfo'); const CoralUserIdSymbol = Symbol('CoralUserId'); const NintendoAccountIdSymbol = Symbol('NintendoAccountId'); +const ZncaApiSymbol = Symbol('ZncaApi'); +const ZncaApiPromiseSymbol = Symbol('ZncaApiPromise'); -export default class CoralApi implements CoralApiInterface { +export const RequestFlagAddProductVersionSymbol = Symbol('RequestFlagAddProductVersionSymbol'); +export const RequestFlagAddPlatformSymbol = Symbol('RequestFlagAddPlatformSymbol'); +export const RequestFlagNoAuthenticationSymbol = Symbol('RequestFlagNoAuthenticationSymbol'); +export const RequestFlagNoEncryptionSymbol = Symbol('RequestFlagNoEncryptionSymbol'); +export const RequestFlagNoParameterSymbol = Symbol('RequestFlagNoParameterSymbol'); +export const RequestFlagRequestIdSymbol = Symbol('RequestFlagRequestIdSymbol'); + +export interface RequestFlags { + [RequestFlagAddProductVersionSymbol]: boolean; + [RequestFlagAddPlatformSymbol]: boolean; + [RequestFlagNoAuthenticationSymbol]: boolean; + [RequestFlagNoEncryptionSymbol]: boolean; + [RequestFlagNoParameterSymbol]: boolean; + [RequestFlagRequestIdSymbol]: RequestFlagRequestId; +} +export enum RequestFlagRequestId { + NONE, + AFTER, + BEFORE, +} + +class EncryptedRequestBody { + constructor( + readonly request_encryption: RequestEncryptionProvider, + readonly encrypted: Uint8Array, + readonly data: T | null = null, + ) {} +} + +export default class CoralApi extends AbstractCoralApi implements CoralApiInterface { [RemoteConfigSymbol]!: CoralRemoteConfig | null; [ClientInfoSymbol]: ClientInfo; - [CoralUserIdSymbol]: string; + [CoralUserIdSymbol]: number; [NintendoAccountIdSymbol]: string; + [ZncaApiSymbol]: ZncaApi | null; + [ZncaApiPromiseSymbol]: Promise | null; + + request_encryption: RequestEncryptionProvider | null = null; onTokenExpired: ((data?: CoralError, res?: Response) => Promise) | null = null; /** @internal */ @@ -84,15 +377,24 @@ export default class CoralApi implements CoralApiInterface { protected constructor( public token: string, public useragent: string | null = getAdditionalUserAgents(), - coral_user_id: string, + coral_user_id: number, na_id: string, znca_version = ZNCA_VERSION, znca_useragent = ZNCA_USER_AGENT, + znca_api?: ZncaApi | null, config?: CoralRemoteConfig, ) { + super(); + this[ClientInfoSymbol] = {platform: ZNCA_PLATFORM, version: znca_version, useragent: znca_useragent}; this[CoralUserIdSymbol] = coral_user_id; this[NintendoAccountIdSymbol] = na_id; + this[ZncaApiSymbol] = znca_api ?? null; + this[ZncaApiPromiseSymbol] = null; + + if (znca_api?.supportsEncryption()) { + this.request_encryption = znca_api; + } Object.defineProperty(this, RemoteConfigSymbol, {enumerable: false, value: config ?? null}); Object.defineProperty(this, 'token', {enumerable: false, value: this.token}); @@ -109,57 +411,512 @@ export default class CoralApi implements CoralApiInterface { return this[ClientInfoSymbol].useragent; } + initZncaApi() { + if (this[ZncaApiPromiseSymbol]) return this[ZncaApiPromiseSymbol]; + + return this[ZncaApiPromiseSymbol] = createZncaApi({ + ...this[ClientInfoSymbol], + useragent: this.useragent ?? getAdditionalUserAgents(), + }).then(provider => { + this[ZncaApiSymbol] = provider; + + if (provider.supportsEncryption()) { + this.request_encryption = provider; + } + + return provider; + }).finally(() => this[ZncaApiPromiseSymbol] = null); + } + async fetch( - url: string, method = 'GET', body?: string, headers?: object, + url: URL | string, method = 'GET', body?: string | Uint8Array | EncryptedRequestBody, _headers?: HeadersInit, + flags: Partial = {}, /** @internal */ _autoRenewToken = true, - /** @internal */ _attempt = 0, ): Promise> { - if (this._token_expired && _autoRenewToken && !this._renewToken) { - if (!this.onTokenExpired || _attempt) throw new Error('Token expired'); + if (!this[ZncaApiSymbol]) await this.initZncaApi(); - this._renewToken = this.onTokenExpired.call(null).then(data => { - if (data) this.setTokenWithSavedToken(data); - }).finally(() => { - this._renewToken = null; + return (new CoralApiRequest(this, url, method, body, _headers, flags, _autoRenewToken)).fetch(); + } + + async call( + url: string, parameter: R & Partial = {} as R, + /** @internal */ _autoRenewToken = true, + ) { + const body = {} as any; + + const ri = parameter[RequestFlagRequestIdSymbol] ?? RequestFlagRequestId.NONE; + + if (ri === RequestFlagRequestId.AFTER) body.parameter = parameter; + + if (ri !== RequestFlagRequestId.NONE) { + // Android - lowercase, iOS - uppercase + const uuid = randomUUID(); + body.requestId = uuid; + } + + if (ri !== RequestFlagRequestId.AFTER && !parameter[RequestFlagNoParameterSymbol]) body.parameter = parameter; + + return this.fetch(url, 'POST', JSON.stringify(body), {}, parameter, _autoRenewToken); + } + + async getCurrentUser() { + return this.call('/v4/User/ShowSelf', { + id: this[CoralUserIdSymbol], + }); + } + + async getWebServiceToken(id: number, /** @internal */ _attempt = 0): Promise> { + await this._renewToken; + + const parameter: WebServiceTokenParameter = { + id, + registrationToken: '', + f: '', + requestId: '', + timestamp: 0, + }; + + const provider = this[ZncaApiSymbol] ?? await this.initZncaApi(); + + const fdata = await provider.genf(this.token, HashMethod.WEB_SERVICE, { + na_id: this[NintendoAccountIdSymbol], coral_user_id: '' + this[CoralUserIdSymbol], + }, provider.supportsEncryption() ? { + url: ZNC_URL + '/v4/Game/GetWebServiceToken', + parameter, + } : undefined); + + let body; + + if (provider.supportsEncryption() && fdata.encrypt_request_result) { + body = new EncryptedRequestBody(provider, fdata.encrypt_request_result); + } else { + parameter.f = fdata.f; + parameter.requestId = fdata.request_id; + parameter.timestamp = fdata.timestamp; + + body = JSON.stringify({ + parameter, }); + + if (provider.supportsEncryption()) { + const result = await provider.encryptRequest(ZNC_URL + '/v4/Game/GetWebServiceToken', null, body); + + body = new EncryptedRequestBody(provider, result.data, body); + } } - if (this._renewToken && _autoRenewToken) { - await this._renewToken; + try { + return await this.fetch('/v4/Game/GetWebServiceToken', 'POST', body, undefined, { + [RequestFlagAddPlatformSymbol]: true, + [RequestFlagAddProductVersionSymbol]: true, + }, false); + } catch (err) { + if (err instanceof CoralErrorResponse && err.status === CoralStatus.TOKEN_EXPIRED && !_attempt && this.onTokenExpired) { + debug('Error getting web service token, renewing token before retrying', err); + // _renewToken will be awaited when calling getWebServiceToken + this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, err.data, err.response as Response).then(data => { + if (data) this.setTokenWithSavedToken(data); + }).finally(() => { + this._renewToken = null; + }); + return this.getWebServiceToken(id, _attempt + 1); + } else { + throw err; + } } + } + + async getToken(token: string, user: NintendoAccountUserCoral): Promise { + const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID); + + return this.getTokenWithNintendoAccountToken(nintendoAccountToken, user); + } + + async getTokenWithNintendoAccountToken( + nintendoAccountToken: NintendoAccountToken, user: NintendoAccountUserCoral, + ): Promise { + const parameter: AccountTokenParameter = { + naBirthday: user.birthday, + timestamp: 0, + f: '', + requestId: '', + naIdToken: nintendoAccountToken.id_token, + }; + + const provider = this[ZncaApiSymbol] ?? await this.initZncaApi(); + + const fdata = await provider.genf(nintendoAccountToken.id_token, HashMethod.CORAL, { + na_id: user.id, coral_user_id: '' + this[CoralUserIdSymbol], + }, provider.supportsEncryption() ? { + url: ZNC_URL + '/v3/Account/GetToken', + parameter, + } : undefined); + + let body; + + if (provider.supportsEncryption() && fdata.encrypt_request_result) { + body = new EncryptedRequestBody(provider, fdata.encrypt_request_result); + } else { + parameter.timestamp = fdata.timestamp; + parameter.f = fdata.f; + parameter.requestId = fdata.request_id; + + body = JSON.stringify({ + parameter, + }); + + if (provider.supportsEncryption()) { + const result = await provider.encryptRequest(ZNC_URL + '/v3/Account/GetToken', null, body); + + body = new EncryptedRequestBody(provider, result.data, body); + } + } + + const data = await this.fetch('/v3/Account/GetToken', 'POST', body, undefined, { + [RequestFlagAddPlatformSymbol]: true, + [RequestFlagAddProductVersionSymbol]: true, + [RequestFlagNoAuthenticationSymbol]: true, + }, false); + + return { + nintendoAccountToken, + // user, + f: fdata, + nsoAccount: data, + credential: data.webApiServerCredential, + }; + } + + async renewToken(token: string, user: NintendoAccountUserCoral) { + const data = await this.getToken(token, user); + this.setTokenWithSavedToken(data); + return data; + } + + async renewTokenWithNintendoAccountToken(token: NintendoAccountToken, user: NintendoAccountUserCoral) { + const data = await this.getTokenWithNintendoAccountToken(token, user); + this.setTokenWithSavedToken(data); + return data; + } + + protected setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) { + this.token = data.credential.accessToken; + this[CoralUserIdSymbol] = data.nsoAccount.user.id; + if ('user' in data) this[NintendoAccountIdSymbol] = data.user.id; + this._token_expired = false; + } + + static async createWithSessionToken(token: string, useragent = getAdditionalUserAgents()) { + const data = await this.loginWithSessionToken(token, useragent); + return {nso: this.createWithSavedToken(data, useragent), data}; + } + + static async createWithNintendoAccountToken( + token: NintendoAccountToken, user: NintendoAccountUserCoral, + useragent = getAdditionalUserAgents() + ) { + const data = await this.loginWithNintendoAccountToken(token, user, useragent); + return {nso: this.createWithSavedToken(data, useragent), data}; + } + + static createWithSavedToken(data: CoralAuthData, useragent = getAdditionalUserAgents()) { + return new this( + data.credential.accessToken, + useragent, + data.nsoAccount.user.id, + data.user.id, + data.znca_version, + data.znca_useragent, + data[ZncaApiSymbol] ?? null, + ); + } + + static async loginWithSessionToken(token: string, useragent = getAdditionalUserAgents()): Promise { + const { default: { coral: config } } = await import('../common/remote-config.js'); + if (!config) throw new Error('Remote configuration prevents Coral authentication'); + + // Nintendo Account token + const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID); + + // Nintendo Account user data + const user = await getNintendoAccountUser(nintendoAccountToken); + + return this.loginWithNintendoAccountToken(nintendoAccountToken, user, useragent); + } + + static async loginWithNintendoAccountToken( + nintendoAccountToken: NintendoAccountToken, + user: NintendoAccountUserCoral, + useragent = getAdditionalUserAgents(), + ) { + const { default: { coral: config } } = await import('../common/remote-config.js'); + + if (!config) throw new Error('Remote configuration prevents Coral authentication'); + const znca_useragent = `com.nintendo.znca/${config.znca_version}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`; + + const provider = await createZncaApi({ + platform: ZNCA_PLATFORM, + version: config.znca_version, + useragent, + }); + + const parameter: AccountLoginParameter = { + naIdToken: nintendoAccountToken.id_token, + naBirthday: user.birthday, + naCountry: user.country, + language: user.language, + + // These fields will be filled by the f-generation API when encrypting the request data + timestamp: 0, + requestId: '', + f: '', + }; + + const fdata = await provider.genf(nintendoAccountToken.id_token, HashMethod.CORAL, { + na_id: user.id, + }, provider.supportsEncryption() ? { + url: ZNC_URL + '/v3/Account/Login', + parameter, + } : undefined); + + debug('fdata', fdata); + + debug('Getting Nintendo Switch Online app token'); + + let encrypted: [RequestEncryptionProvider] | null = null; + let body; + + if (provider.supportsEncryption() && fdata.encrypt_request_result) { + encrypted = [provider]; + body = fdata.encrypt_request_result; + } else { + parameter.timestamp = fdata.timestamp; + parameter.requestId = fdata.request_id; + parameter.f = fdata.f; + + body = JSON.stringify({ + parameter, + }); + + if (provider.supportsEncryption()) { + const result = await provider.encryptRequest(ZNC_URL + '/v3/Account/Login', null, body); + + encrypted = [provider]; + body = result.data; + } + } + + const headers = new Headers({ + 'X-Platform': ZNCA_PLATFORM, + 'X-ProductVersion': config.znca_version, + 'Content-Type': encrypted ? 'application/octet-stream' : 'application/json; charset=utf-8', + 'Accept': (encrypted ? 'application/octet-stream,' : '') + 'application/json', + 'User-Agent': znca_useragent, + }); const [signal, cancel] = timeoutSignal(); - const response = await fetch(ZNC_URL + url, { - method, - headers: Object.assign({ - 'X-Platform': this[ClientInfoSymbol].platform, - 'X-ProductVersion': this[ClientInfoSymbol].version, - 'Authorization': 'Bearer ' + this.token, - 'Content-Type': 'application/json; charset=utf-8', - 'User-Agent': this[ClientInfoSymbol].useragent, - }, headers), + const response = await fetch(ZNC_URL + '/v3/Account/Login', { + method: 'POST', + headers, body, signal, }).finally(cancel); - const data = await response.json().catch(err => null) as CoralResponse | null; + debug('fetch %s %s, response %s', 'POST', '/v3/Account/Login', response.status); - debug('fetch %s %s, response %s, status %d %s, correlationId %s', method, url, response.status, - data?.status, CoralStatus[data?.status!], data?.correlationId); - - if (response.status !== 200 || !data) { - throw new CoralErrorResponse('[znc] Non-200 status code', response, data as CoralError); + if (response.status !== 200) { + throw await CoralErrorResponse.fromResponse(response, '[znc] Non-200 status code'); } - if (data.status === CoralStatus.TOKEN_EXPIRED && _autoRenewToken && !_attempt && this.onTokenExpired) { - this._token_expired = true; - // _renewToken will be awaited when calling fetch - this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, data, response).then(data => { - if (data) this.setTokenWithSavedToken(data); + const data: CoralResponse = encrypted ? + JSON.parse((await encrypted[0].decryptResponse(new Uint8Array(await response.arrayBuffer()))).data) : + await response.json() as CoralResponse; + + if ('errorMessage' in data) { + throw new CoralErrorResponse('[znc] ' + data.errorMessage, response, data); + } + if (data.status !== CoralStatus.OK) { + throw new CoralErrorResponse('[znc] Unknown error', response, data); + } + + debug('Got Nintendo Switch Online app token', data); + + return { + nintendoAccountToken, + user, + f: fdata, + nsoAccount: data.result, + credential: data.result.webApiServerCredential, + znca_version: config.znca_version, + znca_useragent, + + [ZncaApiSymbol]: provider, + }; + } +} + +class CoralApiRequest { + constructor( + readonly coral: CoralApi, + + readonly url: URL | string, + readonly method: string, + readonly body: string | Uint8Array | EncryptedRequestBody | undefined, + readonly headers: HeadersInit | undefined, + readonly flags: Partial, + readonly auto_renew_token: boolean, + ) {} + + async fetch(_attempt = 0): Promise> { + if (this.coral._token_expired && this.auto_renew_token && !this.coral._renewToken) { + if (!this.coral.onTokenExpired || _attempt) throw new Error('Token expired'); + + this.coral._renewToken = this.coral.onTokenExpired.call(null).then(data => { + // @ts-expect-error + if (data) this.coral.setTokenWithSavedToken(data); }).finally(() => { - this._renewToken = null; + this.coral._renewToken = null; }); - return this.fetch(url, method, body, headers, _autoRenewToken, _attempt + 1); + } + + if (this.coral._renewToken && this.auto_renew_token) { + await this.coral._renewToken; + } + + const headers = new Headers(this.headers); + + headers.append('Content-Type', 'application/json'); + headers.append('Accept-Language', 'en-GB'); + + if (this.flags[RequestFlagAddProductVersionSymbol]) { + headers.append('X-ProductVersion', this.coral[ClientInfoSymbol].version); + } + + headers.append('Accept', 'application/json'); + + headers.append('User-Agent', this.coral[ClientInfoSymbol].useragent); + + if (!this.flags[RequestFlagNoAuthenticationSymbol] && this.coral.token) { + headers.append('Authorization', 'Bearer ' + this.coral.token); + } + + if (this.flags[RequestFlagAddPlatformSymbol]) { + headers.append('X-Platform', this.coral[ClientInfoSymbol].platform); + } + + headers.append('Pragma', 'no-cache'); + headers.append('Cache-Control', 'no-cache'); + + let body = this.body; + let encrypted: [RequestEncryptionProvider] | null = null; + + if (this.coral.request_encryption && typeof this.body === 'string' && !this.flags[RequestFlagNoEncryptionSymbol]) { + const result = await this.coral.request_encryption.encryptRequest( + new URL(this.url, ZNC_URL).href, + !this.flags[RequestFlagNoAuthenticationSymbol] && this.coral.token ? this.coral.token : null, + this.body, + ); + + headers.set('Content-Type', 'application/octet-stream'); + headers.set('Accept', 'application/octet-stream,application/json'); + + body = result.data; + encrypted = [this.coral.request_encryption]; + } + + if (body instanceof EncryptedRequestBody) { + const result = body; + + headers.set('Content-Type', 'application/octet-stream'); + headers.set('Accept', 'application/octet-stream,application/json'); + + body = result.encrypted; + encrypted = [result.request_encryption]; + } + + const [signal, cancel] = timeoutSignal(); + const response = await fetch(new URL(this.url, ZNC_URL), { + method: this.method, + headers, + body, + signal, + }).finally(cancel); + + return this.handleResponse(response, encrypted?.[0], _attempt); + } + + async handleResponse( + response: Response, request_encryption: RequestEncryptionProvider | undefined, + /** @internal */ _attempt: number, + ) { + const data = new Uint8Array(await response.arrayBuffer()); + + if (response.headers.get('Content-Type')?.match(/^application\/json($|;)/i)) { + if (request_encryption) { + return this.handleEncryptedJsonResponse(response, data, request_encryption, _attempt); + } + + return this.decodeJsonResponse(response, data, _attempt); + } + + if (!response.ok) { + throw new CoralErrorResponse('[znc] Non-200 status code', response, data); + } + + throw new CoralErrorResponse('[znc] Unacceptable response type', response, data); + } + + private async handleEncryptedJsonResponse( + response: Response, data: Uint8Array, + request_encryption: RequestEncryptionProvider, + /** @internal */ _attempt: number, + ) { + const decrypted = await request_encryption.decryptResponse(data); + + return this.decodeJsonResponse(response, decrypted.data, _attempt); + } + + private async decodeJsonResponse( + response: Response, data: string | Uint8Array, + /** @internal */ _attempt: number, + ) { + let json: CoralResponse; + + try { + const decoded = typeof data === 'string' ? data : (new TextDecoder()).decode(data); + json = JSON.parse(decoded); + } catch (err) { + if (!response.ok) { + throw new CoralErrorResponse('[znc] Non-200 status code', response, data); + } + + throw new CoralErrorResponse('Error parsing JSON response', response, data); + } + + if (!response.ok) { + throw new CoralErrorResponse('[znc] Non-200 status code', response, json as CoralError); + } + + return this.handleJsonResponse(response, json, _attempt); + } + + private async handleJsonResponse( + response: Response, data: CoralResponse, + /** @internal */ _attempt: number, + ) { + debug('fetch %s %s, response %s, status %d %s, correlationId %s', this.method, this.url, response.status, + data.status, CoralStatus[data.status], data?.correlationId); + + if (data.status === CoralStatus.TOKEN_EXPIRED && this.auto_renew_token && !_attempt && this.coral.onTokenExpired) { + this.coral._token_expired = true; + // _renewToken will be awaited when calling fetch + this.coral._renewToken = this.coral._renewToken ?? this.coral.onTokenExpired.call(null, data, response).then(data => { + // @ts-expect-error + if (data) this.coral.setTokenWithSavedToken(data); + }).finally(() => { + this.coral._renewToken = null; + }); + return this.fetch(_attempt + 1); } if ('errorMessage' in data) { @@ -181,314 +938,6 @@ export default class CoralApi implements CoralApiInterface { return result as Result; } - - async call( - url: string, parameter = {}, - /** @internal */ _autoRenewToken = true - ) { - const uuid = randomUUID(); - - return this.fetch(url, 'POST', JSON.stringify({ - parameter, - requestId: uuid, - }), {}, _autoRenewToken); - } - - async getAnnouncements() { - return this.call('/v1/Announcement/List'); - } - - async getFriendList() { - return this.call('/v3/Friend/List'); - } - - async addFavouriteFriend(nsa_id: string) { - return this.call<{}>('/v3/Friend/Favorite/Create', { - nsaId: nsa_id, - }); - } - - async removeFavouriteFriend(nsa_id: string) { - return this.call<{}>('/v3/Friend/Favorite/Delete', { - nsaId: nsa_id, - }); - } - - async getWebServices() { - return this.call('/v1/Game/ListWebServices'); - } - - async getActiveEvent() { - return this.call('/v1/Event/GetActiveEvent'); - } - - async getEvent(id: number) { - return this.call('/v1/Event/Show', { - id, - }); - } - - async getUser(id: number) { - return this.call('/v3/User/Show', { - id, - }); - } - - async getUserByFriendCode(friend_code: string, hash?: string) { - if (!FRIEND_CODE.test(friend_code)) throw new Error('Invalid friend code'); - if (hash && !FRIEND_CODE_HASH.test(hash)) throw new Error('Invalid friend code hash'); - - return hash ? this.call('/v3/Friend/GetUserByFriendCodeHash', { - friendCode: friend_code, - friendCodeHash: hash, - }) : this.call('/v3/Friend/GetUserByFriendCode', { - friendCode: friend_code, - }); - } - - async sendFriendRequest(nsa_id: string) { - return this.call<{}>('/v3/FriendRequest/Create', { - nsaId: nsa_id, - }); - } - - async getCurrentUser() { - return this.call('/v3/User/ShowSelf'); - } - - async getFriendCodeUrl() { - return this.call('/v3/Friend/CreateFriendCodeUrl'); - } - - async getCurrentUserPermissions() { - return this.call('/v3/User/Permissions/ShowSelf'); - } - - async updateCurrentUserPermissions(to: PresencePermissions, from: PresencePermissions, etag: string) { - return this.call<{}>('/v3/User/Permissions/UpdateSelf', { - permissions: { - presence: { - toValue: to, - fromValue: from, - }, - }, - etag, - }); - } - - async getWebServiceToken(id: number, /** @internal */ _attempt = 0): Promise> { - await this._renewToken; - - const data = await f(this.token, HashMethod.WEB_SERVICE, { - platform: this[ClientInfoSymbol].platform, - version: this[ClientInfoSymbol].version, - useragent: this.useragent ?? getAdditionalUserAgents(), - user: {na_id: this[NintendoAccountIdSymbol], coral_user_id: this[CoralUserIdSymbol]}, - }); - - const req: WebServiceTokenParameter = { - id, - registrationToken: '', - f: data.f, - requestId: data.request_id, - timestamp: data.timestamp, - }; - - try { - const uuid = randomUUID(); - - return this.fetch('/v2/Game/GetWebServiceToken', 'POST', JSON.stringify({ - parameter: req, - requestId: uuid, - }), { - 'X-IntegrityTokenError': 'NETWORK_ERROR', - }, false); - } catch (err) { - if (err instanceof CoralErrorResponse && err.status === CoralStatus.TOKEN_EXPIRED && !_attempt && this.onTokenExpired) { - debug('Error getting web service token, renewing token before retrying', err); - // _renewToken will be awaited when calling getWebServiceToken - this._renewToken = this._renewToken ?? this.onTokenExpired.call(null, err.data, err.response as Response).then(data => { - if (data) this.setTokenWithSavedToken(data); - }).finally(() => { - this._renewToken = null; - }); - return this.getWebServiceToken(id, _attempt + 1); - } else { - throw err; - } - } - } - - async getToken(token: string, user: NintendoAccountUser): Promise { - const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID); - - return this.getTokenWithNintendoAccountToken(nintendoAccountToken, user); - } - - async getTokenWithNintendoAccountToken( - nintendoAccountToken: NintendoAccountToken, user: NintendoAccountUser, - ): Promise { - const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, { - platform: this[ClientInfoSymbol].platform, - version: this[ClientInfoSymbol].version, - useragent: this.useragent ?? getAdditionalUserAgents(), - user: {na_id: user.id, coral_user_id: this[CoralUserIdSymbol]}, - }); - - const req: AccountTokenParameter = { - naBirthday: user.birthday, - timestamp: fdata.timestamp, - f: fdata.f, - requestId: fdata.request_id, - naIdToken: nintendoAccountToken.id_token, - }; - - const uuid = randomUUID(); - - const data = await this.fetch('/v3/Account/GetToken', 'POST', JSON.stringify({ - parameter: req, - requestId: uuid, - }), { - 'X-IntegrityTokenError': 'NETWORK_ERROR', - }, false); - - return { - nintendoAccountToken, - // user, - f: fdata, - nsoAccount: data, - credential: data.webApiServerCredential, - }; - } - - async renewToken(token: string, user: NintendoAccountUser) { - const data = await this.getToken(token, user); - this.setTokenWithSavedToken(data); - return data; - } - - async renewTokenWithNintendoAccountToken(token: NintendoAccountToken, user: NintendoAccountUser) { - const data = await this.getTokenWithNintendoAccountToken(token, user); - this.setTokenWithSavedToken(data); - return data; - } - - protected setTokenWithSavedToken(data: CoralAuthData | PartialCoralAuthData) { - this.token = data.credential.accessToken; - this[CoralUserIdSymbol] = '' + data.nsoAccount.user.id; - if ('user' in data) this[NintendoAccountIdSymbol] = data.user.id; - this._token_expired = false; - } - - static async createWithSessionToken(token: string, useragent = getAdditionalUserAgents()) { - const data = await this.loginWithSessionToken(token, useragent); - return {nso: this.createWithSavedToken(data, useragent), data}; - } - - static async createWithNintendoAccountToken( - token: NintendoAccountToken, user: NintendoAccountUser, - useragent = getAdditionalUserAgents() - ) { - const data = await this.loginWithNintendoAccountToken(token, user, useragent); - return {nso: this.createWithSavedToken(data, useragent), data}; - } - - static createWithSavedToken(data: CoralAuthData, useragent = getAdditionalUserAgents()) { - return new this( - data.credential.accessToken, - useragent, - '' + data.nsoAccount.user.id, - data.user.id, - data.znca_version, - data.znca_useragent, - ); - } - - static async loginWithSessionToken(token: string, useragent = getAdditionalUserAgents()): Promise { - const { default: { coral: config } } = await import('../common/remote-config.js'); - if (!config) throw new Error('Remote configuration prevents Coral authentication'); - - // Nintendo Account token - const nintendoAccountToken = await getNintendoAccountToken(token, ZNCA_CLIENT_ID); - - // Nintendo Account user data - const user = await getNintendoAccountUser(nintendoAccountToken); - - return this.loginWithNintendoAccountToken(nintendoAccountToken, user, useragent); - } - - static async loginWithNintendoAccountToken( - nintendoAccountToken: NintendoAccountToken, - user: NintendoAccountUser, - useragent = getAdditionalUserAgents(), - ) { - const { default: { coral: config } } = await import('../common/remote-config.js'); - - if (!config) throw new Error('Remote configuration prevents Coral authentication'); - const znca_useragent = `com.nintendo.znca/${config.znca_version}(${ZNCA_PLATFORM}/${ZNCA_PLATFORM_VERSION})`; - - const fdata = await f(nintendoAccountToken.id_token, HashMethod.CORAL, { - platform: ZNCA_PLATFORM, - version: config.znca_version, - useragent, - user: {na_id: user.id}, - }); - - debug('Getting Nintendo Switch Online app token'); - - const parameter: AccountLoginParameter = { - naIdToken: nintendoAccountToken.id_token, - naBirthday: user.birthday, - naCountry: user.country, - language: user.language, - timestamp: fdata.timestamp, - requestId: fdata.request_id, - f: fdata.f, - }; - - const [signal, cancel] = timeoutSignal(); - const response = await fetch(ZNC_URL + '/v3/Account/Login', { - method: 'POST', - headers: { - 'X-Platform': ZNCA_PLATFORM, - 'X-ProductVersion': config.znca_version, - 'X-IntegrityTokenError': 'NETWORK_ERROR', - 'Content-Type': 'application/json; charset=utf-8', - 'User-Agent': znca_useragent, - }, - body: JSON.stringify({ - parameter, - }), - signal, - }).finally(cancel); - - debug('fetch %s %s, response %s', 'POST', '/v3/Account/Login', response.status); - - if (response.status !== 200) { - throw await CoralErrorResponse.fromResponse(response, '[znc] Non-200 status code'); - } - - const data = await response.json() as CoralResponse; - - if ('errorMessage' in data) { - throw new CoralErrorResponse('[znc] ' + data.errorMessage, response, data); - } - if (data.status !== CoralStatus.OK) { - throw new CoralErrorResponse('[znc] Unknown error', response, data); - } - - debug('Got Nintendo Switch Online app token', data); - - return { - nintendoAccountToken, - user, - f: fdata, - nsoAccount: data.result, - credential: data.result.webApiServerCredential, - znca_version: config.znca_version, - znca_useragent, - }; - } } export class CoralErrorResponse extends ErrorResponse implements HasErrorDescription { @@ -510,9 +959,14 @@ export class CoralErrorResponse extends ErrorResponse implements Has const na_client_settings = { client_id: ZNCA_CLIENT_ID, - scope: 'openid user user.birthday user.mii user.screenName', + scope: 'openid user user.birthday user.screenName', }; +export type NintendoAccountUserCoral = + NintendoAccountUser | + // Nintendo Account session token obtained before 3.0.1 + NintendoAccountUser; + export class NintendoAccountSessionAuthorisationCoral extends NintendoAccountSessionAuthorisation { protected constructor( authorise_url: string, @@ -535,12 +989,14 @@ export class NintendoAccountSessionAuthorisationCoral extends NintendoAccountSes export interface CoralAuthData { nintendoAccountToken: NintendoAccountToken; - user: NintendoAccountUser; + user: NintendoAccountUserCoral; f: FResult; nsoAccount: AccountLogin; credential: AccountLogin['webApiServerCredential']; znca_version: string; znca_useragent: string; + + [ZncaApiSymbol]?: ZncaApi; } export type PartialCoralAuthData = diff --git a/src/api/f.ts b/src/api/f.ts index 9058cd8..aa7b39d 100644 --- a/src/api/f.ts +++ b/src/api/f.ts @@ -311,6 +311,7 @@ export type FResult = { f: string; user?: {na_id: string; coral_user_id?: string;}; result: unknown; + encrypt_request_result?: Uint8Array; } & ({ provider: 'flapg'; result: FlapgApiResponse; @@ -328,6 +329,15 @@ interface ZncaApiOptions { platform?: string; version?: string; user?: {na_id: string; coral_user_id?: string;}; + encrypt_request?: EncryptRequestOptions; +} +interface EncryptRequestOptions { + url: string; + parameter: AccountLoginParameter | AccountTokenParameter | WebServiceTokenParameter; +} + +export async function createZncaApi(options?: ZncaApiOptions) { + return getPreferredZncaApiFromEnvironment(options) ?? await getDefaultZncaApi(options); } export function getPreferredZncaApiFromEnvironment(options?: ZncaApiOptions): ZncaApi | null; diff --git a/src/api/moon.ts b/src/api/moon.ts index 0c661a2..2b1c984 100644 --- a/src/api/moon.ts +++ b/src/api/moon.ts @@ -198,6 +198,8 @@ const na_client_settings = { ].join(' '), }; +export type NintendoAccountUserMoon = NintendoAccountUser; + export class NintendoAccountSessionAuthorisationMoon extends NintendoAccountSessionAuthorisation { protected constructor( authorise_url: string, @@ -220,7 +222,7 @@ export class NintendoAccountSessionAuthorisationMoon extends NintendoAccountSess export interface MoonAuthData { nintendoAccountToken: NintendoAccountToken; - user: NintendoAccountUser; + user: NintendoAccountUserMoon; znma_version: string; znma_build: string; znma_useragent: string; diff --git a/src/api/na.ts b/src/api/na.ts index b8480f8..caab01f 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -11,6 +11,8 @@ const debug = createDebug('nxapi:api:na'); export class NintendoAccountSessionAuthorisation { readonly scope: string; + result: NintendoAccountSessionAuthorisationResult | null = null; + protected constructor( readonly client_id: string, scope: string | string[], @@ -20,13 +22,23 @@ export class NintendoAccountSessionAuthorisation { readonly redirect_uri = 'npf' + client_id + '://auth', ) { this.scope = typeof scope === 'string' ? scope : scope.join(' '); + + Object.defineProperty(this, 'result', {enumerable: false}); } async getSessionToken(code: string, state?: string): Promise> async getSessionToken(params: URLSearchParams): Promise> async getSessionToken(code: string | URLSearchParams | null, state?: string | null) { + if (this.result) { + throw new Error('NintendoAccountSessionAuthorisation already completed'); + } + + let result_state = state; + if (code instanceof URLSearchParams) { - if (code.get('state') !== this.state) { + const result_state = code.get('state'); + + if (result_state !== this.state) { throw new TypeError('Invalid state'); } @@ -46,6 +58,8 @@ export class NintendoAccountSessionAuthorisation { throw new TypeError('Invalid code'); } + Object.defineProperty(this, 'result', {value: {state: result_state, code}, writable: false}); + return getNintendoAccountSessionToken(code, this.verifier, this.client_id); } @@ -63,6 +77,11 @@ export class NintendoAccountSessionAuthorisation { } } +interface NintendoAccountSessionAuthorisationResult { + state: string | null | undefined; + code: string; +} + export class NintendoAccountSessionAuthorisationError extends Error { constructor(readonly code: string, message?: string) { super(message); @@ -182,7 +201,7 @@ export async function getNintendoAccountToken(token: string, client_id: string) return defineResponse(nintendoAccountToken, response); } -export async function getNintendoAccountUser(token: NintendoAccountToken) { +export async function getNintendoAccountUser(token: NintendoAccountToken) { debug('Getting Nintendo Account user info'); const [signal, cancel] = timeoutSignal(); @@ -201,7 +220,7 @@ export async function getNintendoAccountUser(token: NintendoAccountToken) { throw await NintendoAccountErrorResponse.fromResponse(response, '[na] Non-200 status code'); } - const user = await response.json() as NintendoAccountUser | NintendoAccountError; + const user = await response.json() as NintendoAccountUser | NintendoAccountError; if ('errorCode' in user) { throw new NintendoAccountErrorResponse('[na] ' + user.detail, response, user); @@ -221,7 +240,7 @@ export interface NintendoAccountSessionTokenJwtPayload extends JwtPayload { typ: 'session_token'; iss: 'https://accounts.nintendo.com'; /** Unknown - scopes the token is valid for? */ - 'st:scp': number[]; + 'st:scp': NintendoAccountJwtScope[]; /** Subject (Nintendo Account ID) */ sub: string; exp: number; @@ -231,7 +250,7 @@ export interface NintendoAccountSessionTokenJwtPayload extends JwtPayload { } export interface NintendoAccountToken { - scope: string[]; + scope: NintendoAccountScope[]; token_type: 'Bearer'; id_token: string; access_token?: string; @@ -246,7 +265,7 @@ export interface NintendoAccountIdTokenJwtPayload extends JwtPayload { aud: string; iss: 'https://accounts.nintendo.com'; jti: string; - at_hash: string; // ?? + at_hash: string; typ: 'id_token'; country: string; } @@ -258,7 +277,7 @@ export interface NintendoAccountAccessTokenJwtPayload extends JwtPayload { sub: string; iat: number; 'ac:grt': number; // ?? - 'ac:scp': number[]; // ?? + 'ac:scp': NintendoAccountJwtScope[]; exp: number; /** Audience (client ID) */ aud: string; @@ -329,70 +348,121 @@ export enum NintendoAccountJwtScope { // 'userNotificationMessage:anyClients:write' = -1, } -export interface NintendoAccountUser { - emailOptedIn: boolean; - language: string; - country: string; - timezone: { - name: string; - id: string; - utcOffsetSeconds: number; - utcOffset: string; - }; - region: null; +export interface NintendoAccountUser { + id: string; nickname: string; - clientFriendsOptedIn: boolean; - mii: Mii | null; + iconUri: string; + /** requires scope user.screenName */ + screenName: NintendoAccountScope.USER_SCREENNAME extends S ? string : undefined; + /** requires scope user.birthday */ + birthday: NintendoAccountScope.USER_BIRTHDAY extends S ? string : undefined; isChild: boolean; - eachEmailOptedIn: { - survey: { - updatedAt: number; - optedIn: boolean; - }; - deals: { - updatedAt: number; - optedIn: boolean; - }; - }; + gender: NintendoAccountGender; + /** requires scope user.email */ + email: NintendoAccountScope.USER_EMAIL extends S ? string : undefined; + emailVerified: boolean; + phoneNumberEnabled: boolean; + /** requires scope user.links[].id/user.links.*.id */ + links: + NintendoAccountScope.USER_LINKS extends S ? + Partial> | null : + Partial, NintendoAccountLink | null>> | null | undefined; + country: string; + region: null; + language: string; + timezone: NintendoAccountTimezone; + /** requires scope user.mii */ + mii: NintendoAccountScope.USER_MII extends S ? Mii | null : undefined; + /** requires scope user.mii */ + candidateMiis: NintendoAccountScope.USER_MII extends S ? unknown[] : undefined; + emailOptedIn: boolean; + emailOptedInUpdatedAt: number; + eachEmailOptedIn: Record; + clientFriendsOptedIn: boolean; + clientFriendsOptedInUpdatedAt: number; + analyticsOptedIn: boolean; + analyticsOptedInUpdatedAt: number; + analyticsPermissions: Record; + createdAt: number; updatedAt: number; - candidateMiis: unknown[]; +} + +enum NintendoAccountGender { + UNKNOWN = 'unknown', + FEMALE = 'female', + MALE = 'male', +} + +enum NintendoAccountLinkType { + NINTENDO_NETWORK = 'nintendoNetwork', + APPLE = 'apple', + GOOGLE = 'google', +} +interface NintendoAccountLink { id: string; createdAt: number; - emailVerified: boolean; - analyticsPermissions: { - internalAnalysis: { - updatedAt: number; - permitted: boolean; - }; - targetMarketing: { - updatedAt: number; - permitted: boolean; - }; - }; - emailOptedInUpdatedAt: number; - birthday: string; - screenName: string; - gender: string; - analyticsOptedInUpdatedAt: number; - analyticsOptedIn: boolean; - clientFriendsOptedInUpdatedAt: number; +} + +type NintendoAccountLinkScope = { + [NintendoAccountScope.USER_LINKS_NNID]: NintendoAccountLinkType.NINTENDO_NETWORK; +}; +type NintendoAccountLinkTypes = + S extends keyof NintendoAccountLinkScope ? NintendoAccountLinkScope[S] : never; + +interface NintendoAccountTimezone { + id: string; + name: string; + utcOffset: string; + utcOffsetSeconds: number; +} + +enum NintendoAccountEmailType { + SURVEY = 'survey', + DEALS = 'deals', +} +interface NintendoAccountEmailOptedIn { + optedIn: boolean; + updatedAt: number; +} + +enum NintendoAccountAnalyticsType { + INTERNAL_ANALYSIS = 'internalAnalysis', + TARGET_MARKETING = 'targetMarketing', +} +interface NintendoAccountAnalyticsPermission { + updatedAt: number; + permitted: boolean; } export interface Mii { - favoriteColor: string; id: string; - updatedAt: number; + clientId: '1cfe3a55ed8924d9'; + type: 'profile'; + favoriteColor: MiiColour; coreData: { '4': string; }; - clientId: '1cfe3a55ed8924d9'; - imageUriTemplate: string; storeData: { '3': string; }; + imageUriTemplate: string; imageOrigin: string; etag: string; - type: 'profile'; + updatedAt: number; +} +export enum MiiColour { + RED = 'red', + ORANGE = 'orange', + YELLOW = 'yellow', + YELLOWGREEN = 'yellowgreen', + GREEN = 'green', + BLUE = 'blue', + SKYBLUE = 'skyblue', + PINK = 'pink', + PURPLE = 'purple', + BROWN = 'brown', + WHITE = 'white', + BLACK = 'black', } export interface NintendoAccountAuthError { diff --git a/src/api/util.ts b/src/api/util.ts index ffc5b61..077d13d 100644 --- a/src/api/util.ts +++ b/src/api/util.ts @@ -55,10 +55,10 @@ export class ErrorResponse extends Error { }); } - static async fromResponse(response: UndiciResponse, message: string) { + static async fromResponse(response: UndiciResponse, message: string) { const body = await response.arrayBuffer(); - return new this(message, response, body); + return new this(message, response, body); } } diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 7881027..00ac552 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -1,7 +1,7 @@ import { fetch, Response } from 'undici'; -import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl } from './coral-types.js'; +import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl, WebService_4, Media, Announcements_4, Friend_4 } from './coral-types.js'; import { defineResponse, ErrorResponse, ResponseSymbol } from './util.js'; -import { CoralApiInterface, CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, ResponseDataSymbol, Result } from './coral.js'; +import { AbstractCoralApi, CoralApiInterface, CoralAuthData, CorrelationIdSymbol, PartialCoralAuthData, RequestFlagAddPlatformSymbol, RequestFlagAddProductVersionSymbol, RequestFlagNoParameterSymbol, RequestFlagRequestIdSymbol, RequestFlags, ResponseDataSymbol, Result } from './coral.js'; import { NintendoAccountToken, NintendoAccountUser } from './na.js'; import { SavedToken } from '../common/auth/coral.js'; import createDebug from '../util/debug.js'; @@ -10,18 +10,25 @@ import { getAdditionalUserAgents, getUserAgent } from '../util/useragent.js'; const debug = createDebug('nxapi:api:znc-proxy'); -export default class ZncProxyApi implements CoralApiInterface { +export default class ZncProxyApi extends AbstractCoralApi implements CoralApiInterface { constructor( - private url: string, + private url: URL | string, // ZncApi uses the NSO token (valid for a few hours) // ZncProxyApi uses the Nintendo Account session token (valid for two years) public token: string, public useragent = getAdditionalUserAgents() - ) {} + ) { + super(); + } + + async fetchProxyApi(url: URL | string, method = 'GET', body?: string, headers?: object) { + if (typeof url === 'string' && url.startsWith('/')) url = url.substring(1); + + const base_url = typeof this.url === 'string' ? new URL(this.url) : this.url; + if (typeof this.url === 'string' && !base_url.pathname.endsWith('/')) base_url.pathname += '/'; - async fetch(url: string, method = 'GET', body?: string, headers?: object) { const [signal, cancel] = timeoutSignal(); - const response = await fetch(this.url + url, { + const response = await fetch(new URL(url, this.url), { method, headers: Object.assign({ 'Authorization': 'na ' + this.token, @@ -31,7 +38,8 @@ export default class ZncProxyApi implements CoralApiInterface { signal, }).finally(cancel); - debug('fetch %s %s, response %s', method, url, response.status); + const debug_url = typeof url === 'string' ? '/' + url : url.toString(); + debug('fetch %s %s, response %s', method, debug_url, response.status); if (!response.ok) { throw await ZncProxyErrorResponse.fromResponse(response, '[zncproxy] Non-2xx status code'); @@ -42,70 +50,97 @@ export default class ZncProxyApi implements CoralApiInterface { return defineResponse(data, response); } - async call(url: string, parameter = {}): Promise> { - throw new Error('Not supported in ZncProxyApi'); + async call(url: string, parameter: R & Partial = {} as R): Promise> { + const options: [string, unknown][] = []; + + if (parameter[RequestFlagAddPlatformSymbol]) options.push(['add_platform', true]); + if (parameter[RequestFlagAddProductVersionSymbol]) options.push(['add_version', true]); + if (parameter[RequestFlagNoParameterSymbol]) options.push(['no_parameter', true]); + if (RequestFlagRequestIdSymbol in parameter) options.push(['request_id', parameter[RequestFlagRequestIdSymbol]]); + + const result = await this.fetchProxyApi<{result: T}>('call', 'POST', JSON.stringify({ + url, + parameter, + + options: options.length ? Object.fromEntries(options) : undefined, + })); + + return createResult(result, result.result); } async getAnnouncements() { - const result = await this.fetch<{announcements: Announcements}>('/announcements'); + const result = await this.fetchProxyApi<{announcements: Announcements_4}>('announcements'); return createResult(result, result.announcements); } async getFriendList() { - const result = await this.fetch<{friends: Friend[]}>('/friends'); - return createResult(result, result); + const result = await this.fetchProxyApi<{friends: Friend_4[]; extract_ids?: string[]}>('friends'); + + return createResult(result, Object.assign(result, { + extractFriendsIds: result.extract_ids ?? result.friends.slice(0, 10).map(f => f.nsaId), + })); } async addFavouriteFriend(nsa_id: string) { - const result = await this.fetch('/friend/' + nsa_id, 'POST', JSON.stringify({ + const result = await this.fetchProxyApi('friend/' + nsa_id, 'PATCH', JSON.stringify({ isFavoriteFriend: true, })); return createResult(result, {}); } async removeFavouriteFriend(nsa_id: string) { - const result = await this.fetch('/friend/' + nsa_id, 'POST', JSON.stringify({ + const result = await this.fetchProxyApi('friend/' + nsa_id, 'PATCH', JSON.stringify({ isFavoriteFriend: false, })); return createResult(result, {}); } async getWebServices() { - const result = await this.fetch<{webservices: WebService[]}>('/webservices'); + const result = await this.fetchProxyApi<{webservices: WebService_4[]}>('webservices'); return createResult(result, result.webservices); } + async getChats() { + const result = await this.fetchProxyApi<{chats: unknown[]}>('chats'); + return createResult(result, result.chats); + } + + async getMedia() { + const result = await this.fetchProxyApi<{media: Media[]}>('media'); + return createResult(result, result); + } + async getActiveEvent() { - const result = await this.fetch<{activeevent: ActiveEvent}>('/activeevent'); + const result = await this.fetchProxyApi<{activeevent: ActiveEvent}>('activeevent'); return createResult(result, result.activeevent); } async getEvent(id: number) { - const result = await this.fetch<{event: Event}>('/event/' + id); + const result = await this.fetchProxyApi<{event: Event}>('event/' + id); return createResult(result, result.event); } async getUser(id: number) { - const result = await this.fetch<{user: User}>('/user/' + id); + const result = await this.fetchProxyApi<{user: User}>('user/' + id); return createResult(result, result.user); } async getUserByFriendCode(friend_code: string, hash?: string) { - const result = await this.fetch<{user: FriendCodeUser}>('/friendcode/' + friend_code); + const result = await this.fetchProxyApi<{user: FriendCodeUser}>('friendcode/' + friend_code); return createResult(result, result.user); } - async sendFriendRequest(nsa_id: string): Promise> { - throw new Error('Not supported in ZncProxyApi'); - } + // async sendFriendRequest(nsa_id: string): Promise> { + // throw new Error('Not supported in ZncProxyApi'); + // } async getCurrentUser() { - const result = await this.fetch<{user: CurrentUser}>('/user'); + const result = await this.fetchProxyApi<{user: CurrentUser}>('user'); return createResult(result, result.user); } async getFriendCodeUrl() { - const result = await this.fetch<{friendcode: FriendCodeUrl}>('/friendcode'); + const result = await this.fetchProxyApi<{friendcode: FriendCodeUrl}>('friendcode'); return createResult(result, result.friendcode); } @@ -118,14 +153,14 @@ export default class ZncProxyApi implements CoralApiInterface { }); } - async updateCurrentUserPermissions( - to: PresencePermissions, from: PresencePermissions, etag: string - ): Promise> { - throw new Error('Not supported in ZncProxyApi'); - } + // async updateCurrentUserPermissions( + // to: PresencePermissions, from: PresencePermissions, etag: string + // ): Promise> { + // throw new Error('Not supported in ZncProxyApi'); + // } async getWebServiceToken(id: number) { - const result = await this.fetch<{token: WebServiceToken}>('/webservice/' + id + '/token'); + const result = await this.fetchProxyApi<{token: WebServiceToken}>('webservice/' + id + '/token'); return createResult(result, result.token); } @@ -140,8 +175,8 @@ export default class ZncProxyApi implements CoralApiInterface { } async renewToken() { - const data = await this.fetch('/auth'); - data.proxy_url = this.url; + const data = await this.fetchProxyApi('auth'); + data.proxy_url = this.url.toString(); return data; } @@ -157,7 +192,7 @@ export default class ZncProxyApi implements CoralApiInterface { static async createWithSessionToken(url: string, token: string) { const nso = new this(url, token); - const data = await nso.fetch('/auth'); + const data = await nso.fetchProxyApi('auth'); data.proxy_url = url; return {nso, data}; @@ -190,6 +225,8 @@ export interface AuthToken { created_at: number; } export interface AuthPolicy { + api?: boolean; + announcements?: boolean; list_friends?: boolean; list_friends_presence?: boolean; diff --git a/src/app/browser/util.tsx b/src/app/browser/util.tsx index 685b5bd..e6ace61 100644 --- a/src/app/browser/util.tsx +++ b/src/app/browser/util.tsx @@ -4,12 +4,13 @@ import { ColorSchemeName, LayoutChangeEvent, Platform, StyleProp, StyleSheet, us import { i18n, TFunction } from 'i18next'; import { I18nextProvider, initReactI18next } from 'react-i18next'; import type { User as DiscordUser } from 'discord-rpc'; +import { NintendoAccountUserCoral } from '../../api/coral.js'; +import { NintendoAccountUserMoon } from '../../api/moon.js'; import { ErrorResponse } from '../../api/util.js'; -import { DiscordPresence } from '../../discord/types.js'; -import ipc, { events } from './ipc.js'; -import { NintendoAccountUser } from '../../api/na.js'; import { SavedToken } from '../../common/auth/coral.js'; import { SavedMoonToken } from '../../common/auth/moon.js'; +import { DiscordPresence } from '../../discord/types.js'; +import ipc, { events } from './ipc.js'; import { BACKGROUND_COLOUR_MAIN_DARK, BACKGROUND_COLOUR_MAIN_LIGHT, DEFAULT_ACCENT_COLOUR } from './constants.js'; import createI18n from '../i18n/index.js'; @@ -243,7 +244,7 @@ export function useColourScheme() { } export interface User { - user: NintendoAccountUser; + user: NintendoAccountUserCoral | NintendoAccountUserMoon; nso: SavedToken | null; nsotoken: string | undefined; moon: SavedMoonToken | null; diff --git a/src/cli/nso/http-server.ts b/src/cli/nso/http-server.ts index 69683f0..522c3e1 100644 --- a/src/cli/nso/http-server.ts +++ b/src/cli/nso/http-server.ts @@ -6,8 +6,8 @@ import * as persist from 'node-persist'; import express, { Request, RequestHandler, Response } from 'express'; import bodyParser from 'body-parser'; import type { Arguments as ParentArguments } from './index.js'; -import CoralApi, { CoralApiInterface, CoralErrorResponse } from '../../api/coral.js'; -import { Announcement, CoralStatus, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, Presence } from '../../api/coral-types.js'; +import CoralApi, { CoralApiInterface, CoralErrorResponse, RequestFlagAddPlatformSymbol, RequestFlagAddProductVersionSymbol, RequestFlagNoParameterSymbol, RequestFlagRequestId, RequestFlagRequestIdSymbol, RequestFlags } from '../../api/coral.js'; +import { Announcement, Announcement_4, CoralStatus, CurrentUser, Friend, FriendCodeUrl, FriendCodeUser, Presence } from '../../api/coral-types.js'; import ZncProxyApi, { AuthPolicy, AuthToken, ZncPresenceEventStreamEvent } from '../../api/znc-proxy.js'; import createDebug from '../../util/debug.js'; import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; @@ -134,6 +134,9 @@ class Server extends HttpServer { app.post('/api/znc/tokens', bodyParser.json(), this.createProxyRequestHandler(r => this.handleCreateTokenRequest(r), true)); + app.post('/api/znc/call', this.authTokenMiddleware, bodyParser.json(), + this.createProxyRequestHandler(r => this.handleApiCallRequest(r))); + app.get('/api/znc/announcements', this.authTokenMiddleware, this.localAuthMiddleware, this.createProxyRequestHandler(r => this.handleAnnouncementsRequest(r), true)); @@ -155,6 +158,8 @@ class Server extends HttpServer { this.createProxyRequestHandler(r => this.handleFriendRequest(r, r.req.params.nsaid))); app.post('/api/znc/friend/:nsaid', bodyParser.json(), this.createProxyRequestHandler(r => this.handleUpdateFriendRequest(r, r.req.params.nsaid), true)); + app.patch('/api/znc/friend/:nsaid', bodyParser.json(), + this.createProxyRequestHandler(r => this.handleUpdateFriendRequest(r, r.req.params.nsaid), true)); app.get('/api/znc/friend/:nsaid/presence', this.authTokenMiddleware, this.localAuthMiddleware, this.createProxyRequestHandler(r => this.handleFriendPresenceRequest(r, r.req.params.nsaid))); @@ -321,7 +326,7 @@ class Server extends HttpServer { async handleAuthRequest({user}: RequestDataWithUser) { if (user.nso instanceof ZncProxyApi) { - return user.nso.fetch('/auth'); + return user.nso.fetchProxyApi('/auth'); } else { return user.data; } @@ -386,6 +391,56 @@ class Server extends HttpServer { }; } + // + // Coral API call + // + + async handleApiCallRequest({req, policy}: RequestData) { + if (policy && !policy.api) { + throw new ResponseError(403, 'token_unauthorised'); + } + + const flags: Partial = {}; + + if (req.body.options?.add_platform) flags[RequestFlagAddPlatformSymbol] = true; + if (req.body.options?.add_version) flags[RequestFlagAddProductVersionSymbol] = true; + if (req.body.options?.no_parameter) flags[RequestFlagNoParameterSymbol] = true; + + if (req.body.options && 'request_id' in req.body.options) { + if (typeof req.body.options.request_id !== 'number' || !RequestFlagRequestId[req.body.options.request_id]) { + throw new ResponseError(400, 'invalid_request', 'Invalid options.request_id'); + } + + flags[RequestFlagRequestIdSymbol] = req.body.options.request_id; + } + + if (typeof req.body.url !== 'string') { + throw new ResponseError(400, 'invalid_request', 'Invalid url field'); + } + + if (!('parameter' in req.body) && flags[RequestFlagNoParameterSymbol]) { + // parameter is excluded for /v3/User/Permissions/ShowSelf + // parameter is excluded for /v3/Friend/CreateFriendCodeUrl + // allow just not providing it + } else if (typeof req.body.parameter !== 'object' || !req.body.parameter) { + // parameter is an array for /v5/PushNotification/Settings/Update + throw new ResponseError(400, 'invalid_request', 'Invalid parameter field'); + } + + const user = await this.getCoralUser(req); + + if (!(user.nso instanceof CoralApi) && !(user.nso instanceof ZncProxyApi)) { + throw new ResponseError(500, 'unknown_error'); + } + + const result = await user.nso.call(req.body.url, { + ...req.body.parameter ?? null, + ...flags, + }); + + return result; + } + // // Announcements // This is cached for all users. @@ -398,7 +453,7 @@ class Server extends HttpServer { const user = await this.getCoralUser(req); - const announcements: Announcement[] = user.announcements.result; + const announcements: Announcement_4[] = user.announcements.result; return {announcements}; } @@ -451,6 +506,7 @@ class Server extends HttpServer { const user = await this.getCoralUser(req); const friends = await user.getFriends(); + const extract_ids = user.friends.result.extractFriendsIds; const updated = user.updated.friends; res.setHeader('Cache-Control', 'private, immutable, max-age=' + cacheMaxAge(updated, this.update_interval)); @@ -458,6 +514,8 @@ class Server extends HttpServer { return { friends: policy?.friends ? friends.filter(f => policy.friends!.includes(f.nsaId)) : friends, + extract_ids: policy?.friends ? + extract_ids.filter(id => policy.friends!.includes(id)) : extract_ids, updated, }; } diff --git a/src/cli/nso/znc-proxy-tokens.ts b/src/cli/nso/znc-proxy-tokens.ts index bc6629d..98f5c40 100644 --- a/src/cli/nso/znc-proxy-tokens.ts +++ b/src/cli/nso/znc-proxy-tokens.ts @@ -39,7 +39,7 @@ export function builder(yargs: Argv) { await storage.getItem('NintendoAccountToken.' + usernsid); const {nso, data} = await getToken(storage, token, argv.zncProxyUrl); - const tokens = await nso.fetch('/tokens'); + const tokens = await nso.fetchProxyApi('/tokens'); const table = new Table({ head: [ @@ -138,7 +138,7 @@ export function builder(yargs: Argv) { friends_presence: argv.policyFriendsPresence as string[] | undefined, } : null; - const auth = await nso.fetch<{token: string;} & AuthToken>('/tokens', 'POST', JSON.stringify({policy}), { + const auth = await nso.fetchProxyApi<{token: string;} & AuthToken>('/tokens', 'POST', JSON.stringify({policy}), { 'Content-Type': 'application/json', }); diff --git a/src/common/notify.ts b/src/common/notify.ts index 5ce62ba..8a94a3a 100644 --- a/src/common/notify.ts +++ b/src/common/notify.ts @@ -85,13 +85,13 @@ export class ZncNotifications extends Loop { imageUri: '', channel: FriendRouteChannel.FRIEND_CODE, }, - presence: await nso.fetch('/friend/' + r.friend + '/presence'), + presence: await nso.fetchProxyApi('/friend/' + r.friend + '/presence'), }; return friend; } - return (await nso.fetch<{friend: Friend}>('/friend/' + r.friend)).friend; + return (await nso.fetchProxyApi<{friend: Friend}>('/friend/' + r.friend)).friend; })); } if (req.includes('webservices')) { diff --git a/src/common/users.ts b/src/common/users.ts index e713210..6d848bc 100644 --- a/src/common/users.ts +++ b/src/common/users.ts @@ -2,12 +2,11 @@ import * as crypto from 'node:crypto'; import * as persist from 'node-persist'; import { Response } from 'undici'; import createDebug from '../util/debug.js'; -import CoralApi, { CoralApiInterface, Result } from '../api/coral.js'; +import CoralApi, { CoralApiInterface, NintendoAccountUserCoral, Result } from '../api/coral.js'; import ZncProxyApi from '../api/znc-proxy.js'; -import { Announcements, Friends, Friend, GetActiveEventResult, CoralSuccessResponse, WebService, WebServices, CoralError } from '../api/coral-types.js'; +import { Announcements, Friends, Friend, GetActiveEventResult, CoralSuccessResponse, WebService, WebServices, CoralError, Announcements_4, Friends_4, WebServices_4, ListMedia, ListChat, WebService_4, CurrentUser } 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'; const debug = createDebug('nxapi:users'); @@ -64,14 +63,20 @@ export default class Users { return new Users(async token => { const {nso, data} = await getToken(storage, token, znc_proxy_url, ratelimit); - const [announcements, friends, webservices, active_event] = await Promise.all([ + const [announcements, friends, webservices, chats, media, active_event, coral_user] = await Promise.all([ nso.getAnnouncements(), nso.getFriendList(), nso.getWebServices(), + nso.getChats(), + nso.getMedia(), nso.getActiveEvent(), + nso.getCurrentUser(), ]); - const user = new CoralUser(nso, data, announcements, friends, webservices, active_event); + const user = new CoralUser( + nso, data, + announcements, friends, webservices, chats, media, active_event, coral_user, + ); if (nso instanceof CoralApi && nso.onTokenExpired) { const renewToken = nso.onTokenExpired; @@ -98,10 +103,13 @@ export default class Users { export interface CoralUserData extends UserData { nso: T; data: SavedToken; - announcements: CoralSuccessResponse; - friends: CoralSuccessResponse; - webservices: CoralSuccessResponse; + announcements: CoralSuccessResponse; + friends: CoralSuccessResponse; + webservices: CoralSuccessResponse; + chats: CoralSuccessResponse; + media: CoralSuccessResponse; active_event: CoralSuccessResponse; + user: CoralSuccessResponse; } export class CoralUser implements CoralUserData { @@ -115,22 +123,27 @@ export class CoralUser implements CoralU announcements: Date.now(), friends: Date.now(), webservices: Date.now(), + chats: Date.now(), + media: Date.now(), active_event: Date.now(), + user: Date.now(), }; delay_retry_after_error = 5 * 1000; // 5 seconds update_interval = 10 * 1000; // 10 seconds - update_interval_announcements = 30 * 60 * 1000; // 30 minutes - onUpdatedWebServices: ((webservices: Result) => void) | null = null; + onUpdatedWebServices: ((webservices: Result) => void) | null = null; constructor( public nso: T, public data: SavedToken, - public announcements: CoralSuccessResponse, - public friends: CoralSuccessResponse, - public webservices: CoralSuccessResponse, + public announcements: CoralSuccessResponse, + public friends: CoralSuccessResponse, + public webservices: CoralSuccessResponse, + public chats: CoralSuccessResponse, + public media: CoralSuccessResponse, public active_event: CoralSuccessResponse, + public user: CoralSuccessResponse, ) {} private async update(key: keyof CoralUser['updated'], callback: () => Promise, ttl: number) { @@ -159,14 +172,19 @@ export class CoralUser implements CoralU async getAnnouncements() { await this.update('announcements', async () => { + // Always requested together when refreshing notifications page + this.getWebServices(); + this.announcements = await this.nso.getAnnouncements(); - }, this.update_interval_announcements); + }, this.update_interval); return this.announcements.result; } async getFriends() { await this.update('friends', async () => { + // No simultaneous requests when refreshing friend list page + this.friends = await this.nso.getFriendList(); }, this.update_interval); @@ -175,6 +193,9 @@ export class CoralUser implements CoralU async getWebServices() { await this.update('webservices', async () => { + // Always requested together when refreshing notifications page + this.getAnnouncements(); + const webservices = this.webservices = await this.nso.getWebServices(); this.onUpdatedWebServices?.call(null, webservices); @@ -183,14 +204,69 @@ export class CoralUser implements CoralU return this.webservices.result; } + async getChats() { + await this.update('chats', async () => { + // Always requested together when refreshing main page + Promise.all([ + this.getAnnouncements(), + this.getFriends(), + this.getWebServices(), + this.getMedia(), + this.getActiveEvent(), + ]); + + this.chats = await this.nso.getChats(); + }, this.update_interval); + + return this.chats.result; + } + + async getMedia() { + await this.update('media', async () => { + // No simultaneous requests when refreshing media page + + this.media = await this.nso.getMedia(); + }, this.update_interval); + + return this.media.result.media; + } + async getActiveEvent() { await this.update('active_event', async () => { + // Always requested together when refreshing main page + Promise.all([ + this.getAnnouncements(), + this.getFriends(), + this.getWebServices(), + this.getChats(), + this.getMedia(), + ]); + this.active_event = await this.nso.getActiveEvent(); }, this.update_interval); return this.active_event.result; } + async getCurrentUser() { + await this.update('user', async () => { + // Always requested together when refreshing main page + Promise.all([ + this.getAnnouncements(), + this.getFriends(), + this.getWebServices(), + this.getChats(), + this.getMedia(), + ]); + + // or, then user page requests /v4/User/ShowSelf, /v3/User/Permissions/ShowSelf, /v4/User/PlayLog/Show + + this.user = await this.nso.getCurrentUser(); + }, this.update_interval); + + return this.user.result; + } + async addFriend(nsa_id: string) { if (!(this.nso instanceof CoralApi)) { throw new Error('Cannot send friend requests using Coral API proxy'); @@ -233,7 +309,7 @@ export interface CachedWebServicesList { async function maybeUpdateWebServicesListCache( cached_webservices: Map, store: Store, // storage: persist.LocalStorage, - user: NintendoAccountUser, webservices: WebService[] + user: NintendoAccountUserCoral, webservices: WebService_4[] ) { const webservices_hash = crypto.createHash('sha256').update(JSON.stringify(webservices)).digest('hex'); if (cached_webservices.get(user.language) === webservices_hash) return;