feat(nnas): centralize success response creation

This commit is contained in:
Jonathan Barrow
2025-04-11 19:06:36 -04:00
parent c10e5be3cd
commit 3845a6aa85
8 changed files with 222 additions and 221 deletions

View File

@@ -16,6 +16,7 @@ type CreateNNASErrorResponseOptions = {
type CreateNNASResponseOptions = {
status?: number;
body: Record<string, any>;
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);

View File

@@ -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());
});
});
/**

View File

@@ -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;

View File

@@ -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<void> => {
// TODO - Finish this
response.send(xmlbuilder.create({
device: ''
}).end());
return createNNASResponse(response, {
body: {
device: ''
}
});
});
export default router;

View File

@@ -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());
});
}
});

View File

@@ -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);
}

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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());
]
}
});
});
/**

View File

@@ -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;