Coral 3.0.3

This commit is contained in:
Samuel Elliott 2025-07-22 01:33:03 +01:00
parent 2959c923bb
commit 57ce3c21de
No known key found for this signature in database
GPG Key ID: 8420C7CDE43DC4D6
11 changed files with 1181 additions and 471 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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;

View File

@ -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;

View File

@ -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<HasResponse<NintendoAccountSessionToken, Response>>
async getSessionToken(params: URLSearchParams): Promise<HasResponse<NintendoAccountSessionToken, Response>>
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<S extends NintendoAccountScope>(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<S> | 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<S extends NintendoAccountScope = never> {
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<Record<NintendoAccountLinkType, NintendoAccountLink | null>> | null :
Partial<Record<NintendoAccountLinkTypes<S>, 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<NintendoAccountEmailType, NintendoAccountEmailOptedIn>;
clientFriendsOptedIn: boolean;
clientFriendsOptedInUpdatedAt: number;
analyticsOptedIn: boolean;
analyticsOptedInUpdatedAt: number;
analyticsPermissions: Record<NintendoAccountAnalyticsType, NintendoAccountAnalyticsPermission>;
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 NintendoAccountScope = NintendoAccountScope.USER_LINKS> =
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 {

View File

@ -55,10 +55,10 @@ export class ErrorResponse<T = unknown> extends Error {
});
}
static async fromResponse(response: UndiciResponse, message: string) {
static async fromResponse<T>(response: UndiciResponse, message: string) {
const body = await response.arrayBuffer();
return new this(message, response, body);
return new this<T>(message, response, body);
}
}

View File

@ -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<T = unknown>(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<T = unknown>(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<T = unknown>(url: string, parameter = {}): Promise<Result<T>> {
throw new Error('Not supported in ZncProxyApi');
async call<T extends {}, R extends {}>(url: string, parameter: R & Partial<RequestFlags> = {} as R): Promise<Result<T>> {
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<Result<{}>> {
throw new Error('Not supported in ZncProxyApi');
}
// async sendFriendRequest(nsa_id: string): Promise<Result<{}>> {
// 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<Result<{}>> {
throw new Error('Not supported in ZncProxyApi');
}
// async updateCurrentUserPermissions(
// to: PresencePermissions, from: PresencePermissions, etag: string
// ): Promise<Result<{}>> {
// 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<SavedToken>('/auth');
data.proxy_url = this.url;
const data = await this.fetchProxyApi<SavedToken>('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<SavedToken>('/auth');
const data = await nso.fetchProxyApi<SavedToken>('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;

View File

@ -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;

View File

@ -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<RequestFlags> = {};
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,
};
}

View File

@ -39,7 +39,7 @@ export function builder(yargs: Argv<ParentArguments>) {
await storage.getItem('NintendoAccountToken.' + usernsid);
const {nso, data} = await getToken(storage, token, argv.zncProxyUrl);
const tokens = await nso.fetch<AuthTokens>('/tokens');
const tokens = await nso.fetchProxyApi<AuthTokens>('/tokens');
const table = new Table({
head: [
@ -138,7 +138,7 @@ export function builder(yargs: Argv<ParentArguments>) {
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',
});

View File

@ -85,13 +85,13 @@ export class ZncNotifications extends Loop {
imageUri: '',
channel: FriendRouteChannel.FRIEND_CODE,
},
presence: await nso.fetch<Presence>('/friend/' + r.friend + '/presence'),
presence: await nso.fetchProxyApi<Presence>('/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')) {

View File

@ -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<T extends UserData> {
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<T extends UserData> {
export interface CoralUserData<T extends CoralApiInterface = CoralApi> extends UserData {
nso: T;
data: SavedToken;
announcements: CoralSuccessResponse<Announcements>;
friends: CoralSuccessResponse<Friends>;
webservices: CoralSuccessResponse<WebServices>;
announcements: CoralSuccessResponse<Announcements_4>;
friends: CoralSuccessResponse<Friends_4>;
webservices: CoralSuccessResponse<WebServices_4>;
chats: CoralSuccessResponse<ListChat>;
media: CoralSuccessResponse<ListMedia>;
active_event: CoralSuccessResponse<GetActiveEventResult>;
user: CoralSuccessResponse<CurrentUser>;
}
export class CoralUser<T extends CoralApiInterface = CoralApi> implements CoralUserData<T> {
@ -115,22 +123,27 @@ export class CoralUser<T extends CoralApiInterface = CoralApi> 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<WebServices>) => void) | null = null;
onUpdatedWebServices: ((webservices: Result<WebServices_4>) => void) | null = null;
constructor(
public nso: T,
public data: SavedToken,
public announcements: CoralSuccessResponse<Announcements>,
public friends: CoralSuccessResponse<Friends>,
public webservices: CoralSuccessResponse<WebServices>,
public announcements: CoralSuccessResponse<Announcements_4>,
public friends: CoralSuccessResponse<Friends_4>,
public webservices: CoralSuccessResponse<WebServices_4>,
public chats: CoralSuccessResponse<ListChat>,
public media: CoralSuccessResponse<ListMedia>,
public active_event: CoralSuccessResponse<GetActiveEventResult>,
public user: CoralSuccessResponse<CurrentUser>,
) {}
private async update(key: keyof CoralUser['updated'], callback: () => Promise<void>, ttl: number) {
@ -159,14 +172,19 @@ export class CoralUser<T extends CoralApiInterface = CoralApi> 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<T extends CoralApiInterface = CoralApi> 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<T extends CoralApiInterface = CoralApi> 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<string, string>, 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;