From 5d5d8548e97547efef5377d593ab86d9f6b6698f Mon Sep 17 00:00:00 2001 From: Connor Lewis <50084106+imconnorngl@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:30:04 +0100 Subject: [PATCH 1/2] fix: identify as Lunar Client and stop direct third-party image loads - Send "Lunar Client (skycrypt-embed.lunarclient.com)" as the User-Agent on every outbound request: SkyCrypt API, CMS, image proxy, Sentry tunnel, card assets, takumi image fetches, and all server-side event.fetch calls via handleFetch. - Load player renders from skins.mcstats.com instead of nmsr.nickac.dev. - Serve SkyCrypt API textures through a same-origin /textures proxy so the browser never requests them from sky.shiiyu.moe with its own User-Agent; theme images on first-party hosts now go through /api/image-proxy. - Drop sky.shiiyu.moe and cupcake.shiiyu.moe from the CSP img/connect allowlists and cupcake.shiiyu.moe from the CSRF trusted origins. --- src/hooks.server.ts | 12 +++++- .../components/cards/components/Player.svelte | 2 +- .../components/misc/CommandSearchGroup.svelte | 4 +- .../components/misc/ContributorCard.svelte | 4 +- src/lib/components/misc/SEO.svelte | 6 +-- src/lib/components/newsroom/PostCard.svelte | 2 +- src/lib/layouts/stats/Main.svelte | 4 +- src/lib/layouts/stats/PlayerProfile.svelte | 4 +- src/lib/shared/api/mutator/cms-instance.ts | 6 ++- src/lib/shared/api/mutator/custom-instance.ts | 7 +++- src/lib/shared/api/texture-proxy.spec.ts | 41 +++++++++++++++++++ src/lib/shared/api/texture-proxy.ts | 40 ++++++++++++++++++ src/lib/shared/constants/user-agent.ts | 2 + src/lib/shared/items/resolve-item-texture.ts | 3 +- src/lib/shared/themes/engine.spec.ts | 7 ++-- src/lib/shared/themes/engine.ts | 2 - src/routes/api/image-proxy/+server.ts | 3 +- src/routes/api/tunnel/+server.ts | 2 + src/routes/newsroom/[slug]/+page.svelte | 2 +- .../stats/[ign]/[[profile]]/card/+server.ts | 18 ++++++-- src/routes/textures/[...path]/+server.ts | 39 ++++++++++++++++++ vite.config.ts | 7 +--- 22 files changed, 181 insertions(+), 36 deletions(-) create mode 100644 src/lib/shared/api/texture-proxy.spec.ts create mode 100644 src/lib/shared/api/texture-proxy.ts create mode 100644 src/lib/shared/constants/user-agent.ts create mode 100644 src/routes/textures/[...path]/+server.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 3acc284eb..79aad36d1 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,5 +1,6 @@ +import { USER_AGENT } from "$lib/shared/constants/user-agent"; import { handleErrorWithSentry, sentryHandle } from "@sentry/sveltekit"; -import { type Handle } from "@sveltejs/kit"; +import { type Handle, type HandleFetch } from "@sveltejs/kit"; import { sequence } from "@sveltejs/kit/hooks"; // This fork is a public stats/card embed (skycrypt-embed.lunarclient.com) deployed @@ -25,7 +26,7 @@ const headersHandler = (async ({ event, resolve }) => { // Cross-Origin policies // COEP intentionally unsafe-none: tightening would require all cross-origin - // resources (textures.minecraft.net, nmsr.nickac.dev, etc.) to send CORP + // resources (textures.minecraft.net, skins.mcstats.com, etc.) to send CORP // headers, which they don't control. response.headers.set("Cross-Origin-Embedder-Policy", "unsafe-none"); response.headers.set("Cross-Origin-Opener-Policy", "same-origin"); @@ -44,6 +45,13 @@ const headersHandler = (async ({ event, resolve }) => { return response; }) satisfies Handle; +// Every server-side `event.fetch` identifies as the embed, never as the visitor's browser. +export const handleFetch = (({ request, fetch }) => { + const headers = new Headers(request.headers); + headers.set("User-Agent", USER_AGENT); + return fetch(new Request(request, { headers })); +}) satisfies HandleFetch; + // If you have a custom error handler, pass it to `handleErrorWithSentry` export const handleError = handleErrorWithSentry(); diff --git a/src/lib/components/cards/components/Player.svelte b/src/lib/components/cards/components/Player.svelte index 1bc6df17a..fd26e88c9 100644 --- a/src/lib/components/cards/components/Player.svelte +++ b/src/lib/components/cards/components/Player.svelte @@ -22,7 +22,7 @@ {/if} {profile?.username} diff --git a/src/lib/components/misc/CommandSearchGroup.svelte b/src/lib/components/misc/CommandSearchGroup.svelte index 5f26489cd..a6fbcd4fc 100644 --- a/src/lib/components/misc/CommandSearchGroup.svelte +++ b/src/lib/components/misc/CommandSearchGroup.svelte @@ -22,8 +22,8 @@ diff --git a/src/lib/components/misc/SEO.svelte b/src/lib/components/misc/SEO.svelte index 328f8ee34..9ef72b47d 100644 --- a/src/lib/components/misc/SEO.svelte +++ b/src/lib/components/misc/SEO.svelte @@ -21,7 +21,7 @@ const profileDescription = $derived( isStatsPage && !isValidEmbed ? getShortDescription(embedData) : getLongDescription(embedData) ); - const profileImage = $derived(`https://nmsr.nickac.dev/bust/${embedData.uuid}?y=-20`); + const profileImage = $derived(`https://skins.mcstats.com/bust/${embedData.uuid}?scale=2`); const themeColor = $derived( embedData.rank?.plusColor || embedData.rank?.rankColor || (mode.current === "light" ? "#dbdbdb" : "#282828") ); @@ -73,8 +73,8 @@ {/if} diff --git a/src/lib/components/newsroom/PostCard.svelte b/src/lib/components/newsroom/PostCard.svelte index d564494b7..5fb4db3a9 100644 --- a/src/lib/components/newsroom/PostCard.svelte +++ b/src/lib/components/newsroom/PostCard.svelte @@ -77,7 +77,7 @@ {#snippet child({ props })}
- + @@ -176,7 +176,7 @@
diff --git a/src/lib/layouts/stats/PlayerProfile.svelte b/src/lib/layouts/stats/PlayerProfile.svelte index 065f61b24..5ae129b43 100644 --- a/src/lib/layouts/stats/PlayerProfile.svelte +++ b/src/lib/layouts/stats/PlayerProfile.svelte @@ -83,13 +83,13 @@ Steve diff --git a/src/lib/shared/api/mutator/cms-instance.ts b/src/lib/shared/api/mutator/cms-instance.ts index 739fd0786..ce7624078 100644 --- a/src/lib/shared/api/mutator/cms-instance.ts +++ b/src/lib/shared/api/mutator/cms-instance.ts @@ -1,6 +1,7 @@ import { getRequestEvent } from "$app/server"; import { env as envPrivate } from "$env/dynamic/private"; import { env as envPublic } from "$env/dynamic/public"; +import { USER_AGENT } from "$lib/shared/constants/user-agent"; import { error } from "@sveltejs/kit"; // NOTE: Supports cases where `content-type` is other than `json` @@ -34,7 +35,10 @@ export const cmsFetch = async (url: string, options: RequestInit): Promise const requestUrl = getUrl(url); - const response = await fetchFunction(requestUrl, options); + const headers = new Headers(options.headers); + headers.set("User-Agent", USER_AGENT); + + const response = await fetchFunction(requestUrl, { ...options, headers }); const data = await getBody(response); return { status: response.status, data, headers: response.headers } as T; diff --git a/src/lib/shared/api/mutator/custom-instance.ts b/src/lib/shared/api/mutator/custom-instance.ts index 654c34bd8..6799f57ae 100644 --- a/src/lib/shared/api/mutator/custom-instance.ts +++ b/src/lib/shared/api/mutator/custom-instance.ts @@ -1,6 +1,8 @@ import { getRequestEvent } from "$app/server"; import { env as envPrivate } from "$env/dynamic/private"; import { env as envPublic } from "$env/dynamic/public"; +import { proxyApiAssetUrls } from "$lib/shared/api/texture-proxy"; +import { USER_AGENT } from "$lib/shared/constants/user-agent"; import { readApiResponse } from "./readApiResponse"; const { PUBLIC_SERVER_API_URL } = envPublic; @@ -54,13 +56,14 @@ export const customFetch = async (url: string, options: RequestInit): Promise headers: { ...Object.fromEntries(headers), "X-API-Token": serverApiToken, - "User-Agent": "Lunar Client (skycrypt-embed.lunarclient.com)" + "User-Agent": USER_AGENT } }; const requestUrl = getUrl(url); const response = await (event?.fetch ?? fetch)(requestUrl, requestInit); - const data = await readApiResponse(response, requestUrl, requestInit.method); + // Asset URLs go through our texture proxy so the browser never fetches them from the API directly. + const data = proxyApiAssetUrls(await readApiResponse(response, requestUrl, requestInit.method)); return { status: response.status, data, headers: response.headers } as T; }; diff --git a/src/lib/shared/api/texture-proxy.spec.ts b/src/lib/shared/api/texture-proxy.spec.ts new file mode 100644 index 000000000..269ef0ddc --- /dev/null +++ b/src/lib/shared/api/texture-proxy.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, vi } from "vitest"; + +vi.mock("$env/dynamic/public", () => ({ + env: { + PUBLIC_API_URL: "https://sky.shiiyu.moe/api/", + PUBLIC_SERVER_API_URL: "http://backend:8080/api/" + } +})); + +const { proxyApiAssetUrls, unproxyApiAssetUrls } = await import("./texture-proxy"); + +describe("proxyApiAssetUrls", () => { + it("rewrites nested API asset URLs to the texture proxy", ({ expect }) => { + const data = { + texture_path: "https://sky.shiiyu.moe/api/item/FLAMEBREAKER_LEGGINGS", + pets: [{ texture: "http://backend:8080/api/head/abc" }], + texture: "https://sky.shiiyu.moe/cache/rendered/x.webp", + other: "https://example.com/api/item/X", + level: 5 + }; + + expect(proxyApiAssetUrls(data)).toEqual({ + texture_path: "/textures/api/item/FLAMEBREAKER_LEGGINGS", + pets: [{ texture: "/textures/api/head/abc" }], + texture: "/textures/cache/rendered/x.webp", + other: "https://example.com/api/item/X", + level: 5 + }); + }); + + it("leaves non-plain objects untouched", ({ expect }) => { + const blob = new Blob(["png"]); + expect(proxyApiAssetUrls(blob)).toBe(blob); + }); +}); + +describe("unproxyApiAssetUrls", () => { + it("points proxied markup back at the server API origin", ({ expect }) => { + expect(unproxyApiAssetUrls('')).toBe(''); + }); +}); diff --git a/src/lib/shared/api/texture-proxy.ts b/src/lib/shared/api/texture-proxy.ts new file mode 100644 index 000000000..69d2a6ecd --- /dev/null +++ b/src/lib/shared/api/texture-proxy.ts @@ -0,0 +1,40 @@ +import { env } from "$env/dynamic/public"; + +/** + * Same-origin path that SkyCrypt API assets (item renders, heads, resolved textures) are loaded through, so the browser + * never requests them from the API host with its own User-Agent. + */ +export const TEXTURE_PROXY_PATH = "/textures"; + +const apiOrigins = (): string[] => { + const urls = [env.PUBLIC_API_URL, env.PUBLIC_SERVER_API_URL].filter((url): url is string => !!url); + return [...new Set(urls.map((url) => new URL(url).origin))]; +}; + +/** The origin the proxy fetches from; the server URL may be a private hostname (e.g. Docker). */ +export const textureUpstreamOrigin = (): string => new URL(env.PUBLIC_SERVER_API_URL).origin; + +/** Rewrites every absolute SkyCrypt API URL inside `data` to its same-origin proxy path. */ +export function proxyApiAssetUrls(data: T): T { + const prefixes = apiOrigins().map((origin) => `${origin}/`); + + const walk = (value: unknown): unknown => { + if (typeof value === "string") { + const prefix = prefixes.find((p) => value.startsWith(p)); + return prefix ? `${TEXTURE_PROXY_PATH}/${value.slice(prefix.length)}` : value; + } + if (Array.isArray(value)) return value.map(walk); + // Only plain JSON objects; leave Blobs (PNG endpoints) and other instances untouched. + if (value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, walk(entry)])); + } + return value; + }; + + return walk(data) as T; +} + +/** Points proxied paths in server-rendered markup back at the API, for renderers without an origin. */ +export function unproxyApiAssetUrls(html: string): string { + return html.replaceAll(`"${TEXTURE_PROXY_PATH}/`, `"${textureUpstreamOrigin()}/`); +} diff --git a/src/lib/shared/constants/user-agent.ts b/src/lib/shared/constants/user-agent.ts new file mode 100644 index 000000000..1ddfd0d1c --- /dev/null +++ b/src/lib/shared/constants/user-agent.ts @@ -0,0 +1,2 @@ +/** User-Agent sent on every outbound request, in place of the visitor's browser User-Agent. */ +export const USER_AGENT = "Lunar Client (skycrypt-embed.lunarclient.com)"; diff --git a/src/lib/shared/items/resolve-item-texture.ts b/src/lib/shared/items/resolve-item-texture.ts index 575e8256e..2af95977a 100644 --- a/src/lib/shared/items/resolve-item-texture.ts +++ b/src/lib/shared/items/resolve-item-texture.ts @@ -1,3 +1,4 @@ +import { USER_AGENT } from "$lib/shared/constants/user-agent"; import { readEnabledPacksCookie, serializePackIds } from "$lib/shared/resource-packs"; export type ResolvedItemTexture = { @@ -28,7 +29,7 @@ export function resolveItemTexture(textureUrl: string, texturePack?: string): Pr const resolver = texturePack ? null : resolverUrl(textureUrl, enabledPacks); const resolution = resolver - ? fetch(resolver) + ? fetch(resolver, { headers: { "User-Agent": USER_AGENT } }) .then(async (response) => { if (!response.ok) throw new Error(`Failed to resolve item texture: ${response.status}`); return (await response.json()) as ResolvedItemTexture; diff --git a/src/lib/shared/themes/engine.spec.ts b/src/lib/shared/themes/engine.spec.ts index c93a3fe2a..8a2785fb4 100644 --- a/src/lib/shared/themes/engine.spec.ts +++ b/src/lib/shared/themes/engine.spec.ts @@ -185,7 +185,7 @@ describe("Theme Engine", () => { expect(rule).not.toContain("/img/themes/light/bg.avif"); }); - it("uses direct URLs for non-local first-party images", ({ expect }) => { + it("proxies non-local first-party images", ({ expect }) => { const rule = ThemeEngine.themeToCssRule( withDarkExtras(customTheme("remote-first-party-assets"), { minecraft: { @@ -197,8 +197,9 @@ describe("Theme Engine", () => { }) ); - expect(rule).toContain(" --bg-url: url(https://sky.shiiyu.moe/img/custom/user-bg.avif);"); - expect(rule).not.toContain("/api/image-proxy"); + expect(rule).toContain( + ` --bg-url: url(/api/image-proxy?url=${encodeURIComponent("https://sky.shiiyu.moe/img/custom/user-bg.avif")});` + ); }); it("omits undefined css vars", ({ expect }) => { diff --git a/src/lib/shared/themes/engine.ts b/src/lib/shared/themes/engine.ts index 47631911f..1e53697af 100644 --- a/src/lib/shared/themes/engine.ts +++ b/src/lib/shared/themes/engine.ts @@ -53,8 +53,6 @@ function themeImageUrl(url: string): string { if (targetUrl.pathname.startsWith(REMOVED_FIRST_PARTY_THEME_IMAGE_PREFIX)) { return "url(/img/bg.avif)"; } - - return `url(${targetUrl.href})`; } return `url(/api/image-proxy?url=${encodeURIComponent(url)})`; diff --git a/src/routes/api/image-proxy/+server.ts b/src/routes/api/image-proxy/+server.ts index c66252615..660b8b0c6 100644 --- a/src/routes/api/image-proxy/+server.ts +++ b/src/routes/api/image-proxy/+server.ts @@ -1,3 +1,4 @@ +import { USER_AGENT } from "$lib/shared/constants/user-agent"; import type { RequestHandler } from "./$types"; // An internal endpoint used to proxy images for security/privacy reasons. @@ -49,7 +50,7 @@ export const GET: RequestHandler = async ({ request }) => { signal: controller.signal, headers: { // 4. Custom User-Agent - "User-Agent": "SkyCrypt-Image-Proxy/1.0" + "User-Agent": USER_AGENT } }); clearTimeout(timeoutId); diff --git a/src/routes/api/tunnel/+server.ts b/src/routes/api/tunnel/+server.ts index 4aa2a3f35..15bfcd00c 100644 --- a/src/routes/api/tunnel/+server.ts +++ b/src/routes/api/tunnel/+server.ts @@ -1,4 +1,5 @@ import { env } from "$env/dynamic/public"; +import { USER_AGENT } from "$lib/shared/constants/user-agent"; import { json } from "@sveltejs/kit"; import type { RequestHandler } from "./$types"; @@ -26,6 +27,7 @@ export const POST: RequestHandler = async ({ request }) => { const upstream_sentry_url = `https://${PUBLIC_SENTRY_HOST}/api/${project_id}/envelope/`; await fetch(upstream_sentry_url, { method: "POST", + headers: { "User-Agent": USER_AGENT }, body: envelopeBytes }); diff --git a/src/routes/newsroom/[slug]/+page.svelte b/src/routes/newsroom/[slug]/+page.svelte index d52b5fd6c..31eea0611 100644 --- a/src/routes/newsroom/[slug]/+page.svelte +++ b/src/routes/newsroom/[slug]/+page.svelte @@ -128,7 +128,7 @@ {#if author.mcUuid} {/if} diff --git a/src/routes/stats/[ign]/[[profile]]/card/+server.ts b/src/routes/stats/[ign]/[[profile]]/card/+server.ts index 84ac993bd..d87ced5c8 100644 --- a/src/routes/stats/[ign]/[[profile]]/card/+server.ts +++ b/src/routes/stats/[ign]/[[profile]]/card/+server.ts @@ -10,6 +10,8 @@ import { getProfileStats, getSelectedProfileStats } from "$src/lib/shared/api/skycrypt-api.remote"; +import { unproxyApiAssetUrls } from "$src/lib/shared/api/texture-proxy"; +import { USER_AGENT } from "$src/lib/shared/constants/user-agent"; import { render } from "svelte/server"; import type { Font, ImageSource } from "takumi-js"; import { ImageResponse } from "takumi-js/response"; @@ -48,7 +50,8 @@ export const GET: RequestHandler = async ({ params, request, url }) => { settings } }); - const html = `${head}${body}`; + // takumi has no page origin to resolve proxied texture paths against; it fetches them itself. + const html = unproxyApiAssetUrls(`${head}${body}`); const componentRenderDuration = performance.now() - componentRenderStart; // Diagnostic: Extract and log all dynamic image sources present in the HTML template @@ -70,7 +73,7 @@ export const GET: RequestHandler = async ({ params, request, url }) => { stylesheets: [appStyles], emoji: "twemoji", signal: request.signal, - images, + images: { sources: images, fetch: fetchWithUserAgent }, fonts }); @@ -110,7 +113,7 @@ export const GET: RequestHandler = async ({ params, request, url }) => { }, stylesheets: [appStyles], emoji: "twemoji", - images, + images: { sources: images, fetch: fetchWithUserAgent }, fonts }); @@ -162,9 +165,16 @@ async function fetchSelectedProfileCardData(uuid: string) { }; } +/** Fetches with the embed User-Agent; also used by takumi for remote sources in the card. */ +function fetchWithUserAgent(input: string, init?: RequestInit): Promise { + const headers = new Headers(init?.headers); + headers.set("User-Agent", USER_AGENT); + return fetch(input, { ...init, headers }); +} + /** Validates external asset buffers, checks HTTP status, and verifies that responses aren't HTML error/redirect pages. */ async function fetchAssetBuffer(url: string, assetName: string): Promise { - const res = await fetch(url); + const res = await fetchWithUserAgent(url); if (!res.ok) { throw new Error(`[Asset Fetch Failed] ${assetName} (${url}) returned HTTP ${res.status}: ${res.statusText}`); } diff --git a/src/routes/textures/[...path]/+server.ts b/src/routes/textures/[...path]/+server.ts new file mode 100644 index 000000000..c13edba4e --- /dev/null +++ b/src/routes/textures/[...path]/+server.ts @@ -0,0 +1,39 @@ +import { proxyApiAssetUrls, textureUpstreamOrigin } from "$lib/shared/api/texture-proxy"; +import { USER_AGENT } from "$lib/shared/constants/user-agent"; +import { json } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; + +// Proxies SkyCrypt API assets so they are requested with our User-Agent instead of the +// visitor's. Only images and the JSON from item `/resolve` lookups are passed through. +export const GET: RequestHandler = async ({ params, url }) => { + const upstream = new URL(textureUpstreamOrigin()); + // Assigning the pathname (rather than resolving a relative URL) keeps `//host` paths on the API origin. + upstream.pathname = `/${params.path}`; + upstream.search = url.search; + + const response = await fetch(upstream, { headers: { "User-Agent": USER_AGENT } }); + if (!response.ok) { + await response.body?.cancel(); + return new Response(null, { status: response.status === 404 ? 404 : 502 }); + } + + const contentType = response.headers.get("content-type") ?? ""; + const cacheControl = response.headers.get("cache-control") ?? "public, max-age=3600"; + + if (upstream.pathname.endsWith("/resolve") && contentType.startsWith("application/json")) { + return json(proxyApiAssetUrls(await response.json()), { headers: { "Cache-Control": cacheControl } }); + } + + if (!contentType.startsWith("image/")) { + await response.body?.cancel(); + return new Response(null, { status: 502 }); + } + + return new Response(response.body, { + headers: { + "Content-Type": contentType, + "Cache-Control": cacheControl, + "X-Content-Type-Options": "nosniff" + } + }); +}; diff --git a/vite.config.ts b/vite.config.ts index 2f2464e04..0cbb0fd68 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -41,7 +41,6 @@ export default defineConfig({ }, csrf: { trustedOrigins: [ - "https://cupcake.shiiyu.moe", "https://sky.shiiyu.moe", "http://localhost:5173", "http://localhost:4173", @@ -60,9 +59,7 @@ export default defineConfig({ "data:", "https://textures.minecraft.net", "http://localhost:8080", - "https://cupcake.shiiyu.moe", - "https://sky.shiiyu.moe", - "https://nmsr.nickac.dev", + "https://skins.mcstats.com", "https://cms.shiiyu.moe", "http://localhost:3000", "https://eliteskyblock.com" @@ -72,8 +69,6 @@ export default defineConfig({ "https://mowojang.matdoes.dev", "https://mowojang.seraph.si", "http://localhost:8080", - "https://cupcake.shiiyu.moe", - "https://sky.shiiyu.moe", "https://cms.shiiyu.moe", "http://localhost:3000" ], From f13df12bd3fb33dd5be05a01524dd4ed3c8ec0ff Mon Sep 17 00:00:00 2001 From: Connor Lewis <50084106+imconnorngl@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:42:38 +0100 Subject: [PATCH 2/2] fix: map nmsr.nickac.dev renders in API data to skins.mcstats.com --- src/lib/shared/api/texture-proxy.spec.ts | 19 +++++++++++++++++++ src/lib/shared/api/texture-proxy.ts | 23 +++++++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/lib/shared/api/texture-proxy.spec.ts b/src/lib/shared/api/texture-proxy.spec.ts index 269ef0ddc..8bacfaab5 100644 --- a/src/lib/shared/api/texture-proxy.spec.ts +++ b/src/lib/shared/api/texture-proxy.spec.ts @@ -28,6 +28,25 @@ describe("proxyApiAssetUrls", () => { }); }); + it("maps nmsr.nickac.dev renders to skins.mcstats.com", ({ expect }) => { + const id = "aad581b2f90048a785a7573d31d7b862"; + expect( + proxyApiAssetUrls([ + `https://nmsr.nickac.dev/headiso/${id}?noshading&no=shadow`, + `https://nmsr.nickac.dev/face/${id}`, + `https://nmsr.nickac.dev/bust/${id}?y=-20`, + `https://nmsr.nickac.dev/fullbody/${id}?no=shadow`, + `https://nmsr.nickac.dev/unknown/${id}` + ]) + ).toEqual([ + `https://skins.mcstats.com/skull/${id}?scale=2`, + `https://skins.mcstats.com/face/${id}?size=512`, + `https://skins.mcstats.com/bust/${id}?scale=2`, + `https://skins.mcstats.com/body/front/${id}?scale=2`, + `https://nmsr.nickac.dev/unknown/${id}` + ]); + }); + it("leaves non-plain objects untouched", ({ expect }) => { const blob = new Blob(["png"]); expect(proxyApiAssetUrls(blob)).toBe(blob); diff --git a/src/lib/shared/api/texture-proxy.ts b/src/lib/shared/api/texture-proxy.ts index 69d2a6ecd..389816920 100644 --- a/src/lib/shared/api/texture-proxy.ts +++ b/src/lib/shared/api/texture-proxy.ts @@ -14,14 +14,33 @@ const apiOrigins = (): string[] => { /** The origin the proxy fetches from; the server URL may be a private hostname (e.g. Docker). */ export const textureUpstreamOrigin = (): string => new URL(env.PUBLIC_SERVER_API_URL).origin; -/** Rewrites every absolute SkyCrypt API URL inside `data` to its same-origin proxy path. */ +// The API embeds nmsr.nickac.dev player renders; serve the skins.mcstats.com equivalent instead. +const NMSR_URL = /^https:\/\/nmsr\.nickac\.dev\/([a-z]+)\/([^/?#]+)/; +const MCSTATS_RENDERS: Record = { + face: "face/{id}?size=512", + headiso: "skull/{id}?scale=2", + bust: "bust/{id}?scale=2", + fullbody: "body/front/{id}?scale=2" +}; + +function mcstatsRenderUrl(url: string): string | null { + const [, mode, id] = url.match(NMSR_URL) ?? []; + const render = mode ? MCSTATS_RENDERS[mode] : undefined; + return render ? `https://skins.mcstats.com/${render.replace("{id}", id)}` : null; +} + +/** + * Rewrites every absolute SkyCrypt API URL inside `data` to its same-origin proxy path, and nmsr.nickac.dev renders to + * skins.mcstats.com. + */ export function proxyApiAssetUrls(data: T): T { const prefixes = apiOrigins().map((origin) => `${origin}/`); const walk = (value: unknown): unknown => { if (typeof value === "string") { const prefix = prefixes.find((p) => value.startsWith(p)); - return prefix ? `${TEXTURE_PROXY_PATH}/${value.slice(prefix.length)}` : value; + if (prefix) return `${TEXTURE_PROXY_PATH}/${value.slice(prefix.length)}`; + return mcstatsRenderUrl(value) ?? value; } if (Array.isArray(value)) return value.map(walk); // Only plain JSON objects; leave Blobs (PNG endpoints) and other instances untouched.