diff --git a/.env.example b/.env.example index a5ac422..ea6052f 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,5 @@ VINO_JP_TV_PROGRAM_DETAILS_BASE_URL=https://... VINO_JP_TV_SEASON_CAST_URL=https://... VINO_JP_STAFF_PIDS=pid1,pid2... + +VINO_JP_MII_IMAGE_PNG_BASE_URL=https://mii-unsecure.ariankordi.net/miis/image.png diff --git a/src/env.ts b/src/env.ts index c7c1299..e387561 100644 --- a/src/env.ts +++ b/src/env.ts @@ -32,6 +32,8 @@ export const env = createEnv({ // yoinked from google's ai on having an array as an .env var :/ VINO_JP_STAFF_PIDS: z.string().transform((str) => str.split(",").map((s) => s.trim())), + + VINO_JP_MII_IMAGE_PNG_BASE_URL: z.string().url() }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/src/routes/api/act.ts b/src/routes/api/act.ts index c848429..54d38e2 100644 --- a/src/routes/api/act.ts +++ b/src/routes/api/act.ts @@ -7,8 +7,10 @@ import { db } from "../../utils/db.ts"; import { z } from "zod"; import { BskyClient } from "../../utils/bsky.ts"; import { env } from "../../env.ts"; +import NnidResolver from "../../utils/NnidResolver.mjs"; import crypto from "crypto"; import { logger } from "../../utils/logger.ts"; +import { getRealIpFromRequest } from "../../utils/other.ts"; // Key must be 32 bytes for AES-256 const AES_KEY = Buffer.from(env.VINO_JP_CONFIG_BSKY_AES_KEY, "base64"); @@ -54,6 +56,10 @@ router.post( const countryCode = token.country; const principalId = token.pid; + if (principalId === undefined) { + return res.status(400).json({ status: "error" }); + } + const existing = await db("account") .where({ pid: principalId }) .first(); @@ -67,22 +73,20 @@ router.post( }); } - const checkPID = await fetch( - `https://mii-unsecure.ariankordi.net/mii_data/?pid=${principalId}&api_id=1&force_refresh=1` - ); - if (!checkPID.ok) { + let miiResponse; + try { + miiResponse = await NnidResolver.miiFromPid(String(principalId)); + } catch (err) { console.warn( - `Mii Unsecure Pretendo fetching error for : ${token.pid} ${token.serial_number}` + `Mii data fetching error for: ${token.pid} ${token.serial_number}`, err ); return res.status(500).json({ status: "error_not_pretendo", }); } - const checkPIDData = await checkPID.json() as any; - - const mii_name = checkPIDData!.name!; - const mii_data = checkPIDData!.data!; + const mii_name = miiResponse.name; + const mii_data = miiResponse.miiData; const mii = new Mii(Buffer.from(mii_data, "base64")); @@ -134,21 +138,7 @@ router.post( const tvProviderTzChosen = data.tv_provider_tz; // Extract the user's IP - let ip = - req.headers["cf-connecting-ip"] || - req.headers["x-forwarded-for"] || - req.connection.remoteAddress || - req.ip; - - // If x-forwarded-for contains multiple IPs, take the first - if (typeof ip === "string" && ip.includes(",")) { - ip = ip.split(",")[0]; - } - - // Strip IPv6 prefix - if (typeof ip === "string" && ip.startsWith("::ffff:")) { - ip = ip.substring(7); - } + const ip = getRealIpFromRequest(req); const ipReq = await fetch(`https://ipwho.is/${ip}`); const ipInfo = await ipReq.json() as any; diff --git a/src/routes/api/miis.ts b/src/routes/api/miis.ts index e742461..0989ebc 100644 --- a/src/routes/api/miis.ts +++ b/src/routes/api/miis.ts @@ -1,6 +1,7 @@ import express, { type Request, type Response, type Router } from "express"; import NodeCache from "node-cache"; +import { env } from "../../env.ts"; const router: Router = express.Router(); @@ -23,7 +24,7 @@ router.get("/", async (req: Request, res: Response) => { } const url = - `https://mii-unsecure.ariankordi.net/miis/image.png?verifyCRC16=1&${query}`; + `${env.VINO_JP_MII_IMAGE_PNG_BASE_URL}?verifyCRC16=1&${query}`; const response = await fetch(url); diff --git a/src/routes/api/social.ts b/src/routes/api/social.ts index 4c520e9..0cbddd6 100644 --- a/src/routes/api/social.ts +++ b/src/routes/api/social.ts @@ -1,6 +1,8 @@ import express, { type Request, type Response, type Router } from "express"; import multer from "multer"; import { env } from "../../env.ts"; +import { getMiiImageUrl, expressionFromFeeling } from "../../utils/other.ts"; +import NnidResolver from "../../utils/NnidResolver.mjs"; import crypto from "crypto"; import { BskyClient } from "../../utils/bsky.ts"; import { parseServiceToken } from "../../utils/serviceToken.ts"; @@ -156,7 +158,7 @@ router.get( "/getUserData/:pid", async (req: Request, res: Response): Promise => { try { - const pid = req.params.pid!; + const pid = req.params.pid as string; if (!pid) return res.status(400).json({ error: "Missing pid" }); const account = await db("account") @@ -188,15 +190,7 @@ router.get( latest_post_id = JSON.parse(cachedUserData).latest_post_id; } else { try { - const miiResp = await fetch( - `https://mii-unsecure.ariankordi.net/mii_data/?pid=${pid}&api_id=1` - ); - - if (miiResp.ok) { - const miiData = await miiResp.json() as any; - user_id = miiData?.user_id || null; - } - + user_id = (await NnidResolver.miiFromPid(pid)).userId; } catch (err) { console.error("Mii fetch error:", err); } @@ -454,30 +448,14 @@ router.post( if (post && post.length > 0) { const postIdForLink = post[0]; - const getFeelingQueryFromNumber = ( - feeling_id: number - ): string => { - switch (feeling_id) { - case 1: - return "smile_open_mouth"; - case 2: - return "like_wink_left"; - case 3: - return "surprise_open_mouth"; - case 4: - return "frustrated"; - case 5: - return "sorrow"; - default: - return "normal"; - } - }; try { const webhookUrl = env.VINO_JP_CONFIG_DC_WEBHOOK_URL; const miiName = account.mii_name || "Unknown Mii"; - const miiImage = `https://mii-unsecure.ariankordi.net/miis/image.png?verifyCRC16=0&width=128&expression=${getFeelingQueryFromNumber(feelingId)}&data=${encodeURIComponent(account.mii_data)}&type=face`; + const expression = expressionFromFeeling(feelingId); + const miiImageUrl = getMiiImageUrl(account.mii_data, + `type=face&width=128&expression=${expression}`); const isSpoilerPost = safeIsSpoiler === 1; @@ -485,7 +463,7 @@ router.post( if (isSpoilerPost) { embed = { - author: { name: miiName, icon_url: miiImage }, + author: { name: miiName, icon_url: miiImageUrl }, title: postForm.topic_tag || "Untitled Topic", url: `https://projectrose.cafe/tvii/olv/topic/${encodeURIComponent(postForm.topic_tag)}`, description: `**[Spoiler, View in browser](https://projectrose.cafe/tvii/olv/post/${encodeURIComponent(postIdForLink!)})**`, @@ -497,7 +475,7 @@ router.post( description += `\n\n[View in browser](https://projectrose.cafe/tvii/olv/post/${encodeURIComponent(postIdForLink!)})`; embed = { - author: { name: miiName, icon_url: miiImage }, + author: { name: miiName, icon_url: miiImageUrl }, title: postForm.topic_tag || "Untitled Topic", url: `https://projectrose.cafe/tvii/olv/topic/${encodeURIComponent(postForm.topic_tag)}`, description, diff --git a/src/routes/api/title.ts b/src/routes/api/title.ts index 9997525..3875dc3 100644 --- a/src/routes/api/title.ts +++ b/src/routes/api/title.ts @@ -6,6 +6,7 @@ import path from "path"; import { fileURLToPath } from "url"; import Redis from "ioredis"; import { env } from "../../env.ts"; +import { getMiiImageUrl } from "../../utils/other.ts"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -126,14 +127,15 @@ router.get("/", async (req: Request, res: Response) => { const randomBgPath = backgrounds[Math.floor(Math.random() * backgrounds.length)]; - const mii_url = - "https://mii-unsecure.ariankordi.net/miis/image.png?data=" + - encodeURIComponent(randomUser.mii_data) + - "&type=face&width=226&resourceType=middle&texResolution=168&verifyCRC16=0"; - - const [bg, mii, mii_bg, name_bg] = await Promise.all([ + const miiImageUrl = getMiiImageUrl( + randomUser.mii_data, + `type=face&width=226&texResolution=168`); + const [bg, miiImage, mii_bg, name_bg] = await Promise.all([ loadImage(randomBgPath!), - loadImage(mii_url), + loadImage(miiImageUrl).catch((err: unknown) => { + console.warn("Mii image unavailable while obtaining card", err); + return null; + }), loadImage(mii_bg_path), loadImage(sign_path) ]); @@ -144,7 +146,9 @@ router.get("/", async (req: Request, res: Response) => { ctx.drawImage(bg, 0, 0); ctx.drawImage(mii_bg, mii_x, mii_y); - ctx.drawImage(mii, mii_x + 4, mii_y + 4); + if (miiImage) { + ctx.drawImage(miiImage, mii_x + 4, mii_y + 4); + } ctx.drawImage(name_bg, 184, 310); // title diff --git a/src/routes/ui/vino-jp.ts b/src/routes/ui/vino-jp.ts index 3a3b208..9466a6d 100644 --- a/src/routes/ui/vino-jp.ts +++ b/src/routes/ui/vino-jp.ts @@ -1,9 +1,10 @@ import express, { type Request, type Response, type Router } from "express"; import { join } from "path"; import { parseServiceToken } from "../../utils/serviceToken.ts"; -import { db } from "../..//utils/db.ts"; -import { getRegion } from "../..//utils/other.ts"; +import { db } from "../../utils/db.ts"; +import { getRealIpFromRequest, getRegion } from "../../utils/other.ts"; import Mii from "@pretendonetwork/mii-js"; +import NnidResolver from "../../utils/NnidResolver.mjs"; const router: Router = express.Router(); @@ -54,7 +55,7 @@ router.get("/index.html", async (req: Request, res: Response): Promise => { const token = parseServiceToken(req); - if (!token.ok) { + if (!token.ok || token.pid === undefined) { return res.sendStatus(404); } @@ -98,61 +99,39 @@ router.get("/index.html", async (req: Request, res: Response): Promise => { if (now.getTime() - lastUpdate.getTime() > oneHour) { try { - const updateMiiData = await fetch( - `https://mii-unsecure.ariankordi.net/mii_data/?pid=${token.pid}&api_id=1&force_refresh=1` - ); - - if (updateMiiData.ok) { - const PIDData = await updateMiiData.json() as any; - - const mii_name = PIDData.name; - const mii_data = PIDData.data; - - const mii = new Mii(Buffer.from(mii_data, "base64")); - const mii_bday = mii.birthDay + "/" + mii.birthMonth; - - // Extract real IP (Cloudflare first) - let ip = - req.headers["cf-connecting-ip"] || - req.headers["x-forwarded-for"] || - req.connection.remoteAddress || - req.ip; - - if (typeof ip === "string" && ip.includes(",")) { - ip = ip.split(",")[0]; - } - - if (typeof ip === "string" && ip.startsWith("::ffff:")) { - ip = ip.substring(7); - } - - // timezone lookup - const ipReq = await fetch(`https://ipwho.is/${ip}`); - const ipInfo = await ipReq.json() as any; - - if ( - ipInfo?.success && - ipInfo?.timezone && - typeof ipInfo.timezone.offset === "number" - ) { - utc_offset = ipInfo.timezone.offset; - } - - Object.assign(updateValues, { - mii_name, - mii_data, - mii_bday, - utc_offset, - last_data_update: new Date().toISOString(), - }); - - console.log(`PNID Data + UTC updated for PID ${token.pid}`); - } else { - updateValues.last_data_update = new Date().toISOString(); + const userData = await NnidResolver.miiFromPid(String(token.pid)); + const mii_name = userData.name; + const mii_data = userData.miiData; + + const mii = new Mii(Buffer.from(mii_data, "base64")); + const mii_bday = mii.birthDay + "/" + mii.birthMonth; + + const ip = getRealIpFromRequest(req); + + // timezone lookup + const ipReq = await fetch(`https://ipwho.is/${ip}`); + const ipInfo = await ipReq.json() as any; + + if ( + ipInfo?.success && + ipInfo?.timezone && + typeof ipInfo.timezone.offset === "number" + ) { + utc_offset = ipInfo.timezone.offset; } + + Object.assign(updateValues, { + mii_name, + mii_data, + mii_bday, + utc_offset + }); + + console.log(`PNID Data + UTC updated for PID ${token.pid}`); } catch (err) { console.warn("Mii/IP update failed:", err); - updateValues.last_data_update = new Date().toISOString(); + } finally { + updateValues.last_data_update = new Date().toISOString(); } } diff --git a/src/utils/NnidResolver.mjs b/src/utils/NnidResolver.mjs new file mode 100644 index 0000000..febbbd7 --- /dev/null +++ b/src/utils/NnidResolver.mjs @@ -0,0 +1,124 @@ +/** + * @file NnidResolver.mjs + * Obtained from: https://gist.github.com/ariankordi/0348465eaa2d4c5b95fddd0c00b36795 + * Simple class for retrieving information about Nintendo + * Network IDs/Pretendo Network IDs and their Mii data. + * Uses fetch() API in Node, Bun, and web. + * + * In the browser, you will need to use a CORS proxy + * or create your own reverse proxy to the actual API. + * The following is an example of how to set up a basic proxy + * using mitmdump, but you can redo this in nginx, Express.js, etc. + * @example + * + * // Install mitmproxy and run: + * `mitmdump --no-http2 --mode reverse:https://account.pretendo.cc --listen-port 8282 --modify-headers "/Access-Control-Allow-Origin/*" --modify-headers "/Access-Control-Allow-Headers/X-Nintendo-Client-ID,X-Nintendo-Client-Secret" --set "block_list=/~m OPTIONS/200"` + * NnidResolver.baseUrl = 'https://localhost:8282/v1/api'; + * console.debug(await NnidResolver.miiFromPid('1742653218')); + * @author Arian Kordi + */ +// @ts-check +/* eslint @stylistic/indent: ['error', 2] -- Indent rules. */ + +/** + * Response from the /v1/api/miis endpoint. + * @typedef {Object} MiiResponse + * @property {string} pid Principal ID. + * @property {string} name Name of the user's Mii character. + * @property {string} miiData Mii data encoded as Base64. (96 bytes, 3DS/Wii U format) + * @property {string} userId Nintendo Network ID username. + */ + +/** + * Resolves user data ({@link MiiResponse}) from NNAS, aka + * Nintendo Network Account Server/Service/System (Wii U/3DS). + * + * Only supports unofficial services, defaulting to {@link https://pretendo.network}. + * @example + * + * console.debug(await NnidUserResolver.miiFromPid(await NnidUserResolver.pidFromUserId('PN_Jon'))); + */ +export default class NnidResolver { + /** + * API base for Pretendo Network. + * Note that this blocks HTTP/2 clients, and a workaround for Bun is applied. + */ + static baseUrl = 'https://account.pretendo.cc/v1/api'; + // account.nintendo.net would require a client certificate, however + // its /v1/api/miis endpoint stopped working in 2024. + + /** @private */ static _get = (/** @type {string} */ url) => + fetch(url, { + headers: { + 'Accept': '*/*', // account.nintendo.net optionally accepts JSON. + 'X-Nintendo-Client-ID': 'a2efa818a34fa16b8afbc8a74eba3eda', + 'X-Nintendo-Client-Secret': 'c91cdb5658bd4954ade78533a339cf9a' + }, + // Force HTTP 1.1: https://github.com/oven-sh/bun/blob/450072ba3eaadaa40ff3104a14173e73f5c5b83f/packages/bun-types/globals.d.ts#L2004-L2016 + // @ts-ignore -- This property is specific to Bun. + protocol: 'http1.1' + }).then((r) => { + // Only return if the response is OK and contains XML like expected. + // account.nintendo.net: application/xml;charset=UTF-8 + // account.pretendo.cc: text/xml; charset=utf-8 + if (r.ok && r.headers.get('content-type')?.includes('/xml')) { + return r.text(); + } + throw r; // Reject when response is not OK. + }); + + /** + * Ad-hoc/amateur method of extracting XML tag value. + * Does NOT un-escape any characters, or handle multi-line XML. + * @private + */ + static _extractXmlTag = (/** @type {string} */ doc, /** @type {string} */ tag) => + doc.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`))?.[1] || ''; + + /** @private */ static _nameFromMiiDataBase64 = (/** @type {string} */ miiData) => + // Decode Base64, decode UTF-16LE from bytes @ 0x1A + 20 chars, return text before NULL terminator. + new TextDecoder('utf-16le').decode(Uint8Array.from(atob(miiData), c => c.charCodeAt(0)).subarray(26, 46)).split('\0')[0]; + + /** + * Obtains PID (principal ID) as a string from the user ID. + * @throws {Error} Throws if the user does not exist. + * @throws {Response} Throws for all other HTTP errors. + */ + static async pidFromUserId(/** @type {string} */ userId, + /** @type {string} */ base = NnidResolver.baseUrl) { + const body = await NnidResolver._get(base + '/admin/mapped_ids?input_type=user_id&output_type=pid&input=' + encodeURIComponent(userId)); + const pid = NnidResolver._extractXmlTag(body, 'out_id'); + if (!pid) { + throw new Error('User not found.'); + } + return pid; + } + + /** + * Obtains {@link MiiResponse} from the PID. + * Throws an exception if user doesn't exist. + * @throws {Response} Throws if the PID does not exist, + * or for any other HTTP error. + * @throws {Error} Throws if the Mii data is empty. + */ + static async miiFromPid(/** @type {string} */ pid, + /** @type {string} */ base = NnidResolver.baseUrl) { + const body = await NnidResolver._get(base + '/miis?pids=' + pid); + + const miiData = NnidResolver._extractXmlTag(body, 'data'); + if (!miiData) { // For a non-existent user, we should instead see 404. + throw new Error('Mii data is unexpectedly empty in response.'); + } + // 'name' is the only field escaped with XML entities - NOTHING else should ever have them. + // Due to ease + a Pretendo bug (https://github.com/PretendoNetwork/account/issues/122), + // the name will instead be decoded from the Mii data. + const name = NnidResolver._nameFromMiiDataBase64(miiData); + + // NOTE: No fields except for name should ever contain XML entities. + return /** @type {MiiResponse} */ ({ + pid: NnidResolver._extractXmlTag(body, 'pid'), + name, miiData, + userId: NnidResolver._extractXmlTag(body, 'user_id') + }); + } +} diff --git a/src/utils/other.ts b/src/utils/other.ts index a7222f6..87bec22 100644 --- a/src/utils/other.ts +++ b/src/utils/other.ts @@ -1,3 +1,5 @@ +import { env } from "../env.ts"; + // All NN-allowed countries grouped by region export const NN_COUNTRIES = { JPN: [ @@ -26,4 +28,43 @@ export function getRegion(country: string): "USA" | "EUR" | "JPN" { if (NN_COUNTRIES.JPN.includes(c)) return "JPN"; if (NN_COUNTRIES.USA.includes(c)) return "USA"; return "EUR"; -} \ No newline at end of file +} + +export function getRealIpFromRequest(req: import('express').Request) { + // Extract the user's IP (Cloudflare first) + let ip = + // WARNING: If not running behind Cloudflare, this can be spoofed. + req.headers["cf-connecting-ip"] as string | undefined || + req.headers["x-forwarded-for"] as string | undefined || + req.socket.remoteAddress || + req.ip; + + // If x-forwarded-for contains multiple IPs, take the first + if (typeof ip === "string" && ip.includes(",")) { + ip = ip.split(",")[0]; + } + + // Strip IPv6 prefix + if (typeof ip === "string" && ip.startsWith("::ffff:")) { + ip = ip.substring(7); + } + + return ip; +} + +/** Gets Mii Studio API expression string from Miiverse "feeling" ID. */ +export const expressionFromFeeling = (feeling: number) => + [ /* 0 */ 'normal', + /* 1 */ 'smile_open_mouth', + /* 2 */ 'like_wink_left', + /* 3 */ 'surprise_open_mouth', + /* 4 */ 'frustrated', + /* 5 */ 'sorrow' + ][feeling] || 'normal'; + +/** + * @param data Compatible Base64 Mii data. + * @param param URL query parameters, e.g.: `width=128&expression=normal` + */ +export const getMiiImageUrl = (data: string, param: string) => + `${env.VINO_JP_MII_IMAGE_PNG_BASE_URL}?data=${data}&verifyCRC16=0&resourceType=middle&${param}`;