From 81cec767dfdadbcb4cff6f4fe00602822ac536d1 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Tue, 26 Apr 2022 16:50:19 +0100 Subject: [PATCH] Export NookLink client, remove duplicate ErrorResponse class --- src/api/nooklink.ts | 18 ++---- src/api/splatnet2.ts | 13 ++-- src/api/util.ts | 25 +++++++- src/api/znc-proxy.ts | 8 ++- src/api/znc.ts | 99 +++++++++++++---------------- src/app/browser/util.ts | 24 +------ src/app/preload-webservice/index.ts | 2 +- src/cli/nso/presence.ts | 1 - src/cli/splatnet2/monitor.ts | 2 +- src/cli/splatnet2/util.ts | 60 +++++++++++------ src/index.ts | 6 ++ 11 files changed, 136 insertions(+), 122 deletions(-) diff --git a/src/api/nooklink.ts b/src/api/nooklink.ts index 78cadb1..f8232ad 100644 --- a/src/api/nooklink.ts +++ b/src/api/nooklink.ts @@ -42,10 +42,7 @@ export default class NooklinkApi { debug('fetch %s %s, response %s', method, url, response.status); if (response.status !== 200 && response.status !== 201) { - const data = response.headers.get('Content-Type')?.match(/\bapplication\/json\b/i) ? - await response.json() : await response.text(); - - throw new ErrorResponse('[nooklink] Unknown error', response, data); + throw new ErrorResponse('[nooklink] Unknown error', response, await response.text()); } const data = await response.json() as T | WebServiceError; @@ -113,15 +110,17 @@ export default class NooklinkApi { debug('fetch %s %s, response %s', 'GET', url, response.status); + const body = await response.text(); + if (response.status !== 200) { - throw new ErrorResponse('Unknown error', response); + throw new ErrorResponse('[nooklink] Unknown error', response, body); } const cookies = response.headers.get('Set-Cookie'); const match = cookies?.match(/\b_gtoken=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i); if (!match) { - throw new ErrorResponse('Response didn\'t include _gtoken cookie', response); + throw new ErrorResponse('[nooklink] Response didn\'t include _gtoken cookie', response, body); } const gtoken = decodeURIComponent(match[1]); @@ -132,8 +131,6 @@ export default class NooklinkApi { const expires_at = expires ? Date.parse(expires) : Date.now() + webserviceToken.expiresIn * 1000; - const body = await response.text(); - return { webserviceToken, url: url.toString(), @@ -178,10 +175,7 @@ export class NooklinkUserApi { debug('fetch %s %s, response %s', method, url, response.status); if (response.status !== 200 && response.status !== 201) { - const data = response.headers.get('Content-Type')?.match(/\bapplication\/json\b/i) ? - await response.json() : await response.text(); - - throw new ErrorResponse('[nooklink] Unknown error', response, data); + throw new ErrorResponse('[nooklink] Unknown error', response, await response.text()); } const data = await response.json() as T | WebServiceError; diff --git a/src/api/splatnet2.ts b/src/api/splatnet2.ts index 57015b2..a2d909f 100644 --- a/src/api/splatnet2.ts +++ b/src/api/splatnet2.ts @@ -43,10 +43,7 @@ export default class SplatNet2Api { debug('fetch %s %s, response %s', method, url, response.status); if (response.status !== 200) { - const data = response.headers.get('Content-Type')?.match(/\bapplication\/json\b/i) ? - await response.json() : await response.text(); - - throw new ErrorResponse('[splatnet2] Unknown error', response, data); + throw new ErrorResponse('[splatnet2] Unknown error', response, await response.text()); } updateIksmSessionLastUsed(this.iksm_session); @@ -244,15 +241,17 @@ ${colour} debug('fetch %s %s, response %s', 'GET', url, response.status); + const body = await response.text(); + if (response.status !== 200) { - throw new ErrorResponse('Unknown error', response); + throw new ErrorResponse('[splatnet2] Unknown error', response, body); } const cookies = response.headers.get('Set-Cookie'); const match = cookies?.match(/\biksm_session=([^;]*)(;(\s*((?!expires)[a-z]+=([^;]*));?)*(\s*(expires=([^;]*));?)?|$)/i); if (!match) { - throw new ErrorResponse('Response didn\'t include iksm_session cookie', response); + throw new ErrorResponse('[splatnet2] Response didn\'t include iksm_session cookie', response, body); } const iksm_session = decodeURIComponent(match[1]); @@ -264,8 +263,6 @@ ${colour} const expires_at = expires ? Date.parse(expires) : Date.now() + 24 * 60 * 60 * 1000; - const body = await response.text(); - const ml = body.match(/]*))?)*\s+lang=(?:"([^"]*)"|([^\s>]*))/i); const mr = body.match(/]*))?)*\s+data-region=(?:"([^"]*)"|([^\s>]*))/i); const mu = body.match(/]*))?)*\s+data-unique-id=(?:"([^"]*)"|([^\s>]*))/i); diff --git a/src/api/util.ts b/src/api/util.ts index b793111..5ad2076 100644 --- a/src/api/util.ts +++ b/src/api/util.ts @@ -2,15 +2,38 @@ import * as crypto from 'crypto'; import { Response } from 'node-fetch'; export class ErrorResponse extends Error { + readonly body: string | undefined; + readonly data: T | undefined = undefined; + constructor( message: string, readonly response: Response, - readonly data: T = undefined as any + body?: string | T ) { super(message); + + if (typeof body === 'string') { + this.body = body; + try { + this.data = body ? JSON.parse(body) : undefined; + } catch (err) {} + } else if (typeof body !== 'undefined') { + this.data = body; + } } } +Object.defineProperty(ErrorResponse, Symbol.hasInstance, { + configurable: true, + value: (instance: ErrorResponse) => { + return instance instanceof Error && + 'response' in instance && + instance.response instanceof Response && + 'body' in instance && + 'data' in instance; + }, +}); + export interface JwtHeader { typ?: 'JWT'; alg: JwtAlgorithm; diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index c180f85..8e15480 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -1,6 +1,6 @@ import fetch from 'node-fetch'; import createDebug from 'debug'; -import { AccountToken, ActiveEvent, Announcements, CurrentUser, Event, Friend, PresencePermissions, User, WebService, WebServiceToken, ZncStatus, ZncSuccessResponse } from './znc-types.js'; +import { ActiveEvent, Announcements, CurrentUser, Event, Friend, PresencePermissions, User, WebService, WebServiceToken, ZncStatus, ZncSuccessResponse } from './znc-types.js'; import { ErrorResponse } from './util.js'; import ZncApi from './znc.js'; import { SavedToken, version } from '../util.js'; @@ -34,7 +34,7 @@ export default class ZncProxyApi implements ZncApi { if (response.status === 204) return null!; if (response.status !== 200) { - throw new ErrorResponse('[zncproxy] Unknown error', response); + throw new ErrorResponse('[zncproxy] Unknown error', response, await response.text()); } const data = await response.json() as T; @@ -42,6 +42,10 @@ export default class ZncProxyApi implements ZncApi { return data; } + 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: ZncStatus.OK as const, result: response.announcements, correlationId: ''}; diff --git a/src/api/znc.ts b/src/api/znc.ts index 0e39192..3b55b52 100644 --- a/src/api/znc.ts +++ b/src/api/znc.ts @@ -51,78 +51,73 @@ export default class ZncApi { return data; } - async getAnnouncements() { - return this.fetch('/v1/Announcement/List', 'POST', '{"parameter":{}}'); - } - - async getFriendList() { - return this.fetch('/v3/Friend/List', 'POST', '{"parameter":{}}'); - } - - async addFavouriteFriend(nsaid: string) { - return this.fetch<{}>('/v3/Friend/Favorite/Create', 'POST', JSON.stringify({ - parameter: { - nsaId: nsaid, - }, - })); - } - - async removeFavouriteFriend(nsaid: string) { - return this.fetch<{}>('/v3/Friend/Favorite/Delete', 'POST', JSON.stringify({ - parameter: { - nsaId: nsaid, - }, - })); - } - - async getWebServices() { + async call(url: string, parameter = {}) { const uuid = uuidgen(); - return this.fetch('/v1/Game/ListWebServices', 'POST', JSON.stringify({ + return this.fetch(url, 'POST', JSON.stringify({ + parameter, requestId: uuid, })); } + async getAnnouncements() { + return this.call('/v1/Announcement/List'); + } + + async getFriendList() { + return this.call('/v3/Friend/List'); + } + + async addFavouriteFriend(nsaid: string) { + return this.call<{}>('/v3/Friend/Favorite/Create', { + nsaId: nsaid, + }); + } + + async removeFavouriteFriend(nsaid: string) { + return this.call<{}>('/v3/Friend/Favorite/Delete', { + nsaId: nsaid, + }); + } + + async getWebServices() { + return this.call('/v1/Game/ListWebServices'); + } + async getActiveEvent() { - return this.fetch('/v1/Event/GetActiveEvent', 'POST', '{"parameter":{}}'); + return this.call('/v1/Event/GetActiveEvent'); } async getEvent(id: number) { - return this.fetch('/v1/Event/Show', 'POST', JSON.stringify({ - parameter: { - id, - }, - })); + return this.call('/v1/Event/Show', { + id, + }); } async getUser(id: number) { - return this.fetch('/v3/User/Show', 'POST', JSON.stringify({ - parameter: { - id, - }, - })); + return this.call('/v3/User/Show', { + id, + }); } async getCurrentUser() { - return this.fetch('/v3/User/ShowSelf', 'POST', '{"parameter":{}}'); + return this.call('/v3/User/ShowSelf'); } async getCurrentUserPermissions() { - return this.fetch('/v3/User/Permissions/ShowSelf', 'POST', '{"parameter":{}}'); + return this.call('/v3/User/Permissions/ShowSelf'); } async updateCurrentUserPermissions(to: PresencePermissions, from: PresencePermissions, etag: string) { - return this.fetch<{}>('/v3/User/Permissions/UpdateSelf', 'POST', JSON.stringify({ - parameter: { - permissions: { - presence: { - toValue: to, - fromValue: from, - }, + return this.call<{}>('/v3/User/Permissions/UpdateSelf', { + permissions: { + presence: { + toValue: to, + fromValue: from, }, - etag, }, - })); + etag, + }); } async getWebServiceToken(id: string) { @@ -142,9 +137,7 @@ export default class ZncApi { timestamp, }; - return this.fetch('/v2/Game/GetWebServiceToken', 'POST', JSON.stringify({ - parameter: req, - })); + return this.call('/v2/Game/GetWebServiceToken', req); } async getToken(token: string, user: NintendoAccountUser) { @@ -168,9 +161,7 @@ export default class ZncApi { naIdToken: id_token, }; - const data = await this.fetch('/v3/Account/GetToken', 'POST', JSON.stringify({ - parameter: req, - })); + const data = await this.call('/v3/Account/GetToken', req); return { uuid, diff --git a/src/app/browser/util.ts b/src/app/browser/util.ts index 6c42e22..010ce09 100644 --- a/src/app/browser/util.ts +++ b/src/app/browser/util.ts @@ -1,4 +1,6 @@ import * as React from 'react'; +import fetch, { RequestInfo, RequestInit, Response } from 'node-fetch'; +import { ErrorResponse } from '../../index.js'; export enum RequestState { NOT_LOADING, @@ -40,28 +42,6 @@ export function useFetch(requestInfo: RequestInfo | null, init?: RequestInit, return useAsync(requestInfo ? f : null); } -export class ErrorResponse extends Error { - readonly data: any | undefined = undefined; - - constructor(message: string, readonly response: Response, readonly body?: string) { - super(message); - - try { - this.data = body ? JSON.parse(body) : undefined; - } catch (err) {} - } -} - -Object.defineProperty(ErrorResponse, Symbol.hasInstance, { - configurable: true, - value: (instance: ErrorResponse) => { - return instance instanceof Error && - 'response' in instance && - 'body' in instance && - 'data' in instance; - }, -}); - export function useFetchJson(requestInfo: RequestInfo | null, init?: RequestInit) { return useFetch(requestInfo, init, response => { if (response.status !== 200) { diff --git a/src/app/preload-webservice/index.ts b/src/app/preload-webservice/index.ts index 9332aff..db0eefd 100644 --- a/src/app/preload-webservice/index.ts +++ b/src/app/preload-webservice/index.ts @@ -1,4 +1,4 @@ -import { contextBridge, ipcRenderer } from 'electron'; +import { ipcRenderer } from 'electron'; import createDebug from 'debug'; // Logs are written to the browser window developer tools, and are hidden by default (enable verbose logs) diff --git a/src/cli/nso/presence.ts b/src/cli/nso/presence.ts index 6f78473..3c1f517 100644 --- a/src/cli/nso/presence.ts +++ b/src/cli/nso/presence.ts @@ -1,4 +1,3 @@ -import * as path from 'path'; import createDebug from 'debug'; import persist from 'node-persist'; import DiscordRPC from 'discord-rpc'; diff --git a/src/cli/splatnet2/monitor.ts b/src/cli/splatnet2/monitor.ts index b93b43f..6a80bc8 100644 --- a/src/cli/splatnet2/monitor.ts +++ b/src/cli/splatnet2/monitor.ts @@ -186,7 +186,7 @@ export class SplatNet2RecordsMonitor extends Loop { } async handleError(err: Error | ErrorResponse): Promise { - if ('response' in err && err.data.code === 'AUTHENTICATION_ERROR') { + if ('response' in err && err.data?.code === 'AUTHENTICATION_ERROR') { // Token expired debug('Renewing iksm_session cookie'); diff --git a/src/cli/splatnet2/util.ts b/src/cli/splatnet2/util.ts index e625ad0..4af44da 100644 --- a/src/cli/splatnet2/util.ts +++ b/src/cli/splatnet2/util.ts @@ -1,7 +1,6 @@ import createDebug from 'debug'; import persist from 'node-persist'; import * as fs from 'fs'; -import * as path from 'path'; import { getToken } from '../../util.js'; import SplatNet2Api from '../../api/splatnet2.js'; import { WebServiceToken } from '../../api/znc-types.js'; @@ -47,7 +46,7 @@ export async function getIksmToken(storage: persist.LocalStorage, token: string, await storage.setItem('IksmToken.' + token, existingToken); if (!iksm_sessions.has(existingToken.iksm_session)) { - iksm_sessions.set(existingToken.iksm_session, [storage, token, null]); + iksm_sessions.set(existingToken.iksm_session, [storage, token, null, null]); } return { @@ -59,7 +58,7 @@ export async function getIksmToken(storage: persist.LocalStorage, token: string, debug('Using existing token'); if (!iksm_sessions.has(existingToken.iksm_session)) { - iksm_sessions.set(existingToken.iksm_session, [storage, token, null]); + iksm_sessions.set(existingToken.iksm_session, [storage, token, null, null]); } return { @@ -79,7 +78,7 @@ export async function renewIksmToken(splatnet: SplatNet2Api, storage: persist.Lo await storage.setItem('IksmToken.' + token, existingToken); if (!iksm_sessions.has(existingToken.iksm_session)) { - iksm_sessions.set(existingToken.iksm_session, [storage, token, null]); + iksm_sessions.set(existingToken.iksm_session, [storage, token, null, null]); } iksm_sessions.delete(splatnet.iksm_session); @@ -88,30 +87,51 @@ export async function renewIksmToken(splatnet: SplatNet2Api, storage: persist.Lo splatnet.useragent = existingToken.useragent; } -const iksm_sessions = new Map(); +const iksm_sessions = new Map(); export function updateIksmSessionLastUsed(iksm_session: string, last_used: number = Date.now()) { const match = iksm_sessions.get(iksm_session); if (!match) return; - const [storage, token] = match; + const [storage, token,, timeout] = match; - iksm_sessions.set(iksm_session, [storage, token, last_used]); + const new_timeout = timeout ?? setTimeout(() => { + const match = iksm_sessions.get(iksm_session); + if (!match) return; + + const [storage, token, last_used, timeout] = match; + if (timeout === new_timeout) match[3] = null; + + writeUpdatedIksmSessionLastUsed(storage, token, last_used!); + match[2] = null; + }, 1000); + + iksm_sessions.set(iksm_session, [storage, token, last_used, new_timeout]); } -process.on('exit', () => { - for (const [iksm_session, [storage, token, last_used]] of iksm_sessions) { +function writeUpdatedIksmSessionLastUsed(storage: persist.LocalStorage, token: string, last_used: number) { + const datum_str = fs.readFileSync(storage.getDatumPath('IksmToken.' + token), 'utf-8'); + const datum: persist.Datum = storage.parse(datum_str); + const data: SavedIksmSessionToken = datum.value; + + if (data.last_used && data.last_used >= last_used) return; + + data.last_used = last_used; + + const new_datum_str = storage.stringify(datum); + fs.writeFileSync(storage.getDatumPath('IksmToken.' + token), new_datum_str, 'utf-8'); +} + +function writeUpdatedIksmSessionsLastUsed() { + for (const [iksm_session, data] of iksm_sessions) { + const [storage, token, last_used, timeout] = data; + if (timeout) clearTimeout(timeout), data[3] = null; if (!last_used) continue; - const datum_str = fs.readFileSync(storage.getDatumPath('IksmToken.' + token), 'utf-8'); - const datum: persist.Datum = storage.parse(datum_str); - const data: SavedIksmSessionToken = datum.value; - - if (data.last_used && data.last_used >= last_used) continue; - - data.last_used = last_used; - - const new_datum_str = storage.stringify(datum); - fs.writeFileSync(storage.getDatumPath('IksmToken.' + token), new_datum_str, 'utf-8'); + writeUpdatedIksmSessionLastUsed(storage, token, last_used); + data[2] = null; } -}); +} + +process.on('exit', () => writeUpdatedIksmSessionsLastUsed()); +process.on('uncaughtExceptionMonitor', () => writeUpdatedIksmSessionsLastUsed()); diff --git a/src/index.ts b/src/index.ts index 58014f1..21a925e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,12 @@ export { ShareColour as SplatNet2ProfileColour, } from './api/splatnet2.js'; export * as splatnet2 from './api/splatnet2-types.js'; +export { + default as NooklinkApi, + NooklinkUserApi, + MessageType as NooklinkMessageType, +} from './api/nooklink.js'; +export * as nooklink from './api/nooklink-types.js'; export { getTitleIdFromEcUrl } from './util.js'; export { ErrorResponse } from './api/util.js';