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
2 changes: 2 additions & 0 deletions src/worker/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
copiedHeaders.set('X-Crafthead-Request-Cache-Hit', hitCache ? 'yes' : 'no');
if (!copiedHeaders.has('Content-Type')) {
copiedHeaders.set('Content-Type', interpreted.requested === RequestedKind.Profile ? 'application/json' : 'image/png');
} else if (copiedHeaders.get('Content-Type') !== 'application/json' && copiedHeaders.get('Content-Type')?.includes?.('text/plain') && interpreted.requested === RequestedKind.Profile) {
copiedHeaders.set('Content-Type', 'application/json');
} else {
console.log(`Content-Type header already on response: ${copiedHeaders.get('Content-Type')}, not overriding.`);
}
Expand Down Expand Up @@ -121,7 +123,7 @@
}
}

async function handleRequest(request: Request, env: Env, ctx: ExecutionContext) {

Check warning on line 126 in src/worker/index.ts

View workflow job for this annotation

GitHub Actions / Node 22 Test

'ctx' is defined but never used
const startTime = new Date();
const interpreted = interpretRequest(request);
if (!interpreted) {
Expand Down
2 changes: 1 addition & 1 deletion src/worker/services/mojang/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
this.env = env;
this.request = request;
}
async lookupUsername(username: string, gatherer: PromiseGatherer | null): Promise<MojangUsernameLookupResult | null> {

Check warning on line 61 in src/worker/services/mojang/api.ts

View workflow job for this annotation

GitHub Actions / Node 22 Test

'gatherer' is defined but never used
let lookupResponse: Response;
if (this.env.PLAYERDB) {
const request = new Request(`https://playerdb.co/api/player/minecraft/${username}`, {
Expand Down Expand Up @@ -98,7 +98,7 @@
}
}

async fetchProfile(id: string, gatherer: PromiseGatherer | null): Promise<CacheComputeResult<MojangProfile | null>> {

Check warning on line 101 in src/worker/services/mojang/api.ts

View workflow job for this annotation

GitHub Actions / Node 22 Test

'gatherer' is defined but never used
let profileResponse: Response;
if (this.env.PLAYERDB) {
const request = new Request(`https://playerdb.co/api/player/minecraft/${id}`, {
Expand Down Expand Up @@ -129,7 +129,7 @@
};
return {
result: data,
source: 'miss',
source: returnedProfile?.meta?.cached_at ? 'hit' : 'miss',
};
} else if (profileResponse.status === 206 || profileResponse.status === 204) {
return {
Expand Down
92 changes: 60 additions & 32 deletions src/worker/services/mojang/service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ALEX_SKIN, STEVE_SKIN } from '../../data';
import { IdentityKind, TextureKind } from '../../request';
import { ALEX_SKIN, EMPTY, STEVE_SKIN } from '../../data';
import { IdentityKind, RequestedKind, TextureKind } from '../../request';
import {
fromHex,
javaHashCode,
Expand Down Expand Up @@ -66,54 +66,63 @@ export default class MojangRequestService {
/**
* Fetches a texture directly from the Mojang servers. Assumes the request has been normalized already.
*/
private async retrieveTextureDirect(request: CraftheadRequest, gatherer: PromiseGatherer, kind: TextureKind): Promise<Response> {
private async retrieveTextureDirect(request: CraftheadRequest, gatherer: PromiseGatherer, kind: TextureKind): Promise<TextureResponse> {
if (request.identityType === IdentityKind.TextureID) {
const textureResponse = await MojangRequestService.fetchTextureFromId(request.identity);
return MojangRequestService.constructTextureResponse(textureResponse, request);
return {
texture: await MojangRequestService.constructTextureResponse(textureResponse, request),
};
}
const rawUuid = fromHex(request.identity);
if (uuidVersion(rawUuid) === 4) {
const lookup = await this.mojangApi.fetchProfile(request.identity, gatherer);
if (lookup.result) {
const textureResponse = await MojangRequestService.fetchTextureFromProfile(lookup.result, kind);
if (textureResponse) {
return MojangRequestService.constructTextureResponse(textureResponse, request, lookup.source);
return {
texture: await MojangRequestService.constructTextureResponse(textureResponse, request, lookup.source),
model: textureResponse.model,
};
}
return new Response(STEVE_SKIN, {
return {
texture: new Response(STEVE_SKIN, {
status: 404,
headers: {
'X-Crafthead-Profile-Cache-Hit': 'not-found',
},
}),
};
}
return {
texture: new Response(STEVE_SKIN, {
status: 404,
headers: {
'X-Crafthead-Profile-Cache-Hit': 'not-found',
'X-Crafthead-Skin-Model': 'default',
},
});
}
return new Response(STEVE_SKIN, {
}),
};
}

return {
texture: new Response(STEVE_SKIN, {
status: 404,
headers: {
'X-Crafthead-Profile-Cache-Hit': 'not-found',
'X-Crafthead-Skin-Model': 'default',
'X-Crafthead-Profile-Cache-Hit': 'offline-mode',
},
});
}

return new Response(STEVE_SKIN, {
status: 404,
headers: {
'X-Crafthead-Profile-Cache-Hit': 'offline-mode',
'X-Crafthead-Skin-Model': 'default',
},
});
}),
};
}

private static async constructTextureResponse(textureResponse: TextureResponse, request: CraftheadRequest, source?: string): Promise<Response> {
const buff = await textureResponse.texture.arrayBuffer();
if (buff && buff.byteLength > 0) {
const headers = new Headers(textureResponse.texture.headers);
if (!headers.has('X-Crafthead-Profile-Cache-Hit')) {
headers.set('X-Crafthead-Profile-Cache-Hit', source || 'miss');
}
return new Response(buff, {
status: 200,
headers: {
'X-Crafthead-Profile-Cache-Hit': source || 'miss',
'X-Crafthead-Skin-Model': request.model || textureResponse.model || 'default',
},
headers,
});
}
return new Response(STEVE_SKIN, {
Expand All @@ -133,30 +142,41 @@ export default class MojangRequestService {

const normalized = await this.normalizeRequest(request, gatherer);
const skin = await this.retrieveTextureDirect(normalized, gatherer, TextureKind.SKIN);
if (skin.status === 404) {
if (skin.texture.status === 404) {
// Offline mode ID (usually when we have a username and the username isn't valid)
const rawUuid = fromHex(normalized.identity);
if (Math.abs(javaHashCode(rawUuid)) % 2 === 0) {
return new Response(STEVE_SKIN, {
headers: {
'X-Crafthead-Profile-Cache-Hit': 'invalid-profile',
'X-Crafthead-Skin-Model': 'default',
},
});
}
return new Response(ALEX_SKIN, {
headers: {
'X-Crafthead-Profile-Cache-Hit': 'invalid-profile',
'X-Crafthead-Skin-Model': 'slim',
},
});
}
return skin;
if ([RequestedKind.Skin, RequestedKind.Body, RequestedKind.Bust].includes(normalized.requested)) {
skin.texture.headers.set('X-Crafthead-Skin-Model', request.model || skin.model || 'default');
}

return skin.texture;
}

async retrieveCape(request: CraftheadRequest, gatherer: PromiseGatherer): Promise<Response> {
const normalized = await this.normalizeRequest(request, gatherer);
return this.retrieveTextureDirect(normalized, gatherer, TextureKind.CAPE);
const cape = await this.retrieveTextureDirect(normalized, gatherer, TextureKind.CAPE);
if (cape.texture.status === 404) {
return new Response(EMPTY, {
status: 404,
headers: {
'X-Crafthead-Profile-Cache-Hit': 'invalid-profile',
},
});
}
return cape.texture;
}

private static async fetchTextureFromProfile(profile: MojangProfile, type: TextureKind): Promise<TextureResponse | undefined> {
Expand All @@ -180,8 +200,16 @@ export default class MojangRequestService {
throw new Error(`Unable to retrieve texture from Mojang, http status ${textureResponse.status}`);
}

const response = new Response(textureResponse.body);
const textureID = textureUrl.split('/').pop();
if (textureID) {
response.headers.set('X-Crafthead-Texture-ID', textureID);
}
//console.log('Successfully retrieved texture');
return { texture: textureResponse, model: texturesData?.SKIN?.metadata?.model };
return {
texture: response,
model: texturesData?.SKIN?.metadata?.model,
};
}
}

Expand Down
77 changes: 76 additions & 1 deletion test/worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import worker from '../src/worker/index';

const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;

describe('worker', () => {
describe('worker requests', () => {
it('responds with HTML for index', async () => {
const request = new IncomingRequest('http://crafthead.net');
const ctx = createExecutionContext();
Expand Down Expand Up @@ -202,3 +202,78 @@ describe('worker', () => {
expect(json.properties[0].name).toBe('textures');
});
});

describe('worker headers', () => {
it('responds with expected headers', async () => {
const request = new IncomingRequest('http://crafthead.net/avatar/ef6134805b6244e4a4467fbe85d65513');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.headers.get('access-control-allow-origin')).toBe('*');
expect(response.headers.get('cache-control')).toBe('max-age=14400');
expect(response.headers.get('content-type')).toBe('image/png');
expect(response.headers.get('x-crafthead-request-cache-hit')).toBe('no');
expect(response.headers.get('x-crafthead-texture-id')).toBe('9d2e80355eed693e3f0485893ef04ff6a507f3aab33f2bedb48cef56e30f67d0');
expect(response.headers.get('x-crafthead-skin-model')).toBeNull();

// make second response to check cache hit
const ctx2 = createExecutionContext();
const response2 = await worker.fetch(request, env, ctx2);
await waitOnExecutionContext(ctx2);
expect(response2.headers.get('x-crafthead-request-cache-hit')).toBe('yes');
});

it('responds with expected headers for profile', async () => {
const request = new IncomingRequest('http://crafthead.net/profile/CherryJimbo');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.headers.get('access-control-allow-origin')).toBe('*');
expect(response.headers.get('cache-control')).toBe('max-age=14400');
expect(response.headers.get('content-type')).toBe('application/json');
expect(response.headers.get('x-crafthead-request-cache-hit')).toBe('no');

// make second response to check cache hit
const ctx2 = createExecutionContext();
const response2 = await worker.fetch(request, env, ctx2);
await waitOnExecutionContext(ctx2);
expect(response2.headers.get('x-crafthead-request-cache-hit')).toBe('yes');
});

it('responds with expected headers for body', async () => {
const request = new IncomingRequest('http://crafthead.net/body/CherryJimbo');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.headers.get('access-control-allow-origin')).toBe('*');
expect(response.headers.get('cache-control')).toBe('max-age=14400');
expect(response.headers.get('content-type')).toBe('image/png');
expect(response.headers.get('x-crafthead-request-cache-hit')).toBe('no');
expect(response.headers.get('x-crafthead-skin-model')).toBe('default');
expect(response.headers.get('x-crafthead-texture-id')).toBe('9d2e80355eed693e3f0485893ef04ff6a507f3aab33f2bedb48cef56e30f67d0');

// make second response to check cache hit
const ctx2 = createExecutionContext();
const response2 = await worker.fetch(request, env, ctx2);
await waitOnExecutionContext(ctx2);
expect(response2.headers.get('x-crafthead-request-cache-hit')).toBe('yes');
});

it('responds with expected headers for body (slim)', async () => {
const request = new IncomingRequest('http://crafthead.net/body/Alex');
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
await waitOnExecutionContext(ctx);
expect(response.headers.get('access-control-allow-origin')).toBe('*');
expect(response.headers.get('cache-control')).toBe('max-age=14400');
expect(response.headers.get('content-type')).toBe('image/png');
expect(response.headers.get('x-crafthead-request-cache-hit')).toBe('no');
expect(response.headers.get('x-crafthead-skin-model')).toBe('slim');

// make second response to check cache hit
const ctx2 = createExecutionContext();
const response2 = await worker.fetch(request, env, ctx2);
await waitOnExecutionContext(ctx2);
expect(response2.headers.get('x-crafthead-request-cache-hit')).toBe('yes');
});
});
Loading