From 3845a6aa85fd049823c9bde1fa4c7e2e93917554 Mon Sep 17 00:00:00 2001 From: Jonathan Barrow Date: Fri, 11 Apr 2025 19:06:36 -0400 Subject: [PATCH] feat(nnas): centralize success response creation --- src/services/nnas/create-response.ts | 3 +- src/services/nnas/routes/admin.ts | 13 +- src/services/nnas/routes/content.ts | 214 +++++++++++++-------------- src/services/nnas/routes/devices.ts | 10 +- src/services/nnas/routes/miis.ts | 13 +- src/services/nnas/routes/oauth.ts | 19 +-- src/services/nnas/routes/people.ts | 140 +++++++++--------- src/services/nnas/routes/provider.ts | 31 ++-- 8 files changed, 222 insertions(+), 221 deletions(-) diff --git a/src/services/nnas/create-response.ts b/src/services/nnas/create-response.ts index 7ca5c5e..075ad27 100644 --- a/src/services/nnas/create-response.ts +++ b/src/services/nnas/create-response.ts @@ -16,6 +16,7 @@ type CreateNNASErrorResponseOptions = { type CreateNNASResponseOptions = { status?: number; body: Record; + xmlbuilder_options?: xmlbuilder.CreateOptions } // TODO - This can largely be removed once an upgrade to Express v5 is done, since v5 supports throwing errors in async routes @@ -37,7 +38,7 @@ export function createNNASResponse(response: express.Response, options: CreateNN response.set('X-Nintendo-Date', new Date().getTime().toString()); const body = xmlbuilder - .create(options.body) + .create(options.body, options.xmlbuilder_options) .commentBefore('WARNING! DO NOT SHARE ANYTHING IN THIS REQUEST OR RESPONSE WITH UNTRUSTED USERS! REQUESTS AND RESPONSES CONTAIN SENSITIVE INFORMATION ABOUT YOUR DEVICE/ACCOUNT SUCH AS PASSWORDS, EMAILS, CERTIFICATES, ETC!').end(); response.status(options.status || 200).send(body); diff --git a/src/services/nnas/routes/admin.ts b/src/services/nnas/routes/admin.ts index 91b69b3..dce846f 100644 --- a/src/services/nnas/routes/admin.ts +++ b/src/services/nnas/routes/admin.ts @@ -1,7 +1,6 @@ import express from 'express'; -import xmlbuilder from 'xmlbuilder'; import { getValueFromQueryString } from '@/util'; -import { createNNASErrorResponse } from '@/services/nnas/create-response'; +import { createNNASErrorResponse, createNNASResponse } from '@/services/nnas/create-response'; import { PNID } from '@/models/pnid'; const router = express.Router(); @@ -96,11 +95,13 @@ router.get('/mapped_ids', async (request: express.Request, response: express.Res results.push(result); } - response.send(xmlbuilder.create({ - mapped_ids: { - mapped_id: results + return createNNASResponse(response, { + body: { + mapped_ids: { + mapped_id: results + } } - }).end()); + }); }); /** diff --git a/src/services/nnas/routes/content.ts b/src/services/nnas/routes/content.ts index a60cc30..a04a8b8 100644 --- a/src/services/nnas/routes/content.ts +++ b/src/services/nnas/routes/content.ts @@ -1,5 +1,5 @@ import express from 'express'; -import xmlbuilder from 'xmlbuilder'; +import { createNNASResponse } from '@/services/nnas/create-response'; import timezones from '@/services/nnas/timezones.json'; const router = express.Router(); @@ -10,115 +10,113 @@ const router = express.Router(); * Description: Sends the client requested agreement */ router.get('/agreements/:type/:region/:version', (request: express.Request, response: express.Response): void => { - response.set('Content-Type', 'text/xml'); - response.set('Server', 'Nintendo 3DS (http)'); - response.set('X-Nintendo-Date', new Date().getTime().toString()); + return createNNASResponse(response, { + body: { + agreements: { + agreement: [ + { + country: 'US', + language: 'en', + language_name: 'English', + publish_date: '2014-09-29T20:07:35', + texts: { + '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + '@xsi:type': 'chunkedStoredAgreementText', - response.send(xmlbuilder.create({ - agreements: { - agreement: [ - { - country: 'US', - language: 'en', - language_name: 'English', - publish_date: '2014-09-29T20:07:35', - texts: { - '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - '@xsi:type': 'chunkedStoredAgreementText', - - main_title: { - '#cdata': 'Pretendo Network Services Agreement' - }, - agree_text: { - '#cdata': 'I Accept' - }, - non_agree_text: { - '#cdata': 'I Decline' - }, - main_text: { - '@index': '1', - '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' - }, - sub_title: { - '#cdata': 'Privacy Policy' - }, - sub_text: { - '@index': '1', - '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' + main_title: { + '#cdata': 'Pretendo Network Services Agreement' + }, + agree_text: { + '#cdata': 'I Accept' + }, + non_agree_text: { + '#cdata': 'I Decline' + }, + main_text: { + '@index': '1', + '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' + }, + sub_title: { + '#cdata': 'Privacy Policy' + }, + sub_text: { + '@index': '1', + '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' + }, }, + type: 'NINTENDO-NETWORK-EULA', + version: '0300', }, - type: 'NINTENDO-NETWORK-EULA', - version: '0300', - }, - { - country: 'US', - language: 'en', - language_name: 'Español', - publish_date: '2014-09-29T20:07:35', - texts: { - '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - '@xsi:type': 'chunkedStoredAgreementText', + { + country: 'US', + language: 'en', + language_name: 'Español', + publish_date: '2014-09-29T20:07:35', + texts: { + '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + '@xsi:type': 'chunkedStoredAgreementText', - main_title: { - '#cdata': 'Pretendo Network Services Agreement' - }, - agree_text: { - '#cdata': 'I Accept' - }, - non_agree_text: { - '#cdata': 'I Decline' - }, - main_text: { - '@index': '1', - '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' - }, - sub_title: { - '#cdata': 'Privacy Policy' - }, - sub_text: { - '@index': '1', - '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' + main_title: { + '#cdata': 'Pretendo Network Services Agreement' + }, + agree_text: { + '#cdata': 'I Accept' + }, + non_agree_text: { + '#cdata': 'I Decline' + }, + main_text: { + '@index': '1', + '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' + }, + sub_title: { + '#cdata': 'Privacy Policy' + }, + sub_text: { + '@index': '1', + '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' + }, }, + type: 'NINTENDO-NETWORK-EULA', + version: '0300', }, - type: 'NINTENDO-NETWORK-EULA', - version: '0300', - }, - { - country: 'US', - language: 'en', - language_name: 'Français', - publish_date: '2014-09-29T20:07:35', - texts: { - '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', - '@xsi:type': 'chunkedStoredAgreementText', + { + country: 'US', + language: 'en', + language_name: 'Français', + publish_date: '2014-09-29T20:07:35', + texts: { + '@xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + '@xsi:type': 'chunkedStoredAgreementText', - main_title: { - '#cdata': 'Pretendo Network Services Agreement' + main_title: { + '#cdata': 'Pretendo Network Services Agreement' + }, + agree_text: { + '#cdata': 'I Accept' + }, + non_agree_text: { + '#cdata': 'I Decline' + }, + main_text: { + '@index': '1', + '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' + }, + sub_title: { + '#cdata': 'Privacy Policy' + }, + sub_text: { + '@index': '1', + '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' + }, }, - agree_text: { - '#cdata': 'I Accept' - }, - non_agree_text: { - '#cdata': 'I Decline' - }, - main_text: { - '@index': '1', - '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' - }, - sub_title: { - '#cdata': 'Privacy Policy' - }, - sub_text: { - '@index': '1', - '#cdata': 'Welcome to Pretendo\'s Christmas public beta! This is supplied with no liability or warranty, and is a stress test of our current services.This test is not expected to last long- term, and the data may be kept for later testing; this data will not be shared outside of Pretendo, and will be deleted at the end of our testing period.' - }, - }, - type: 'NINTENDO-NETWORK-EULA', - version: '0300', - } - ] + type: 'NINTENDO-NETWORK-EULA', + version: '0300', + } + ] + } } - }).end()); + }); }); /** @@ -127,10 +125,6 @@ router.get('/agreements/:type/:region/:version', (request: express.Request, resp * Description: Sends the client the requested timezones */ router.get('/time_zones/:countryCode/:language', (request: express.Request, response: express.Response): void => { - response.set('Content-Type', 'text/xml'); - response.set('Server', 'Nintendo 3DS (http)'); - response.set('X-Nintendo-Date', new Date().getTime().toString()); - /* // * Old method. Crashes WiiU when sending a list with over 32 entries, but otherwise works // * countryTimezones is "countries-and-timezones" module @@ -155,11 +149,13 @@ router.get('/time_zones/:countryCode/:language', (request: express.Request, resp const regionLanguages = timezones[countryCode as keyof typeof timezones]; const regionTimezones = regionLanguages[language as keyof typeof regionLanguages] ? regionLanguages[language as keyof typeof regionLanguages] : Object.values(regionLanguages)[0]; - response.send(xmlbuilder.create({ - timezones: { - timezone: regionTimezones + return createNNASResponse(response, { + body: { + timezones: { + timezone: regionTimezones + } } - }).end()); + }); }); export default router; \ No newline at end of file diff --git a/src/services/nnas/routes/devices.ts b/src/services/nnas/routes/devices.ts index f46e6a4..39632ce 100644 --- a/src/services/nnas/routes/devices.ts +++ b/src/services/nnas/routes/devices.ts @@ -1,5 +1,5 @@ import express from 'express'; -import xmlbuilder from 'xmlbuilder'; +import { createNNASResponse } from '@/services/nnas/create-response'; const router = express.Router(); @@ -10,9 +10,11 @@ const router = express.Router(); */ router.get('/@current/status', async (request: express.Request, response: express.Response): Promise => { // TODO - Finish this - response.send(xmlbuilder.create({ - device: '' - }).end()); + return createNNASResponse(response, { + body: { + device: '' + } + }); }); export default router; \ No newline at end of file diff --git a/src/services/nnas/routes/miis.ts b/src/services/nnas/routes/miis.ts index 5182b79..63ddd48 100644 --- a/src/services/nnas/routes/miis.ts +++ b/src/services/nnas/routes/miis.ts @@ -1,7 +1,6 @@ import express from 'express'; -import xmlbuilder from 'xmlbuilder'; import { getValueFromQueryString } from '@/util'; -import { createNNASErrorResponse } from '@/services/nnas/create-response'; +import { createNNASErrorResponse, createNNASResponse } from '@/services/nnas/create-response'; import { PNID } from '@/models/pnid'; import { config } from '@/config-manager'; import { YesNoBoolString } from '@/types/common/yes-no-bool-string'; @@ -118,11 +117,13 @@ router.get('/', async (request: express.Request, response: express.Response): Pr if (miis.length === 0) { response.status(404).end(); } else { - response.send(xmlbuilder.create({ - miis: { - mii: miis + return createNNASResponse(response, { + body: { + miis: { + mii: miis + } } - }).end()); + }); } }); diff --git a/src/services/nnas/routes/oauth.ts b/src/services/nnas/routes/oauth.ts index 2eba4bc..564376f 100644 --- a/src/services/nnas/routes/oauth.ts +++ b/src/services/nnas/routes/oauth.ts @@ -1,9 +1,8 @@ import express from 'express'; -import xmlbuilder from 'xmlbuilder'; import bcrypt from 'bcrypt'; import { getPNIDByNNASRefreshToken, getPNIDByUsername } from '@/database'; import { generateOAuthTokens } from '@/util'; -import { createNNASErrorResponse } from '@/services/nnas/create-response'; +import { createNNASErrorResponse, createNNASResponse } from '@/services/nnas/create-response'; import { Device } from '@/models/device'; import { SystemType } from '@/types/common/token'; @@ -155,15 +154,17 @@ router.post('/access_token/generate', async (request: express.Request, response: try { const tokenGeneration = generateOAuthTokens(SystemType.WIIU, pnid); - response.send(xmlbuilder.create({ - OAuth20: { - access_token: { - token: tokenGeneration.accessToken, - refresh_token: tokenGeneration.refreshToken, - expires_in: tokenGeneration.expiresInSecs.access + return createNNASResponse(response, { + body: { + OAuth20: { + access_token: { + token: tokenGeneration.accessToken, + refresh_token: tokenGeneration.refreshToken, + expires_in: tokenGeneration.expiresInSecs.access + } } } - }).commentBefore('WARNING! DO NOT SHARE ANYTHING IN THIS REQUEST OR RESPONSE WITH UNTRUSTED USERS! IT CAN BE USED TO IMPERSONATE YOU AND YOUR CONSOLE, POTENTIALLY GETTING YOU BANNED!').end()); // TODO - This is ugly + }); } catch { response.status(500); } diff --git a/src/services/nnas/routes/people.ts b/src/services/nnas/routes/people.ts index ba1e7cf..73f91de 100644 --- a/src/services/nnas/routes/people.ts +++ b/src/services/nnas/routes/people.ts @@ -1,12 +1,11 @@ import crypto from 'node:crypto'; import express from 'express'; -import xmlbuilder from 'xmlbuilder'; import bcrypt from 'bcrypt'; import moment from 'moment'; import ratelimit from '@/middleware/ratelimit'; import { connection as databaseConnection, doesPNIDExist, getPNIDProfileJSONByPID } from '@/database'; import { getValueFromHeaders, nintendoPasswordHash, sendConfirmationEmail, sendPNIDDeletedEmail } from '@/util'; -import { createNNASErrorResponse } from '@/services/nnas/create-response'; +import { createNNASErrorResponse, createNNASResponse } from '@/services/nnas/create-response'; import { PNID } from '@/models/pnid'; import { NEXAccount } from '@/models/nex-account'; import { LOG_ERROR } from '@/logger'; @@ -182,11 +181,13 @@ router.post('/', ratelimit, async (request: express.Request, response: express.R await sendConfirmationEmail(pnid); - response.send(xmlbuilder.create({ - person: { - pid: pnid.pid + return createNNASResponse(response, { + body: { + person: { + pid: pnid.pid + } } - }).end()); + }); }); /** @@ -195,10 +196,6 @@ router.post('/', ratelimit, async (request: express.Request, response: express.R * Description: Gets a users profile */ router.get('/@me/profile', async (request: express.Request, response: express.Response): Promise => { - response.set('Content-Type', 'text/xml'); - response.set('Server', 'Nintendo 3DS (http)'); - response.set('X-Nintendo-Date', new Date().getTime().toString()); - const pnid = request.pnid; if (!pnid) { @@ -231,9 +228,14 @@ router.get('/@me/profile', async (request: express.Request, response: express.Re }); } - response.send(xmlbuilder.create({ - person - }, { separateArrayItems: true }).end()); + return createNNASResponse(response, { + body: { + person + }, + xmlbuilder_options: { + separateArrayItems: true // TODO - Is this really needed? + } + }); }); /** @@ -242,10 +244,6 @@ router.get('/@me/profile', async (request: express.Request, response: express.Re * Description: Gets user profile, seems to be the same as https://account.nintendo.net/v1/api/people/@me/profile */ router.post('/@me/devices', async (request: express.Request, response: express.Response): Promise => { - response.set('Content-Type', 'text/xml'); - response.set('Server', 'Nintendo 3DS (http)'); - response.set('X-Nintendo-Date', new Date().getTime().toString()); - // * We don't care about the device attributes // * The console ignores them and PNIDs are not tied to consoles anyway // * So the server also ignores them and does not save the ones posted here @@ -284,9 +282,11 @@ router.post('/@me/devices', async (request: express.Request, response: express.R }); } - response.send(xmlbuilder.create({ - person - }).end()); + return createNNASResponse(response, { + body: { + person + } + }); }); /** @@ -295,10 +295,6 @@ router.post('/@me/devices', async (request: express.Request, response: express.R * Description: Returns only user devices */ router.get('/@me/devices', async (request: express.Request, response: express.Response): Promise => { - response.set('Content-Type', 'text/xml'); - response.set('Server', 'Nintendo 3DS (http)'); - response.set('X-Nintendo-Date', new Date().getTime().toString()); - const pnid = request.pnid; const deviceID = getValueFromHeaders(request.headers, 'x-nintendo-device-id'); const acceptLanguage = getValueFromHeaders(request.headers, 'accept-language'); @@ -334,25 +330,27 @@ router.get('/@me/devices', async (request: express.Request, response: express.Re }); } - response.send(xmlbuilder.create({ - devices: [ - { - device: { - device_id: deviceID, - language: acceptLanguage, - updated: moment().format('YYYY-MM-DDTHH:MM:SS'), - pid: pnid.pid, - platform_id: platformID, - region: region, - serial_number: serialNumber, - status: 'ACTIVE', - system_version: systemVersion, - type: 'RETAIL', - updated_by: 'USER' + return createNNASResponse(response, { + body: { + devices: [ + { + device: { + device_id: deviceID, + language: acceptLanguage, + updated: moment().format('YYYY-MM-DDTHH:MM:SS'), + pid: pnid.pid, + platform_id: platformID, + region: region, + serial_number: serialNumber, + status: 'ACTIVE', + system_version: systemVersion, + type: 'RETAIL', + updated_by: 'USER' + } } - } - ] - }).end()); + ] + } + }); }); /** @@ -361,10 +359,6 @@ router.get('/@me/devices', async (request: express.Request, response: express.Re * Description: Gets user profile, seems to be the same as https://account.nintendo.net/v1/api/people/@me/profile */ router.get('/@me/devices/owner', async (request: express.Request, response: express.Response): Promise => { - response.set('Content-Type', 'text/xml'); - response.set('Server', 'Nintendo 3DS (http)'); - response.set('X-Nintendo-Date', moment().add(5, 'h').toString()); - const pnid = request.pnid; if (!pnid) { @@ -397,9 +391,11 @@ router.get('/@me/devices/owner', async (request: express.Request, response: expr }); } - response.send(xmlbuilder.create({ - person - }).end()); + return createNNASResponse(response, { + body: { + person + } + }); }); /** @@ -408,13 +404,11 @@ router.get('/@me/devices/owner', async (request: express.Request, response: expr * Description: Unknown use */ router.get('/@me/devices/status', async (_request: express.Request, response: express.Response): Promise => { - response.set('Content-Type', 'text/xml'); - response.set('Server', 'Nintendo 3DS (http)'); - response.set('X-Nintendo-Date', moment().add(5, 'h').toString()); - - response.send(xmlbuilder.create({ - device: {} - }).end()); + return createNNASResponse(response, { + body: { + device: {} + } + }); }); @@ -610,23 +604,25 @@ router.get('/@me/emails', async (request: express.Request, response: express.Res }); } - response.send(xmlbuilder.create({ - emails: [ - { - email: { - address: pnid.email.address, - id: pnid.email.id, - parent: pnid.email.parent ? 'Y' : 'N', - primary: pnid.email.primary ? 'Y' : 'N', - reachable: pnid.email.reachable ? 'Y' : 'N', - type: 'DEFAULT', // * what is this? - updated_by: 'USER', // * need to actually update this - validated: pnid.email.validated ? 'Y' : 'N', - validated_date: pnid.email.validated_date, + return createNNASResponse(response, { + body: { + emails: [ + { + email: { + address: pnid.email.address, + id: pnid.email.id, + parent: pnid.email.parent ? 'Y' : 'N', + primary: pnid.email.primary ? 'Y' : 'N', + reachable: pnid.email.reachable ? 'Y' : 'N', + type: 'DEFAULT', // * what is this? + updated_by: 'USER', // * need to actually update this + validated: pnid.email.validated ? 'Y' : 'N', + validated_date: pnid.email.validated_date, + } } - } - ] - }).end()); + ] + } + }); }); /** diff --git a/src/services/nnas/routes/provider.ts b/src/services/nnas/routes/provider.ts index 25583cf..c34f0bf 100644 --- a/src/services/nnas/routes/provider.ts +++ b/src/services/nnas/routes/provider.ts @@ -1,8 +1,7 @@ import express from 'express'; -import xmlbuilder from 'xmlbuilder'; import { getServerByClientID, getServerByGameServerID } from '@/database'; import { generateToken, getValueFromHeaders, getValueFromQueryString, isSystemType } from '@/util'; -import { createNNASErrorResponse } from '@/services/nnas/create-response'; +import { createNNASErrorResponse, createNNASResponse } from '@/services/nnas/create-response'; import { NEXAccount } from '@/models/nex-account'; import { SystemType, TokenOptions, TokenType } from '@/types/common/token'; @@ -100,11 +99,13 @@ router.get('/service_token/@me', async (request: express.Request, response: expr const serviceTokenBuffer = generateToken(server.aes_key, tokenOptions); const serviceToken = request.isCemu ? serviceTokenBuffer.toString('hex') : serviceTokenBuffer.toString('base64'); - response.send(xmlbuilder.create({ - service_token: { - token: serviceToken + return createNNASResponse(response, { + body: { + service_token: { + token: serviceToken + } } - }).end()); + }); }); /** @@ -219,15 +220,17 @@ router.get('/nex_token/@me', async (request: express.Request, response: express. nexToken = Buffer.from(nexToken || '', 'base64').toString('hex'); } - response.send(xmlbuilder.create({ - nex_token: { - host: server.ip, - nex_password: nexAccount.password, - pid: nexAccount.pid, - port: server.port, - token: nexToken + return createNNASResponse(response, { + body: { + nex_token: { + host: server.ip, + nex_password: nexAccount.password, + pid: nexAccount.pid, + port: server.port, + token: nexToken + } } - }).end()); + }); }); export default router; \ No newline at end of file