Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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");
Expand All @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/cards/components/Player.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
</div>
{/if}
<img
src="https://nmsr.nickac.dev/fullbody/{profile?.uuid}?no=shadow"
src="https://skins.mcstats.com/body/front/{profile?.uuid}?scale=2"
alt={profile?.username}
class="relative h-full object-cover" />
</div>
4 changes: 2 additions & 2 deletions src/lib/components/misc/CommandSearchGroup.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
<Avatar.Image
loading="lazy"
src={item.uuid
? `https://nmsr.nickac.dev/face/${item.uuid}`
: "https://nmsr.nickac.dev/face/bc8ea1f51f253ff5142ca11ae45193a4ad8c3ab5e9c6eec8ba7a4fcb7bac40"}
? `https://skins.mcstats.com/face/${item.uuid}?size=512`
: "https://skins.mcstats.com/face/bc8ea1f51f253ff5142ca11ae45193a4ad8c3ab5e9c6eec8ba7a4fcb7bac40?size=512"}
alt={item.ign}
class="aspect-square size-4 [image-rendering:pixelated]" />
<Avatar.Fallback
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/misc/ContributorCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
<img
loading="lazy"
src={options?.tip
? "https://nmsr.nickac.dev/face/bc8ea1f51f253ff5142ca11ae45193a4ad8c3ab5e9c6eec8ba7a4fcb7bac40"
: `https://nmsr.nickac.dev/face/${user.id}`}
? "https://skins.mcstats.com/face/bc8ea1f51f253ff5142ca11ae45193a4ad8c3ab5e9c6eec8ba7a4fcb7bac40?size=512"
: `https://skins.mcstats.com/face/${user.id}?size=512`}
alt={user.username} />
</Item.Media>
<Item.Content>
Expand Down
6 changes: 3 additions & 3 deletions src/lib/components/misc/SEO.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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")
);
Expand Down Expand Up @@ -73,8 +73,8 @@
<link
rel="icon"
href={isStatsPage
? `https://nmsr.nickac.dev/face/${embedData.uuid}`
: `https://nmsr.nickac.dev/bust/${embedData.uuid}?y=-20`}
? `https://skins.mcstats.com/face/${embedData.uuid}?size=512`
: `https://skins.mcstats.com/bust/${embedData.uuid}?scale=2`}
sizes="32x32"
type="image/png" />
{/if}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/newsroom/PostCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
<Avatar.Root class="size-4 shrink-0">
<Avatar.Image
loading="lazy"
src="https://nmsr.nickac.dev/face/{author.mcUuid}"
src="https://skins.mcstats.com/face/{author.mcUuid}?size=512"
alt={displayName}
class="size-full [image-rendering:pixelated]" />
<Avatar.Fallback
Expand Down
4 changes: 2 additions & 2 deletions src/lib/layouts/stats/Main.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
<Avatar.Root>
{#snippet child({ props })}
<div transition:fade={{ duration: 300, easing: cubicOut }} {...props}>
<Avatar.Image loading="lazy" src="https://nmsr.nickac.dev/fullbody/{profile.uuid}?no=shadow" alt="{profile.username}'s avatar" class="max-h-[32rem] object-cover" />
<Avatar.Image loading="lazy" src="https://skins.mcstats.com/body/front/{profile.uuid}?scale=2" alt="{profile.username}'s avatar" class="max-h-[32rem] object-cover" />
<Avatar.Fallback>
<Image class="size-24 object-cover text-foreground" />
</Avatar.Fallback>
Expand Down Expand Up @@ -176,7 +176,7 @@
<div transition:fade={{ duration: 300, easing: cubicOut }} {...props}>
<Avatar.Image
loading="lazy"
src="https://nmsr.nickac.dev/fullbody/{profile.uuid}?no=shadow"
src="https://skins.mcstats.com/body/front/{profile.uuid}?scale=2"
alt="{profile.username}'s avatar"
class="max-h-128 object-cover" />
<Avatar.Fallback>
Expand Down
4 changes: 2 additions & 2 deletions src/lib/layouts/stats/PlayerProfile.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,13 @@
<Avatar.Root class="size-8 shrink-0 after:border-none">
<Avatar.Image
loading="lazy"
src="https://nmsr.nickac.dev/face/{member.uuid}"
src="https://skins.mcstats.com/face/{member.uuid}?size=512"
alt={member.username}
class="aspect-square size-8 rounded-none [image-rendering:pixelated] group-data-[removed=true]:grayscale-100" />
<Avatar.Fallback>
<img
loading="lazy"
src="https://nmsr.nickac.dev/face/bc8ea1f51f253ff5142ca11ae45193a4ad8c3ab5e9c6eec8ba7a4fcb7bac40"
src="https://skins.mcstats.com/face/bc8ea1f51f253ff5142ca11ae45193a4ad8c3ab5e9c6eec8ba7a4fcb7bac40?size=512"
alt="Steve"
class="aspect-square size-8 rounded-none [image-rendering:pixelated] group-data-[removed=true]:grayscale-100" />
</Avatar.Fallback>
Expand Down
6 changes: 5 additions & 1 deletion src/lib/shared/api/mutator/cms-instance.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand Down Expand Up @@ -34,7 +35,10 @@ export const cmsFetch = async <T>(url: string, options: RequestInit): Promise<T>

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<T>(response);

return { status: response.status, data, headers: response.headers } as T;
Expand Down
7 changes: 5 additions & 2 deletions src/lib/shared/api/mutator/custom-instance.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -54,13 +56,14 @@ export const customFetch = async <T>(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;
};
60 changes: 60 additions & 0 deletions src/lib/shared/api/texture-proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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("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);
});
});

describe("unproxyApiAssetUrls", () => {
it("points proxied markup back at the server API origin", ({ expect }) => {
expect(unproxyApiAssetUrls('<img src="/textures/api/item/X">')).toBe('<img src="http://backend:8080/api/item/X">');
});
});
59 changes: 59 additions & 0 deletions src/lib/shared/api/texture-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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;

// 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<string, string> = {
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<T>(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));
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.
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()}/`);
}
2 changes: 2 additions & 0 deletions src/lib/shared/constants/user-agent.ts
Original file line number Diff line number Diff line change
@@ -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)";
3 changes: 2 additions & 1 deletion src/lib/shared/items/resolve-item-texture.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { USER_AGENT } from "$lib/shared/constants/user-agent";
import { readEnabledPacksCookie, serializePackIds } from "$lib/shared/resource-packs";

export type ResolvedItemTexture = {
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions src/lib/shared/themes/engine.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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 }) => {
Expand Down
2 changes: 0 additions & 2 deletions src/lib/shared/themes/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)})`;
Expand Down
3 changes: 2 additions & 1 deletion src/routes/api/image-proxy/+server.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/routes/api/tunnel/+server.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
});

Expand Down
2 changes: 1 addition & 1 deletion src/routes/newsroom/[slug]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@
{#if author.mcUuid}
<Avatar.Image
loading="lazy"
src="https://nmsr.nickac.dev/face/{author.mcUuid}"
src="https://skins.mcstats.com/face/{author.mcUuid}?size=512"
alt={displayName}
class="size-10 [image-rendering:pixelated]" />
{/if}
Expand Down
Loading
Loading