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}
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 @@
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.