Compare commits

..

34 Commits

Author SHA1 Message Date
Azures
0e2a23ccbb
Use environment variables for endpoint URLs 2025-12-08 21:05:09 +01:00
41690f84c7 Custom endpoints
- minor change to customise textures & session server host for custom yggdrasil server
2025-12-08 21:02:48 +01:00
jomo
d6293cc73d bump version to 2.1.5 2024-02-01 22:29:09 +01:00
jomo
c155c8d098 update dependencies 2024-02-01 22:25:43 +01:00
jomo
bba004acc7 improve URL parsing
uses `new URL()` and `decodeURI()` instead of `url.parse()`
also checks that the requested file is in a subdirectory of `public/` before serving the file

fixes path traversal vulnerability GHSA-5cxq-25mp-q5f2
2024-02-01 22:24:29 +01:00
jomo
9cb32a843f strip dashes from uuids before handling them 2024-02-01 22:19:02 +01:00
jomo
e44ebda56f periodically log number of current skin and cape requests 2024-02-01 22:00:44 +01:00
jomo
fb4d24de6b bump version to 2.1.4 2020-12-12 23:46:41 +01:00
Jonathan Madeley
59f27f0769 mcuuid.net -> minecraftuuid.com 2020-12-12 23:39:13 +01:00
jomo
019ca37037 improve Dockerfile 2020-12-12 23:38:04 +01:00
jomo
56765488e0 improve test script 2020-12-12 23:37:13 +01:00
jomo
1328f98746 change old tests from usernames to uuids 2020-12-12 22:50:29 +01:00
jomo
ef4b2f8005 fix an issue with rate limiting 2020-12-12 22:49:31 +01:00
jomo
fe5ce6b688 update dependencies, remove some devDependencies 2020-12-12 22:48:57 +01:00
jomo
a6e8e6b0f9 delete travis stuff 2020-12-12 22:45:58 +01:00
jomo
29955a1765 improve mojang status message
as Mojang has removed their status page and their status API is no longer updating,
status information is now fetched from https://mc-heads.net/json/mc_status
and the warning message links to https://mc-heads.net/mcstatus

see #271, closes #272
2020-09-10 22:32:23 +02:00
jomo
265a98d404 pass on caching status information foor 3D renders
this was falsely always set to 2, indicating the skin was downloaded, even when it was cached
2020-07-13 00:41:21 +02:00
jomo
624bf0e338 don't count session_requests when SESSIONS_RATE_LIMIT is not set 2020-07-13 00:14:27 +02:00
jomo
db565f86c8 delete unnecessary files 2020-07-13 00:01:50 +02:00
jomo
0d2fe02cbc bump version to 2.1.3 2020-04-05 05:40:25 +02:00
jomo
e69b3f38fb new logo \o/ 2020-04-05 05:15:59 +02:00
jomo
22309efba9 show quotes on frontpage 2020-04-05 05:15:27 +02:00
jomo
3bd76ad918 update popular users and tools 2020-04-05 05:14:02 +02:00
jomo
22448c098b use 500 instead of 502 when using Cloudflare
Otherwise Cloudflare will replace images served with 502
with their own error page. This can only be turned off
in paid plans of Cloudflare.
2020-04-05 02:42:14 +02:00
jomo
7ad6f85aec improve regex 2020-03-30 01:23:16 +02:00
jomo
b87be6f9f3 simplify package.json 2020-03-29 20:59:33 +02:00
jomo
e0233f2899 document undocumented functions 2020-03-29 20:13:24 +02:00
jomo
14cbcae60c bump version to 2.1.2 2020-03-29 07:44:21 +02:00
jomo
eae7745758 add Content-Length, fixes #238 2020-03-29 07:43:23 +02:00
jomo
7f95a34e29 simplify http status codes, update website info 2020-03-29 07:43:23 +02:00
jomo
15a4f17560 add rate limit option for sessionserver
any outgoing requests to the sessionserver
that would exceed the configured rate limit are skipped
to prevent being blocked by CloudFront

if a texture hash is cached but outdated, the cache ttl will be bumped
as if the request succeeded, in order to lower requests in the near future
2020-03-29 07:43:23 +02:00
jomo
d967db3ad4 use environment variables for configuration 2020-03-29 07:32:39 +02:00
jomo
d81e2777d2 delete unused function 2020-03-28 23:38:20 +01:00
jomo
ea1ae64283 add 403 to expected response codes 2020-03-28 23:37:08 +01:00
34 changed files with 2352 additions and 2127 deletions

View File

@ -1,2 +0,0 @@
https://github.com/mojodna/heroku-buildpack-cairo.git
https://github.com/heroku/heroku-buildpack-nodejs.git

6
.dockerignore Normal file
View File

@ -0,0 +1,6 @@
.*
*.md
Dockerfile
LICENSE
images/
node_modules/

View File

@ -1,21 +0,0 @@
# We use EditorConfig to standardize settings between contributors
# See http://editorconfig.org for more info and plugin downloads
root = true
[*]
end_of_line = lf
insert_final_newline = false
trim_trailing_whitespace = true
[*.{js, json, yml}]
indent_style = space
indent_size = 2
charset = utf-8
[*.md]
trim_trailing_whitespace = false
[.gitignore]
# echo "filename" >> .gitignorre
insert_final_newline = true

6
.gitignore vendored
View File

@ -1,9 +1,3 @@
images/*/*.png images/*/*.png
node_modules/ node_modules/
coverage/ coverage/
.DS_Store
*.log
*.rdb
*.sublime-*
config.js
lib/public/images/sponsor.png

View File

@ -1,24 +0,0 @@
language: node_js
node_js:
- 12.16.1
sudo: false
addons:
apt:
sources:
- ubuntu-toolchain-r-test
packages:
- libcairo2-dev
- libjpeg8-dev
- libpango1.0-dev
- libgif-dev
- build-essential
- g++-4.8
script:
- npm run-script test-travis
env:
- TRAVIS=true CXX=g++-4.8
services:
- redis-server
cache:
directories:
- node_modules

View File

@ -1,27 +1,35 @@
FROM node:12-alpine FROM node:12-alpine AS builder
ARG REDIS_URL RUN apk --no-cache add git python3 build-base redis cairo-dev pango-dev jpeg-dev giflib-dev
ARG DEBUG
ARG EPHEMERAL_STORAGE
RUN apk --no-cache --virtual .build-deps add git python build-base RUN adduser -D app
RUN apk --no-cache --virtual .canvas-deps add cairo-dev pango-dev jpeg-dev giflib-dev USER app
RUN mkdir -p /crafatar/images/faces
RUN mkdir -p /crafatar/images/helms
RUN mkdir -p /crafatar/images/skins
RUN mkdir -p /crafatar/images/renders
RUN mkdir -p /crafatar/images/capes
VOLUME /crafatar/images
COPY package.json www.js crafatar/
COPY config.example.js crafatar/config.js
COPY lib/ crafatar/lib/
WORKDIR /crafatar
COPY --chown=app package.json package-lock.json /home/app/crafatar/
WORKDIR /home/app/crafatar
RUN npm install RUN npm install
EXPOSE 3000 COPY --chown=app . .
ENTRYPOINT npm start RUN mkdir -p images/faces images/helms images/skins images/renders images/capes
ARG VERBOSE_TEST
ARG DEBUG
RUN nohup redis-server & npm test
FROM node:12-alpine
RUN apk --no-cache add cairo pango jpeg giflib
RUN adduser -D app
USER app
RUN mkdir /home/app/crafatar
WORKDIR /home/app/crafatar
RUN mkdir -p images/faces images/helms images/skins images/renders images/capes
COPY --chown=app --from=builder /home/app/crafatar/node_modules/ node_modules/
COPY --chown=app package.json www.js config.js ./
COPY --chown=app lib/ lib/
VOLUME /home/app/crafatar/images
ENV NODE_ENV production
ENTRYPOINT ["npm", "start"]
EXPOSE 3000

View File

@ -1 +0,0 @@
web: npm start

View File

