From 64cbb77808dec661755cdd1ecf86a0ab861f6e3a Mon Sep 17 00:00:00 2001 From: Arian Kordi Date: Fri, 8 May 2026 23:02:41 -0400 Subject: [PATCH 1/4] Update code related to Mii images to get the image.png base from env + handle image request in title.ts --- .env.example | 2 ++ src/env.ts | 2 ++ src/routes/api/miis.ts | 3 ++- src/routes/api/social.ts | 27 ++++++--------------------- src/routes/api/title.ts | 20 ++++++++++++-------- src/utils/other.ts | 21 ++++++++++++++++++++- 6 files changed, 44 insertions(+), 31 deletions(-) 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/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..8a1578b 100644 --- a/src/routes/api/social.ts +++ b/src/routes/api/social.ts @@ -1,6 +1,7 @@ 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 crypto from "crypto"; import { BskyClient } from "../../utils/bsky.ts"; import { parseServiceToken } from "../../utils/serviceToken.ts"; @@ -454,30 +455,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 +470,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 +482,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/utils/other.ts b/src/utils/other.ts index a7222f6..0311eeb 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,21 @@ 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 +} + +/** 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}`; From 815728133317560714e65ca76ea704542bf39f93 Mon Sep 17 00:00:00 2001 From: Arian Kordi Date: Fri, 8 May 2026 23:10:07 -0400 Subject: [PATCH 2/4] Split "getRealIpFromRequest" helper into other.ts --- src/routes/api/act.ts | 17 ++--------------- src/routes/ui/vino-jp.ts | 19 +++---------------- src/utils/other.ts | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 31 deletions(-) diff --git a/src/routes/api/act.ts b/src/routes/api/act.ts index c848429..c717ed1 100644 --- a/src/routes/api/act.ts +++ b/src/routes/api/act.ts @@ -9,6 +9,7 @@ import { BskyClient } from "../../utils/bsky.ts"; import { env } from "../../env.ts"; 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"); @@ -134,21 +135,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/ui/vino-jp.ts b/src/routes/ui/vino-jp.ts index 3a3b208..6dcdb56 100644 --- a/src/routes/ui/vino-jp.ts +++ b/src/routes/ui/vino-jp.ts @@ -1,8 +1,8 @@ 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"; const router: Router = express.Router(); @@ -111,20 +111,7 @@ router.get("/index.html", async (req: Request, res: Response): Promise => { 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); - } + const ip = getRealIpFromRequest(req); // timezone lookup const ipReq = await fetch(`https://ipwho.is/${ip}`); diff --git a/src/utils/other.ts b/src/utils/other.ts index 0311eeb..87bec22 100644 --- a/src/utils/other.ts +++ b/src/utils/other.ts @@ -30,6 +30,28 @@ export function getRegion(country: string): "USA" | "EUR" | "JPN" { return "EUR"; } +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', From 633ff973098a8499a49bf3f8ca481d9c8e4eebdb Mon Sep 17 00:00:00 2001 From: Arian Kordi Date: Fri, 8 May 2026 23:14:21 -0400 Subject: [PATCH 3/4] Obtain Pretendo Network ID data from account.pretendo.cc directly. --- src/routes/api/act.ts | 21 +++++----- src/routes/api/social.ts | 12 ++---- src/routes/ui/vino-jp.ts | 72 +++++++++++++++------------------ src/utils/nnasClient.ts | 87 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 src/utils/nnasClient.ts diff --git a/src/routes/api/act.ts b/src/routes/api/act.ts index c717ed1..c7a6314 100644 --- a/src/routes/api/act.ts +++ b/src/routes/api/act.ts @@ -7,6 +7,7 @@ import { db } from "../../utils/db.ts"; import { z } from "zod"; import { BskyClient } from "../../utils/bsky.ts"; import { env } from "../../env.ts"; +import NnasClient from "../../utils/nnasClient.ts"; import crypto from "crypto"; import { logger } from "../../utils/logger.ts"; import { getRealIpFromRequest } from "../../utils/other.ts"; @@ -55,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(); @@ -68,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 NnasClient.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.data; const mii = new Mii(Buffer.from(mii_data, "base64")); diff --git a/src/routes/api/social.ts b/src/routes/api/social.ts index 8a1578b..d8d1146 100644 --- a/src/routes/api/social.ts +++ b/src/routes/api/social.ts @@ -2,6 +2,7 @@ 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 NnasClient from "../../utils/nnasClient.ts"; import crypto from "crypto"; import { BskyClient } from "../../utils/bsky.ts"; import { parseServiceToken } from "../../utils/serviceToken.ts"; @@ -157,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") @@ -189,14 +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 NnasClient.miiFromPid(pid)).userId; } catch (err) { console.error("Mii fetch error:", err); diff --git a/src/routes/ui/vino-jp.ts b/src/routes/ui/vino-jp.ts index 6dcdb56..23b2fda 100644 --- a/src/routes/ui/vino-jp.ts +++ b/src/routes/ui/vino-jp.ts @@ -4,6 +4,7 @@ import { parseServiceToken } from "../../utils/serviceToken.ts"; import { db } from "../../utils/db.ts"; import { getRealIpFromRequest, getRegion } from "../../utils/other.ts"; import Mii from "@pretendonetwork/mii-js"; +import NnasClient from "../../utils/nnasClient.ts"; 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,48 +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; - - 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, - 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 NnasClient.miiFromPid(String(token.pid)); + const mii_name = userData.name; + const mii_data = userData.data; + + 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/nnasClient.ts b/src/utils/nnasClient.ts new file mode 100644 index 0000000..7e1a1db --- /dev/null +++ b/src/utils/nnasClient.ts @@ -0,0 +1,87 @@ +export interface MiiResponse { + /** Principal ID. */ + pid: string; + /** Mii name. */ + name: string; + /** 96-byte Base64 encoded Mii data. */ + data: string; + /** Nintendo Network ID. */ + userId: string; +} + +/** Ad-hoc method of extracting XML tag value. */ +function extractXmlTag(xml: string, tag: string): string { + const match = xml.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`)); + return match?.[1] ?? ""; +} + +/** + * Minimal client for Nintendo Network Account System/Server/Service/whatever it's called. + * It only: + * - Resolves User ID -> PID + * - Obtains /v1/api/miis response + * - Does not send client certificate (meaning it only works on custom servers) + */ +export default class NnasClient { + /** + * API base: {@link https://pretendo.network} + * As of writing, this blocks HTTP/2 clients and + * may break if Bun fetch gets H2 support. + */ + private static readonly baseUrl = "https://account.pretendo.cc/v1/api"; + + /** + * Headers including the Wii U client ID/secret. + * {@link https://github.com/kinnay/NintendoClients/wiki/Account-Server#headers} + */ + private static readonly headers: Record = { + "Accept": "application/xml", + "X-Nintendo-Client-ID": "a2efa818a34fa16b8afbc8a74eba3eda", + "X-Nintendo-Client-Secret": "c91cdb5658bd4954ade78533a339cf9a", + }; + + /** + * Obtains PID as a string from the user ID. + * Throws an exception if user doesn't exist. + */ + static async pidFromUserId(userId: string): Promise { + const resp = await fetch( + `${NnasClient.baseUrl}/admin/mapped_ids?input_type=user_id&output_type=pid&input=${encodeURIComponent(userId)}`, + { headers: NnasClient.headers } + ); + if (!resp.ok) { + throw new Error(`mapped_ids fetch failed: ${resp.status}`); + } + const body = await resp.text(); + const pid = 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. + */ + static async miiFromPid(pid: string): Promise { + const resp = await fetch( + `${NnasClient.baseUrl}/miis?pids=${pid}`, + { headers: NnasClient.headers } + ); + if (!resp.ok) { + throw new Error(`miis fetch failed: ${resp.status}`); + } + const body = await resp.text(); + const mii: Partial = { + pid: extractXmlTag(body, "pid"), + name: extractXmlTag(body, "name"), + data: extractXmlTag(body, "data"), + userId: extractXmlTag(body, "user_id"), + }; + if (!mii.data) { + throw new Error("no mii data in response"); + } + return mii as MiiResponse; + } +} From 5efe41307511985a9d0c51fc94c97dbeb5bbec29 Mon Sep 17 00:00:00 2001 From: Arian Kordi Date: Mon, 11 May 2026 14:30:10 -0400 Subject: [PATCH 4/4] Use NnidResolver recently posted by me on GitHub Gist, confirmed working with upcoming Bun update --- src/routes/api/act.ts | 6 +- src/routes/api/social.ts | 5 +- src/routes/ui/vino-jp.ts | 6 +- src/utils/NnidResolver.mjs | 124 +++++++++++++++++++++++++++++++++++++ src/utils/nnasClient.ts | 87 -------------------------- 5 files changed, 132 insertions(+), 96 deletions(-) create mode 100644 src/utils/NnidResolver.mjs delete mode 100644 src/utils/nnasClient.ts diff --git a/src/routes/api/act.ts b/src/routes/api/act.ts index c7a6314..54d38e2 100644 --- a/src/routes/api/act.ts +++ b/src/routes/api/act.ts @@ -7,7 +7,7 @@ import { db } from "../../utils/db.ts"; import { z } from "zod"; import { BskyClient } from "../../utils/bsky.ts"; import { env } from "../../env.ts"; -import NnasClient from "../../utils/nnasClient.ts"; +import NnidResolver from "../../utils/NnidResolver.mjs"; import crypto from "crypto"; import { logger } from "../../utils/logger.ts"; import { getRealIpFromRequest } from "../../utils/other.ts"; @@ -75,7 +75,7 @@ router.post( let miiResponse; try { - miiResponse = await NnasClient.miiFromPid(String(principalId)); + miiResponse = await NnidResolver.miiFromPid(String(principalId)); } catch (err) { console.warn( `Mii data fetching error for: ${token.pid} ${token.serial_number}`, err @@ -86,7 +86,7 @@ router.post( } const mii_name = miiResponse.name; - const mii_data = miiResponse.data; + const mii_data = miiResponse.miiData; const mii = new Mii(Buffer.from(mii_data, "base64")); diff --git a/src/routes/api/social.ts b/src/routes/api/social.ts index d8d1146..0cbddd6 100644 --- a/src/routes/api/social.ts +++ b/src/routes/api/social.ts @@ -2,7 +2,7 @@ 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 NnasClient from "../../utils/nnasClient.ts"; +import NnidResolver from "../../utils/NnidResolver.mjs"; import crypto from "crypto"; import { BskyClient } from "../../utils/bsky.ts"; import { parseServiceToken } from "../../utils/serviceToken.ts"; @@ -190,8 +190,7 @@ router.get( latest_post_id = JSON.parse(cachedUserData).latest_post_id; } else { try { - user_id = (await NnasClient.miiFromPid(pid)).userId; - + user_id = (await NnidResolver.miiFromPid(pid)).userId; } catch (err) { console.error("Mii fetch error:", err); } diff --git a/src/routes/ui/vino-jp.ts b/src/routes/ui/vino-jp.ts index 23b2fda..9466a6d 100644 --- a/src/routes/ui/vino-jp.ts +++ b/src/routes/ui/vino-jp.ts @@ -4,7 +4,7 @@ import { parseServiceToken } from "../../utils/serviceToken.ts"; import { db } from "../../utils/db.ts"; import { getRealIpFromRequest, getRegion } from "../../utils/other.ts"; import Mii from "@pretendonetwork/mii-js"; -import NnasClient from "../../utils/nnasClient.ts"; +import NnidResolver from "../../utils/NnidResolver.mjs"; const router: Router = express.Router(); @@ -99,9 +99,9 @@ router.get("/index.html", async (req: Request, res: Response): Promise => { if (now.getTime() - lastUpdate.getTime() > oneHour) { try { - const userData = await NnasClient.miiFromPid(String(token.pid)); + const userData = await NnidResolver.miiFromPid(String(token.pid)); const mii_name = userData.name; - const mii_data = userData.data; + const mii_data = userData.miiData; const mii = new Mii(Buffer.from(mii_data, "base64")); const mii_bday = mii.birthDay + "/" + mii.birthMonth; 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/nnasClient.ts b/src/utils/nnasClient.ts deleted file mode 100644 index 7e1a1db..0000000 --- a/src/utils/nnasClient.ts +++ /dev/null @@ -1,87 +0,0 @@ -export interface MiiResponse { - /** Principal ID. */ - pid: string; - /** Mii name. */ - name: string; - /** 96-byte Base64 encoded Mii data. */ - data: string; - /** Nintendo Network ID. */ - userId: string; -} - -/** Ad-hoc method of extracting XML tag value. */ -function extractXmlTag(xml: string, tag: string): string { - const match = xml.match(new RegExp(`<${tag}>([^<]*)<\\/${tag}>`)); - return match?.[1] ?? ""; -} - -/** - * Minimal client for Nintendo Network Account System/Server/Service/whatever it's called. - * It only: - * - Resolves User ID -> PID - * - Obtains /v1/api/miis response - * - Does not send client certificate (meaning it only works on custom servers) - */ -export default class NnasClient { - /** - * API base: {@link https://pretendo.network} - * As of writing, this blocks HTTP/2 clients and - * may break if Bun fetch gets H2 support. - */ - private static readonly baseUrl = "https://account.pretendo.cc/v1/api"; - - /** - * Headers including the Wii U client ID/secret. - * {@link https://github.com/kinnay/NintendoClients/wiki/Account-Server#headers} - */ - private static readonly headers: Record = { - "Accept": "application/xml", - "X-Nintendo-Client-ID": "a2efa818a34fa16b8afbc8a74eba3eda", - "X-Nintendo-Client-Secret": "c91cdb5658bd4954ade78533a339cf9a", - }; - - /** - * Obtains PID as a string from the user ID. - * Throws an exception if user doesn't exist. - */ - static async pidFromUserId(userId: string): Promise { - const resp = await fetch( - `${NnasClient.baseUrl}/admin/mapped_ids?input_type=user_id&output_type=pid&input=${encodeURIComponent(userId)}`, - { headers: NnasClient.headers } - ); - if (!resp.ok) { - throw new Error(`mapped_ids fetch failed: ${resp.status}`); - } - const body = await resp.text(); - const pid = 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. - */ - static async miiFromPid(pid: string): Promise { - const resp = await fetch( - `${NnasClient.baseUrl}/miis?pids=${pid}`, - { headers: NnasClient.headers } - ); - if (!resp.ok) { - throw new Error(`miis fetch failed: ${resp.status}`); - } - const body = await resp.text(); - const mii: Partial = { - pid: extractXmlTag(body, "pid"), - name: extractXmlTag(body, "name"), - data: extractXmlTag(body, "data"), - userId: extractXmlTag(body, "user_id"), - }; - if (!mii.data) { - throw new Error("no mii data in response"); - } - return mii as MiiResponse; - } -}