diff --git a/src/api/coral.ts b/src/api/coral.ts index 9e77340..e2b4120 100644 --- a/src/api/coral.ts +++ b/src/api/coral.ts @@ -4,7 +4,7 @@ import createDebug from 'debug'; import { f, FResult, HashMethod } from './f.js'; import { AccountLogin, AccountToken, Announcements, CurrentUser, CurrentUserPermissions, Event, Friends, GetActiveEventResult, PresencePermissions, User, WebServices, WebServiceToken, CoralErrorResponse, CoralResponse, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl, AccountTokenParameter, AccountLoginParameter } from './coral-types.js'; import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountToken, NintendoAccountUser } from './na.js'; -import { ErrorResponse } from './util.js'; +import { ErrorResponse, ResponseSymbol } from './util.js'; import { JwtPayload } from '../util/jwt.js'; import { getAdditionalUserAgents } from '../util/useragent.js'; import { timeoutSignal } from '../util/misc.js'; @@ -22,6 +22,24 @@ export const ZNCA_CLIENT_ID = '71b963c1b7b6d119'; const FRIEND_CODE = /^\d{4}-\d{4}-\d{4}$/; const FRIEND_CODE_HASH = /^[A-Za-z0-9]{10}$/; +export const ResponseDataSymbol = Symbol('ResponseData'); +export const CorrelationIdSymbol = Symbol('CorrelationId'); + +export type Result = T & ResultData; + +export interface ResultData { + [ResponseSymbol]: Response; + [ResponseDataSymbol]: CoralSuccessResponse; + [CorrelationIdSymbol]: string; + + /** @deprecated */ + status: CoralStatus.OK; + /** @deprecated */ + result: T; + /** @deprecated */ + correlationId: string; +} + export default class CoralApi { onTokenExpired: ((data: CoralErrorResponse, res: Response) => Promise) | null = null; /** @internal */ @@ -38,7 +56,7 @@ export default class CoralApi { url: string, method = 'GET', body?: string, headers?: object, /** @internal */ _autoRenewToken = true, /** @internal */ _attempt = 0 - ): Promise> { + ): Promise> { if (this._renewToken && _autoRenewToken) { await this._renewToken; } @@ -80,7 +98,17 @@ export default class CoralApi { throw new ErrorResponse('[znc] Unknown error', response, data); } - return data; + const result = data.result; + + Object.defineProperty(result, ResponseSymbol, {enumerable: false, value: response}); + Object.defineProperty(result, ResponseDataSymbol, {enumerable: false, value: data}); + Object.defineProperty(result, CorrelationIdSymbol, {enumerable: false, value: data.correlationId}); + + Object.defineProperty(result, 'status', {enumerable: false, value: CoralStatus.OK}); + Object.defineProperty(result, 'result', {enumerable: false, value: data.result}); + Object.defineProperty(result, 'correlationId', {enumerable: false, value: data.correlationId}); + + return result as Result; } async call( diff --git a/src/api/f.ts b/src/api/f.ts index 6bbad87..2978f86 100644 --- a/src/api/f.ts +++ b/src/api/f.ts @@ -2,7 +2,7 @@ import process from 'node:process'; import fetch from 'node-fetch'; import createDebug from 'debug'; import { v4 as uuidgen } from 'uuid'; -import { ErrorResponse } from './util.js'; +import { defineResponse, ErrorResponse } from './util.js'; import { timeoutSignal } from '../util/misc.js'; import { getUserAgent } from '../util/useragent.js'; @@ -109,7 +109,7 @@ export async function flapg( debugFlapg('Got f parameter', data); - return data; + return defineResponse(data, response); } /** @deprecated */ @@ -197,7 +197,7 @@ export async function iminkf( debugImink('Got f parameter "%s"', data.f); - return data; + return defineResponse(data, response); } export interface IminkFRequest { @@ -276,7 +276,7 @@ export async function genf( debugZncaApi('Got f parameter', data, response.headers); - return data; + return defineResponse(data, response); } export interface AndroidZncaFRequest { diff --git a/src/api/moon.ts b/src/api/moon.ts index 615b938..26677e8 100644 --- a/src/api/moon.ts +++ b/src/api/moon.ts @@ -1,7 +1,7 @@ import fetch from 'node-fetch'; import createDebug from 'debug'; import { getNintendoAccountToken, getNintendoAccountUser, NintendoAccountToken, NintendoAccountUser } from './na.js'; -import { ErrorResponse } from './util.js'; +import { defineResponse, ErrorResponse } from './util.js'; import { DailySummaries, Devices, MonthlySummaries, MonthlySummary, MoonError, ParentalControlSettingState, SmartDevices, User } from './moon-types.js'; import { timeoutSignal } from '../util/misc.js'; @@ -59,7 +59,7 @@ export default class MoonApi { throw new ErrorResponse('[moon] ' + data.title, response, data); } - return data; + return defineResponse(data, response); } async getUser() { diff --git a/src/api/na.ts b/src/api/na.ts index 103f904..0578219 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -1,6 +1,6 @@ import fetch from 'node-fetch'; import createDebug from 'debug'; -import { ErrorResponse } from './util.js'; +import { defineResponse, ErrorResponse } from './util.js'; import { JwtPayload } from '../util/jwt.js'; import { timeoutSignal } from '../util/misc.js'; @@ -26,18 +26,22 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str signal, }).finally(cancel); + if (response.status !== 200) { + throw new ErrorResponse('[na] Non-200 status code', response, await response.text()); + } + const token = await response.json() as NintendoAccountSessionToken | NintendoAccountAuthError | NintendoAccountError; if ('errorCode' in token) { - throw new ErrorResponse('[na] ' + token.detail, response, token); + throw new ErrorResponse('[na] ' + token.detail, response, token); } if ('error' in token) { - throw new ErrorResponse('[na] ' + token.error_description ?? token.error, response, token); + throw new ErrorResponse('[na] ' + token.error_description ?? token.error, response, token); } debug('Got Nintendo Account session token', token); - return token; + return defineResponse(token, response); } export async function getNintendoAccountToken(token: string, client_id: string) { @@ -59,18 +63,22 @@ export async function getNintendoAccountToken(token: string, client_id: string) signal, }).finally(cancel); + if (response.status !== 200) { + throw new ErrorResponse('[na] Non-200 status code', response, await response.text()); + } + const nintendoAccountToken = await response.json() as NintendoAccountToken | NintendoAccountAuthError | NintendoAccountError; if ('errorCode' in nintendoAccountToken) { - throw new ErrorResponse('[na] ' + nintendoAccountToken.detail, response, nintendoAccountToken); + throw new ErrorResponse('[na] ' + nintendoAccountToken.detail, response, nintendoAccountToken); } if ('error' in nintendoAccountToken) { - throw new ErrorResponse('[na] ' + nintendoAccountToken.error_description ?? nintendoAccountToken.error, response, nintendoAccountToken); + throw new ErrorResponse('[na] ' + nintendoAccountToken.error_description ?? nintendoAccountToken.error, response, nintendoAccountToken); } debug('Got Nintendo Account token', nintendoAccountToken); - return nintendoAccountToken; + return defineResponse(nintendoAccountToken, response); } export async function getNintendoAccountUser(token: NintendoAccountToken) { @@ -88,15 +96,19 @@ export async function getNintendoAccountUser(token: NintendoAccountToken) { signal, }).finally(cancel); + if (response.status !== 200) { + throw new ErrorResponse('[na] Non-200 status code', response, await response.text()); + } + const user = await response.json() as NintendoAccountUser | NintendoAccountError; if ('errorCode' in user) { - throw new ErrorResponse('[na] ' + user.detail, response, user); + throw new ErrorResponse('[na] ' + user.detail, response, user); } debug('Got Nintendo Account user info', user); - return user; + return defineResponse(user, response); } export interface NintendoAccountSessionToken { @@ -214,7 +226,7 @@ export enum NintendoAccountJwtScope { // 'userNotificationMessage:anyClients:write' = -1, // 1, 31 - // 'offline' = -1, + // 'offline' = -1, // 'user.links.nintendoNetwork.id' = -1, } diff --git a/src/api/nooklink.ts b/src/api/nooklink.ts index 5f63260..d27afea 100644 --- a/src/api/nooklink.ts +++ b/src/api/nooklink.ts @@ -2,7 +2,7 @@ import fetch from 'node-fetch'; import createDebug from 'debug'; import { WebServiceToken } from './coral-types.js'; import { NintendoAccountUser } from './na.js'; -import { ErrorResponse } from './util.js'; +import { defineResponse, ErrorResponse } from './util.js'; import CoralApi from './coral.js'; import { WebServiceError, Users, AuthToken, UserProfile, Newspapers, Newspaper, Emoticons, Reaction, IslandProfile } from './nooklink-types.js'; import { timeoutSignal } from '../util/misc.js'; @@ -32,7 +32,7 @@ export default class NooklinkApi { 'User-Agent': this.useragent, 'Cookie': '_gtoken=' + encodeURIComponent(this.gtoken), 'dnt': '1', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'en-GB,en-US;q=0.8', 'Origin': 'https://web.sd.lp1.acbaa.srv.nintendo.net', 'Content-Type': 'application/json', @@ -51,10 +51,10 @@ export default class NooklinkApi { const data = await response.json() as T | WebServiceError; if ('code' in data) { - throw new ErrorResponse('[nooklink] Error ' + data.code, response, data); + throw new ErrorResponse('[nooklink] Error ' + data.code, response, data); } - return data; + return defineResponse(data, response); } async getUsers() { @@ -119,7 +119,7 @@ export default class NooklinkApi { const body = await response.text(); if (response.status !== 200) { - throw new ErrorResponse('[nooklink] Unknown error', response, body); + throw new ErrorResponse('[nooklink] Non-200 status code', response, body); } const cookies = response.headers.get('Set-Cookie'); @@ -170,9 +170,8 @@ export class NooklinkUserApi { 'User-Agent': this.useragent, 'Cookie': '_gtoken=' + encodeURIComponent(this.gtoken), 'dnt': '1', - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept': 'application/json, text/plain,*/*', 'Accept-Language': 'en-GB,en-US;q=0.8', - 'X-Requested-With': 'com.nintendo.znca', 'Origin': 'https://web.sd.lp1.acbaa.srv.nintendo.net', 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.auth_token, @@ -191,10 +190,10 @@ export class NooklinkUserApi { const data = await response.json() as T | WebServiceError; if ('code' in data) { - throw new ErrorResponse('[nooklink] Error ' + data.code, response, data); + throw new ErrorResponse('[nooklink] Error ' + data.code, response, data); } - return data; + return defineResponse(data, response); } async getUserProfile(id?: string) { diff --git a/src/api/splatnet2.ts b/src/api/splatnet2.ts index a7c1f97..d6aa6a8 100644 --- a/src/api/splatnet2.ts +++ b/src/api/splatnet2.ts @@ -3,7 +3,7 @@ import createDebug from 'debug'; import { v4 as uuidgen } from 'uuid'; import { WebServiceToken } from './coral-types.js'; import { NintendoAccountUser } from './na.js'; -import { ErrorResponse } from './util.js'; +import { defineResponse, ErrorResponse } from './util.js'; import CoralApi from './coral.js'; import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, LeagueMatchRankings, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingRecords, XPowerRankingSummary } from './splatnet2-types.js'; import { timeoutSignal } from '../util/misc.js'; @@ -61,10 +61,10 @@ export default class SplatNet2Api { const data = await response.json() as T | WebServiceError; if ('code' in data) { - throw new ErrorResponse('[splatnet2] ' + data.message, response, data); + throw new ErrorResponse('[splatnet2] ' + data.message, response, data); } - return data; + return defineResponse(data, response); } async getRecords() { @@ -295,7 +295,7 @@ ${colour} const body = await response.text(); if (response.status !== 200) { - throw new ErrorResponse('[splatnet2] Unknown error', response, body); + throw new ErrorResponse('[splatnet2] Non-200 status code', response, body); } const cookies = response.headers.get('Set-Cookie'); diff --git a/src/api/util.ts b/src/api/util.ts index 84e1bd3..f306a3b 100644 --- a/src/api/util.ts +++ b/src/api/util.ts @@ -1,6 +1,18 @@ import * as util from 'node:util'; import { Response as NodeFetchResponse } from 'node-fetch'; +export const ResponseSymbol = Symbol('Response'); + +export interface ResponseData { + [ResponseSymbol]: R; +} +export type HasResponse = T & ResponseData; + +export function defineResponse(data: T, response: R) { + Object.defineProperty(data, ResponseSymbol, {enumerable: false, value: response}); + return data as HasResponse; +} + export class ErrorResponse extends Error { readonly body: string | undefined; readonly data: T | undefined = undefined; diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 4a1fefb..87fdb99 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -1,8 +1,8 @@ import fetch, { Response } from 'node-fetch'; import createDebug from 'debug'; import { ActiveEvent, Announcements, CurrentUser, Event, Friend, Presence, PresencePermissions, User, WebService, WebServiceToken, CoralErrorResponse, CoralStatus, CoralSuccessResponse, FriendCodeUser, FriendCodeUrl } from './coral-types.js'; -import { ErrorResponse } from './util.js'; -import CoralApi from './coral.js'; +import { defineResponse, ErrorResponse, ResponseSymbol } from './util.js'; +import CoralApi, { CorrelationIdSymbol, ResponseDataSymbol, Result } from './coral.js'; import { NintendoAccountUser } from './na.js'; import { SavedToken } from '../common/auth/coral.js'; import { timeoutSignal } from '../util/misc.js'; @@ -41,106 +41,100 @@ export default class ZncProxyApi implements CoralApi { debug('fetch %s %s, response %s', method, url, response.status); - if (response.status === 204) return null!; - - if (response.status !== 200) { + if (response.status !== 200 && response.status !== 204) { throw new ErrorResponse('[zncproxy] Non-200/204 status code', response, await response.text()); } - const data = await response.json() as T; + const data = (response.status === 204 ? {} : await response.json()) as T; - return data; + return defineResponse(data, response); } - async call(url: string, parameter = {}): Promise> { + async call(url: string, parameter = {}): Promise> { throw new Error('Not supported in ZncProxyApi'); } async getAnnouncements() { - const response = await this.fetch<{announcements: Announcements}>('/announcements'); - return {status: CoralStatus.OK as const, result: response.announcements, correlationId: ''}; + const result = await this.fetch<{announcements: Announcements}>('/announcements'); + return createResult(result, result.announcements); } async getFriendList() { - const response = await this.fetch<{friends: Friend[]}>('/friends'); - return {status: CoralStatus.OK as const, result: response, correlationId: ''}; + const result = await this.fetch<{friends: Friend[]}>('/friends'); + return createResult(result, result); } async addFavouriteFriend(nsaid: string) { - await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({ + const result = await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({ isFavoriteFriend: true, })); - return {status: CoralStatus.OK as const, result: {}, correlationId: ''}; + return createResult(result, {}); } async removeFavouriteFriend(nsaid: string) { - await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({ + const result = await this.fetch('/friend/' + nsaid, 'POST', JSON.stringify({ isFavoriteFriend: false, })); - return {status: CoralStatus.OK as const, result: {}, correlationId: ''}; + return createResult(result, {}); } async getWebServices() { - const response = await this.fetch<{webservices: WebService[]}>('/webservices'); - return {status: CoralStatus.OK as const, result: response.webservices, correlationId: ''}; + const result = await this.fetch<{webservices: WebService[]}>('/webservices'); + return createResult(result, result.webservices); } async getActiveEvent() { - const response = await this.fetch<{activeevent: ActiveEvent}>('/activeevent'); - return {status: CoralStatus.OK as const, result: response.activeevent, correlationId: ''}; + const result = await this.fetch<{activeevent: ActiveEvent}>('/activeevent'); + return createResult(result, result.activeevent); } async getEvent(id: number) { - const response = await this.fetch<{event: Event}>('/event/' + id); - return {status: CoralStatus.OK as const, result: response.event, correlationId: ''}; + const result = await this.fetch<{event: Event}>('/event/' + id); + return createResult(result, result.event); } async getUser(id: number) { - const response = await this.fetch<{user: User}>('/user/' + id); - return {status: CoralStatus.OK as const, result: response.user, correlationId: ''}; + const result = await this.fetch<{user: User}>('/user/' + id); + return createResult(result, result.user); } async getUserByFriendCode(friend_code: string, hash?: string) { - const response = await this.fetch<{user: FriendCodeUser}>('/friendcode/' + friend_code); - return {status: CoralStatus.OK as const, result: response.user, correlationId: ''}; + const result = await this.fetch<{user: FriendCodeUser}>('/friendcode/' + friend_code); + return createResult(result, result.user); } - async sendFriendRequest(nsa_id: string): Promise> { + async sendFriendRequest(nsa_id: string): Promise> { throw new Error('Not supported in ZncProxyApi'); } async getCurrentUser() { - const response = await this.fetch<{user: CurrentUser}>('/user'); - return {status: CoralStatus.OK as const, result: response.user, correlationId: ''}; + const result = await this.fetch<{user: CurrentUser}>('/user'); + return createResult(result, result.user); } async getFriendCodeUrl() { - const response = await this.fetch<{friendcode: FriendCodeUrl}>('/friendcode'); - return {status: CoralStatus.OK as const, result: response.friendcode, correlationId: ''}; + const result = await this.fetch<{friendcode: FriendCodeUrl}>('/friendcode'); + return createResult(result, result.friendcode); } async getCurrentUserPermissions() { const user = await this.getCurrentUser(); - return { - status: CoralStatus.OK as const, - result: { - etag: user.result.etag, - permissions: user.result.permissions, - }, - correlationId: '', - }; + return createResult(user, { + etag: user.etag, + permissions: user.permissions, + }); } async updateCurrentUserPermissions( to: PresencePermissions, from: PresencePermissions, etag: string - ): Promise> { + ): Promise> { throw new Error('Not supported in ZncProxyApi'); } async getWebServiceToken(id: string) { - const response = await this.fetch<{token: WebServiceToken}>('/webservice/' + id + '/token'); - return {status: CoralStatus.OK as const, result: response.token, correlationId: ''}; + const result = await this.fetch<{token: WebServiceToken}>('/webservice/' + id + '/token'); + return createResult(result, result.token); } async getToken(token: string, user: NintendoAccountUser): ReturnType { @@ -162,6 +156,24 @@ export default class ZncProxyApi implements CoralApi { } } +function createResult(data: R & {[ResponseSymbol]: Response}, result: T): Result { + const coral_result: CoralSuccessResponse = { + status: CoralStatus.OK as const, + result, + correlationId: '', + }; + + Object.defineProperty(result, ResponseSymbol, {enumerable: false, value: data[ResponseSymbol]}); + Object.defineProperty(result, ResponseDataSymbol, {enumerable: false, value: coral_result}); + Object.defineProperty(result, CorrelationIdSymbol, {enumerable: false, value: ''}); + + Object.defineProperty(result, 'status', {enumerable: false, value: CoralStatus.OK}); + Object.defineProperty(result, 'result', {enumerable: false, value: result}); + Object.defineProperty(result, 'correlationId', {enumerable: false, value: ''}); + + return result as Result; +} + export interface AuthToken { user: string; policy?: AuthPolicy; diff --git a/src/common/remote-config.ts b/src/common/remote-config.ts index c7d6340..e388c4f 100644 --- a/src/common/remote-config.ts +++ b/src/common/remote-config.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs/promises'; import fetch from 'node-fetch'; import createDebug from 'debug'; import mkdirp from 'mkdirp'; -import { ErrorResponse } from '../api/util.js'; +import { ErrorResponse, ResponseSymbol } from '../api/util.js'; import { timeoutSignal } from '../util/misc.js'; import { getUserAgent } from '../util/useragent.js'; import { paths } from '../util/storage.js'; @@ -18,7 +18,6 @@ const MAX_FRESH = 24 * 60 * 60; // 1 day in seconds const MAX_STALE = 24 * 60 * 60; // 1 day in seconds const SourceSymbol = Symbol('Source'); -const ResponseSymbol = Symbol('Response'); const CachedSymbol = Symbol('Cached'); const default_config: NxapiRemoteConfig = {