From 4bf9908362b38332ef23b13e899b27016b7ed0f6 Mon Sep 17 00:00:00 2001 From: Samuel Elliott Date: Sun, 12 Jun 2022 15:36:00 +0100 Subject: [PATCH] Fetch timeout --- src/api/f.ts | 17 +++++++++++++---- src/api/moon.ts | 9 ++++++--- src/api/na.ts | 13 ++++++++++--- src/api/nooklink.ts | 21 ++++++++++++++------- src/api/splatnet2.ts | 13 +++++++++---- src/api/znc-proxy.ts | 12 ++++++++---- src/common/splatnet2/dump-records.ts | 19 +++++++++++-------- src/common/splatnet2/dump-results.ts | 19 +++++++++++-------- src/common/update.ts | 4 +++- src/util/jwt.ts | 4 +++- src/util/misc.ts | 10 ++++++++++ 11 files changed, 98 insertions(+), 43 deletions(-) diff --git a/src/api/f.ts b/src/api/f.ts index b4e08e5..1193a5b 100644 --- a/src/api/f.ts +++ b/src/api/f.ts @@ -3,6 +3,7 @@ import fetch from 'node-fetch'; import createDebug from 'debug'; import { ErrorResponse } from './util.js'; import { version } from '../util/product.js'; +import { timeoutSignal } from '../util/misc.js'; const debugS2s = createDebug('nxapi:api:s2s'); const debugFlapg = createDebug('nxapi:api:flapg'); @@ -16,6 +17,7 @@ const debugZncaApi = createDebug('nxapi:api:znca-api'); export async function getLoginHash(token: string, timestamp: string | number, useragent?: string) { debugS2s('Getting login hash'); + const [signal, cancel] = timeoutSignal(); const response = await fetch('https://elifessler.com/s2s/api/gen2', { method: 'POST', headers: { @@ -26,7 +28,8 @@ export async function getLoginHash(token: string, timestamp: string | number, us naIdToken: token, timestamp: '' + timestamp, }).toString(), - }); + signal, + }).finally(cancel); const data = await response.json() as LoginHashApiResponse | LoginHashApiError; @@ -56,6 +59,7 @@ export async function flapg( token, timestamp, guid, iid, }); + const [signal, cancel] = timeoutSignal(); const response = await fetch('https://flapg.com/ika2/api/login?public', { headers: { 'User-Agent': (useragent ? useragent + ' ' : '') + 'nxapi/' + version, @@ -66,7 +70,8 @@ export async function flapg( 'x-ver': '3', 'x-iid': iid, }, - }); + signal, + }).finally(cancel); const data = await response.json() as FlapgApiResponse; @@ -110,6 +115,7 @@ export async function iminkf( request_id: uuid, }; + const [signal, cancel] = timeoutSignal(); const response = await fetch('https://api.imink.app/f', { method: 'POST', headers: { @@ -117,7 +123,8 @@ export async function iminkf( 'User-Agent': (useragent ? useragent + ' ' : '') + 'nxapi/' + version, }, body: JSON.stringify(req), - }); + signal, + }).finally(cancel); const data = await response.json() as IminkFResponse | IminkFError; @@ -163,6 +170,7 @@ export async function genf( uuid, }; + const [signal, cancel] = timeoutSignal(); const response = await fetch(url, { method: 'POST', headers: { @@ -170,7 +178,8 @@ export async function genf( 'User-Agent': (useragent ? useragent + ' ' : '') + 'nxapi/' + version, }, body: JSON.stringify(req), - }); + signal, + }).finally(cancel); const data = await response.json() as AndroidZncaFResponse | AndroidZncaFError; diff --git a/src/api/moon.ts b/src/api/moon.ts index 62a344c..64c3e38 100644 --- a/src/api/moon.ts +++ b/src/api/moon.ts @@ -3,6 +3,7 @@ import createDebug from 'debug'; import { getNintendoAccountToken, getNintendoAccountUser } from './na.js'; import { ErrorResponse } from './util.js'; import { DailySummaries, Devices, MonthlySummaries, MonthlySummary, MoonError, ParentalControlSettingState, SmartDevices, User } from './moon-types.js'; +import { timeoutSignal } from '../util/misc.js'; const debug = createDebug('nxapi:api:moon'); @@ -19,8 +20,9 @@ export default class MoonApi { ) {} async fetch(url: string, method = 'GET', body?: string, headers?: object) { + const [signal, cancel] = timeoutSignal(); const response = await fetch(MOON_URL + url, { - method: method, + method, headers: Object.assign({ 'Authorization': 'Bearer ' + this.token, 'Cache-Control': 'no-store', @@ -37,8 +39,9 @@ export default class MoonApi { 'User-Agent': 'moon_ANDROID/' + ZNMA_VERSION + ' (com.nintendo.znma; build:' + ZNMA_BUILD + '; ANDROID 26)', }, headers), - body: body, - }); + body, + signal, + }).finally(cancel); debug('fetch %s %s, response %s', method, url, response.status); diff --git a/src/api/na.ts b/src/api/na.ts index f29dc53..d05f1a9 100644 --- a/src/api/na.ts +++ b/src/api/na.ts @@ -2,12 +2,14 @@ import fetch from 'node-fetch'; import createDebug from 'debug'; import { ErrorResponse } from './util.js'; import { JwtPayload } from '../util/jwt.js'; +import { timeoutSignal } from '../util/misc.js'; const debug = createDebug('nxapi:api:na'); export async function getNintendoAccountSessionToken(code: string, verifier: string, client_id: string) { debug('Getting Nintendo Account session token'); + const [signal, cancel] = timeoutSignal(); const response = await fetch('https://accounts.nintendo.com/connect/1.0.0/api/session_token', { method: 'POST', headers: { @@ -21,7 +23,8 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str session_token_code: code, session_token_code_verifier: verifier, }).toString(), - }); + signal, + }).finally(cancel); const token = await response.json() as NintendoAccountSessionToken | NintendoAccountError; @@ -37,6 +40,7 @@ export async function getNintendoAccountSessionToken(code: string, verifier: str export async function getNintendoAccountToken(token: string, client_id: string) { debug('Getting Nintendo Account token'); + const [signal, cancel] = timeoutSignal(); const response = await fetch('https://accounts.nintendo.com/connect/1.0.0/api/token', { method: 'POST', headers: { @@ -49,7 +53,8 @@ export async function getNintendoAccountToken(token: string, client_id: string) session_token: token, grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer-session-token', }), - }); + signal, + }).finally(cancel); const nintendoAccountToken = await response.json() as NintendoAccountToken | NintendoAccountError; @@ -65,6 +70,7 @@ export async function getNintendoAccountToken(token: string, client_id: string) export async function getNintendoAccountUser(token: NintendoAccountToken) { debug('Getting Nintendo Account user info'); + const [signal, cancel] = timeoutSignal(); const response = await fetch('https://api.accounts.nintendo.com/2.0.0/users/me', { headers: { 'Accept-Language': 'en-GB', @@ -73,7 +79,8 @@ export async function getNintendoAccountUser(token: NintendoAccountToken) { 'Accept': 'application/json', 'Authorization': 'Bearer ' + token.access_token!, }, - }); + signal, + }).finally(cancel); const user = await response.json() as NintendoAccountUser | NintendoAccountError; diff --git a/src/api/nooklink.ts b/src/api/nooklink.ts index 70fca09..bd4cb9d 100644 --- a/src/api/nooklink.ts +++ b/src/api/nooklink.ts @@ -5,6 +5,7 @@ import { NintendoAccountUser } from './na.js'; import { ErrorResponse } from './util.js'; import ZncApi from './znc.js'; import { WebServiceError, Users, AuthToken, UserProfile, Newspapers, Newspaper, Emoticons, Reaction, IslandProfile } from './nooklink-types.js'; +import { timeoutSignal } from '../util/misc.js'; const debug = createDebug('nxapi:api:nooklink'); @@ -22,8 +23,9 @@ export default class NooklinkApi { ) {} async fetch(url: string, method = 'GET', body?: string | FormData, headers?: object) { + const [signal, cancel] = timeoutSignal(); const response = await fetch(NOOKLINK_URL + url, { - method: method, + method, headers: Object.assign({ 'Upgrade-Insecure-Requests': '1', 'User-Agent': this.useragent, @@ -36,8 +38,9 @@ export default class NooklinkApi { 'Content-Type': 'application/json', 'X-Blanco-Version': BLANCO_VERSION, }, headers), - body: body, - }); + body, + signal, + }).finally(cancel); debug('fetch %s %s, response %s', method, url, response.status); @@ -96,6 +99,7 @@ export default class NooklinkApi { na_lang: user.language, }).toString(); + const [signal, cancel] = timeoutSignal(); const response = await fetch(url.toString(), { headers: { 'Upgrade-Insecure-Requests': '1', @@ -106,7 +110,8 @@ export default class NooklinkApi { 'Accept-Language': 'en-GB,en-US;q=0.8', 'X-Requested-With': 'com.nintendo.znca', }, - }); + signal, + }).finally(cancel); debug('fetch %s %s, response %s', 'GET', url, response.status); @@ -154,8 +159,9 @@ export class NooklinkUserApi { ) {} async fetch(url: string, method = 'GET', body?: string | FormData, headers?: object) { + const [signal, cancel] = timeoutSignal(); const response = await fetch(NOOKLINK_URL + url, { - method: method, + method, headers: Object.assign({ 'Upgrade-Insecure-Requests': '1', 'User-Agent': this.useragent, @@ -169,8 +175,9 @@ export class NooklinkUserApi { 'Authorization': 'Bearer ' + this.auth_token, 'X-Blanco-Version': BLANCO_VERSION, }, headers), - body: body, - }); + body, + signal, + }).finally(cancel); debug('fetch %s %s, response %s', method, url, response.status); diff --git a/src/api/splatnet2.ts b/src/api/splatnet2.ts index 22635bd..28ecf5d 100644 --- a/src/api/splatnet2.ts +++ b/src/api/splatnet2.ts @@ -6,6 +6,7 @@ import { NintendoAccountUser } from './na.js'; import { ErrorResponse } from './util.js'; import ZncApi from './znc.js'; import { ActiveFestivals, CoopResult, CoopResults, CoopSchedules, HeroRecords, NicknameAndIcons, PastFestivals, Records, Result, Results, Schedules, ShareResponse, ShopMerchandises, Stages, Timeline, WebServiceError, XPowerRankingSummary } from './splatnet2-types.js'; +import { timeoutSignal } from '../util/misc.js'; const debug = createDebug('nxapi:api:splatnet2'); @@ -29,8 +30,9 @@ export default class SplatNet2Api { ) {} async fetch(url: string, method = 'GET', body?: string | FormData, headers?: object) { + const [signal, cancel] = timeoutSignal(); const response = await fetch(SPLATNET2_URL + url, { - method: method, + method, headers: Object.assign({ 'Upgrade-Insecure-Requests': '1', 'User-Agent': this.useragent, @@ -40,8 +42,9 @@ export default class SplatNet2Api { 'Accept-Language': 'en-GB,en-US;q=0.8', 'X-Requested-With': 'com.nintendo.znca', }, headers), - body: body, - }); + body, + signal, + }).finally(cancel); debug('fetch %s %s, response %s', method, url, response.status); @@ -230,6 +233,7 @@ ${colour} na_lang: user.language, }).toString(); + const [signal, cancel] = timeoutSignal(); const response = await fetch(url.toString(), { headers: { 'Upgrade-Insecure-Requests': '1', @@ -240,7 +244,8 @@ ${colour} 'Accept-Language': 'en-GB,en-US;q=0.8', 'X-Requested-With': 'com.nintendo.znca', }, - }); + signal, + }).finally(cancel); debug('fetch %s %s, response %s', 'GET', url, response.status); diff --git a/src/api/znc-proxy.ts b/src/api/znc-proxy.ts index 3bfb81d..af78fdf 100644 --- a/src/api/znc-proxy.ts +++ b/src/api/znc-proxy.ts @@ -6,6 +6,7 @@ import ZncApi from './znc.js'; import { version } from '../util/product.js'; import { NintendoAccountUser } from './na.js'; import { SavedToken } from '../common/auth/nso.js'; +import { timeoutSignal } from '../util/misc.js'; const debug = createDebug('nxapi:api:znc-proxy'); @@ -26,14 +27,16 @@ export default class ZncProxyApi implements ZncApi { ) {} async fetch(url: string, method = 'GET', body?: string, headers?: object) { + const [signal, cancel] = timeoutSignal(); const response = await fetch(this.url + url, { - method: method, + method, headers: Object.assign({ 'Authorization': 'na ' + this.token, 'User-Agent': (this.useragent ? this.useragent + ' ' : '') + 'nxapi/' + version, }, headers), - body: body, - }); + body, + signal, + }).finally(cancel); debug('fetch %s %s, response %s', method, url, response.status); @@ -178,7 +181,8 @@ export type PresenceUrlResponse = Friend | {friend: Friend}; export async function getPresenceFromUrl(presence_url: string) { - const response = await fetch(presence_url); + const [signal, cancel] = timeoutSignal(); + const response = await fetch(presence_url, {signal}).finally(cancel); debug('fetch %s %s, response %s', 'GET', presence_url, response.status); diff --git a/src/common/splatnet2/dump-records.ts b/src/common/splatnet2/dump-records.ts index dc9bb17..032ed35 100644 --- a/src/common/splatnet2/dump-records.ts +++ b/src/common/splatnet2/dump-records.ts @@ -6,6 +6,7 @@ import createDebug from 'debug'; import fetch from 'node-fetch'; import SplatNet2Api, { ShareColour } from '../../api/splatnet2.js'; import { Challenge, NicknameAndIcon, Records, Stages } from '../../api/splatnet2-types.js'; +import { timeoutSignal } from '../../util/misc.js'; const debug = createDebug('nxapi:splatnet2:dump-records'); @@ -98,10 +99,6 @@ export async function dumpProfileImage( debug('Fetching profile image URL'); const share = await splatnet.shareProfile(stage?.id ?? stages.stages[0].id, favourite_colour as ShareColour); - debug('Fetching profile image'); - const image_response = await fetch(share.url); - const image = await image_response.arrayBuffer(); - debug('Writing profile image data %s', filename); await fs.writeFile(file, JSON.stringify({ share, @@ -109,6 +106,11 @@ export async function dumpProfileImage( colour: favourite_colour, }, null, 4) + '\n', 'utf-8'); + debug('Fetching profile image', share); + const [signal, cancel] = timeoutSignal(); + const image_response = await fetch(share.url, {signal}).finally(cancel); + const image = await image_response.arrayBuffer(); + debug('Writing profile image %s', image_filename); await fs.writeFile(image_file, Buffer.from(image)); @@ -135,13 +137,14 @@ export async function dumpChallenges( debug('Fetching challenge image URL for %s', challenge.key); const share = await splatnet.shareChallenge(challenge.key, season); - debug('Fetching challenge image for %s', challenge.key); - const image_response = await fetch(share.url); - const image = await image_response.arrayBuffer(); - debug('Writing challenge image data %s', filename); await fs.writeFile(file, JSON.stringify({share}, null, 4) + '\n', 'utf-8'); + debug('Fetching challenge image for %s', challenge.key, share); + const [signal, cancel] = timeoutSignal(); + const image_response = await fetch(share.url, {signal}).finally(cancel); + const image = await image_response.arrayBuffer(); + debug('Writing challenge image %s', filename); await fs.writeFile(image_file, Buffer.from(image)); } diff --git a/src/common/splatnet2/dump-results.ts b/src/common/splatnet2/dump-results.ts index 901e2d3..1fc8487 100644 --- a/src/common/splatnet2/dump-results.ts +++ b/src/common/splatnet2/dump-results.ts @@ -5,6 +5,7 @@ import createDebug from 'debug'; import fetch from 'node-fetch'; import SplatNet2Api from '../../api/splatnet2.js'; import { NicknameAndIcon } from '../../api/splatnet2-types.js'; +import { timeoutSignal } from '../../util/misc.js'; const debug = createDebug('nxapi:splatnet2:dump-results'); @@ -45,15 +46,16 @@ export async function dumpResults( debug('Fetching battle results summary image URL'); const share = await splatnet.shareResultsSummary(); - debug('Fetching battle results summary image'); - const image_response = await fetch(share.url); - const image = await image_response.arrayBuffer(); - debug('Writing battle results summary image data %s', filename); await fs.writeFile(file, JSON.stringify({ share, }, null, 4) + '\n', 'utf-8'); + debug('Fetching battle results summary image', share); + const [signal, cancel] = timeoutSignal(); + const image_response = await fetch(share.url, {signal}).finally(cancel); + const image = await image_response.arrayBuffer(); + debug('Writing battle results summary image %s', filename); await fs.writeFile(image_file, Buffer.from(image)); } @@ -102,15 +104,16 @@ export async function dumpResults( debug('Fetching battle results image URL'); const share = await splatnet.shareResult(item.battle_number); - debug('Fetching battle results image'); - const image_response = await fetch(share.url); - const image = await image_response.arrayBuffer(); - debug('Writing battle results image data %s', filename); await fs.writeFile(file, JSON.stringify({ share, }, null, 4) + '\n', 'utf-8'); + debug('Fetching battle results image', share); + const [signal, cancel] = timeoutSignal(); + const image_response = await fetch(share.url, {signal}).finally(cancel); + const image = await image_response.arrayBuffer(); + debug('Writing battle results image %s', filename); await fs.writeFile(image_file, Buffer.from(image)); } diff --git a/src/common/update.ts b/src/common/update.ts index f318e2d..4ae2a86 100644 --- a/src/common/update.ts +++ b/src/common/update.ts @@ -5,6 +5,7 @@ import createDebug from 'debug'; import mkdirp from 'mkdirp'; import { dir, version } from '../util/product.js'; import { paths } from '../util/storage.js'; +import { timeoutSignal } from '../util/misc.js'; const debug = createDebug('nxapi:update'); @@ -41,7 +42,8 @@ export async function checkUpdates() { debug('Checking for updates'); try { - const response = await fetch(RELEASES_URL); + const [signal, cancel] = timeoutSignal(); + const response = await fetch(RELEASES_URL, {signal}).finally(cancel); const releases = await response.json() as Release[]; const current = releases.find(r => r.tag_name === 'v' + version); diff --git a/src/util/jwt.ts b/src/util/jwt.ts index a88dba2..2399fb8 100644 --- a/src/util/jwt.ts +++ b/src/util/jwt.ts @@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer'; import createDebug from 'debug'; import persist from 'node-persist'; import fetch from 'node-fetch'; +import { timeoutSignal } from './misc.js'; const debug = createDebug('nxapi:util:jwt'); @@ -135,7 +136,8 @@ export async function getJwks(url: string, storage?: persist.LocalStorage) { if (!cached_keyset || cached_keyset.expires_at <= Date.now()) { debug('Downloading JSON Web Key Set from %s', url); - const response = await fetch(url); + const [signal, cancel] = timeoutSignal(); + const response = await fetch(url, {signal}).finally(cancel); const jwks = await response.json() as Jwks; diff --git a/src/util/misc.ts b/src/util/misc.ts index 3086d71..ec1c07b 100644 --- a/src/util/misc.ts +++ b/src/util/misc.ts @@ -18,3 +18,13 @@ export function hrduration(duration: number, short = false) { return minutes + ' ' + minute_str + (minutes === 1 ? '' : 's'); } } + +export function timeoutSignal(ms = 10 * 1000) { + const controller = new AbortController(); + + const timeout = setTimeout(() => { + controller.abort(new Error('Timeout')); + }, ms); + + return [controller.signal, () => clearTimeout(timeout)] as const; +}