diff --git a/.vscode/generate-schemas.sh b/.vscode/generate-schemas.sh index 6bcd9f7..6963810 100755 --- a/.vscode/generate-schemas.sh +++ b/.vscode/generate-schemas.sh @@ -15,3 +15,5 @@ npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type CoopResult npx ts-json-schema-generator --path src/api/splatnet2-types.ts --type CoopResultWithPlayerNicknameAndIcons --no-type-check > .vscode/schema/splatnet2/coop-result.schema.json npx ts-json-schema-generator --path src/api/nooklink-types.ts --type Newspaper --no-type-check > .vscode/schema/nooklink/newspaper.schema.json + +npx ts-json-schema-generator --path src/common/remote-config.ts --type NxapiRemoteConfig --no-type-check > .vscode/schema/remote-config.schema.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 58c7f13..155e2c7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,6 +49,11 @@ "fileMatch": ["**/nooklink-newspaper-*.json"], "url": "./.vscode/schema/nooklink/newspaper.schema.json" }, + + { + "fileMatch": ["**/resources/common/remote-config.json", "**/data/remote-config.json"], + "url": "./.vscode/schema/remote-config.schema.json" + }, ], "typescript.tsdk": "node_modules/typescript/lib", "typescript.preferences.quoteStyle": "single", diff --git a/resources/common/remote-config.json b/resources/common/remote-config.json new file mode 100644 index 0000000..4dec776 --- /dev/null +++ b/resources/common/remote-config.json @@ -0,0 +1,15 @@ +{ + "require_version": [], + "coral": { + "znca_version": "2.1.1" + }, + "coral_auth": { + "splatnet2statink": {}, + "flapg": {}, + "imink": {} + }, + "moon": { + "znma_version": "1.17.0", + "znma_build": "261" + } +} diff --git a/src/cli/util/index.ts b/src/cli/util/index.ts index 4827b50..a135b98 100644 --- a/src/cli/util/index.ts +++ b/src/cli/util/index.ts @@ -3,3 +3,4 @@ export * as validateDiscordTitles from './validate-discord-titles.js'; export * as exportDiscordTitles from './export-discord-titles.js'; export * as discordActivity from './discord-activity.js'; export * as discordRpc from './discord-rpc.js'; +export * as remoteConfig from './remote-config.js'; diff --git a/src/cli/util/remote-config.ts b/src/cli/util/remote-config.ts new file mode 100644 index 0000000..080beb4 --- /dev/null +++ b/src/cli/util/remote-config.ts @@ -0,0 +1,35 @@ +import createDebug from 'debug'; +import type { Arguments as ParentArguments } from '../util.js'; +import { ArgumentsCamelCase, Argv, YargsArguments } from '../../util/yargs.js'; + +const debug = createDebug('cli:util:remote-config'); + +export const command = 'remote-config'; +export const desc = 'Show nxapi remote configuration'; + +export function builder(yargs: Argv) { + return yargs.option('json', { + describe: 'Output raw JSON', + type: 'boolean', + }).option('json-pretty-print', { + describe: 'Output pretty-printed JSON', + type: 'boolean', + }); +} + +type Arguments = YargsArguments>; + +export async function handler(argv: ArgumentsCamelCase) { + const { default: config } = await import('../../common/remote-config.js'); + + if (argv.jsonPrettyPrint) { + console.log(JSON.stringify(config, null, 4)); + return; + } + if (argv.json) { + console.log(JSON.stringify(config)); + return; + } + + console.log('Remote config', config); +} diff --git a/src/common/remote-config.ts b/src/common/remote-config.ts new file mode 100644 index 0000000..5dbc0a5 --- /dev/null +++ b/src/common/remote-config.ts @@ -0,0 +1,217 @@ +import * as path from 'node:path'; +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 { timeoutSignal } from '../util/misc.js'; +import { getUserAgent } from '../util/useragent.js'; +import { paths } from '../util/storage.js'; +import { dev, dir, git, version } from '../util/product.js'; + +const debug = createDebug('nxapi:remote-config'); + +const CONFIG_URL = 'https://nxapi.ta.fancy.org.uk/data/config.json'; +/** Maximum time in seconds to consider cached data fresh */ +const MAX_FRESH = 24 * 60 * 60; // 1 day in seconds +/** Maximum time in seconds to allow using cached data after it's considered stale */ +const MAX_STALE = 24 * 60 * 60; // 1 day in seconds + +const default_config: NxapiRemoteConfig = { + require_version: [version], + ...JSON.parse(await fs.readFile(path.join(dir, 'resources', 'common', 'remote-config.json'), 'utf-8')), +}; + +async function loadRemoteConfig() { + await mkdirp(paths.cache); + const config_cache_path = path.resolve(paths.cache, 'config.json'); + + const url = process.env.NXAPI_CONFIG_URL ?? CONFIG_URL; + + let data: RemoteConfigCacheData | undefined = undefined; + let must_revalidate = true; + + try { + data = JSON.parse(await fs.readFile(config_cache_path, 'utf-8')); + + if (data && (data.stale_at ?? data.expires_at) > Date.now()) { + // Response is still fresh + return data; + } + + if (data && data.expires_at > Date.now()) { + // Response is stale, but not expired + must_revalidate = false; + } + } catch (err) {} + + try { + const config = await getRemoteConfig(url, undefined, data ? { + previous: data.data, + updated_at: new Date(data.updated_at), + etag: data.etag, + } : undefined); + const response = config[ResponseSymbol]; + + const cache_directives = (response.headers.get('Cache-Control') ?? '') + .split(',').map(d => d.trim().toLowerCase()).filter(d => d); + const max_age_directive = cache_directives.find(d => d.startsWith('max-age='))?.substr(8); + const max_age = max_age_directive ? Math.min(parseInt(max_age_directive), MAX_FRESH) : null; + const stale_ie_directive = cache_directives.find(d => d.startsWith('stale-if-error='))?.substr(15); + const stale_ie = stale_ie_directive ? Math.min(parseInt(stale_ie_directive), MAX_STALE) : null; + + const stale_at = max_age ? Date.now() + (max_age * 1000) : null; + const expires_at = + cache_directives.includes('no-store') || cache_directives.includes('no-cache') ? 0 : + stale_ie && max_age ? Date.now() + (max_age * 1000) + (stale_ie * 1000) : + stale_at ?? 0; + + const new_cache: RemoteConfigCacheData = { + created_at: config[CachedSymbol] ? data!.created_at : Date.now(), + updated_at: new Date(response.headers.get('Last-Modified') ?? Date.now()).getTime(), + etag: response.headers.get('ETag'), + revalidated_at: config[CachedSymbol] ? Date.now() : null, + stale_at, + expires_at, + url: response.url, + headers: response.headers.raw(), + data: config, + }; + + await fs.writeFile(config_cache_path, JSON.stringify(new_cache, null, 4) + '\n', 'utf-8'); + return new_cache; + } catch (err) { + // Throw if the data was never loaded or has expired + if (!data || must_revalidate) throw err; + return data; + } +} + +const ResponseSymbol = Symbol('Response'); +const CachedSymbol = Symbol('Cached'); + +async function getRemoteConfig(url: string, useragent?: string, cache?: { + previous: NxapiRemoteConfig; + updated_at: Date; + etag: string | null; +}) { + debug('Getting remote config from %s', url); + + const [signal, cancel] = timeoutSignal(); + const response = await fetch(url, { + headers: { + 'User-Agent': getUserAgent(), + 'X-nxapi-Version': version, + 'X-nxapi-Revision': git?.revision ?? undefined!, + 'If-Modified-Since': cache ? cache.updated_at.toUTCString() : undefined!, + 'If-None-Match': cache?.etag ?? undefined!, + }, + signal, + }).finally(cancel); + + if (cache && response.status === 304) { + return Object.assign({}, cache.previous, { + [ResponseSymbol]: response, + [CachedSymbol]: true, + }); + } + + if (response.status !== 200) { + throw new ErrorResponse('[nxapi] Unknown error', response, await response.text()); + } + + const config = await response.json() as NxapiRemoteConfig; + + debug('Got remote config', config); + + return Object.assign(config, { + [ResponseSymbol]: response, + [CachedSymbol]: false, + }); +} + +async function tryLoadRemoteConfig() { + try { + return await loadRemoteConfig(); + } catch (err) { + console.warn('Failed to load remote configuration; falling back to default configuration', err); + return null; + } +} + +const debug_fixed_config: NxapiRemoteConfig | null = + !dev ? null : + await fs.readFile(path.join(paths.data, 'remote-config.json'), 'utf-8').then(JSON.parse).catch(err => { + if (err.code === 'ENOENT') return null; + + debug('Error reading local debug config'); + console.warn('Error reading local debug configuration', err); + return null; + }) || null; + +export enum RemoteConfigMode { + /** Always use local configuration */ + DISABLE, + /** Always use remote configuration */ + REQUIRE, + /** Try to use remote configuration, but allow falling back to local configuration */ + OPPORTUNISTIC, +} + +export const mode = + process.env.NXAPI_ENABLE_REMOTE_CONFIG !== '1' ? RemoteConfigMode.DISABLE : + process.env.NXAPI_REMOTE_CONFIG_FALLBACK === '1' ? RemoteConfigMode.OPPORTUNISTIC : + RemoteConfigMode.REQUIRE; + +export const cache = + debug_fixed_config ? null : + mode === RemoteConfigMode.DISABLE ? null : + mode === RemoteConfigMode.OPPORTUNISTIC ? await tryLoadRemoteConfig() : + await loadRemoteConfig(); +const config = debug_fixed_config ?? cache?.data ?? default_config; + +if (cache && !config.require_version.includes(version)) { + throw new Error('nxapi update required'); +} + +export default config; + +export interface RemoteConfigCacheData { + created_at: number; + updated_at: number; + etag: string | null; + revalidated_at: number | null; + /** Timestamp we must attempt to update the cache, but can continue to use the data if it fails */ + stale_at: number | null; + /** Timestamp we must discard the cache require re-downloading the data */ + expires_at: number; + url: string; + headers: Record; + data: NxapiRemoteConfig; +} + +export interface NxapiRemoteConfig { + /** + * Versions that may connect to Nintendo and third-party auth APIs. The nxapi version number is sent to the server + * so specific APIs can be disabled instead of using this. + */ + require_version: string[]; + + // If null the API should not be used + coral: CoralRemoteConfig | null; + coral_auth: { + splatnet2statink: {} | null; + flapg: {} | null; + imink: {} | null; + }; + moon: MoonRemoteConfig | null; +} + +export interface CoralRemoteConfig { + znca_version: string; // '2.1.1' +} + +export interface MoonRemoteConfig { + znma_version: string; // '1.17.0' + znma_build: string; // '261' +}