import mongoose from 'mongoose'; import bcrypt from 'bcrypt'; import joi from 'joi'; import { nintendoPasswordHash, decryptToken, unpackToken } from '@/util'; import { PNID } from '@/models/pnid'; import { Server } from '@/models/server'; import { LOG_ERROR } from '@/logger'; import { config } from '@/config-manager'; import { HydratedPNIDDocument } from '@/types/mongoose/pnid'; import { IDeviceAttribute } from '@/types/mongoose/device-attribute'; import { HydratedServerDocument } from '@/types/mongoose/server'; import { PNIDProfile } from '@/types/services/nnas/pnid-profile'; import { ConnectionData } from '@/types/services/api/connection-data'; import { ConnectionResponse } from '@/types/services/api/connection-response'; import { DiscordConnectionData } from '@/types/services/api/discord-connection-data'; const connection_string = config.mongoose.connection_string; const options = config.mongoose.options; // TODO - Extend this later with more settings const discordConnectionSchema = joi.object({ id: joi.string() }); const accessModeOrder: Record = { prod: ['prod'], test: ['test', 'prod'], dev: ['dev', 'test', 'prod'] }; let _connection: mongoose.Connection; export async function connect(): Promise { await mongoose.connect(connection_string, options); _connection = mongoose.connection; _connection.on('error', console.error.bind(console, 'connection error:')); } export function connection(): mongoose.Connection { return _connection; } export function verifyConnected(): void { if (!connection()) { throw new Error('Cannot make database requets without being connected'); } } export async function getPNIDByUsername(username: string): Promise { verifyConnected(); return await PNID.findOne({ usernameLower: username.toLowerCase() }); } export async function getPNIDByPID(pid: number): Promise { verifyConnected(); return await PNID.findOne({ pid }); } export async function getPNIDByEmailAddress(email: string): Promise { verifyConnected(); // TODO - Update documents to store email normalized return await PNID.findOne({ 'email.address': email }); } export async function doesPNIDExist(username: string): Promise { verifyConnected(); return !!await getPNIDByUsername(username); } export async function getPNIDByBasicAuth(token: string): Promise { verifyConnected(); // * Wii U sends Basic auth as `username password`, where the password may not have spaces // * This is not to spec, but that is the consoles fault not ours const decoded = Buffer.from(token, 'base64').toString(); const parts = decoded.split(' '); const username = parts[0]; const password = parts[1]; const pnid = await getPNIDByUsername(username); if (!pnid) { return null; } const hashedPassword = nintendoPasswordHash(password, pnid.pid); if (!bcrypt.compareSync(hashedPassword, pnid.password)) { return null; } return pnid; } export async function getPNIDByTokenAuth(token: string): Promise { verifyConnected(); try { const decryptedToken = decryptToken(Buffer.from(token, 'hex')); const unpackedToken = unpackToken(decryptedToken); const pnid = await getPNIDByPID(unpackedToken.pid); if (pnid) { const expireTime = Math.floor((Number(unpackedToken.expire_time) / 1000)); if (Math.floor(Date.now() / 1000) > expireTime) { return null; } } return pnid; } catch (error: any) { // TODO - Handle error LOG_ERROR(error); return null; } } export async function getPNIDProfileJSONByPID(pid: number): Promise { verifyConnected(); const pnid = await getPNIDByPID(pid); if (!pnid) { return null; } const device = pnid.devices[0]; // * Just grab the first device let device_attributes: { device_attribute: { name: string; value: string; created_date: string; }; }[] = []; if (device) { device_attributes = device.device_attributes.map((attribute: IDeviceAttribute) => { const name = attribute.name; const value = attribute.value; const created_date = attribute.created_date; return { device_attribute: { name, value, created_date: created_date ? created_date : '' } }; }); } return { // *accounts: {}, // * We need to figure this out, no idea what these values mean or what they do active_flag: pnid.flags.active ? 'Y' : 'N', birth_date: pnid.birthdate, country: pnid.country, create_date: pnid.creation_date, device_attributes: device_attributes, gender: pnid.gender, language: pnid.language, updated: pnid.updated, marketing_flag: pnid.flags.marketing ? 'Y' : 'N', off_device_flag: pnid.flags.off_device ? 'Y' : 'N', pid: pnid.pid, 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', updated_by: 'USER', // * Can also be INTERNAL WS, don't know the difference validated: pnid.email.validated ? 'Y' : 'N', validated_date: pnid.email.validated ? pnid.email.validated_date : '' }, mii: { status: 'COMPLETED', data: pnid.mii.data.replace(/(\r\n|\n|\r)/gm, ''), id: pnid.mii.id, mii_hash: pnid.mii.hash, mii_images: { mii_image: { // * Images MUST be loaded over HTTPS or console ignores them // * Bunny CDN is the only CDN which seems to support TLS 1.0/1.1 (required) cached_url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`, id: pnid.mii.image_id, url: `${config.cdn.base_url}/mii/${pnid.pid}/standard.tga`, type: 'standard' } }, name: pnid.mii.name, primary: pnid.mii.primary ? 'Y' : 'N', }, region: pnid.region, tz_name: pnid.timezone.name, user_id: pnid.username, utc_offset: pnid.timezone.offset }; } export async function getServerByGameServerID(gameServerID: string, accessMode: string): Promise { const searchModes = accessModeOrder[accessMode] ?? accessModeOrder.prod; // Default to prod if invalid mode const servers = await Server.find({ game_server_id: gameServerID, access_mode: { $in: searchModes } }); for (const mode of searchModes) { const server = servers.find(s => s.access_mode === mode); if (server) return server; } return null; } export async function getServerByTitleID(titleID: string, accessMode: string): Promise { const searchModes = accessModeOrder[accessMode] ?? accessModeOrder.prod; const servers = await Server.find({ title_ids: titleID, access_mode: { $in: searchModes } }); for (const mode of searchModes) { const server = servers.find(s => s.access_mode === mode); if (server) return server; } return null; } export async function getServerByClientID(clientID: string, accessMode: string): Promise { const searchModes = accessModeOrder[accessMode] ?? accessModeOrder.prod; const servers = await Server.find({ client_id: clientID, access_mode: { $in: searchModes } }); for (const mode of searchModes) { const server = servers.find(s => s.access_mode === mode); if (server) return server; } return null; } export async function addPNIDConnection(pnid: HydratedPNIDDocument, data: ConnectionData, type: string): Promise { if (type === 'discord') { return await addPNIDConnectionDiscord(pnid, data); } } export async function addPNIDConnectionDiscord(pnid: HydratedPNIDDocument, data: DiscordConnectionData): Promise { const valid = discordConnectionSchema.validate(data); if (valid.error) { return { app: 'api', status: 400, error: 'Invalid or missing connection data' }; } await PNID.updateOne({ pid: pnid.pid }, { $set: { 'connections.discord.id': data.id } }); return { app: 'api', status: 200 }; } export async function removePNIDConnection(pnid: HydratedPNIDDocument, type: string): Promise { // * Add more connections later? if (type === 'discord') { return await removePNIDConnectionDiscord(pnid); } } export async function removePNIDConnectionDiscord(pnid: HydratedPNIDDocument): Promise { await PNID.updateOne({ pid: pnid.pid }, { $set: { 'connections.discord.id': '' } }); return { app: 'api', status: 200 }; }