@ -1,8 +1,8 @@
# Crafatar [![travis](https://img.shields.io/travis/crafatar/crafatar/master.svg?style=flat-square)](https://travis-ci.org/crafatar/crafatar/) [![Coverage Status](https://img.shields.io/coveralls/crafatar/crafatar.svg?style=flat-square)](https://coveralls.io/r/crafatar/crafatar) [![Code Climate](https://img.shields.io/codeclimate/github/crafatar/crafatar.svg?style=flat-square)](https://codeclimate.com/github/crafatar/crafatar) # Crafatar
[![dependency status](https://img.shields.io/david/crafatar/crafatar.svg?style=flat-square)](https://david-dm.org/crafatar/crafatar) [![devDependency status](https://img.shields.io/david/dev/crafatar/crafatar.svg?style=flat-square)](https://david-dm.org/crafatar/crafatar#info=devDependencies) [![docs status](https://inch-ci.org/github/crafatar/crafatar.svg?branch=master&style=flat-square)](https://inch-ci.org/github/crafatar/crafatar) <img alt="logo" src="lib/public/logo.png" align="right" width="128px" height="128px">
[![travis](https://img.shields.io/travis/crafatar/crafatar/master.svg?style=flat-square)](https://travis-ci.org/crafatar/crafatar/) [![Coverage Status](https://img.shields.io/coveralls/crafatar/crafatar.svg?style=flat-square)](https://coveralls.io/r/crafatar/crafatar) [![Code Climate](https://img.shields.io/codeclimate/github/crafatar/crafatar.svg?style=flat-square)](https://codeclimate.com/github/crafatar/crafatar) [![dependency status](https://img.shields.io/david/crafatar/crafatar.svg?style=flat-square)](https://david-dm.org/crafatar/crafatar) [![devDependency status](https://img.shields.io/david/dev/crafatar/crafatar.svg?style=flat-square)](https://david-dm.org/crafatar/crafatar#info=devDependencies) [![docs status](https://inch-ci.org/github/crafatar/crafatar.svg?branch=master&style=flat-square)](https://inch-ci.org/github/crafatar/crafatar)
<img alt="logo" src="lib/public/logo.png" align="right">
<a href="https://crafatar.com">Crafatar</a> serves Minecraft avatars based on the skin for use in external applications. <a href="https://crafatar.com">Crafatar</a> serves Minecraft avatars based on the skin for use in external applications.
Inspired by <a href="https://gravatar.com">Gravatar</a> (hence the name) and <a href="https://minotar.net">Minotar</a>. Inspired by <a href="https://gravatar.com">Gravatar</a> (hence the name) and <a href="https://minotar.net">Minotar</a>.
@ -34,6 +34,14 @@ Please [visit the website](https://crafatar.com) for details.
# Installation # Installation
## Docker
```sh
docker network create crafatar
docker run --net crafatar -d --name redis redis
docker run --net crafatar -v crafatar-images:/home/app/crafatar/images -e REDIS_URL=redis://redis -p 3000:3000 crafatar/crafatar
```
## Manual ## Manual
- Install [nodejs](https://nodejs.org/) 12 (LTS) - Install [nodejs](https://nodejs.org/) 12 (LTS)
@ -44,26 +52,9 @@ Please [visit the website](https://crafatar.com) for details.
Crafatar is now available at http://0.0.0.0:3000. Crafatar is now available at http://0.0.0.0:3000.
## Docker ## Configration / Environment variables
```sh See the `config.js` file.
docker pull crafatar/crafatar
docker network create crafatar
docker run --net crafatar -d --name redis redis
docker run --net crafatar -v crafatar-images:/crafatar/images -e REDIS_URL=redis://redis -p 3000:3000 crafatar/crafatar
```
## Environment variables
| Variable | Default | Description |
| :- | :- | :- |
| `BIND` | `0.0.0.0` | Hostname to listen on |
| `PORT` | `3000` | Port to listen on |
| `DEBUG` | `false` | Enable verbose debug logging |
| `REDIS_URL` | `redis://127.0.0.1:6379` | URI of the redis server |
| `EPHEMERAL_STORAGE` | | If set, redis is flushed on start* |
\* Use this to avoid issues when you have a persistent redis database but an ephemeral storage
# Operational notes # Operational notes

View File

@ -1,30 +0,0 @@
{
"name": "Crafatar",
"description": "A blazing fast API for Minecraft faces!",
"repository": "https://github.com/crafatar/crafatar",
"keywords": [
"node",
"minecraft",
"avatar",
"redis"
],
"website": "https://crafatar.com/",
"env": {
"EPHEMERAL_STORAGE": {
"description": "Set to true if your storage is gone after deploying",
"required": false,
"value": true
}
},
"addons": [
"rediscloud"
],
"buildpacks": [
{
"url": "https://github.com/mojodna/heroku-buildpack-cairo.git"
},
{
"url": "https://github.com/heroku/heroku-buildpack-nodejs.git"
}
]
}

View File

@ -1,40 +0,0 @@
var config = {
avatars: {
min_size: 1, // for avatars
max_size: 512, // for avatars; too big values might lead to slow response time or DoS
default_size: 160 // for avatars; size to be used when no size given
},
renders: {
min_scale: 1, // for 3D rendered skins
max_scale: 10, // for 3D rendered skins; too big values might lead to slow response time or DoS
default_scale: 6 // for 3D rendered skins; scale to be used when no scale given
},
cleaner: {
interval: 600, // interval seconds to check limits
disk_limit: 524288, // min allowed free KB on disk to trigger image deletion
redis_limit: 24576, // max allowed used KB on redis to trigger redis flush
amount: 50000 // amount of skins for which all image types are deleted
},
directories: {
faces: "./images/faces/", // directory where faces are kept. must have trailing "/"
helms: "./images/helms/", // directory where helms are kept. must have trailing "/"
skins: "./images/skins/", // directory where skins are kept. must have trailing "/"
renders: "./images/renders/", // directory where rendered skins are kept. must have trailing "/"
capes: "./images/capes/" // directory where capes are kept. must have trailing "/"
},
caching: {
local: 1200, // seconds until we will check if user's skin changed. should be > 60 to comply with Mojang's rate limit
browser: 3600 // seconds until browser will request image again
},
server: {
http_timeout: 2000, // ms until connection to Mojang is dropped
debug_enabled: false, // enables logging.debug & editing index page
log_time: true // set to false if you use an external logger that provides timestamps
},
sponsor: {
sidebar: '<hr><div class="list-group"><a class="list-group-item sponsor-item" href="https://akliz.net/crafatar" target="_blank" title="Applies to all modpacks and plans for the first billing cycle only.">Save 20% on a Minecraft server with Akliz.</a></div>',
top_right: '<a href="https://akliz.net/crafatar" target="_blank" title="Crafatar is sponsored by Akliz" class="sponsor"><img src="/images/sponsor.png" alt="Akliz"></a>'
},
};
module.exports = config;

69
config.js Normal file
View File

@ -0,0 +1,69 @@
var config = {
avatars: {
// for avatars
min_size: parseInt(process.env.AVATAR_MIN) || 1,
// for avatars; large values might lead to slow response time or DoS
max_size: parseInt(process.env.AVATAR_MAX) || 512,
// for avatars; size to be used when no size given
default_size: parseInt(process.env.AVATAR_DEFAULT) || 160
},
renders: {
// for 3D rendered skins
min_scale: parseInt(process.env.RENDER_MIN) || 1,
// for 3D rendered skins; large values might lead to slow response time or DoS
max_scale: parseInt(process.env.RENDER_MAX) || 10,
// for 3D rendered skins; scale to be used when no scale given
default_scale: parseInt(process.env.RENDER_DEFAULT) || 6
},
directories: {
// directory where faces are kept. must have trailing "/"
faces: process.env.FACE_DIR || "./images/faces/",
// directory where helms are kept. must have trailing "/"
helms: process.env.HELM_DIR || "./images/helms/",
// directory where skins are kept. must have trailing "/"
skins: process.env.SKIN_DIR || "./images/skins/",
// directory where rendered skins are kept. must have trailing "/"
renders: process.env.RENDER_DIR || "./images/renders/",
// directory where capes are kept. must have trailing "/"
capes: process.env.CAPE_DIR || "./images/capes/"
},
caching: {
// seconds until we will check if user's skin changed.
// Should be > 60 to comply with Mojang's rate limit
local: parseInt(process.env.CACHE_LOCAL) || 1200,
// seconds until browser will request image again
browser: parseInt(process.env.CACHE_BROWSER) || 3600,
// If true, redis is flushed on start.
// Use this to avoid issues when you have a persistent redis database but an ephemeral storage
ephemeral: process.env.EPHEMERAL_STORAGE === "true",
// Used for information on the front page
cloudflare: process.env.CLOUDFLARE === "true"
},
// URL of your redis server
redis: process.env.REDIS_URL || 'redis://localhost:6379',
server: {
// port to listen on
port: parseInt(process.env.PORT) || 3000,
// IP address to listen on
bind: process.env.BIND || "0.0.0.0",
// ms until connection to Mojang is dropped
http_timeout: parseInt(process.env.EXTERNAL_HTTP_TIMEOUT) || 2000,
// enables logging.debug & editing index page
debug_enabled: process.env.DEBUG === "true",
// set to false if you use an external logger that provides timestamps,
log_time: process.env.LOG_TIME === "true",
// rate limit per second for outgoing requests to the Mojang session server
// requests exceeding this limit are skipped and considered failed
sessions_rate_limit: parseInt(process.env.SESSIONS_RATE_LIMIT)
},
sponsor: {
sidebar: process.env.SPONSOR_SIDE,
top_right: process.env.SPONSOR_TOP_RIGHT
},
endpoints: {
textures_url: process.env.TEXTURES_ENDPOINT || "https://textures.minecraft.net/texture/",
session_url: process.env.SESSION_ENDPOINT || "https://sessionserver.mojang.com/session/minecraft/profile/"
}
};
module.exports = config;

View File

@ -1,7 +1,6 @@
var logging = require("./logging"); var logging = require("./logging");
var node_redis = require("redis"); var node_redis = require("redis");
var config = require("../config"); var config = require("../config");
var url = require("url");
var redis = null; var redis = null;
@ -9,19 +8,10 @@ var redis = null;
// flushes redis when using ephemeral storage (e.g. Heroku) // flushes redis when using ephemeral storage (e.g. Heroku)
function connect_redis() { function connect_redis() {
logging.log("connecting to redis..."); logging.log("connecting to redis...");
// parse redis env redis = node_redis.createClient(config.redis);
var redis_env = process.env.REDISCLOUD_URL || process.env.REDIS_URL;
var redis_url = redis_env ? url.parse(redis_env) : {};
redis_url.port = redis_url.port || 6379;
redis_url.hostname = redis_url.hostname || "localhost";
// connect to redis
redis = node_redis.createClient(redis_url.port, redis_url.hostname);
if (redis_url.auth) {
redis.auth(redis_url.auth.split(":")[1]);
}
redis.on("ready", function() { redis.on("ready", function() {
logging.log("Redis connection established."); logging.log("Redis connection established.");
if (process.env.EPHEMERAL_STORAGE) { if (config.caching.ephemeral) {
logging.log("Storage is ephemeral, flushing redis"); logging.log("Storage is ephemeral, flushing redis");
redis.flushall(); redis.flushall();
} }
@ -41,35 +31,6 @@ exp.get_redis = function() {
return redis; return redis;
}; };
// updates the redis instance's server_info object
// callback: error, info object
exp.info = function(callback) {
redis.info(function(err, res) {
// parse the info command and store it in redis.server_info
// this code block was taken from mranney/node_redis#on_info_cmd
// http://git.io/LBUNbg
var lines = res.toString().split("\r\n");
var obj = {};
lines.forEach(function(line) {
var parts = line.split(":");
if (parts[1]) {
obj[parts[0]] = parts[1];
}
});
obj.versions = [];
if (obj.redis_version) {
obj.redis_version.split(".").forEach(function(num) {
obj.versions.push(+num);
});
}
redis.server_info = obj;
callback(err, redis.server_info);
});
};
// set model type to value of *slim* // set model type to value of *slim*
exp.set_slim = function(rid, userId, slim, callback) { exp.set_slim = function(rid, userId, slim, callback) {
logging.debug(rid, "setting slim for", userId, "to " + slim); logging.debug(rid, "setting slim for", userId, "to " + slim);

View File

@ -7,8 +7,8 @@ var skins = require("./skins");
var path = require("path"); var path = require("path");
var fs = require("fs"); var fs = require("fs");
// 0098cb60-fa8e-427c-b299-793cbd302c9a // 0098cb60fa8e427cb299793cbd302c9a
var valid_user_id = /^[0-9a-f-A-F-]{32,36}$/; // uuid var valid_user_id = /^[0-9a-fA-F]{32}$/; // uuid
var hash_pattern = /[0-9a-f]+$/; var hash_pattern = /[0-9a-f]+$/;
// gets the hash from the textures.minecraft.net +url+ // gets the hash from the textures.minecraft.net +url+
@ -122,13 +122,22 @@ var requests = {
cape: {} cape: {}
}; };
function push_request(userId, type, fun) { var loginterval = setInterval(function(){
var skinreqs = Object.keys(requests.skin).length;
var capereqs = Object.keys(requests.cape).length;
if (skinreqs || capereqs) {
logging.log("Currently waiting for " + skinreqs + " skin requests and " + capereqs + " cape requests.");
}
}, 1000);
// add a request for +userId+ and +type+ to the queue
function push_request(userId, type, callback) {
// avoid special properties (e.g. 'constructor') // avoid special properties (e.g. 'constructor')
var userId_safe = "!" + userId; var userId_safe = "!" + userId;
if (!requests[type][userId_safe]) { if (!requests[type][userId_safe]) {
requests[type][userId_safe] = []; requests[type][userId_safe] = [];
} }
requests[type][userId_safe].push(fun); requests[type][userId_safe].push(callback);
} }
// calls back all queued requests that match userId and type // calls back all queued requests that match userId and type
@ -162,7 +171,6 @@ function store_images(rid, userId, cache_details, type, callback) {
logging.debug(rid, "adding to request queue"); logging.debug(rid, "adding to request queue");
push_request(userId, type, callback); push_request(userId, type, callback);
} else { } else {
// add request to the queue
push_request(userId, type, callback); push_request(userId, type, callback);
networking.get_profile(rid, userId, function(err, profile) { networking.get_profile(rid, userId, function(err, profile) {
@ -176,7 +184,7 @@ function store_images(rid, userId, cache_details, type, callback) {
resume(userId, "cape", cache_err, null, false); resume(userId, "cape", cache_err, null, false);
}); });
} else { } else {
// an error occured, not caching. we can try in 60 seconds // an error occured, not caching. we can try again in 60 seconds
resume(userId, type, err, null, false); resume(userId, type, err, null, false);
} }
} else { } else {
@ -240,10 +248,13 @@ exp.get_image_hash = function(rid, userId, type, callback) {
} }
store_images(rid, userId, cache_details, type, function(store_err, new_hash, slim) { store_images(rid, userId, cache_details, type, function(store_err, new_hash, slim) {
if (store_err) { if (store_err) {
// we might have a cached hash although an error occured // an error occured, but we have a cached hash
// (e.g. Mojang servers not reachable, using outdated hash) // (e.g. Mojang servers not reachable, using outdated hash)
cache.update_timestamp(rid, userId, true, function(err2) {
callback(err2 || store_err, -1, cache_details && cached_hash, slim); // bump the TTL after hitting the rate limit
var ratelimited = store_err.code === "RATELIMIT";
cache.update_timestamp(rid, userId, !ratelimited, function(err2) {
callback(err2 || store_err, 4, cache_details && cached_hash, slim);
}); });
} else { } else {
var status = cache_details && (cached_hash === new_hash) ? 3 : 2; var status = cache_details && (cached_hash === new_hash) ? 3 : 2;
@ -321,7 +332,7 @@ function get_type(overlay, body) {
} }
// handles creations of 3D renders // handles creations of 3D renders
// callback: error, skin hash, image buffer // callback: error, status, skin hash, image buffer
exp.get_render = function(rid, userId, scale, overlay, body, callback) { exp.get_render = function(rid, userId, scale, overlay, body, callback) {
exp.get_skin(rid, userId, function(err, skin_hash, status, img, slim) { exp.get_skin(rid, userId, function(err, skin_hash, status, img, slim) {
if (!skin_hash) { if (!skin_hash) {
@ -347,7 +358,7 @@ exp.get_render = function(rid, userId, scale, overlay, body, callback) {
callback(null, 0, skin_hash, null); callback(null, 0, skin_hash, null);
} else { } else {
fs.writeFile(renderpath, drawn_img, "binary", function(write_err) { fs.writeFile(renderpath, drawn_img, "binary", function(write_err) {
callback(write_err, 2, skin_hash, drawn_img); callback(write_err, status, skin_hash, drawn_img);
}); });
} }
}); });
@ -384,4 +395,8 @@ exp.get_cape = function(rid, userId, callback) {
}); });
}; };
exp.stoplog = function() {
clearInterval(loginterval);
}
module.exports = exp; module.exports = exp;

View File

@ -23,16 +23,20 @@ function log(level, args, logger) {
} }
} }
// log with INFO level
exp.log = function() { exp.log = function() {
log(" INFO", arguments); log(" INFO", arguments);
}; };
// log with WARN level
exp.warn = function() { exp.warn = function() {
log(" WARN", arguments, console.warn); log(" WARN", arguments, console.warn);
}; };
// log with ERROR level
exp.error = function() { exp.error = function() {
log("ERROR", arguments, console.error); log("ERROR", arguments, console.error);
}; };
if (config.server.debug_enabled || process.env.DEBUG === "true") { // log with DEBUG level if debug logging is enabled
if (config.server.debug_enabled) {
exp.debug = function() { exp.debug = function() {
log("DEBUG", arguments); log("DEBUG", arguments);
}; };

View File

@ -1,80 +1,121 @@
var http_code = require("http").STATUS_CODES;
var logging = require("./logging"); var logging = require("./logging");
var request = require("request"); var request = require("request");
var config = require("../config"); var config = require("../config");
var skins = require("./skins"); var skins = require("./skins");
var http = require("http");
require("./object-patch"); require("./object-patch");
var session_url = "https://sessionserver.mojang.com/session/minecraft/profile/"; var session_url = config.endpoints.session_url;
var textures_url = "http://textures.minecraft.net/texture/"; var textures_url = config.endpoints.textures_url;
// count requests made to session_url in the last 1000ms
var session_requests = [];
var exp = {}; var exp = {};
// returns the amount of outgoing session requests made in the last 1000ms
function req_count() {
var index = session_requests.findIndex((i) => i >= Date.now() - 1000);
if (index >= 0) {
return session_requests.length - index;
} else {
return 0;
}
}
// deletes all entries in session_requests, should be called every 1000ms
exp.resetCounter = function() {
var count = req_count();
if (count) {
var logfunc = count >= config.server.sessions_rate_limit ? logging.warn : logging.debug;
logfunc('Clearing old session requests (count was ' + count + ')');
session_requests.splice(0, session_requests.length - count);
} else {
session_requests = []
}
}
// performs a GET request to the +url+ // performs a GET request to the +url+
// +options+ object includes these options: // +options+ object includes these options:
// encoding (string), default is to return a buffer // encoding (string), default is to return a buffer
// callback: the body, response, // callback: the body, response,
// and error buffer. get_from helper method is available // and error buffer. get_from helper method is available
exp.get_from_options = function(rid, url, options, callback) { exp.get_from_options = function(rid, url, options, callback) {
request.get({ var is_session_req = config.server.sessions_rate_limit && url.startsWith(session_url);
url: url,
headers: {
"User-Agent": "Crafatar (+https://crafatar.com)"
},
timeout: config.server.http_timeout,
followRedirect: false,
encoding: options.encoding || null,
}, function(error, response, body) {
// log url + code + description
var code = response && response.statusCode;
var logfunc = code && code < 405 ? logging.debug : logging.warn; // This is to prevent being blocked by CloudFront for exceeding the rate limit
logfunc(rid, url, code || error && error.code, http_code[code]); if (is_session_req && req_count() >= config.server.sessions_rate_limit) {
var e = new Error("Skipped, rate limit exceeded");
// not necessarily used
var e = new Error(code);
e.name = "HTTP"; e.name = "HTTP";
e.code = "HTTPERROR"; e.code = "RATELIMIT";
switch (code) { var response = new http.IncomingMessage();
case 200: response.statusCode = 403;
case 301:
case 302: // never seen, but mojang might use it in future
case 307: // never seen, but mojang might use it in future
case 308: // never seen, but mojang might use it in future
// these are okay
break;
case 204: // no content, used like 404 by mojang. making sure it really has no content
case 404:
// can be cached as null
body = null;
break;
case 429: // this shouldn't usually happen, but occasionally does
case 500:
case 502: // CloudFront can't reach mojang origin
case 503:
case 504:
// we don't want to cache this
error = error || e;
body = null;
break;
default:
if (!error) {
// Probably 500 or the likes
logging.error(rid, "Unexpected response:", code, body);
}
error = error || e;
body = null;
break;
}
if (body && !body.length) { callback(null, response, e);
// empty response } else {
body = null; is_session_req && session_requests.push(Date.now());
} request.get({
url: url,
headers: {
"User-Agent": "Crafatar (+https://crafatar.com)"
},
timeout: config.server.http_timeout,
followRedirect: false,
encoding: options.encoding || null,
}, function(error, response, body) {
// log url + code + description
var code = response && response.statusCode;
callback(body, response, error); var logfunc = code && (code < 400 || code === 404) ? logging.debug : logging.warn;
}); logfunc(rid, url, code || error && error.code, http.STATUS_CODES[code]);
// not necessarily used
var e = new Error(code);
e.name = "HTTP";
e.code = "HTTPERROR";
switch (code) {
case 200:
case 301:
case 302: // never seen, but mojang might use it in future
case 307: // never seen, but mojang might use it in future
case 308: // never seen, but mojang might use it in future
// these are okay
break;
case 204: // no content, used like 404 by mojang. making sure it really has no content
case 404:
// can be cached as null
body = null;
break;
case 403: // Blocked by CloudFront :(
case 429: // this shouldn't usually happen, but occasionally does
case 500:
case 502: // CloudFront can't reach mojang origin
case 503:
case 504:
// we don't want to cache this
error = error || e;
body = null;
break;
default:
if (!error) {
// Probably 500 or the likes
logging.error(rid, "Unexpected response:", code, body);
}
error = error || e;
body = null;
break;
}
if (body && !body.length) {
// empty response
body = null;
}
callback(body, response, error);
});
}
}; };
// helper method for get_from_options, no options required // helper method for get_from_options, no options required

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 B

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,34 +1,64 @@
var valid_user_id = /^[0-9a-f-A-F-]{32,36}$/; // uuid var valid_user_id = /^[0-9a-f-A-F-]{32,36}$/; // uuid
var xhr = new XMLHttpRequest();
xhr.onload = function() { var quotes = [
var response = JSON.parse(xhr.responseText); ["Crafatar is the best at what it does.", "Shotbow Network", "https://twitter.com/ShotbowNetwork/status/565201303555829762"],
var status = {}; ["Crafatar seems to stand out from others", "Dabsunter", "https://github.com/crafatar/crafatar/wiki/What-people-say-about-Crafatar"],
response.map(function(elem) { ["I cant tell you how much Crafatar helped me along the way! You guys do some amazing work.", "Luke Chatton", "https://github.com/lukechatton"],
var key = Object.keys(elem)[0]; ["It's just awesome! Keep up the good work", "Dannyps", "https://forums.spongepowered.org/t/title-cant-be-empty/4964/22"],
status[key] = elem[key]; ["It's one of the few services that actually does HTTP header caching correctly", "confuser", "https://github.com/BanManagement/BanManager-WebUI/issues/16#issuecomment-73230674"],
}); ["It's so beautiful. &lt;3", "FerusGrim", "https://twitter.com/FerusGrim/status/642824817683656704"],
["Love it! It's great!", "Reddit User", "https://reddit.com/comments/2nth0j/-/cmh5771"],
["Such a useful service!", "Tim Z, NameMC", "https://twitter.com/CoderTimZ/status/602682146793349120"],
["Thanks for providing us with such a reliable service :)", "BeanBlockz", "https://twitter.com/BeanBlockz/status/743927789422845952"],
["This is excellent for my website! Good work.", "cyanide43", "https://reddit.com/comments/2nth0j/-/cmgpq85"],
["This is really cool!", "AlexWebber", "https://forums.spongepowered.org/t/crafatar-a-new-minecraft-avatar-service/4964/19"],
["This really is looking amazing. Absolutely love it!", "Enter_", "https://forums.spongepowered.org/t/crafatar-a-new-minecraft-avatar-service/4964/21"],
["We couldn't believe how flawless your API is, Good job!", "SenceServers", "https://twitter.com/SenceServers/status/697132506626265089"],
["WOW, Crafatar is FAST", "Rileriscool", "https://twitter.com/rileriscool/status/562057234986065921"],
["You deserve way more popularity", "McSlushie", "https://github.com/crafatar/crafatar/wiki/Credit/a8f37373531b1d2c2cb3557ba809542a2ed81626"],
["You do excellent work on Crafatar and are awesome! A very polished, concise & clean project.", "DrCorporate", "https://reddit.com/comments/2r1ns6/-/cnbq5f1"]
];
// shuffle quotes
for (i = quotes.length -1; i > 0; i--) {
var a = Math.floor(Math.random() * i);
var b = quotes[i];
quotes[i] = quotes[a];
quotes[a] = b;
}
var textures_err = status["textures.minecraft.net"] !== "green"; var current_quote = 0;
var session_err = status["sessionserver.mojang.com"] !== "green";
function changeQuote() {
var elem = document.querySelector("#quote");
var quote = quotes[current_quote];
elem.innerHTML = "<b>“" + quote[0] + "”</b><br>― <i>" + quote[1] + "</i>";
elem.href = quote[2];
current_quote = (current_quote + 1) % quotes.length;
}
fetch('https://mc-heads.net/json/mc_status').then(r => r.json()).then(data => {
var textures_err = data.report.skins.status !== "up";
var session_err = data.report.session.status !== "up";
if (textures_err || session_err) { if (textures_err || session_err) {
var warn = document.createElement("div"); var warn = document.createElement("div");
warn.setAttribute("class", "alert alert-warning"); warn.setAttribute("class", "alert alert-warning");
warn.setAttribute("role", "alert"); warn.setAttribute("role", "alert");
warn.innerHTML = "<h5>Mojang issues</h5> Mojang's servers are having trouble <i>right now</i>, this may affect requests at Crafatar. <small><a href=\"https://help.mojang.com\" target=\"_blank\">check status</a>"; warn.innerHTML = "<h5>Mojang issues</h5> Mojang's servers are having trouble <i>right now</i>, this may affect requests at Crafatar. <small><a href=\"https://mc-heads.net/mcstatus\" target=\"_blank\">check status</a>";
document.querySelector("#alerts").appendChild(warn); document.querySelector("#alerts").appendChild(warn);
} }
}; });
document.addEventListener("DOMContentLoaded", function(event) { document.addEventListener("DOMContentLoaded", function(event) {
var avatars = document.querySelector("#avatar-wrapper"); var avatars = document.querySelector("#avatar-wrapper");
// shuffle avatars
for (var i = 0; i < avatars.children.length; i++) { for (var i = 0; i < avatars.children.length; i++) {
// shake 'em on down!
// https://stackoverflow.com/a/11972692/2517068
avatars.appendChild(avatars.children[Math.random() * i | 0]); avatars.appendChild(avatars.children[Math.random() * i | 0]);
} }
setInterval(changeQuote, 5000);
changeQuote();
var tryit = document.querySelector("#tryit"); var tryit = document.querySelector("#tryit");
var tryname = document.querySelector("#tryname"); var tryname = document.querySelector("#tryname");
var images = document.querySelectorAll(".tryit"); var images = document.querySelectorAll(".tryit");
@ -44,7 +74,4 @@ document.addEventListener("DOMContentLoaded", function(event) {
images[j].src = images[j].dataset.src.replace("$", value); images[j].src = images[j].dataset.src.replace("$", value);
} }
}; };
xhr.open("GET", "https://status.mojang.com/check", true);
xhr.send();
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 691 B

After

Width:  |  Height:  |  Size: 8.3 KiB

View File

@ -35,7 +35,6 @@ a.forkme:hover {
a.sponsor { a.sponsor {
position: fixed; position: fixed;
z-index: 1041;
width: 48px; width: 48px;
height: 48px; height: 48px;
right: 0px; right: 0px;
@ -43,6 +42,11 @@ a.sponsor {
margin: 5px; margin: 5px;
} }
.sponsor img {
width: 100%;
height: 100%;
}
a.sponsor-item { a.sponsor-item {
color: #aa7100 !important; color: #aa7100 !important;
font-weight: initial; font-weight: initial;
@ -54,6 +58,22 @@ a.sponsor-item {
background: #fff8ec !important; background: #fff8ec !important;
} }
#quote-wrapper {
line-height: 9.5em;
}
#quote {
display: inline-block;
vertical-align: middle;
line-height: initial;
background: #d4e7ff;
border-color: #94cbfc;
}
#quote:hover {
background: #dcedff;
}
.alert { .alert {
font-size: 1rem; font-size: 1rem;
} }

View File

@ -3,17 +3,18 @@ var config = require("../config");
var crc = require("crc").crc32; var crc = require("crc").crc32;
var human_status = { var human_status = {
"-2": "user error", // e.g. invalid size "-2": "user error", // e.g. invalid size
"-1": "server error", // e.g. mojang/network issues "-1": "server error", // e.g. mojang/network issues
0: "none", // cached as null (user has no skin) 0: "none", // cached as null (user has no skin)
1: "cached", // found on disk 1: "cached", // found on disk
2: "downloaded", // profile downloaded, skin downloaded from mojang servers 2: "downloaded", // profile downloaded, skin downloaded from mojang servers
3: "checked", // profile re-downloaded (was too old), has no skin or skin cached 3: "checked", // profile re-downloaded (was too old), has no skin or skin cached
4: "server error;cached" // tried to check but ran into server error, using cached version
}; };
// print these, but without stacktrace // print these, but without stacktrace
var silent_errors = ["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "EHOSTUNREACH", "ECONNREFUSED", "HTTPERROR"]; var silent_errors = ["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "EHOSTUNREACH", "ECONNREFUSED", "HTTPERROR", "RATELIMIT"];
// handles HTTP responses // handles HTTP responses
// +request+ a http.IncomingMessage // +request+ a http.IncomingMessage
@ -30,6 +31,7 @@ module.exports = function(request, response, result) {
// These headers are the same for every response // These headers are the same for every response
var headers = { var headers = {
"Content-Type": result.body && result.type || "text/plain", "Content-Type": result.body && result.type || "text/plain",
"Content-Length": Buffer.from(result.body || "").length,
"Cache-Control": "max-age=" + config.caching.browser, "Cache-Control": "max-age=" + config.caching.browser,
"Response-Time": Date.now() - request.start, "Response-Time": Date.now() - request.start,
"X-Request-ID": request.id, "X-Request-ID": request.id,
@ -83,13 +85,30 @@ module.exports = function(request, response, result) {
if (result.status === -2) { if (result.status === -2) {
response.writeHead(result.code || 422, headers); response.writeHead(result.code || 422, headers);
} else if (result.status === -1) { } else if (result.status === -1) {
// 500 responses shouldn't be cached // server errors shouldn't be cached
headers["Cache-Control"] = "private, max-age=0, no-cache"; headers["Cache-Control"] = "no-cache, max-age=0";
response.writeHead(result.code || 500, headers); if (result.body && result.hash && !result.hash.startsWith("mhf_")) {
headers["Warning"] = '110 Crafatar "Response is Stale"'
headers["Etag"] = etag;
result.code = result.code || 200;
}
if (result.err && result.err.code === "ENOENT") {
result.code = result.code || 500;
}
if (!result.code) {
// Don't use 502 on Cloudflare
// As they will show their own error page instead
// https://support.cloudflare.com/hc/en-us/articles/200172706
result.code = config.caching.cloudflare ? 500 : 502;
}
response.writeHead(result.code, headers);
} else { } else {
if (result.body) { if (result.body) {
headers.Etag = etag; if (result.status === 4) {
response.writeHead(result.status === 2 ? 201 : 200, headers); headers["Warning"] = '111 Crafatar "Revalidation Failed"'
}
headers["Etag"] = etag;
response.writeHead(200, headers);
} else { } else {
response.writeHead(404, headers); response.writeHead(404, headers);
} }

View File

@ -14,12 +14,10 @@ function handle_default(img_status, userId, size, def, req, err, callback) {
if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") { if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") {
if (helpers.id_valid(def)) { if (helpers.id_valid(def)) {
// clean up the old URL to match new image // clean up the old URL to match new image
var parsed = req.url; req.url.searchParams.delete('default');
delete parsed.query.default; req.url.path_list[1] = def;
delete parsed.search; req.url.pathname = req.url.path_list.join('/');
parsed.path_list[1] = def; var newUrl = req.url.toString();
parsed.pathname = "/" + parsed.path_list.join("/");
var newUrl = url.format(parsed);
callback({ callback({
status: img_status, status: img_status,
redirect: newUrl, redirect: newUrl,
@ -53,9 +51,9 @@ function handle_default(img_status, userId, size, def, req, err, callback) {
// GET avatar request // GET avatar request
module.exports = function(req, callback) { module.exports = function(req, callback) {
var userId = (req.url.path_list[1] || "").split(".")[0]; var userId = (req.url.path_list[1] || "").split(".")[0];
var size = parseInt(req.url.query.size) || config.avatars.default_size; var size = parseInt(req.url.searchParams.get("size")) || config.avatars.default_size;
var def = req.url.query.default; var def = req.url.searchParams.get("default");
var overlay = Object.prototype.hasOwnProperty.call(req.url.query, "overlay") || Object.prototype.hasOwnProperty.call(req.url.query, "helm"); var overlay = req.url.searchParams.has("overlay") || req.url.searchParams.has("helm");
// check for extra paths // check for extra paths
if (req.url.path_list.length > 2) { if (req.url.path_list.length > 2) {
@ -67,6 +65,9 @@ module.exports = function(req, callback) {
return; return;
} }
// strip dashes
userId = userId.replace(/-/g, "");
// Prevent app from crashing/freezing // Prevent app from crashing/freezing
if (size < config.avatars.min_size || size > config.avatars.max_size) { if (size < config.avatars.min_size || size > config.avatars.max_size) {
// "Unprocessable Entity", valid request, but semantically erroneous: // "Unprocessable Entity", valid request, but semantically erroneous:
@ -84,9 +85,6 @@ module.exports = function(req, callback) {
return; return;
} }
// strip dashes
userId = userId.replace(/-/g, "");
try { try {
helpers.get_avatar(req.id, userId, overlay, size, function(err, status, image, hash) { helpers.get_avatar(req.id, userId, overlay, size, function(err, status, image, hash) {
if (err) { if (err) {

View File

@ -4,7 +4,7 @@ var cache = require("../cache");
// GET cape request // GET cape request
module.exports = function(req, callback) { module.exports = function(req, callback) {
var userId = (req.url.path_list[1] || "").split(".")[0]; var userId = (req.url.path_list[1] || "").split(".")[0];
var def = req.url.query.default; var def = req.url.searchParams.get('default');
var rid = req.id; var rid = req.id;
// check for extra paths // check for extra paths
@ -17,6 +17,8 @@ module.exports = function(req, callback) {
return; return;
} }
// strip dashes
userId = userId.replace(/-/g, "");
if (!helpers.id_valid(userId)) { if (!helpers.id_valid(userId)) {
callback({ callback({
status: -2, status: -2,
@ -25,9 +27,6 @@ module.exports = function(req, callback) {
return; return;
} }
// strip dashes
userId = userId.replace(/-/g, "");
try { try {
helpers.get_cape(rid, userId, function(err, hash, status, image) { helpers.get_cape(rid, userId, function(err, hash, status, image) {
if (err) { if (err) {

View File

@ -7,6 +7,7 @@ var ejs = require("ejs");
var str; var str;
var index; var index;
// pre-compile the index page
function compile() { function compile() {
logging.log("Compiling index page"); logging.log("Compiling index page");
str = read(path.join(__dirname, "..", "views", "index.html.ejs"), "utf-8"); str = read(path.join(__dirname, "..", "views", "index.html.ejs"), "utf-8");

View File

@ -17,12 +17,10 @@ function handle_default(rid, scale, overlay, body, img_status, userId, size, def
if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") { if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") {
if (helpers.id_valid(def)) { if (helpers.id_valid(def)) {
// clean up the old URL to match new image // clean up the old URL to match new image
var parsed = req.url; req.url.searchParams.delete('default');
delete parsed.query.default; req.url.path_list[2] = def;
delete parsed.search; req.url.pathname = req.url.path_list.join('/');
parsed.path_list[2] = def; var newUrl = req.url.toString();
parsed.pathname = "/" + parsed.path_list.join("/");
var newUrl = url.format(parsed);
callback({ callback({
status: img_status, status: img_status,
redirect: newUrl, redirect: newUrl,
@ -62,9 +60,9 @@ module.exports = function(req, callback) {
var rid = req.id; var rid = req.id;
var body = raw_type === "body"; var body = raw_type === "body";
var userId = (req.url.path_list[2] || "").split(".")[0]; var userId = (req.url.path_list[2] || "").split(".")[0];
var def = req.url.query.default; var def = req.url.searchParams.get("default");
var scale = parseInt(req.url.query.scale) || config.renders.default_scale; var scale = parseInt(req.url.searchParams.get("scale")) || config.renders.default_scale;
var overlay = Object.prototype.hasOwnProperty.call(req.url.query, "overlay") || Object.prototype.hasOwnProperty.call(req.url.query, "helm"); var overlay = req.url.searchParams.has("overlay") || req.url.searchParams.has("helm");
// check for extra paths // check for extra paths
if (req.url.path_list.length > 3) { if (req.url.path_list.length > 3) {
@ -85,6 +83,9 @@ module.exports = function(req, callback) {
return; return;
} }
// strip dashes
userId = userId.replace(/-/g, "");
if (scale < config.renders.min_scale || scale > config.renders.max_scale) { if (scale < config.renders.min_scale || scale > config.renders.max_scale) {
callback({ callback({
status: -2, status: -2,
@ -99,9 +100,6 @@ module.exports = function(req, callback) {
return; return;
} }
// strip dashes
userId = userId.replace(/-/g, "");
try { try {
helpers.get_render(rid, userId, scale, overlay, body, function(err, status, hash, image) { helpers.get_render(rid, userId, scale, overlay, body, function(err, status, hash, image) {
if (err) { if (err) {

View File

@ -14,12 +14,10 @@ function handle_default(img_status, userId, def, req, err, callback) {
if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") { if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") {
if (helpers.id_valid(def)) { if (helpers.id_valid(def)) {
// clean up the old URL to match new image // clean up the old URL to match new image
var parsed = req.url; req.url.searchParams.delete('default');
delete parsed.query.default; req.url.path_list[1] = def;
delete parsed.search; req.url.pathname = req.url.path_list.join('/');
parsed.path_list[1] = def; var newUrl = req.url.toString();
parsed.pathname = "/" + parsed.path_list.join("/");
var newUrl = url.format(parsed);
callback({ callback({
status: img_status, status: img_status,
redirect: newUrl, redirect: newUrl,
@ -62,7 +60,7 @@ function handle_default(img_status, userId, def, req, err, callback) {
// GET skin request // GET skin request
module.exports = function(req, callback) { module.exports = function(req, callback) {
var userId = (req.url.path_list[1] || "").split(".")[0]; var userId = (req.url.path_list[1] || "").split(".")[0];
var def = req.url.query.default; var def = req.url.searchParams.get("default");
var rid = req.id; var rid = req.id;
// check for extra paths // check for extra paths
@ -75,6 +73,8 @@ module.exports = function(req, callback) {
return; return;
} }
// strip dashes
userId = userId.replace(/-/g, "");
if (!helpers.id_valid(userId)) { if (!helpers.id_valid(userId)) {
callback({ callback({
status: -2, status: -2,
@ -83,9 +83,6 @@ module.exports = function(req, callback) {
return; return;
} }
// strip dashes
userId = userId.replace(/-/g, "");
try { try {
helpers.get_skin(rid, userId, function(err, hash, status, image, slim) { helpers.get_skin(rid, userId, function(err, hash, status, image, slim) {
if (err) { if (err) {

View File

@ -1,6 +1,7 @@
#!/usr/bin/env node #!/usr/bin/env node
var querystring = require("querystring"); var querystring = require("querystring");
var response = require("./response"); var response = require("./response");
var helpers = require("./helpers.js");
var toobusy = require("toobusy-js"); var toobusy = require("toobusy-js");
var logging = require("./logging"); var logging = require("./logging");
var config = require("../config"); var config = require("../config");
@ -21,24 +22,33 @@ var routes = {
// serves assets from lib/public // serves assets from lib/public
function asset_request(req, callback) { function asset_request(req, callback) {
var filename = path.join(__dirname, "public", req.url.path_list.join("/")); const filename = path.join(__dirname, "public", ...req.url.path_list);
fs.access(filename, function(fs_err) { const relative = path.relative(path.join(__dirname, "public"), filename);
if (!fs_err) { if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
fs.readFile(filename, function(err, data) { fs.access(filename, function(fs_err) {
callback({ if (!fs_err) {
body: data, fs.readFile(filename, function(err, data) {
type: mime.getType(filename), callback({
err: err, body: data,
type: mime.getType(filename),
err: err,
});
}); });
}); } else {
} else { callback({
callback({ body: "Not found",
body: "Not found", status: -2,
status: -2, code: 404,
code: 404, });
}); }
} });
}); } else {
callback({
body: "Forbidden",
status: -2,
code: 403,
});
}
} }
// generates a 12 character random string // generates a 12 character random string
@ -46,26 +56,18 @@ function request_id() {
return Math.random().toString(36).substring(2, 14); return Math.random().toString(36).substring(2, 14);
} }
// splits a URL path into an Array // splits decoded URL path into an Array
// the path is resolved and decoded
function path_list(pathname) { function path_list(pathname) {
// remove double and trailing slashes
pathname = pathname.replace(/\/\/+/g, "/").replace(/(.)\/$/, "$1");
var list = pathname.split("/"); var list = pathname.split("/");
list.shift(); list.shift();
for (var i = 0; i < list.length; i++) {
// URL decode
list[i] = querystring.unescape(list[i]);
}
return list; return list;
} }
// handles the +req+ by routing to the request to the appropriate module // handles the +req+ by routing to the request to the appropriate module
function requestHandler(req, res) { function requestHandler(req, res) {
req.url = url.parse(req.url, true); req.url = new URL(decodeURI(req.url), 'http://' + req.headers.host);
req.url.query = req.url.query || {}; req.url.pathname = path.resolve('/', req.url.pathname);
req.url.path_list = path_list(req.url.pathname); req.url.path_list = path_list(req.url.pathname);
req.id = request_id(); req.id = request_id();
req.start = Date.now(); req.start = Date.now();
@ -135,9 +137,10 @@ function requestHandler(req, res) {
var exp = {}; var exp = {};
// Start the server
exp.boot = function(callback) { exp.boot = function(callback) {
var port = process.env.PORT || 3000; var port = config.server.port;
var bind_ip = process.env.BIND || "0.0.0.0"; var bind_ip = config.server.bind;
server = http.createServer(requestHandler).listen(port, bind_ip, function() { server = http.createServer(requestHandler).listen(port, bind_ip, function() {
logging.log("Server running on http://" + bind_ip + ":" + port + "/"); logging.log("Server running on http://" + bind_ip + ":" + port + "/");
if (callback) { if (callback) {
@ -149,7 +152,7 @@ exp.boot = function(callback) {
// wait for established connections to finish (30s max), // wait for established connections to finish (30s max),
// then exit // then exit
process.on("SIGTERM", function() { process.on("SIGTERM", function() {
logging.warn("Got SIGTERM, no longer accepting connections!"); logging.warn("Got SIGTERM, no longer accepting new connections!");
setTimeout(function() { setTimeout(function() {
logging.error("Dropping connections after 30s. Force quit."); logging.error("Dropping connections after 30s. Force quit.");
@ -163,7 +166,9 @@ exp.boot = function(callback) {
}); });
}; };
// Close the server
exp.close = function(callback) { exp.close = function(callback) {
helpers.stoplog();
server.close(callback); server.close(callback);
}; };

View File

@ -3,7 +3,7 @@
<head> <head>
<title>Crafatar A blazing fast API for Minecraft faces!</title> <title>Crafatar A blazing fast API for Minecraft faces!</title>
<meta charset="utf-8"> <meta charset="utf-8">
<link rel="icon" sizes="16x16" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/stylesheets/bootstrap.min.css"> <link rel="stylesheet" href="/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="/stylesheets/style.css"> <link rel="stylesheet" href="/stylesheets/style.css">
<meta name="description" content="A blazing fast API for Minecraft faces with support for avatars, skins, and 3D renders!"> <meta name="description" content="A blazing fast API for Minecraft faces with support for avatars, skins, and 3D renders!">
@ -76,6 +76,7 @@
</div> </div>
</div> </div>
</form> </form>
<p>You can use <a rel="nofollow" target="_blank" href="https://minecraftuuid.com">minecraftuuid.com</a> to find the UUID of a username.</p>
</section> </section>
<section id="avatars"> <section id="avatars">
@ -210,10 +211,12 @@
<h3><a href="#meta-caching">About Caching</a></h3> <h3><a href="#meta-caching">About Caching</a></h3>
<p> <p>
Crafatar checks for skin updates every <%= config.caching.local / 60 %> minutes.<br> Crafatar checks for skin updates every <%= config.caching.local / 60 %> minutes.<br>
Images are cached in your browser for <%= config.caching.browser / 60 %> minutes until a new request to Crafatar is made.<br> Images are also cached in your browser for <%= config.caching.browser / 60 %> minutes unless you clear your browser cache.
In addition, <span title="A CDN and caching proxy">CloudFlare</span> caches up to 2 hours on a per-url basis. <% if (config.caching.cloudflare) { %>
<br>In addition, <span title="A CDN and caching proxy">Cloudflare</span> may cache images as long as your browser would.
<% } %>
</p> </p>
<p>When you changed your skin you can try clearing your browser cache to see the change faster.</p> <p>After changing your Minecraft skin, you can try clearing your browser cache to see the change faster.</p>
</section> </section>
<section id="meta-cors"> <section id="meta-cors">
@ -224,11 +227,30 @@
<section id="meta-http-headers"> <section id="meta-http-headers">
<h3><a href="#meta-http-headers">HTTP Headers</a></h3> <h3><a href="#meta-http-headers">HTTP Headers</a></h3>
<p> <p>
Responses come with some custom HTTP headers, useful for debugging.<br> Crafatar always replies with a <code>200 OK</code> status code when the requested user's skin/cape was found. This is also used in some rare cases when Mojang servers are having issues and the image couldn't be checked for changes, but Crafatar still had a cached version.
Please note that these headers may be cached by <span title="A CDN and caching proxy">CloudFlare</span>. <% if (config.caching.cloudflare) { %>
<code>500 Server Error</code> is used when no skin/cape was found because of Mojang or Crafatar server issues.
<% } else { %>
<code>502 Bad Gateway</code> and <code>500 Server Error</code> are used when no skin/cape was found because of Mojang or Crafatar server issues.
<% } %>
</p>
<p>
Note that requests are usually answered with an image (with Steve/Alex skin), even if an error occured!
</p>
<p>
Responses come with some HTTP headers that are useful for debugging.
<% if (config.caching.cloudflare) { %>
<br>Please note that these headers may be cached by <span title="A CDN and caching proxy">Cloudflare</span>.
<% } %>
</p> </p>
<ul> <ul>
<li>
<b>Warning</b>: When using a cached image after an error occured. One of:
<ul>
<li><code>110 Crafatar "Response is Stale"</code></li>
<li><code>111 Crafatar "Revalidation Failed"</code></li>
</ul>
<li> <li>
<b>X-Storage-Type</b>: Details about how the requested image was stored on the server <b>X-Storage-Type</b>: Details about how the requested image was stored on the server
<ul> <ul>
@ -237,52 +259,56 @@
<li><b>checked</b>: Requested skin details, skin cached. (1 external request)<br> <li><b>checked</b>: Requested skin details, skin cached. (1 external request)<br>
This happens either when the user removed their skin or when it didn't change.</li> This happens either when the user removed their skin or when it didn't change.</li>
<li><b>downloaded</b>: Requested skin details, skin downloaded. (2 external requests)</li> <li><b>downloaded</b>: Requested skin details, skin downloaded. (2 external requests)</li>
<li><b>server error</b>: This can happen, for example, when Mojang's servers are down.<br> <li><b>server error</b>: This can happen, for example, when Mojang's servers are down.</li>
If possible, a cached image is served instead.</li> <li><b>server error;cached</b>: Same as server error, but a cached skin was available.</li>
<li><b>user error</b>: You have done something wrong, such as requesting a malformed uuid.<br> <li><b>user error</b>: You have done something wrong, such as requesting a malformed uuid.<br>
Check the response body for details.</li> Check the response body for details.</li>
</ul> </ul>
<li> <li>
<b>X-Request-ID</b>: The internal ID assigned to this request.<br> <b>X-Request-ID</b>: The internal ID assigned to this request.<br>
If you think something is wrong with your request, please <a href="#contact">contact us</a> and provide this ID. If you think something is wrong with your request, please contact us and provide this ID.
<li>
<b>Response-Time</b>: How long it took Crafatar to answer the request, in ms.
</ul> </ul>
</section> </section>
</section> </section>
<section id="contact">
<h2><a href="#contact">Contact</a></h2>
<ul>
<li>Follow us on twitter <a href="https://twitter.com/crafatar" target="_blank">@crafatar</a></li>
<li>Open an issue <a href="https://github.com/crafatar/crafatar/issues" target="_blank">on GitHub</a></li>
<li><a href="https://webchat.esper.net/?channels=crafatar" target="_blank">Join us</a> in <a href="irc://irc.esper.net/crafatar">#crafatar</a> on irc.esper.net</li>
</ul>
</section>
</section>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<h4>Popular Crafatar users</h4> <h4>Popular Crafatar users</h4>
<div class="list-group"> <div class="list-group">
<a rel="nofollow" href="http://technicpack.net" target="_blank" class="list-group-item">Technic</a>
<a rel="nofollow" href="https://hypixel.net" target="_blank" class="list-group-item">Hypixel</a> <a rel="nofollow" href="https://hypixel.net" target="_blank" class="list-group-item">Hypixel</a>
<a rel="nofollow" href="https://www.minecraft-index.com" target="_blank" class="list-group-item">MC Index</a> <a rel="nofollow" href="https://mineplex.com" target="_blank" class="list-group-item">Mineplex</a>
<a rel="nofollow" href="https://www.minehq.com/hcteams/leaderboards" target="_blank" class="list-group-item">MineHQ</a> <a rel="nofollow" href="https://hivemc.com" target="_blank" class="list-group-item">The Hive</a>
<a rel="nofollow" href="https://shotbow.net" target="_blank" class="list-group-item">Shotbow</a> <a rel="nofollow" href="https://www.technicpack.net" target="_blank" class="list-group-item">Technic Pack</a>
<a rel="nofollow" href="https://namemc.com" target="_blank" class="list-group-item">NameMC</a>
<a rel="nofollow" href="https://mcuuid.net/" target="_blank" class="list-group-item">MCUUID</a> <a rel="nofollow" href="https://mcuuid.net/" target="_blank" class="list-group-item">MCUUID</a>
<a href="https://github.com/crafatar/crafatar/wiki/Who-uses-crafatar%3F" target="_blank" class="list-group-item">and many more…</a> <a href="https://github.com/crafatar/crafatar/wiki/Who-uses-crafatar%3F" target="_blank" class="list-group-item">and many more…</a>
</div> </div>
<p>See also: <a rel="nofollow" href="https://github.com/crafatar/crafatar/wiki/What-people-say-about-Crafatar" target="_blank">what users say</a> about Crafatar</p> <hr>
<h4>Quotes</h4>
<div id="quote-wrapper" class="list-group">
<a id="quote" rel="nofollow" target="_blank" class="list-group-item"></a>
</div>
<p>See <a rel="nofollow" href="https://github.com/crafatar/crafatar/wiki/What-people-say-about-Crafatar" target="_blank">all quotes</a>.</p>
<hr> <hr>
<h4>Crafatar Tools & Plugins</h4> <h4>Crafatar Tools & Plugins</h4>
<div class="list-group"> <div class="list-group">
<a rel="nofollow" href="https://github.com/DiscordSRV/DiscordSRV#readme" target="_blank" class="list-group-item">DiscordSRV</a>
<a rel="nofollow" href="https://github.com/the-obsidian/discourse-minecraft-avatar" target="_blank" class="list-group-item">Discourse Minecraft Avatar</a>
<a rel="nofollow" href="https://xenforo.com/community/resources/associationmc.3232/" target="_blank" class="list-group-item">AssociationMc <i>(XenForo)</i></a> <a rel="nofollow" href="https://xenforo.com/community/resources/associationmc.3232/" target="_blank" class="list-group-item">AssociationMc <i>(XenForo)</i></a>
<a rel="nofollow" href="https://github.com/yeahwhat-mc/discourse-yeahwhat" target="_blank" class="list-group-item">Minecraft Heads <i>(Discourse)</i></a> <a rel="nofollow" href="https://open.vanillaforums.com/addon/crafatar-plugin" target="_blank" class="list-group-item">Crafatar Avatars <i>(Vanilla)</i></a>
<a rel="nofollow" href="http://vanillaforums.org/addon/crafatar-plugin" target="_blank" class="list-group-item">Crafatar Avatars <i>(Vanilla)</i></a> <a rel="nofollow" href="https://www.spigotmc.org/resources/picture-login.4514/" target="_blank" class="list-group-item">Picture Login <i>(Spigot/Bukkit)</i></a>
<a rel="nofollow" href="https://www.spigotmc.org/resources/picture-login.4514/" target="_blank" class="list-group-item">Picture Login <i>(Bukkit)</i></a>
<a rel="nofollow" href="https://github.com/sk89q/Plumeria" target="_blank" class="list-group-item">Plumeria <i>(Discord)</i></a>
<a href="https://github.com/crafatar/crafatar/wiki/Who-uses-crafatar%3F#other-services-using-crafatar" target="_blank" class="list-group-item">and many more…</a> <a href="https://github.com/crafatar/crafatar/wiki/Who-uses-crafatar%3F#other-services-using-crafatar" target="_blank" class="list-group-item">and many more…</a>
</div> </div>
<hr>
<h4>Contact</h4>
<div class="list-group">
<a class="list-group-item" href="https://twitter.com/crafatar" target="_blank">@crafatar on Twitter</a>
<a class="list-group-item" href="https://github.com/crafatar/crafatar/issues" target="_blank">Issue tracker</a>
</div>
<% if (config.sponsor.sidebar) { %> <% if (config.sponsor.sidebar) { %>
<hr>
<%- config.sponsor.sidebar %> <%- config.sponsor.sidebar %>
<% } %> <% } %>
</div> </div>
@ -295,4 +321,4 @@
</div> </div>
</footer> </footer>
</body> </body>
</html> </html>

3380
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,52 +1,25 @@
{ {
"name": "crafatar", "name": "crafatar",
"version": "2.1.1", "version": "2.1.5",
"private": true, "private": true,
"description": "A blazing fast API for Minecraft faces!",
"contributors": [
{
"name": "jomo",
"url": "https://github.com/jomo"
},
{
"name": "Jake",
"url": "https://github.com/Jake0oo0"
}
],
"repository": {
"type": "git",
"url": "https://github.com/crafatar/crafatar"
},
"issues": {
"url": "https://github.com/crafatar/crafatar/issues"
},
"keywords": [
"minecraft",
"avatar"
],
"scripts": { "scripts": {
"postinstall": "cp 'config.example.js' 'config.js'",
"start": "node www.js", "start": "node www.js",
"test": "mocha", "test": "mocha"
"test-travis": "istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage"
}, },
"engines": { "engines": {
"node": "12.16.1" "node": "12.16.1"
}, },
"dependencies": { "dependencies": {
"@randy.tarampi/lwip": "^1.3.1",
"canvas": "^2.6.1", "canvas": "^2.6.1",
"crc": "^3.8.0", "crc": "^3.8.0",
"ejs": "^3.0.1", "ejs": "^3.1.5",
"@randy.tarampi/lwip": "^1.1.0", "mime": "^2.4.6",
"mime": "^2.4.4",
"redis": "^3.0.2", "redis": "^3.0.2",
"request": "^2.88.2", "request": "^2.88.2",
"toobusy-js": "^0.5.1" "toobusy-js": "^0.5.1"
}, },
"devDependencies": { "devDependencies": {
"coveralls": "^3.0.11", "mocha": "^7.2.0"
"istanbul": "^0.4.5",
"mocha": "^7.1.1",
"mocha-lcov-reporter": "^1.3.0"
} }
} }

View File

@ -1,36 +1,55 @@
#!/usr/bin/env bash #!/usr/bin/env bash
hostname="crafatar.com"
async="true" async="true"
random="false"
interval="0.1" interval="0.1"
if [ "$1" = "-s" ]; then
async=""
shift
elif [ "$1" = "-i" ]; then
interval="$2"
shift 2
fi
host="$1"
shift
if [ -z "$host" ] || [ ! -z "$@" ]; then
echo "Usage: $0 [-s | -i <interval>] <host uri>"
exit 1
fi
# insert newline after uuids usage() {
ids="$(cat 'uuids.txt')" echo "Usage: $0 [-s | -r | -i <interval> | -h <hostname>]... <host uri>" >&2
# `brew install coreutils` on OS X exit 1
ids="$(shuf <<< "$ids" 2>/dev/null || gshuf <<< "$ids")" }
get_ids() {
local shuf
if [ "$random" = "true" ]; then
while true; do uuid -v 4; done
else
# `brew install coreutils` on OS X for gshuf
shuf=$(command -v shuf gshuf)
# randomize ids
$shuf < uuids.txt
fi
}
bulk() { bulk() {
trap return INT trap return INT # return from this function on Ctrl+C
echo "$ids" | while read id; do get_ids | while read id; do
if [ -z "$async" ]; then if [ "$async" = "false" ]; then
curl -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" -- "$host/avatars/$id?overlay" curl -H "Host: $hostname" -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" -- "$host/avatars/$id?overlay"
else else
curl -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" -- "$host/avatars/$id?overlay" & curl -H "Host: $hostname" -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" -- "$host/avatars/$id?overlay" &
sleep "$interval" sleep "$interval"
fi fi
done done
} }
while [ $# != 0 ]; do
case "$1" in
-s)
async="false";;
-r)
random="true";;
-i)
interval="$2"
shift;;
*)
[ -n "$host" ] && usage
host="$1";;
esac
shift
done
[ -z "$host" ] && usage
time bulk time bulk

View File

@ -3,7 +3,7 @@
// no spam // no spam
var logging = require("../lib/logging"); var logging = require("../lib/logging");
if (process.env.VERBOSE_TEST !== "true" && process.env.TRAVIS !== "true") { if (process.env.VERBOSE_TEST !== "true") {
logging.log = logging.debug = logging.warn = logging.error = function() {}; logging.log = logging.debug = logging.warn = logging.error = function() {};
} }
@ -88,8 +88,8 @@ describe("Crafatar", function() {
assert.strictEqual(helpers.id_valid("1DCEF164FF0A47F2B9A691385C774EE7"), true); assert.strictEqual(helpers.id_valid("1DCEF164FF0A47F2B9A691385C774EE7"), true);
done(); done();
}); });
it("dashed uuid is valid", function(done) { it("dashed uuid is not valid", function(done) {
assert.strictEqual(helpers.id_valid("0098cb60-fa8e-427c-b299-793cbd302c9a"), true); assert.strictEqual(helpers.id_valid("0098cb60-fa8e-427c-b299-793cbd302c9a"), false);
done(); done();
}); });
it("username is invalid", function(done) { it("username is invalid", function(done) {
@ -158,7 +158,7 @@ describe("Crafatar", function() {
it("should time out on skin download", function(done) { it("should time out on skin download", function(done) {
var original_timeout = config.http_timeout; var original_timeout = config.http_timeout;
config.server.http_timeout = 1; config.server.http_timeout = 1;
networking.get_from(rid(), "http://textures.minecraft.net/texture/477be35554684c28bdeee4cf11c591d3c88afb77e0b98da893fd7bc318c65184", function(body, res, error) { networking.get_from(rid(), config.endpoints.textures_url + "477be35554684c28bdeee4cf11c591d3c88afb77e0b98da893fd7bc318c65184", function(body, res, error) {
assert.notStrictEqual(["ETIMEDOUT", "ESOCKETTIMEDOUT"].indexOf(error.code), -1); assert.notStrictEqual(["ETIMEDOUT", "ESOCKETTIMEDOUT"].indexOf(error.code), -1);
config.server.http_timeout = original_timeout; config.server.http_timeout = original_timeout;
done(); done();
@ -166,7 +166,7 @@ describe("Crafatar", function() {
}); });
it("should not find the skin", function(done) { it("should not find the skin", function(done) {
assert.doesNotThrow(function() { assert.doesNotThrow(function() {
networking.get_from(rid(), "http://textures.minecraft.net/texture/this-does-not-exist", function(img, response, err) { networking.get_from(rid(), config.endpoints.textures_url + "this-does-not-exist", function(img, response, err) {
assert.strictEqual(err, null); // no error here, but it shouldn't throw exceptions assert.strictEqual(err, null); // no error here, but it shouldn't throw exceptions
done(); done();
}); });
@ -258,7 +258,7 @@ describe("Crafatar", function() {
var url = "http://localhost:3000/%61%76%61%74%61%72%73/%61%65%37%39%35%61%61%38%36%33%32%37%34%30%38%65%39%32%61%62%32%35%63%38%61%35%39%66%33%62%61%31"; // avatars/ae795aa86327408e92ab25c8a59f3ba1 var url = "http://localhost:3000/%61%76%61%74%61%72%73/%61%65%37%39%35%61%61%38%36%33%32%37%34%30%38%65%39%32%61%62%32%35%63%38%61%35%39%66%33%62%61%31"; // avatars/ae795aa86327408e92ab25c8a59f3ba1
request.get(url, function(error, res, body) { request.get(url, function(error, res, body) {
assert.ifError(error); assert.ifError(error);
assert.strictEqual(res.statusCode, 201); assert.strictEqual(res.statusCode, 200);
assert_headers(res); assert_headers(res);
assert(res.headers.etag); assert(res.headers.etag);
assert.strictEqual(res.headers["content-type"], "image/png"); assert.strictEqual(res.headers["content-type"], "image/png");
@ -281,7 +281,7 @@ describe("Crafatar", function() {
function req() { function req() {
request.get(url, function(error, res, body) { request.get(url, function(error, res, body) {
assert.ifError(error); assert.ifError(error);
assert.strictEqual(res.statusCode === 201 || res.statusCode === 200, true); assert.strictEqual(res.statusCode, 200);
assert_headers(res); assert_headers(res);
assert(res.headers.etag); assert(res.headers.etag);
assert.strictEqual(res.headers["content-type"], "image/png"); assert.strictEqual(res.headers["content-type"], "image/png");
@ -298,20 +298,24 @@ describe("Crafatar", function() {
var server_tests = { var server_tests = {
"avatar with existing uuid": { "avatar with existing uuid": {
url: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16", url: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
crc32: [3337292777], crc32: [4264176600],
},
"avatar with existing dashed uuid": {
url: "http://localhost:3000/avatars/853c80ef-3c37-49fd-aa49938b674adae6?size=16",
crc32: [4264176600],
}, },
"avatar with non-existent uuid": { "avatar with non-existent uuid": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16", url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16",
crc32: [2416827277, 1243826040], crc32: [3348154329],
}, },
"avatar with non-existent uuid defaulting to mhf_alex": { "avatar with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=mhf_alex", url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=mhf_alex",
crc32: [862751081, 809395677], crc32: [73899130],
}, },
"avatar with non-existent uuid defaulting to uuid": { "avatar with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6", url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0], crc32: [0],
redirect: "/avatars/853c80ef3c3749fdaa49938b674adae6?size=16", redirect: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
}, },
"avatar with non-existent uuid defaulting to url": { "avatar with non-existent uuid defaulting to url": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=http%3A%2F%2Fexample.com%2FCaseSensitive", url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
@ -320,20 +324,20 @@ describe("Crafatar", function() {
}, },
"overlay avatar with existing uuid": { "overlay avatar with existing uuid": {
url: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16&overlay", url: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16&overlay",
crc32: [1710265722], crc32: [575355728],
}, },
"overlay avatar with non-existent uuid": { "overlay avatar with non-existent uuid": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay", url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay",
crc32: [2416827277, 1243826040], crc32: [3348154329],
}, },
"overlay avatar with non-existent uuid defaulting to mhf_alex": { "overlay avatar with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay&default=mhf_alex", url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay&default=mhf_alex",
crc32: [862751081, 809395677], crc32: [73899130],
}, },
"overlay avatar with non-existent uuid defaulting to uuid": { "overlay avatar with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6", url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0], crc32: [0],
redirect: "/avatars/853c80ef3c3749fdaa49938b674adae6?size=16", redirect: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
}, },
"overlay avatar with non-existent uuid defaulting to url": { "overlay avatar with non-existent uuid defaulting to url": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive", url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
@ -342,7 +346,7 @@ describe("Crafatar", function() {
}, },
"cape with existing uuid": { "cape with existing uuid": {
url: "http://localhost:3000/capes/853c80ef3c3749fdaa49938b674adae6", url: "http://localhost:3000/capes/853c80ef3c3749fdaa49938b674adae6",
crc32: [2556702429], crc32: [985789174, 2099310578],
}, },
"cape with non-existent uuid": { "cape with non-existent uuid": {
url: "http://localhost:3000/capes/00000000000000000000000000000000", url: "http://localhost:3000/capes/00000000000000000000000000000000",
@ -355,20 +359,20 @@ describe("Crafatar", function() {
}, },
"skin with existing uuid": { "skin with existing uuid": {
url: "http://localhost:3000/skins/853c80ef3c3749fdaa49938b674adae6", url: "http://localhost:3000/skins/853c80ef3c3749fdaa49938b674adae6",
crc32: [26500336], crc32: [1759176487],
}, },
"skin with non-existent uuid": { "skin with non-existent uuid": {
url: "http://localhost:3000/skins/00000000000000000000000000000000", url: "http://localhost:3000/skins/00000000000000000000000000000000",
crc32: [981937087], crc32: [1853029228],
}, },
"skin with non-existent uuid defaulting to mhf_alex": { "skin with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/skins/00000000000000000000000000000000?default=mhf_alex", url: "http://localhost:3000/skins/00000000000000000000000000000000?default=mhf_alex",
crc32: [2298915739], crc32: [427506205],
}, },
"skin with non-existent uuid defaulting to uuid": { "skin with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/skins/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6", url: "http://localhost:3000/skins/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0], crc32: [0],
redirect: "/skins/853c80ef3c3749fdaa49938b674adae6?size=16", redirect: "http://localhost:3000/skins/853c80ef3c3749fdaa49938b674adae6?size=16",
}, },
"skin with non-existent uuid defaulting to url": { "skin with non-existent uuid defaulting to url": {
url: "http://localhost:3000/skins/00000000000000000000000000000000?default=http%3A%2F%2Fexample.com%2FCaseSensitive", url: "http://localhost:3000/skins/00000000000000000000000000000000?default=http%3A%2F%2Fexample.com%2FCaseSensitive",
@ -390,7 +394,7 @@ describe("Crafatar", function() {
"head render with non-existent uuid defaulting to uuid": { "head render with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=853c80ef3c3749fdaa49938b674adae6", url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0], crc32: [0],
redirect: "/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2", redirect: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2",
}, },
"head render with non-existent uuid defaulting to url": { "head render with non-existent uuid defaulting to url": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=http%3A%2F%2Fexample.com%2FCaseSensitive", url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
@ -412,7 +416,7 @@ describe("Crafatar", function() {
"overlay head with non-existent uuid defaulting to uuid": { "overlay head with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&overlay&default=853c80ef3c3749fdaa49938b674adae6", url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&overlay&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0], crc32: [0],
redirect: "/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay=", redirect: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay=",
}, },
"overlay head render with non-existent uuid defaulting to url": { "overlay head render with non-existent uuid defaulting to url": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive", url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
@ -421,7 +425,7 @@ describe("Crafatar", function() {
}, },
"body render with existing uuid": { "body render with existing uuid": {
url: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2", url: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2",
crc32: [2745192436], crc32: [1144887125],
}, },
"body render with non-existent uuid": { "body render with non-existent uuid": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2", url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2",
@ -429,12 +433,12 @@ describe("Crafatar", function() {
}, },
"body render with non-existent uuid defaulting to mhf_alex": { "body render with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=mhf_alex", url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=mhf_alex",
crc32: [1255106465], crc32: [4280894468],
}, },
"body render with non-existent uuid defaulting to uuid": { "body render with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=853c80ef3c3749fdaa49938b674adae6", url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0], crc32: [0],
redirect: "/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2", redirect: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2",
}, },
"body render with non-existent uuid defaulting to url": { "body render with non-existent uuid defaulting to url": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=http%3A%2F%2Fexample.com%2FCaseSensitive", url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
@ -443,7 +447,7 @@ describe("Crafatar", function() {
}, },
"overlay body render with existing uuid": { "overlay body render with existing uuid": {
url: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay", url: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay",
crc32: [2441671793], crc32: [1107696668],
}, },
"overlay body render with non-existent uuid": { "overlay body render with non-existent uuid": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay", url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay",
@ -451,7 +455,7 @@ describe("Crafatar", function() {
}, },
"overlay body render with non-existent uuid defaulting to mhf_alex": { "overlay body render with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay&default=mhf_alex", url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay&default=mhf_alex",
crc32: [1255106465], crc32: [4280894468],
}, },
"overlay body render with non-existent uuid defaulting to url": { "overlay body render with non-existent uuid defaulting to url": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive", url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
@ -489,7 +493,7 @@ describe("Crafatar", function() {
done(); done();
} else { } else {
assert.strictEqual(res.headers["content-type"], "image/png"); assert.strictEqual(res.headers["content-type"], "image/png");
assert.strictEqual(res.statusCode, res.headers["x-storage-type"] === "downloaded" ? 201 : 200); assert.strictEqual(res.statusCode, 200);
assert(res.headers.etag); assert(res.headers.etag);
assert.strictEqual(res.headers.etag, '"' + hash + '"'); assert.strictEqual(res.headers.etag, '"' + hash + '"');
assert_cache(location.url, res.headers.etag, function() { assert_cache(location.url, res.headers.etag, function() {
@ -535,7 +539,7 @@ describe("Crafatar", function() {
}); });
it("should return a 422 (invalid render type)", function(done) { it("should return a 422 (invalid render type)", function(done) {
request.get("http://localhost:3000/renders/invalid/Jake_0", function(error, res, body) { request.get("http://localhost:3000/renders/invalid/2d5aa9cdaeb049189930461fc9b91cc5", function(error, res, body) {
assert.ifError(error); assert.ifError(error);
assert.strictEqual(res.statusCode, 422); assert.strictEqual(res.statusCode, 422);
done(); done();
@ -564,6 +568,30 @@ describe("Crafatar", function() {
}); });
}(loc)); }(loc));
} }
it("should return /public resources", function(done) {
request.get("http://localhost:3000/javascript/crafatar.js", function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 200);
done();
});
});
it("should not allow path traversal on /public", function(done) {
request.get("http://localhost:3000/../server.js", function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 404);
done();
});
});
it("should not allow encoded path traversal on /public", function(done) {
request.get("http://localhost:3000/%2E%2E/server.js", function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 404);
done();
});
});
}); });
// we have to make sure that we test both a 32x64 and 64x64 skin // we have to make sure that we test both a 32x64 and 64x64 skin
@ -584,7 +612,7 @@ describe("Crafatar", function() {
describe("Networking: Cape", function() { describe("Networking: Cape", function() {
it("should not fail (guaranteed cape)", function(done) { it("should not fail (guaranteed cape)", function(done) {
helpers.get_cape(rid(), "Dinnerbone", function(err, hash, status, img) { helpers.get_cape(rid(), "61699b2ed3274a019f1e0ea8c3f06bc6", function(err, hash, status, img) {
assert.strictEqual(err, null); assert.strictEqual(err, null);
done(); done();
}); });
@ -593,13 +621,13 @@ describe("Crafatar", function() {
before(function() { before(function() {
cache.get_redis().flushall(); cache.get_redis().flushall();
}); });
helpers.get_cape(rid(), "Dinnerbone", function(err, hash, status, img) { helpers.get_cape(rid(), "61699b2ed3274a019f1e0ea8c3f06bc6", function(err, hash, status, img) {
assert.strictEqual(err, null); assert.strictEqual(err, null);
done(); done();
}); });
}); });
it("should not be found", function(done) { it("should not be found", function(done) {
helpers.get_cape(rid(), "Jake_0", function(err, hash, status, img) { helpers.get_cape(rid(), "2d5aa9cdaeb049189930461fc9b91cc5", function(err, hash, status, img) {
assert.ifError(err); assert.ifError(err);
assert.strictEqual(img, null); assert.strictEqual(img, null);
done(); done();
@ -609,7 +637,7 @@ describe("Crafatar", function() {
describe("Networking: Skin", function() { describe("Networking: Skin", function() {
it("should not fail", function(done) { it("should not fail", function(done) {
helpers.get_cape(rid(), "Jake_0", function(err, hash, status, img) { helpers.get_cape(rid(), "2d5aa9cdaeb049189930461fc9b91cc5", function(err, hash, status, img) {
assert.strictEqual(err, null); assert.strictEqual(err, null);
done(); done();
}); });
@ -618,7 +646,7 @@ describe("Crafatar", function() {
before(function() { before(function() {
cache.get_redis().flushall(); cache.get_redis().flushall();
}); });
helpers.get_cape(rid(), "Jake_0", function(err, hash, status, img) { helpers.get_cape(rid(), "2d5aa9cdaeb049189930461fc9b91cc5", function(err, hash, status, img) {
assert.strictEqual(err, null); assert.strictEqual(err, null);
done(); done();
}); });
@ -682,15 +710,28 @@ describe("Crafatar", function() {
describe("Errors", function() { describe("Errors", function() {
before(function() { before(function() {
cache.get_redis().flushall(); cache.get_redis().flushall();
}); });
it("uuid SHOULD be rate limited", function(done) { // Mojang has changed its rate limiting, so we no longer expect to hit the rate limit
// it("uuid SHOULD be rate limited", function(done) {
// networking.get_profile(rid(), uuid, function() {
// networking.get_profile(rid(), uuid, function(err, profile) {
// assert.strictEqual(err.toString(), "HTTP: 429");
// assert.strictEqual(profile, null);
// done();
// });
// });
// });
it("CloudFront rate limit is handled", function(done) {
var original_rate_limit = config.server.sessions_rate_limit;
config.server.sessions_rate_limit = 1;
networking.get_profile(rid(), uuid, function() { networking.get_profile(rid(), uuid, function() {
networking.get_profile(rid(), uuid, function(err, profile) { networking.get_profile(rid(), uuid, function(err, profile) {
assert.strictEqual(err.toString(), "HTTP: 429"); assert.strictEqual(err.code, "RATELIMIT");
assert.strictEqual(profile, null); config.server.sessions_rate_limit = original_rate_limit;
done(); done();
}); });
}); });

View File

@ -4424,4 +4424,4 @@ ffdd082bf54e415b943a8713f2885913
ffe0be5f0cab4b3785f67974c23660bb ffe0be5f0cab4b3785f67974c23660bb
ffe3d4c861354928b932794d85a30567 ffe3d4c861354928b932794d85a30567
ffe72a222ac9463d81d3ee5eafb7f68e ffe72a222ac9463d81d3ee5eafb7f68e
fff854a189644f12b92764fdb4573f8b fff854a189644f12b92764fdb4573f8b

3
www.js
View File

@ -1,3 +1,4 @@
var networking = require("./lib/networking");
var logging = require("./lib/logging"); var logging = require("./lib/logging");
var config = require("./config"); var config = require("./config");
@ -6,4 +7,6 @@ process.on("uncaughtException", function(err) {
process.exit(1); process.exit(1);
}); });
setInterval(networking.resetCounter, 1000);
require("./lib/server.js").boot(); require("./lib/server.js").boot();