Compare commits

..

No commits in common. "master" and "v1.0.0" have entirely different histories.

65 changed files with 2814 additions and 5696 deletions

2
.buildpacks Normal file
View File

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

View File

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

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
# 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,3 +1,9 @@
images/*/*.png images/*/*.png
*.log
node_modules/ node_modules/
.DS_Store
*.rdb
coverage/ coverage/
modules/config.js
undefined*.png
*.sublime-*

17
.travis.yml Normal file
View File

@ -0,0 +1,17 @@
language: node_js
node_js:
- "iojs-v1.3"
before_script:
- cp "modules/config.example.js" "modules/config.js"
before_install:
- sudo apt-get install libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++
notifications:
irc:
channels:
- "irc.esper.net#crafatar"
skip_join: true
services:
- redis-server
cache:
directories:
- node_modules

View File

@ -1,35 +0,0 @@
FROM node:12-alpine AS builder
RUN apk --no-cache add git python3 build-base redis cairo-dev pango-dev jpeg-dev giflib-dev
RUN adduser -D app
USER app
COPY --chown=app package.json package-lock.json /home/app/crafatar/
WORKDIR /home/app/crafatar
RUN npm install
COPY --chown=app . .
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,6 +1,6 @@
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2020 Crafatar Team Copyright (c) 2015 Jake0oo0
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

1
Procfile Normal file
View File

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

View File

@ -1,85 +1,65 @@
# Crafatar # Crafatar [![travis](https://img.shields.io/travis/crafatar/crafatar.svg?style=flat)](https://travis-ci.org/crafatar/crafatar/) [![Coverage Status](https://img.shields.io/coveralls/crafatar/crafatar.svg?style=flat)](https://coveralls.io/r/crafatar/crafatar) [![Code Climate](https://codeclimate.com/github/crafatar/crafatar/badges/gpa.svg)](https://codeclimate.com/github/crafatar/crafatar)
<img alt="logo" src="lib/public/logo.png" align="right" width="128px" height="128px"> [![dependency status](https://img.shields.io/david/crafatar/crafatar.svg?style=flat)](https://david-dm.org/crafatar/crafatar) [![devDependency status](https://img.shields.io/david/dev/crafatar/crafatar.svg?style=flat)](https://david-dm.org/crafatar/crafatar#info=devDependencies)
[![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) https://crafatar.com
<a href="https://crafatar.com">Crafatar</a> serves Minecraft avatars based on the skin for use in external applications. Crafatar 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 [Gravatar](https://gravatar.com) (hence the name) and [Minotar](https://minotar.net).
Image manipulation is done by [lwip](https://github.com/EyalAr/lwip). 3D renders are created with [node-canvas](https://github.com/Automattic/node-canvas) / [cairo](http://cairographics.org/). Image manipulation is done by [lwip](https://github.com/EyalAr/lwip). 3D renders are created with [node-canvas](https://github.com/Automattic/node-canvas), based on math by [confuser](https://github.com/confuser/serverless-mc-skin-viewer).
# Contributions welcome!
There are usually a few [open issues](https://github.com/crafatar/crafatar/issues).
We welcome any opinions or advice in discussions as well as pull requests.
Issues tagged with [![help wanted](https://i.imgur.com/kkozGKY.png "help wanted")](https://github.com/crafatar/crafatar/labels/help%20wanted) show where we could especially need your help!
# Examples
| | | | |
| :---: | :---: | :---: | :---: |
| ![jomo's avatar](https://crafatar.com/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=128) | ![Jake_0's avatar](https://crafatar.com/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=128) | ![Notch's avatar](https://crafatar.com/avatars/069a79f444e94726a5befca90e38aaf5?size=128) | ![sk89q's avatar](https://crafatar.com/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=128) | ![md_5's avatar](https://crafatar.com/avatars/af74a02d19cb445bb07f6866a861f783?size=128) |
| ![jomo's 3d head](https://crafatar.com/renders/head/ae795aa86327408e92ab25c8a59f3ba1?scale=6) | ![Jake_0's 3d head](https://crafatar.com/renders/head/2d5aa9cdaeb049189930461fc9b91cc5?scale=6) | ![Notch's 3d head](https://crafatar.com/renders/head/069a79f444e94726a5befca90e38aaf5?scale=6) | ![sk89q's 3d head](https://crafatar.com/renders/head/0ea8eca3dbf647cc9d1ac64551ca975c?scale=6) | ![md_5's 3d head](https://crafatar.com/renders/head/af74a02d19cb445bb07f6866a861f783?scale=6) |
| ![jomo's 3d body](https://crafatar.com/renders/body/ae795aa86327408e92ab25c8a59f3ba1?scale=6) | ![Jake_0's 3d body](https://crafatar.com/renders/body/2d5aa9cdaeb049189930461fc9b91cc5?scale=6) | ![Notch's 3d body](https://crafatar.com/renders/body/069a79f444e94726a5befca90e38aaf5?scale=6) | ![sk89q's 3d body](https://crafatar.com/renders/body/0ea8eca3dbf647cc9d1ac64551ca975c?scale=6) | ![md_5's 3d body](https://crafatar.com/renders/body/af74a02d19cb445bb07f6866a861f783?scale=6) |
| ![jomo's skin](https://crafatar.com/skins/ae795aa86327408e92ab25c8a59f3ba1) | ![Jake_0's skin](https://crafatar.com/skins/2d5aa9cdaeb049189930461fc9b91cc5) | ![Notch's skin](https://crafatar.com/skins/069a79f444e94726a5befca90e38aaf5) | ![sk89q's skin](https://crafatar.com/skins/0ea8eca3dbf647cc9d1ac64551ca975c) | ![md_5's skin](https://crafatar.com/skins/af74a02d19cb445bb07f6866a861f783) |
![jomo's avatar](https://crafatar.com/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=128) ![Jake_0's avatar](https://crafatar.com/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=128) ![Notch's avatar](https://crafatar.com/avatars/069a79f444e94726a5befca90e38aaf5?size=128) ![sk89q's avatar](https://crafatar.com/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=128) ![md_5's avatar](https://crafatar.com/avatars/af74a02d19cb445bb07f6866a861f783?size=128)
## Usage / Documentation ## Usage / Documentation
Please [visit the website](https://crafatar.com) for details. Please [visit the website](https://crafatar.com) for details.
## Contact ## Contact
* You can [follow](https://twitter.com/crafatar) us on twitter * You can follow us on [![t](https://favicons.githubusercontent.com/twitter.com)@crafatar](https://twitter.com/crafatar)
* Open an [issue](https://github.com/crafatar/crafatar/issues/) on GitHub * Open an [issue](https://github.com/crafatar/crafatar/issues/) on GitHub
* You can [join us](https://webchat.esper.net/?channels=crafatar) in #crafatar on irc.esper.net.
# Installation ## Installation
## Docker #### Heroku
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy)
```sh #### Dokku
docker network create crafatar 0. Install the [dokku-redis](https://github.com/ohardy/dokku-redis#redis-plugin-for-dokku) plugin
docker run --net crafatar -d --name redis redis 0. `dokku redis:start`
docker run --net crafatar -v crafatar-images:/home/app/crafatar/images -e REDIS_URL=redis://redis -p 3000:3000 crafatar/crafatar 0. You also might want to use [docker-options](https://github.com/dyson/dokku-docker-options) for persistent storage:
```
## Manual ```docker
-v /var/lib/crafatar/images:/app/images
-v /var/log/crafatar:/app/logs
```
0. Deploy with ENV config:
- Install [nodejs](https://nodejs.org/) 12 (LTS) ```bash
- Install `redis-server` PORT=5000
- Run `npm install` BIND=0.0.0.0
If that fails, it's likely because because of `node-canvas` dependencies. Follow [this guide](https://github.com/Automattic/node-canvas/wiki#installation-guides) to install them. ```
- Run `npm start`
Crafatar is now available at http://0.0.0.0:3000. #### Local
* Use io.js
* [Install](https://github.com/Automattic/node-canvas/wiki) Cairo.
* `npm install`
* Start `redis-server`
* `npm start`
* Access [http://localhost:3000](http://localhost:3000)
## Configration / Environment variables
See the `config.js` file. ## Tests
```shell
# Operational notes
## inodes
Crafatar stores a lot of images on disk. For avatars, these are 8×8 px PNG images with an average file size of \~90 bytes. This can lead to issues on file systems such as ext4, which (by default) has a bytes-per-inode ratio of 16Kb. With thousands of files with an average file size below this ratio, you will run out of available inodes before running out of disk space. (Note that this will still be reported as `ENOSPC: no space left on device`).
Consider using a different file system, changing the inode ratio, or deleting files before the inode limit is reached.
## disk space and memory usage
Eventually you will run out of disk space and/or redis will be out of memory. Make sure to delete image files and/or flush redis before this happens.
# Tests
```sh
npm test npm test
``` ```
If you want to debug failing tests: If you want to debug failing tests, you can set the env
```sh ```shell
# show logs during tests VERBOSE_TEST=true
env VERBOSE_TEST=true npm test
``` ```
It can be helpful to monitor redis commands to debug caching errors: To debug caching, it can be helpful to monitor redis commands while tests are running:
```sh ```shell
redis-cli monitor redis-cli monitor
``` ```

19
app.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "Crafatar",
"description": "A Minecraft Avatar API written in NodeJS",
"repository": "https://github.com/crafatar/crafatar",
"keywords": [
"node",
"minecraft",
"avatar",
"redis"
],
"website": "https://crafatar.com/",
"env": {
"HEROKU": "true",
"BUILDPACK_URL": "https://github.com/mojodna/heroku-buildpack-multi.git#build-env"
},
"addons": [
"rediscloud"
]
}

21
bin/www.js Normal file
View File

@ -0,0 +1,21 @@
var logging = require ("../modules/logging");
var cleaner = require("../modules/cleaner");
var config = require("../modules/config");
var cluster = require("cluster");
if (cluster.isMaster) {
var cores = config.clusters || require("os").cpus().length;
logging.log("Starting " + cores + " workers");
for (var i = 0; i < cores; i++) {
cluster.fork();
}
cluster.on("exit", function (worker) {
logging.error("Worker #" + worker.id + " died. Rebooting a new one.");
setTimeout(cluster.fork, 100);
});
setInterval(cleaner.run, config.cleaning_interval * 1000);
} else {
require("../server.js").boot();
}

View File

@ -1,69 +0,0 @@
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,116 +0,0 @@
var logging = require("./logging");
var node_redis = require("redis");
var config = require("../config");
var redis = null;
// sets up redis connection
// flushes redis when using ephemeral storage (e.g. Heroku)
function connect_redis() {
logging.log("connecting to redis...");
redis = node_redis.createClient(config.redis);
redis.on("ready", function() {
logging.log("Redis connection established.");
if (config.caching.ephemeral) {
logging.log("Storage is ephemeral, flushing redis");
redis.flushall();
}
});
redis.on("error", function(err) {
logging.error(err);
});
redis.on("end", function() {
logging.warn("Redis connection lost!");
});
}
var exp = {};
// returns the redis instance
exp.get_redis = function() {
return redis;
};
// set model type to value of *slim*
exp.set_slim = function(rid, userId, slim, callback) {
logging.debug(rid, "setting slim for", userId, "to " + slim);
// store userId in lower case if not null
userId = userId && userId.toLowerCase();
redis.hmset(userId, ["a", Number(slim)], callback);
};
// sets the timestamp for +userId+
// if +temp+ is true, the timestamp is set so that the record will be outdated after 60 seconds
// these 60 seconds match the duration of Mojang's rate limit ban
// callback: error
exp.update_timestamp = function(rid, userId, temp, callback) {
logging.debug(rid, "updating cache timestamp (" + temp + ")");
var sub = temp ? config.caching.local - 60 : 0;
var time = Date.now() - sub;
// store userId in lower case if not null
userId = userId && userId.toLowerCase();
redis.hmset(userId, "t", time, function(err) {
callback(err);
});
};
// create the key +userId+, store +skin_hash+, +cape_hash+, +slim+ and current time
// if +skin_hash+ or +cape_hash+ are undefined, they aren't stored
// this is useful to store cape and skin at separate times, without overwriting the other
// +slim+ can be true (alex) or false (steve)
// +callback+ contans error
exp.save_hash = function(rid, userId, skin_hash, cape_hash, slim, callback) {
logging.debug(rid, "caching skin:" + skin_hash + " cape:" + cape_hash + " slim:" + slim);
// store shorter null value instead of "null" string
skin_hash = skin_hash === null ? "" : skin_hash;
cape_hash = cape_hash === null ? "" : cape_hash;
// store userId in lower case if not null
userId = userId && userId.toLowerCase();
var args = [];
if (cape_hash !== undefined) {
args.push("c", cape_hash);
}
if (skin_hash !== undefined) {
args.push("s", skin_hash);
}
if (slim !== undefined) {
args.push("a", Number(!!slim));
}
args.push("t", Date.now());
redis.hmset(userId, args, function(err) {
callback(err);
});
};
// removes the hash for +userId+ from the cache
exp.remove_hash = function(rid, userId) {
logging.debug(rid, "deleting hash from cache");
redis.del(userId.toLowerCase(), "h", "t");
};
// get a details object for +userId+
// {skin: "0123456789abcdef", cape: "gs1gds1g5d1g5ds1", time: 1414881524512}
// callback: error, details
// details is null when userId not cached
exp.get_details = function(userId, callback) {
// get userId in lower case if not null
userId = userId && userId.toLowerCase();
redis.hgetall(userId, function(err, data) {
var details = null;
if (data) {
details = {
skin: data.s === "" ? null : data.s,
cape: data.c === "" ? null : data.c,
slim: data.a === "1",
time: Number(data.t)
};
}
callback(err, details);
});
};
connect_redis();
module.exports = exp;

View File

@ -1,402 +0,0 @@
var networking = require("./networking");
var logging = require("./logging");
var renders = require("./renders");
var config = require("../config");
var cache = require("./cache");
var skins = require("./skins");
var path = require("path");
var fs = require("fs");
// 0098cb60fa8e427cb299793cbd302c9a
var valid_user_id = /^[0-9a-fA-F]{32}$/; // uuid
var hash_pattern = /[0-9a-f]+$/;
// gets the hash from the textures.minecraft.net +url+
function get_hash(url) {
return hash_pattern.exec(url)[0].toLowerCase();
}
// gets the skin for +userId+ with +profile+
// uses +cache_details+ to determine if the skin needs to be downloaded or can be taken from cache
// face and face+helm images are extracted and stored to files
// callback: error, skin hash, slim
function store_skin(rid, userId, profile, cache_details, callback) {
networking.get_skin_info(rid, userId, profile, function(err, url, slim) {
if (err) {
slim = cache_details ? cache_details.slim : undefined;
}
if (!err && url) {
var skin_hash = get_hash(url);
if (cache_details && cache_details.skin === skin_hash) {
cache.update_timestamp(rid, userId, false, function(cache_err) {
callback(cache_err, skin_hash, slim);
});
} else {
logging.debug(rid, "new skin hash:", skin_hash);
var facepath = path.join(config.directories.faces, skin_hash + ".png");
var helmpath = path.join(config.directories.helms, skin_hash + ".png");
var skinpath = path.join(config.directories.skins, skin_hash + ".png");
fs.access(facepath, function(fs_err) {
if (!fs_err) {
logging.debug(rid, "skin already exists, not downloading");
callback(null, skin_hash, slim);
} else {
networking.get_from(rid, url, function(img, response, err1) {
if (err1 || !img) {
callback(err1, null, slim);
} else {
skins.save_image(img, skinpath, function(skin_err) {
if (skin_err) {
callback(skin_err, null, slim);
} else {
skins.extract_face(img, facepath, function(err2) {
if (err2) {
callback(err2, null, slim);
} else {
logging.debug(rid, "face extracted");
skins.extract_helm(rid, facepath, img, helmpath, function(err3) {
logging.debug(rid, "helm extracted");
logging.debug(rid, helmpath);
callback(err3, skin_hash, slim);
});
}
});
}
});
}
});
}
});
}
} else {
callback(err, null);
}
});
}
// gets the cape for +userId+ with +profile+
// uses +cache_details+ to determine if the cape needs to be downloaded or can be taken from cache
// the cape - if downloaded - is stored to file
// callback: error, cape hash
function store_cape(rid, userId, profile, cache_details, callback) {
networking.get_cape_url(rid, userId, profile, function(err, url) {
if (!err && url) {
var cape_hash = get_hash(url);
if (cache_details && cache_details.cape === cape_hash) {
cache.update_timestamp(rid, userId, false, function(cache_err) {
callback(cache_err, cape_hash);
});
} else {
logging.debug(rid, "new cape hash:", cape_hash);
var capepath = path.join(config.directories.capes, cape_hash + ".png");
fs.access(capepath, function(fs_err) {
if (!fs_err) {
logging.debug(rid, "cape already exists, not downloading");
callback(null, cape_hash);
} else {
networking.get_from(rid, url, function(img, response, net_err) {
if (net_err || !img) {
callback(net_err, null);
} else {
skins.save_image(img, capepath, function(skin_err) {
logging.debug(rid, "cape saved");
callback(skin_err, cape_hash);
});
}
});
}
});
}
} else {
callback(err, null);
}
});
}
// used by store_images to queue simultaneous requests for identical userId
// the first request has to be completed until all others are continued
// otherwise we risk running into Mojang's rate limit and deleting the cached skin
var requests = {
skin: {},
cape: {}
};
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')
var userId_safe = "!" + userId;
if (!requests[type][userId_safe]) {
requests[type][userId_safe] = [];
}
requests[type][userId_safe].push(callback);
}
// calls back all queued requests that match userId and type
function resume(userId, type, err, hash, slim) {
var userId_safe = "!" + userId;
var callbacks = requests[type][userId_safe];
if (callbacks) {
if (callbacks.length > 1) {
logging.debug(callbacks.length, "simultaneous requests for", userId);
}
for (var i = 0; i < callbacks.length; i++) {
// continue the request
callbacks[i](err, hash, slim);
// remove from array
callbacks.splice(i, 1);
i--;
}
// it's still an empty array
delete requests[type][userId_safe];
}
}
// downloads the images for +userId+ while checking the cache
// status based on +cache_details+. +type+ specifies which
// image type should be called back on
// callback: error, image hash, slim
function store_images(rid, userId, cache_details, type, callback) {
if (requests[type]["!" + userId]) {
logging.debug(rid, "adding to request queue");
push_request(userId, type, callback);
} else {
push_request(userId, type, callback);
networking.get_profile(rid, userId, function(err, profile) {
if (err || !profile) {
// error or uuid without profile
if (!err && !profile) {
// no error, but uuid without profile
cache.save_hash(rid, userId, null, null, undefined, function(cache_err) {
// we have no profile, so we have neither skin nor cape
resume(userId, "skin", cache_err, null, false);
resume(userId, "cape", cache_err, null, false);
});
} else {
// an error occured, not caching. we can try again in 60 seconds
resume(userId, type, err, null, false);
}
} else {
// no error and we have a profile (if it's a uuid)
store_skin(rid, userId, profile, cache_details, function(store_err, skin_hash, slim) {
if (store_err && !skin_hash) {
// an error occured, not caching. we can try in 60 seconds
resume(userId, "skin", store_err, null, slim);
} else {
cache.save_hash(rid, userId, skin_hash, undefined, slim, function(cache_err) {
resume(userId, "skin", (store_err || cache_err), skin_hash, slim);
});
}
});
store_cape(rid, userId, profile, cache_details, function(store_err, cape_hash) {
if (store_err && !cape_hash) {
// an error occured, not caching. we can try in 60 seconds
resume(userId, "cape", (store_err), cape_hash, false);
} else {
cache.save_hash(rid, userId, undefined, cape_hash, undefined, function(cache_err) {
resume(userId, "cape", (store_err || cache_err), cape_hash, false);
});
}
});
}
});
}
}
var exp = {};
// returns true if the +userId+ is a valid userId
// the UUID might not exist, however
exp.id_valid = function(userId) {
return valid_user_id.test(userId);
};
// decides whether to get a +type+ image for +userId+ from disk or to download it
// callback: error, status, hash, slim
// for status, see response.js
exp.get_image_hash = function(rid, userId, type, callback) {
cache.get_details(userId, function(err, cache_details) {
var cached_hash = null;
if (cache_details !== null) {
cached_hash = type === "skin" ? cache_details.skin : cache_details.cape;
}
if (err) {
callback(err, -1, null, false);
} else {
if (cache_details && cache_details[type] !== undefined && cache_details.time + config.caching.local * 1000 >= Date.now()) {
// use cached image
logging.debug(rid, "userId cached & recently updated");
callback(null, (cached_hash ? 1 : 0), cached_hash, cache_details.slim);
} else {
// download image
if (cache_details && cache_details[type] !== undefined) {
logging.debug(rid, "userId cached, but too old");
logging.debug(rid, JSON.stringify(cache_details));
} else {
logging.debug(rid, "userId not cached");
}
store_images(rid, userId, cache_details, type, function(store_err, new_hash, slim) {
if (store_err) {
// an error occured, but we have a cached hash
// (e.g. Mojang servers not reachable, using outdated hash)
// 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 {
var status = cache_details && (cached_hash === new_hash) ? 3 : 2;
logging.debug(rid, "cached hash:", (cache_details && cached_hash));
logging.debug(rid, "new hash:", new_hash);
callback(null, status, new_hash, slim);
}
});
}
}
});
};
// handles requests for +userId+ avatars with +size+
// callback: error, status, image buffer, skin hash
// image is the user's face+overlay when overlay is true, or the face otherwise
// for status, see get_image_hash
exp.get_avatar = function(rid, userId, overlay, size, callback) {
exp.get_image_hash(rid, userId, "skin", function(err, status, skin_hash, slim) {
if (skin_hash) {
var facepath = path.join(config.directories.faces, skin_hash + ".png");
var helmpath = path.join(config.directories.helms, skin_hash + ".png");
var filepath = facepath;
fs.access(helmpath, function(fs_err) {
if (overlay && !fs_err) {
filepath = helmpath;
}
skins.resize_img(filepath, size, function(img_err, image) {
if (img_err) {
callback(img_err, -1, null, skin_hash);
} else {
status = err ? -1 : status;
callback(err, status, image, skin_hash);
}
});
});
} else {
// hash is null when userId has no skin
callback(err, status, null, null);
}
});
};
// handles requests for +userId+ skins
// callback: error, skin hash, status, image buffer, slim
exp.get_skin = function(rid, userId, callback) {
exp.get_image_hash(rid, userId, "skin", function(err, status, skin_hash, slim) {
if (skin_hash) {
var skinpath = path.join(config.directories.skins, skin_hash + ".png");
fs.access(skinpath, function(fs_err) {
if (!fs_err) {
logging.debug(rid, "skin already exists, not downloading");
skins.open_skin(rid, skinpath, function(skin_err, img) {
callback(skin_err || err, skin_hash, status, img, slim);
});
} else {
networking.save_texture(rid, skin_hash, skinpath, function(net_err, response, img) {
callback(net_err || err, skin_hash, status, img, slim);
});
}
});
} else {
callback(err, null, status, null, slim);
}
});
};
// helper method used for file names
// possible returned names based on +overlay+ and +body+ are:
// body, bodyhelm, head, headhelm
function get_type(overlay, body) {
var text = body ? "body" : "head";
return overlay ? text + "helm" : text;
}
// handles creations of 3D renders
// callback: error, status, skin hash, image buffer
exp.get_render = function(rid, userId, scale, overlay, body, callback) {
exp.get_skin(rid, userId, function(err, skin_hash, status, img, slim) {
if (!skin_hash) {
callback(err, status, skin_hash, null);
return;
}
var renderpath = path.join(config.directories.renders, [skin_hash, scale, get_type(overlay, body), slim ? "s" : "t"].join("-") + ".png");
fs.access(renderpath, function(fs_err) {
if (!fs_err) {
renders.open_render(rid, renderpath, function(render_err, rendered_img) {
callback(render_err, 1, skin_hash, rendered_img);
});
return;
} else {
if (!img) {
callback(err, 0, skin_hash, null);
return;
}
renders.draw_model(rid, img, scale, overlay, body, slim || userId.toLowerCase() === "mhf_alex", function(draw_err, drawn_img) {
if (draw_err) {
callback(draw_err, -1, skin_hash, null);
} else if (!drawn_img) {
callback(null, 0, skin_hash, null);
} else {
fs.writeFile(renderpath, drawn_img, "binary", function(write_err) {
callback(write_err, status, skin_hash, drawn_img);
});
}
});
}
});
});
};
// handles requests for +userId+ capes
// callback: error, cape hash, status, image buffer
exp.get_cape = function(rid, userId, callback) {
exp.get_image_hash(rid, userId, "cape", function(err, status, cape_hash, slim) {
if (!cape_hash) {
callback(err, null, status, null);
return;
}
var capepath = path.join(config.directories.capes, cape_hash + ".png");
fs.access(capepath, function(fs_err) {
if (!fs_err) {
logging.debug(rid, "cape already exists, not downloading");
skins.open_skin(rid, capepath, function(skin_err, img) {
callback(skin_err || err, cape_hash, status, img);
});
} else {
networking.save_texture(rid, cape_hash, capepath, function(net_err, response, img) {
if (response && response.statusCode === 404) {
callback(net_err, cape_hash, status, null);
} else {
callback(net_err, cape_hash, status, img);
}
});
}
});
});
};
exp.stoplog = function() {
clearInterval(loginterval);
}
module.exports = exp;

View File

@ -1,47 +0,0 @@
var config = require("../config");
var exp = {};
// returns all values in the +args+ object separated by " "
function join_args(args) {
var values = [];
for (var i = 0, l = args.length; i < l; i++) {
values.push(args[i]);
}
return values.join(" ");
}
// prints +args+ to +logger+ (defaults to `console.log`)
// the +level+ and a timestamp is prepended to each line of log
// the timestamp can be disabled in the config
function log(level, args, logger) {
logger = logger || console.log;
var time = config.server.log_time ? new Date().toISOString() + " " : "";
var lines = join_args(args).split("\n");
for (var i = 0, l = lines.length; i < l; i++) {
logger(time, level + ":", lines[i]);
}
}
// log with INFO level
exp.log = function() {
log(" INFO", arguments);
};
// log with WARN level
exp.warn = function() {
log(" WARN", arguments, console.warn);
};
// log with ERROR level
exp.error = function() {
log("ERROR", arguments, console.error);
};
// log with DEBUG level if debug logging is enabled
if (config.server.debug_enabled) {
exp.debug = function() {
log("DEBUG", arguments);
};
} else {
exp.debug = function() {};
}
module.exports = exp;

View File

@ -1,201 +0,0 @@
var logging = require("./logging");
var request = require("request");
var config = require("../config");
var skins = require("./skins");
var http = require("http");
require("./object-patch");
var session_url = config.endpoints.session_url;
var textures_url = config.endpoints.textures_url;
// count requests made to session_url in the last 1000ms
var session_requests = [];
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+
// +options+ object includes these options:
// encoding (string), default is to return a buffer
// callback: the body, response,
// and error buffer. get_from helper method is available
exp.get_from_options = function(rid, url, options, callback) {
var is_session_req = config.server.sessions_rate_limit && url.startsWith(session_url);
// This is to prevent being blocked by CloudFront for exceeding the rate limit
if (is_session_req && req_count() >= config.server.sessions_rate_limit) {
var e = new Error("Skipped, rate limit exceeded");
e.name = "HTTP";
e.code = "RATELIMIT";
var response = new http.IncomingMessage();
response.statusCode = 403;
callback(null, response, e);
} else {
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;
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
exp.get_from = function(rid, url, callback) {
exp.get_from_options(rid, url, {}, function(body, response, err) {
callback(body, response, err);
});
};
// gets the URL for a skin/cape from the profile
// +type+ "SKIN" or "CAPE", specifies which to retrieve
// callback: url, slim
exp.get_uuid_info = function(profile, type, callback) {
var properties = Object.get(profile, "properties") || [];
properties.forEach(function(prop) {
if (prop.name === "textures") {
var json = new Buffer.from(prop.value, "base64").toString();
profile = JSON.parse(json);
}
});
var url = Object.get(profile, "textures." + type + ".url");
var slim;
if (type === "SKIN") {
slim = Object.get(profile, "textures.SKIN.metadata.model") === "slim";
}
callback(null, url || null, !!slim);
};
// make a request to sessionserver for +uuid+
// callback: error, profile
exp.get_profile = function(rid, uuid, callback) {
exp.get_from_options(rid, session_url + uuid, { encoding: "utf8" }, function(body, response, err) {
try {
body = body ? JSON.parse(body) : null;
callback(err || null, body);
} catch(e) {
if (e instanceof SyntaxError) {
logging.warn(rid, "Failed to parse JSON", e);
logging.debug(rid, body);
callback(err || null, null);
} else {
throw e;
}
}
});
};
// get the skin URL and type for +userId+
// +profile+ is used if +userId+ is a uuid
// callback: error, url, slim
exp.get_skin_info = function(rid, userId, profile, callback) {
exp.get_uuid_info(profile, "SKIN", callback);
};
// get the cape URL for +userId+
// +profile+ is used if +userId+ is a uuid
exp.get_cape_url = function(rid, userId, profile, callback) {
exp.get_uuid_info(profile, "CAPE", callback);
};
// download the +tex_hash+ image from the texture server
// and save it in the +outpath+ file
// callback: error, response, image buffer
exp.save_texture = function(rid, tex_hash, outpath, callback) {
if (tex_hash) {
var textureurl = textures_url + tex_hash;
exp.get_from(rid, textureurl, function(img, response, err) {
if (err) {
callback(err, response, null);
} else {
skins.save_image(img, outpath, function(img_err) {
callback(img_err, response, img);
});
}
});
} else {
callback(null, null, null);
}
};
module.exports = exp;

View File

@ -1,22 +0,0 @@
// Adds Object.get function
// +pathstr+ is a string of dot-separated nested properties on +ojb+
// returns undefined if any of the properties do not exist
// returns the value of the last property otherwise
//
// Object.get({"foo": {"bar": 123}}, "foo.bar"); // 123
// Object.get({"foo": {"bar": 123}}, "bar.foo"); // undefined
Object.get = function(obj, pathstr) {
var path = pathstr.split(".");
var result = obj;
for (var i = 0; i < path.length; i++) {
var key = path[i];
if (!result || !Object.prototype.hasOwnProperty.call(result, key)) {
return undefined;
} else {
result = result[key];
}
}
return result;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,77 +0,0 @@
var valid_user_id = /^[0-9a-f-A-F-]{32,36}$/; // uuid
var quotes = [
["Crafatar is the best at what it does.", "Shotbow Network", "https://twitter.com/ShotbowNetwork/status/565201303555829762"],
["Crafatar seems to stand out from others", "Dabsunter", "https://github.com/crafatar/crafatar/wiki/What-people-say-about-Crafatar"],
["I cant tell you how much Crafatar helped me along the way! You guys do some amazing work.", "Luke Chatton", "https://github.com/lukechatton"],
["It's just awesome! Keep up the good work", "Dannyps", "https://forums.spongepowered.org/t/title-cant-be-empty/4964/22"],
["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 current_quote = 0;
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) {
var warn = document.createElement("div");
warn.setAttribute("class", "alert alert-warning");
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://mc-heads.net/mcstatus\" target=\"_blank\">check status</a>";
document.querySelector("#alerts").appendChild(warn);
}
});
document.addEventListener("DOMContentLoaded", function(event) {
var avatars = document.querySelector("#avatar-wrapper");
// shuffle avatars
for (var i = 0; i < avatars.children.length; i++) {
avatars.appendChild(avatars.children[Math.random() * i | 0]);
}
setInterval(changeQuote, 5000);
changeQuote();
var tryit = document.querySelector("#tryit");
var tryname = document.querySelector("#tryname");
var images = document.querySelectorAll(".tryit");
tryit.onsubmit = function(e) {
e.preventDefault();
tryname.value = tryname.value.trim();
var value = tryname.value || "853c80ef3c3749fdaa49938b674adae6";
if (!valid_user_id.test(value)) {
tryname.value = "";
return;
}
for (var j = 0; j < images.length; j++) {
images[j].src = images[j].dataset.src.replace("$", value);
}
};
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

View File

@ -1,260 +0,0 @@
// Skin locations are based on the work of Confuser, with 1.8 updates by Jake0oo0
// https://github.com/confuser/serverless-mc-skin-viewer
// Permission to use & distribute https://github.com/confuser/serverless-mc-skin-viewer/blob/master/LICENSE
var logging = require("./logging");
var fs = require("fs");
var cvs = require("canvas");
var exp = {};
// set alpha values to 255
function removeTransparency(canvas) {
var ctx = canvas.getContext("2d");
var imagedata = ctx.getImageData(0, 0, canvas.width, canvas.height);
var data = imagedata.data;
// data is [r,g,b,a, r,g,b,a, *]
for (var i = 0; i < data.length; i += 4) {
// usually we would have to check for alpha = 0
// and set color to black here
// but node-canvas already does that for us
// remove transparency
data[i + 3] = 255;
}
ctx.putImageData(imagedata, 0, 0);
return canvas;
}
// checks if the given +canvas+ has any pixel that is not fully opaque
function hasTransparency(canvas) {
var ctx = canvas.getContext("2d");
var imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
for (var i = 3; i < imageData.length; i += 4) {
if (imageData[i] < 255) {
// found pixel with translucent alpha value
return true;
}
}
return false;
}
// resize the +src+ canvas by +scale+
// returns a new canvas
function resize(src, scale) {
var dst = cvs.createCanvas();
dst.width = scale * src.width;
dst.height = scale * src.height;
var context = dst.getContext("2d");
// don't blur on resize
context.patternQuality = "fast";
context.drawImage(src, 0, 0, src.width * scale, src.height * scale);
return dst;
}
// get a rectangular part of the +src+ canvas
// the returned canvas is scaled by factor +scale+
function getPart(src, x, y, width, height, scale) {
var dst = cvs.createCanvas();
dst.width = scale * width;
dst.height = scale * height;
var context = dst.getContext("2d");
// don't blur on resize
context.patternQuality = "fast";
context.drawImage(src, x, y, width, height, 0, 0, width * scale, height * scale);
return dst;
}
// flip the +src+ canvas horizontally
function flip(src) {
var dst = cvs.createCanvas();
dst.width = src.width;
dst.height = src.height;
var context = dst.getContext("2d");
context.scale(-1, 1);
context.drawImage(src, -src.width, 0);
return dst;
}
// skew for isometric perspective
var skew_a = 26 / 45; // 0.57777777
var skew_b = skew_a * 2; // 1.15555555
// renders a player model with the given skin +img+ and +scale+
// +overlay+ - wether the extra skin layer is rendered
// +is_body+ - false for head only
// +slim+ - wether the player has a slim skin model
// callback: error, image buffer
exp.draw_model = function(rid, img, scale, overlay, is_body, slim, callback) {
var canvas = cvs.createCanvas();
canvas.width = scale * 20;
canvas.height = scale * (is_body ? 45.1 : 18.5);
var ctx = canvas.getContext("2d");
cvs.loadImage(img).then((skin) => {
var old_skin = skin.height === 32;
var arm_width = slim ? 3 : 4;
/* eslint-disable no-multi-spaces */
var head_top = resize(removeTransparency(getPart(skin, 8, 0, 8, 8, 1)), scale);
var head_front = resize(removeTransparency(getPart(skin, 8, 8, 8, 8, 1)), scale);
var head_right = resize(removeTransparency(getPart(skin, 0, 8, 8, 8, 1)), scale);
var arm_right_top = resize(removeTransparency(getPart(skin, 44, 16, arm_width, 4, 1)), scale);
var arm_right_front = resize(removeTransparency(getPart(skin, 44, 20, arm_width, 12, 1)), scale);
var arm_right_side = resize(removeTransparency(getPart(skin, 40, 20, 4, 12, 1)), scale);
var arm_left_top = old_skin ? flip(arm_right_top) : resize(removeTransparency(getPart(skin, 36, 48, arm_width, 4, 1)), scale);
var arm_left_front = old_skin ? flip(arm_right_front) : resize(removeTransparency(getPart(skin, 36, 52, arm_width, 12, 1)), scale);
var leg_right_front = resize(removeTransparency(getPart(skin, 4, 20, 4, 12, 1)), scale);
var leg_right_side = resize(removeTransparency(getPart(skin, 0, 20, 4, 12, 1)), scale);
var leg_left_front = old_skin ? flip(leg_right_front) : resize(removeTransparency(getPart(skin, 20, 52, 4, 12, 1)), scale);
var body_front = resize(removeTransparency(getPart(skin, 20, 20, 8, 12, 1)), scale);
/* eslint-enable no-multi-spaces */
if (overlay) {
if (hasTransparency(getPart(skin, 32, 0, 32, 32, 1))) {
// render head overlay
head_top.getContext("2d").drawImage(getPart(skin, 40, 0, 8, 8, scale), 0, 0);
head_front.getContext("2d").drawImage(getPart(skin, 40, 8, 8, 8, scale), 0, 0);
head_right.getContext("2d").drawImage(getPart(skin, 32, 8, 8, 8, scale), 0, 0);
}
if (!old_skin) {
// See #117
// if MC-89760 gets fixed, we can (probably) simply check the whole skin for transparency
/* eslint-disable no-multi-spaces */
var body_region = getPart(skin, 16, 32, 32, 16, 1);
var right_arm_region = getPart(skin, 48, 48, 16, 16, 1);
var left_arm_region = getPart(skin, 40, 32, 16, 16, 1);
var right_leg_region = getPart(skin, 0, 32, 16, 16, 1);
var left_leg_region = getPart(skin, 0, 48, 16, 16, 1);
/* eslint-enable no-multi-spaces */
if (hasTransparency(body_region)) {
// render body overlay
body_front.getContext("2d").drawImage(getPart(skin, 20, 36, 8, 12, scale), 0, 0);
}
if (hasTransparency(right_arm_region)) {
// render right arm overlay
arm_right_top.getContext("2d").drawImage(getPart(skin, 44, 32, arm_width, 4, scale), 0, 0);
arm_right_front.getContext("2d").drawImage(getPart(skin, 44, 36, arm_width, 12, scale), 0, 0);
arm_right_side.getContext("2d").drawImage(getPart(skin, 40, 36, 4, 12, scale), 0, 0);
}
if (hasTransparency(left_arm_region)) {
// render left arm overlay
arm_left_top.getContext("2d").drawImage(getPart(skin, 36 + 16, 48, arm_width, 4, scale), 0, 0);
arm_left_front.getContext("2d").drawImage(getPart(skin, 36 + 16, 52, arm_width, 12, scale), 0, 0);
}
if (hasTransparency(right_leg_region)) {
// render right leg overlay
leg_right_front.getContext("2d").drawImage(getPart(skin, 4, 36, 4, 12, scale), 0, 0);
leg_right_side.getContext("2d").drawImage(getPart(skin, 0, 36, 4, 12, scale), 0, 0);
}
if (hasTransparency(left_leg_region)) {
// render left leg overlay
leg_left_front.getContext("2d").drawImage(getPart(skin, 4, 52, 4, 12, scale), 0, 0);
}
}
}
var x = 0;
var y = 0;
var z = 0;
var z_offset = scale * 3;
var x_offset = scale * 2;
if (is_body) {
// pre-render front onto separate canvas
var front = cvs.createCanvas();
front.width = scale * 16;
front.height = scale * 24;
var frontc = front.getContext("2d");
frontc.patternQuality = "fast";
frontc.drawImage(arm_right_front, (4 - arm_width) * scale, 0 * scale, arm_width * scale, 12 * scale);
frontc.drawImage(arm_left_front, 12 * scale, 0 * scale, arm_width * scale, 12 * scale);
frontc.drawImage(body_front, 4 * scale, 0 * scale, 8 * scale, 12 * scale);
frontc.drawImage(leg_right_front, 4 * scale, 12 * scale, 4 * scale, 12 * scale);
frontc.drawImage(leg_left_front, 8 * scale, 12 * scale, 4 * scale, 12 * scale);
// top
x = x_offset + scale * 2;
y = scale * -arm_width;
z = z_offset + scale * 8;
ctx.setTransform(1, -skew_a, 1, skew_a, 0, 0);
ctx.drawImage(arm_right_top, y - z - 0.5, x + z, arm_right_top.width + 1, arm_right_top.height + 1);
y = scale * 8;
ctx.drawImage(arm_left_top, y - z, x + z, arm_left_top.width, arm_left_top.height + 1);
// right side
ctx.setTransform(1, skew_a, 0, skew_b, 0, 0);
x = x_offset + scale * 2;
y = 0;
z = z_offset + scale * 20;
ctx.drawImage(leg_right_side, x + y, z - y, leg_right_side.width, leg_right_side.height);
x = x_offset + scale * 2;
y = scale * -arm_width;
z = z_offset + scale * 8;
ctx.drawImage(arm_right_side, x + y, z - y - 0.5, arm_right_side.width, arm_right_side.height + 1);
// front
z = z_offset + scale * 12;
y = 0;
ctx.setTransform(1, -skew_a, 0, skew_b, 0, skew_a);
ctx.drawImage(front, y + x, x + z - 0.5, front.width, front.height);
}
// head top
x = x_offset;
y = -0.5;
z = z_offset;
ctx.setTransform(1, -skew_a, 1, skew_a, 0, 0);
ctx.drawImage(head_top, y - z, x + z, head_top.width, head_top.height + 1);
// head front
x = x_offset + 8 * scale;
y = 0;
z = z_offset - 0.5;
ctx.setTransform(1, -skew_a, 0, skew_b, 0, skew_a);
ctx.drawImage(head_front, y + x, x + z, head_front.width, head_front.height);
// head right
x = x_offset;
y = 0;
z = z_offset;
ctx.setTransform(1, skew_a, 0, skew_b, 0, 0);
ctx.drawImage(head_right, x + y, z - y - 0.5, head_right.width + 0.5, head_right.height + 1);
canvas.toBuffer(function(err, buf) {
if (err) {
logging.error(rid, "error creating buffer:", err);
}
callback(err, buf);
});
});
};
// helper method to open a render from +renderpath+
// callback: error, image buffer
exp.open_render = function(rid, renderpath, callback) {
fs.readFile(renderpath, callback);
};
module.exports = exp;

View File

@ -1,118 +0,0 @@
var logging = require("./logging");
var config = require("../config");
var crc = require("crc").crc32;
var human_status = {
"-2": "user error", // e.g. invalid size
"-1": "server error", // e.g. mojang/network issues
0: "none", // cached as null (user has no skin)
1: "cached", // found on disk
2: "downloaded", // profile downloaded, skin downloaded from mojang servers
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
var silent_errors = ["ETIMEDOUT", "ESOCKETTIMEDOUT", "ECONNRESET", "EHOSTUNREACH", "ECONNREFUSED", "HTTPERROR", "RATELIMIT"];
// handles HTTP responses
// +request+ a http.IncomingMessage
// +response+ a http.ServerResponse
// +result+ an object with:
// * status: see human_status, required for images without err
// * redirect: redirect URL
// * body: file or message, required unless redirect is present or status is < 0
// * type: a valid Content-Type for the body, defaults to "text/plain"
// * hash: image hash, required when body is an image
// * err: a possible Error
// * code: override HTTP response code when status is < 0
module.exports = function(request, response, result) {
// These headers are the same for every response
var headers = {
"Content-Type": result.body && result.type || "text/plain",
"Content-Length": Buffer.from(result.body || "").length,
"Cache-Control": "max-age=" + config.caching.browser,
"Response-Time": Date.now() - request.start,
"X-Request-ID": request.id,
"Access-Control-Allow-Origin": "*",
};
response.on("finish", function() {
logging.log(request.id, request.method, request.url.href, response.statusCode, headers["Response-Time"] + "ms", "(" + (human_status[result.status] || "-") + ")");
});
response.on("error", function(err) {
logging.error(request.id, err);
});
if (result.err) {
var silent = silent_errors.indexOf(result.err.code) !== -1;
if (result.err.stack && !silent) {
logging.error(request.id, result.err.stack);
} else if (silent) {
logging.warn(request.id, result.err);
} else {
logging.error(request.id, result.err);
}
result.status = -1;
}
if (result.status !== undefined && result.status !== null) {
headers["X-Storage-Type"] = human_status[result.status];
}
// use crc32 as a hash function for Etag
var etag = "\"" + crc(result.body || "") + "\"";
// handle etag caching
var incoming_etag = request.headers["if-none-match"];
// also respond with 304 on server error (use client's version)
// don't respond with 304 when debugging is enabled
if (incoming_etag && (incoming_etag === etag || result.status === -1 && !config.server.debug_enabled)) {
response.writeHead(304, headers);
response.end();
return;
}
if (result.redirect) {
headers.Location = result.redirect;
response.writeHead(307, headers);
response.end();
return;
}
if (result.status === -2) {
response.writeHead(result.code || 422, headers);
} else if (result.status === -1) {
// server errors shouldn't be cached
headers["Cache-Control"] = "no-cache, max-age=0";
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 {
if (result.body) {
if (result.status === 4) {
headers["Warning"] = '111 Crafatar "Revalidation Failed"'
}
headers["Etag"] = etag;
response.writeHead(200, headers);
} else {
response.writeHead(404, headers);
}
}
response.end(result.body);
};

View File

@ -1,111 +0,0 @@
var helpers = require("../helpers");
var config = require("../../config");
var skins = require("../skins");
var cache = require("../cache");
var path = require("path");
var url = require("url");
// handle the appropriate 'default=' response
// uses either mhf_steve or mhf_alex (based on +userId+) if no +def+ given
// callback: response object
function handle_default(img_status, userId, size, def, req, err, callback) {
def = def || skins.default_skin(userId);
var defname = def.toLowerCase();
if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") {
if (helpers.id_valid(def)) {
// clean up the old URL to match new image
req.url.searchParams.delete('default');
req.url.path_list[1] = def;
req.url.pathname = req.url.path_list.join('/');
var newUrl = req.url.toString();
callback({
status: img_status,
redirect: newUrl,
err: err,
});
} else {
callback({
status: img_status,
redirect: def,
err: err,
});
}
} else {
// handle steve and alex
def = defname;
if (def.substr(0, 4) !== "mhf_") {
def = "mhf_" + def;
}
skins.resize_img(path.join(__dirname, "..", "public", "images", def + ".png"), size, function(resize_err, image) {
callback({
status: img_status,
body: image,
type: "image/png",
hash: def,
err: resize_err || err,
});
});
}
}
// GET avatar request
module.exports = function(req, callback) {
var userId = (req.url.path_list[1] || "").split(".")[0];
var size = parseInt(req.url.searchParams.get("size")) || config.avatars.default_size;
var def = req.url.searchParams.get("default");
var overlay = req.url.searchParams.has("overlay") || req.url.searchParams.has("helm");
// check for extra paths
if (req.url.path_list.length > 2) {
callback({
status: -2,
body: "Invalid Path",
code: 404,
});
return;
}
// strip dashes
userId = userId.replace(/-/g, "");
// Prevent app from crashing/freezing
if (size < config.avatars.min_size || size > config.avatars.max_size) {
// "Unprocessable Entity", valid request, but semantically erroneous:
// https://tools.ietf.org/html/rfc4918#page-78
callback({
status: -2,
body: "Invalid Size",
});
return;
} else if (!helpers.id_valid(userId)) {
callback({
status: -2,
body: "Invalid UUID",
});
return;
}
try {
helpers.get_avatar(req.id, userId, overlay, size, function(err, status, image, hash) {
if (err) {
if (err.code === "ENOENT") {
// no such file
cache.remove_hash(req.id, userId);
}
}
if (image) {
callback({
status: status,
body: image,
type: "image/png",
err: err,
hash: hash,
});
} else {
handle_default(status, userId, size, def, req, err, callback);
}
});
} catch (e) {
handle_default(-1, userId, size, def, req, e, callback);
}
};

View File

@ -1,53 +0,0 @@
var helpers = require("../helpers");
var cache = require("../cache");
// GET cape request
module.exports = function(req, callback) {
var userId = (req.url.path_list[1] || "").split(".")[0];
var def = req.url.searchParams.get('default');
var rid = req.id;
// check for extra paths
if (req.url.path_list.length > 2) {
callback({
status: -2,
body: "Invalid Path",
code: 404
});
return;
}
// strip dashes
userId = userId.replace(/-/g, "");
if (!helpers.id_valid(userId)) {
callback({
status: -2,
body: "Invalid UUID"
});
return;
}
try {
helpers.get_cape(rid, userId, function(err, hash, status, image) {
if (err) {
if (err.code === "ENOENT") {
// no such file
cache.remove_hash(rid, userId);
}
}
callback({
status: status,
body: image,
type: image ? "image/png" : undefined,
redirect: image ? undefined : def,
hash: hash,
err: err
});
});
} catch(e) {
callback({
status: -1,
err: e
});
}
};

View File

@ -1,34 +0,0 @@
var logging = require("../logging");
var config = require("../../config");
var path = require("path");
var read = require("fs").readFileSync;
var ejs = require("ejs");
var str;
var index;
// pre-compile the index page
function compile() {
logging.log("Compiling index page");
str = read(path.join(__dirname, "..", "views", "index.html.ejs"), "utf-8");
index = ejs.compile(str);
}
compile();
// GET index request
module.exports = function(req, callback) {
if (config.server.debug_enabled) {
// allow changes without reloading
compile();
}
var html = index({
title: "Crafatar",
domain: "https://" + req.headers.host,
config: config
});
callback({
body: html,
type: "text/html; charset=utf-8"
});
};

View File

@ -1,127 +0,0 @@
var logging = require("../logging");
var helpers = require("../helpers");
var renders = require("../renders");
var config = require("../../config");
var cache = require("../cache");
var skins = require("../skins");
var path = require("path");
var url = require("url");
var fs = require("fs");
// handle the appropriate 'default=' response
// uses either mhf_steve or mhf_alex (based on +userId+) if no +def+ given
// callback: response object
function handle_default(rid, scale, overlay, body, img_status, userId, size, def, req, err, callback) {
def = def || skins.default_skin(userId);
var defname = def.toLowerCase();
if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") {
if (helpers.id_valid(def)) {
// clean up the old URL to match new image
req.url.searchParams.delete('default');
req.url.path_list[2] = def;
req.url.pathname = req.url.path_list.join('/');
var newUrl = req.url.toString();
callback({
status: img_status,
redirect: newUrl,
err: err
});
} else {
callback({
status: img_status,
redirect: def,
err: err
});
}
} else {
// handle steve and alex
def = defname;
if (def.substr(0, 4) !== "mhf_") {
def = "mhf_" + def;
}
fs.readFile(path.join(__dirname, "..", "public", "images", def + "_skin.png"), function(fs_err, buf) {
// we render the default skins, but not custom images
renders.draw_model(rid, buf, scale, overlay, body, def === "mhf_alex", function(render_err, def_img) {
callback({
status: img_status,
body: def_img,
type: "image/png",
hash: def,
err: render_err || fs_err || err
});
});
});
}
}
// GET render request
module.exports = function(req, callback) {
var raw_type = req.url.path_list[1] || "";
var rid = req.id;
var body = raw_type === "body";
var userId = (req.url.path_list[2] || "").split(".")[0];
var def = req.url.searchParams.get("default");
var scale = parseInt(req.url.searchParams.get("scale")) || config.renders.default_scale;
var overlay = req.url.searchParams.has("overlay") || req.url.searchParams.has("helm");
// check for extra paths
if (req.url.path_list.length > 3) {
callback({
status: -2,
body: "Invalid Path",
code: 404
});
return;
}
// validate type
if (raw_type !== "body" && raw_type !== "head") {
callback({
status: -2,
body: "Invalid Render Type"
});
return;
}
// strip dashes
userId = userId.replace(/-/g, "");
if (scale < config.renders.min_scale || scale > config.renders.max_scale) {
callback({
status: -2,
body: "Invalid Scale"
});
return;
} else if (!helpers.id_valid(userId)) {
callback({
status: -2,
body: "Invalid UUID"
});
return;
}
try {
helpers.get_render(rid, userId, scale, overlay, body, function(err, status, hash, image) {
if (err) {
if (err.code === "ENOENT") {
// no such file
cache.remove_hash(rid, userId);
}
}
if (image) {
callback({
status: status,
body: image,
type: "image/png",
hash: hash,
err: err
});
} else {
logging.debug(rid, "image not found, using default.");
handle_default(rid, scale, overlay, body, status, userId, scale, def, req, err, callback);
}
});
} catch(e) {
handle_default(rid, scale, overlay, body, -1, userId, scale, def, req, e, callback);
}
};

View File

@ -1,109 +0,0 @@
var helpers = require("../helpers");
var skins = require("../skins");
var cache = require("../cache");
var path = require("path");
var lwip = require("@randy.tarampi/lwip");
var url = require("url");
// handle the appropriate 'default=' response
// uses either mhf_steve or mhf_alex (based on +userId+) if no +def+ given
// callback: response object
function handle_default(img_status, userId, def, req, err, callback) {
def = def || skins.default_skin(userId);
var defname = def.toLowerCase();
if (defname !== "steve" && defname !== "mhf_steve" && defname !== "alex" && defname !== "mhf_alex") {
if (helpers.id_valid(def)) {
// clean up the old URL to match new image
req.url.searchParams.delete('default');
req.url.path_list[1] = def;
req.url.pathname = req.url.path_list.join('/');
var newUrl = req.url.toString();
callback({
status: img_status,
redirect: newUrl,
err: err
});
} else {
callback({
status: img_status,
redirect: def,
err: err
});
}
} else {
// handle steve and alex
def = defname;
if (def.substr(0, 4) !== "mhf_") {
def = "mhf_" + def;
}
lwip.open(path.join(__dirname, "..", "public", "images", def + "_skin.png"), function(lwip_err, image) {
if (image) {
image.toBuffer("png", function(buf_err, buffer) {
callback({
status: img_status,
body: buffer,
type: "image/png",
hash: def,
err: buf_err || lwip_err || err
});
});
} else {
callback({
status: -1,
err: lwip_err || err
});
}
});
}
}
// GET skin request
module.exports = function(req, callback) {
var userId = (req.url.path_list[1] || "").split(".")[0];
var def = req.url.searchParams.get("default");
var rid = req.id;
// check for extra paths
if (req.url.path_list.length > 2) {
callback({
status: -2,
body: "Invalid Path",
code: 404
});
return;
}
// strip dashes
userId = userId.replace(/-/g, "");
if (!helpers.id_valid(userId)) {
callback({
status: -2,
body: "Invalid UUID"
});
return;
}
try {
helpers.get_skin(rid, userId, function(err, hash, status, image, slim) {
if (err) {
if (err.code === "ENOENT") {
// no such file
cache.remove_hash(req.id, userId);
}
}
if (image) {
callback({
status: status,
body: image,
type: "image/png",
hash: hash,
err: err
});
} else {
handle_default(2, userId, def, req, err, callback);
}
});
} catch(e) {
handle_default(-1, userId, def, req, e, callback);
}
};

View File

@ -1,180 +0,0 @@
#!/usr/bin/env node
var querystring = require("querystring");
var response = require("./response");
var helpers = require("./helpers.js");
var toobusy = require("toobusy-js");
var logging = require("./logging");
var config = require("../config");
var http = require("http");
var mime = require("mime");
var path = require("path");
var url = require("url");
var fs = require("fs");
var server = null;
var routes = {
index: require("./routes/index"),
avatars: require("./routes/avatars"),
skins: require("./routes/skins"),
renders: require("./routes/renders"),
capes: require("./routes/capes"),
};
// serves assets from lib/public
function asset_request(req, callback) {
const filename = path.join(__dirname, "public", ...req.url.path_list);
const relative = path.relative(path.join(__dirname, "public"), filename);
if (relative && !relative.startsWith('..') && !path.isAbsolute(relative)) {
fs.access(filename, function(fs_err) {
if (!fs_err) {
fs.readFile(filename, function(err, data) {
callback({
body: data,
type: mime.getType(filename),
err: err,
});
});
} else {
callback({
body: "Not found",
status: -2,
code: 404,
});
}
});
} else {
callback({
body: "Forbidden",
status: -2,
code: 403,
});
}
}
// generates a 12 character random string
function request_id() {
return Math.random().toString(36).substring(2, 14);
}
// splits decoded URL path into an Array
function path_list(pathname) {
var list = pathname.split("/");
list.shift();
return list;
}
// handles the +req+ by routing to the request to the appropriate module
function requestHandler(req, res) {
req.url = new URL(decodeURI(req.url), 'http://' + req.headers.host);
req.url.pathname = path.resolve('/', req.url.pathname);
req.url.path_list = path_list(req.url.pathname);
req.id = request_id();
req.start = Date.now();
var local_path = req.url.path_list[0];
logging.debug(req.id, req.method, req.url.href);
toobusy.maxLag(200);
if (toobusy() && !process.env.TRAVIS) {
response(req, res, {
status: -1,
body: "Server is over capacity :/",
err: "Too busy",
code: 503,
});
return;
}
if (req.method === "GET" || req.method === "HEAD") {
try {
switch (local_path) {
case "":
routes.index(req, function(result) {
response(req, res, result);
});
break;
case "avatars":
routes.avatars(req, function(result) {
response(req, res, result);
});
break;
case "skins":
routes.skins(req, function(result) {
response(req, res, result);
});
break;
case "renders":
routes.renders(req, function(result) {
response(req, res, result);
});
break;
case "capes":
routes.capes(req, function(result) {
response(req, res, result);
});
break;
default:
asset_request(req, function(result) {
response(req, res, result);
});
}
} catch(e) {
var error = JSON.stringify(req.headers) + "\n" + e.stack;
response(req, res, {
status: -1,
body: config.server.debug_enabled ? error : "Internal Server Error",
err: error,
});
}
} else {
response(req, res, {
status: -2,
body: "Method Not Allowed",
code: 405,
});
}
}
var exp = {};
// Start the server
exp.boot = function(callback) {
var port = config.server.port;
var bind_ip = config.server.bind;
server = http.createServer(requestHandler).listen(port, bind_ip, function() {
logging.log("Server running on http://" + bind_ip + ":" + port + "/");
if (callback) {
callback();
}
});
// stop accepting new connections,
// wait for established connections to finish (30s max),
// then exit
process.on("SIGTERM", function() {
logging.warn("Got SIGTERM, no longer accepting new connections!");
setTimeout(function() {
logging.error("Dropping connections after 30s. Force quit.");
process.exit(1);
}, 30000);
server.close(function() {
logging.log("All connections closed, shutting down.");
process.exit();
});
});
};
// Close the server
exp.close = function(callback) {
helpers.stoplog();
server.close(callback);
};
module.exports = exp;
if (require.main === module) {
logging.error("Please use 'npm start' or 'www.js'");
process.exit(1);
}

View File

@ -1,175 +0,0 @@
var logging = require("./logging");
var lwip = require("@randy.tarampi/lwip");
var fs = require("fs");
var exp = {};
// extracts the face from an image +buffer+
// result is saved to a file called +outname+
// callback: error
exp.extract_face = function(buffer, outname, callback) {
lwip.open(buffer, "png", function(err, image) {
if (err) {
callback(err);
} else {
image.batch()
.crop(8, 8, 15, 15) // face
.opacify() // remove transparency
.writeFile(outname, function(write_err) {
if (write_err) {
callback(write_err);
} else {
callback(null);
}
});
}
});
};
// extracts the helm from an image +buffer+ and lays it over a +facefile+
// +facefile+ is the filename of an image produced by extract_face
// result is saved to a file called +outname+
// callback: error
exp.extract_helm = function(rid, facefile, buffer, outname, callback) {
lwip.open(buffer, "png", function(err, skin_img) {
if (err) {
callback(err);
} else {
lwip.open(facefile, function(open_err, face_img) {
if (open_err) {
callback(open_err);
} else {
face_img.toBuffer("png", { compression: "none" }, function(buf_err, face_buffer) {
if (buf_err) {
callback(buf_err);
} else {
// crop to hat transparency-bounding-box
skin_img.crop(32, 0, 63, 31, function(area_err, helm_area) {
if (area_err) {
callback(area_err);
} else {
/* eslint-disable no-labels */
var is_opaque = true;
if (skin_img.__trans) { // eslint-disable-line no-underscore-dangle
xloop:
for (var x = 0; x < helm_area.width(); x++) {
for (var y = 0; y < helm_area.height(); y++) {
// check if transparency-bounding-box has transparency
if (helm_area.getPixel(x, y).a !== 100) {
is_opaque = false;
break xloop;
}
}
}
/* eslint-enable no-labels */
} else {
is_opaque = true;
}
skin_img.crop(8, 8, 15, 15, function(crop_err, helm_img) {
if (crop_err) {
callback(crop_err);
} else {
face_img.paste(0, 0, helm_img, function(img_err, face_helm_img) {
if (img_err) {
callback(img_err);
} else {
if (is_opaque) {
logging.debug(rid, "Skin is not transparent, skipping helm!");
callback(null);
} else {
face_helm_img.toBuffer("png", {compression: "none"}, function(buf_err2, face_helm_buffer) {
if (buf_err2) {
callback(buf_err2);
} else {
if (face_helm_buffer.toString() !== face_buffer.toString()) {
face_helm_img.writeFile(outname, function(write_err) {
callback(write_err);
});
} else {
logging.debug(rid, "helm img == face img, not storing!");
callback(null);
}
}
});
}
}
});
}
});
}
});
}
});
}
});
}
});
};
// resizes the image file +inname+ to +size+ by +size+ pixels
// callback: error, image buffer
exp.resize_img = function(inname, size, callback) {
lwip.open(inname, function(err, image) {
if (err) {
callback(err, null);
} else {
image.batch()
.resize(size, size, "nearest-neighbor") // nearest-neighbor doesn't blur
.toBuffer("png", function(buf_err, buffer) {
if (buf_err) {
callback(buf_err, null);
} else {
callback(null, buffer);
}
});
}
});
};
// returns "mhf_alex" or "mhf_steve" calculated by the +uuid+
exp.default_skin = function(uuid) {
// great thanks to Minecrell for research into Minecraft and Java's UUID hashing!
// https://git.io/xJpV
// MC uses `uuid.hashCode() & 1` for alex
// that can be compacted to counting the LSBs of every 4th byte in the UUID
// an odd sum means alex, an even sum means steve
// XOR-ing all the LSBs gives us 1 for alex and 0 for steve
var lsbs_even = parseInt(uuid[ 7], 16) ^
parseInt(uuid[15], 16) ^
parseInt(uuid[23], 16) ^
parseInt(uuid[31], 16);
return lsbs_even ? "mhf_alex" : "mhf_steve";
};
// helper method for opening a skin file from +skinpath+
// callback: error, image buffer
exp.open_skin = function(rid, skinpath, callback) {
fs.readFile(skinpath, function(err, buf) {
if (err) {
callback(err, null);
} else {
callback(null, buf);
}
});
};
// write the image +buffer+ to the +outpath+ file
// the image is stripped down by lwip.
// callback: error
exp.save_image = function(buffer, outpath, callback) {
lwip.open(buffer, "png", function(err, image) {
if (err) {
callback(err);
} else {
image.writeFile(outpath, function(write_err) {
if (write_err) {
callback(write_err);
} else {
callback(null);
}
});
}
});
};
module.exports = exp;

View File

@ -1,324 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Crafatar A blazing fast API for Minecraft faces!</title>
<meta charset="utf-8">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/stylesheets/bootstrap.min.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="keywords" content="minecraft, avatar, renders, skins, uuid">
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<meta name="copyright" content="Crafatar">
<meta name="language" content="en-US">
<meta name="robots" content="index">
<meta property="og:title" content="Crafatar">
<meta property="og:type" content="website">
<meta property="og:url" content="<%= domain %>">
<meta property="og:image" content="<%= domain %>/logo.png">
<meta property="og:description" content="blazing fast API for Minecraft faces with support for avatars, skins, and 3D renders!">
<meta property="og:determiner" content="a">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary">
<meta name="twitter:creator" content="@Crafatar">
<script src="/javascript/crafatar.js"></script>
</head>
<body lang="en-US">
<a href="https://github.com/crafatar/crafatar" target="_blank" class="forkme">Fork me on GitHub</a>
<% if (config.sponsor.top_right) { %>
<%- config.sponsor.top_right %>
<% } %>
<div class="jumbotron">
<div class="container">
<h1>Crafatar</h1>
<h2>A blazing fast API for Minecraft faces!</h2>
<div id="avatar-wrapper">
<%# These are shuffled by JS %>
<div title="jomo's avatar" class="avatar jomo"></div>
<div title="jake_0's avatar" class="avatar jake_0"></div>
<div title="sk89q's avatar" class="avatar sk89q"></div>
<div title="md_5's avatar" class="avatar md_5"></div>
<div title="notch's avatar" class="avatar notch"></div>
<div title="jeb_'s avatar" class="avatar jeb"></div>
<div title="dinnerbone's avatar" class="avatar dinnerbone flipped"></div>
<div title="ez' avatar" class="avatar ez"></div>
<div title="grumm's avatar" class="avatar grumm flipped"></div>
<div title="themogmimer's avatar" class="avatar themogmimer"></div>
<div title="searge's avatar" class="avatar searge"></div>
<div title="xlson's avatar" class="avatar xlson"></div>
<div title="krisjelbring's avatar" class="avatar krisjelbring"></div>
<div title="aikar's avatar" class="avatar aikar"></div>
<div title="ammar2's avatar" class="avatar ammar2"></div>
<div title="marc's avatar" class="avatar marc"></div>
<div title="mollstam's avatar" class="avatar mollstam"></div>
<div title="evilseph's avatar" class="avatar evilseph"></div>
<div title="thinkofdeath's avatar" class="avatar thinkofdeath"></div>
</div>
</div>
</div>
<div class="container row">
<div class="col-md-9">
<section id="documentation">
<div id="alerts">
</div>
<section id="try">
<h2><a href="#try">Try it</a></h2>
<form id="tryit" action="#">
<div class="row">
<div class="col-md-11">
<input id="tryname" type="text" placeholder="Enter valid UUID">
</div>
<div class="col-md-1">
<input type="submit" value="Go!">
</div>
</div>
</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 id="avatars">
<h2><a href="#avatars">Avatars</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/avatars/$?size=100" src="/avatars/853c80ef3c3749fdaa49938b674adae6?size=100" alt="avatar">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/avatars/<mark class="green">uuid</mark>
</div>
<p>Accepted <a href="#meta-parameters">modifiers</a>: <i><b>size</b>, <b>overlay</b>, <b>default</b></i>.</p>
</div>
</div>
</section>
<section id="head-renders">
<h2><a href="#head-renders">Head Renders</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/renders/head/$" src="/renders/head/853c80ef3c3749fdaa49938b674adae6" alt="head">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/renders/head/<mark class="green">uuid</mark>
</div>
<p>
Accepted <a href="#meta-parameters">modifiers</a>: <i><b>scale</b>, <b>overlay</b>, <b>default</b></i>.
</p>
</div>
</div>
</section>
<section id="body-renders">
<h2><a href="#body-renders">Body Renders</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/renders/body/$" src="/renders/body/853c80ef3c3749fdaa49938b674adae6" alt="body">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/renders/body/<mark class="green">uuid</mark>
</div>
<p>
Accepted <a href="#meta-parameters">modifiers</a>: <i><b>scale</b>, <b>overlay</b>, <b>default</b></i>.
</p>
</div>
</div>
</section>
<section id="skins">
<h2><a href="#skins">Skins</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/skins/$" src="/skins/853c80ef3c3749fdaa49938b674adae6" alt="skin">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/skins/<mark class="green">uuid</mark>
</div>
<p>Accepted <a href="#meta-parameters">modifiers</a>: <i><b>default</b></i>.</p>
</div>
</div>
</section>
<section id="capes">
<h2><a href="#capes">Capes</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/capes/$?default=853c80ef3c3749fdaa49938b674adae6" src="/capes/069a79f444e94726a5befca90e38aaf5?default=853c80ef3c3749fdaa49938b674adae6" alt="cape">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/capes/<mark class="green">uuid</mark>
</div>
<p>Accepted <a href="#meta-parameters">modifiers</a>: <i><b>default</b></i>.</p>
</div>
</div>
</section>
<hr>
<section id="meta">
<h2><a href="#meta">Meta</a></h2>
<p>
You can append <code>.png</code> or any other file extension to the URL path if you like to, but all images are PNG.
</p>
<section id="meta-attribution">
<h3><a href="#meta-attribution">Attribution</a></h3>
<p>
Attribution is not required, but it is <strong>encouraged</strong>.<br>
If you want to show some support for this (free!) service, place a notice like this somewhere:
<span class="code">
Thank you to &lt;a href="https://crafatar.com"&gt;Crafatar&lt;/a&gt; for providing avatars.
</span>
</p>
</section>
<section id="meta-parameters">
<h3><a href="#meta-parameters">URL Parameters</a></h3>
<p>
You can tweak images using <a href="https://en.wikipedia.org/wiki/Query_string" target="_blank">query string parameters</a>.<br>
Example: <code><%= domain %>/avatars/853c80ef3c3749fdaa49938b674adae6<mark class="blue">?</mark><mark class="green">size=4</mark><mark class="blue">&</mark><mark class="green">default=MHF_Steve</mark><mark class="blue">&</mark><mark class="green">overlay</mark></code>
</p>
<ul>
<li><b>size</b>: The size for avatars in pixels. <code><%= config.avatars.min_size %> - <%= config.avatars.max_size %></code>
<li><b>scale</b>: The scale factor for renders. <code><%= config.renders.min_scale %> - <%= config.renders.max_scale %></code>
<li><b>overlay</b>: Apply the <span title="Also known as 'hat' or 'jacket' or 'helm'">overlay</span> to the avatar. Presence of this parameter implies <code>true</code>. This option was previously known as <code>helm</code>.
<li>
<b>default</b>: The fallback to be used when the requested image cannot be served. You can use a <span title="Make sure to properly percent-encode this!">custom URL</span>, any <mark class="green">uuid</mark>, or <code>MHF_Steve</code>/<code>MHF_Alex</code>.<br>
The option defaults to either <code>MHF_Steve</code> or <code>MHF_Alex</code>, depending on Minecraft's default for the requested UUID.
</ul>
</section>
<section id="meta-uuids">
<h3><a href="#meta-uuids">About UUIDs</a></h3>
<p>UUIDs may be any valid Mojang UUID in the blank or dashed format.</p>
<p>Malformed UUIDs are rejected.</p>
</section>
<section id="meta-usernames">
<h3><a href="#meta-usernames">About Usernames</a></h3>
<p>
By <a href="https://twitter.com/MojangSupport/status/964511258601865216" target="_blank">disabling</a> a legacy API in 2018, Mojang has made it practically impossible for Crafatar to support usernames. Please use UUIDs instead!
</p>
<p>All usernames are rejected.</p>
</section>
<section id="meta-caching">
<h3><a href="#meta-caching">About Caching</a></h3>
<p>
Crafatar checks for skin updates every <%= config.caching.local / 60 %> minutes.<br>
Images are also cached in your browser for <%= config.caching.browser / 60 %> minutes unless you clear your browser cache.
<% 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>After changing your Minecraft skin, you can try clearing your browser cache to see the change faster.</p>
</section>
<section id="meta-cors">
<h3><a href="#meta-cors">CORS</a></h3>
<p>Crafatar supports Cross-Origin Resource Sharing, so you can make AJAX request from other sites!</p>
</section>
<section id="meta-http-headers">
<h3><a href="#meta-http-headers">HTTP Headers</a></h3>
<p>
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.
<% 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>
<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>
<b>X-Storage-Type</b>: Details about how the requested image was stored on the server
<ul>
<li><b>none</b>: No external requests. Player has no skin (cached)</li>
<li><b>cached</b>: No external requests. (skin cached)</li>
<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>
<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.</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>
Check the response body for details.</li>
</ul>
<li>
<b>X-Request-ID</b>: The internal ID assigned to this request.<br>
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>
</section>
</section>
</div>
<div class="col-md-3">
<h4>Popular Crafatar users</h4>
<div class="list-group">
<a rel="nofollow" href="https://hypixel.net" target="_blank" class="list-group-item">Hypixel</a>
<a rel="nofollow" href="https://mineplex.com" target="_blank" class="list-group-item">Mineplex</a>
<a rel="nofollow" href="https://hivemc.com" target="_blank" class="list-group-item">The Hive</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 href="https://github.com/crafatar/crafatar/wiki/Who-uses-crafatar%3F" target="_blank" class="list-group-item">and many more…</a>
</div>
<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>
<h4>Crafatar Tools & Plugins</h4>
<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://open.vanillaforums.com/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 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>
<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) { %>
<hr>
<%- config.sponsor.sidebar %>
<% } %>
</div>
</div>
<footer id="footer">
<hr>
<div class="container row">
<p class="pull-right">Copyright Crafatar <%= new Date().getFullYear() %></p>
</div>
</footer>
</body>
</html>

0
logs/.gitkeep Normal file
View File

165
modules/cache.js Normal file
View File

@ -0,0 +1,165 @@
var logging = require("./logging");
var node_redis = require("redis");
var config = require("./config");
var url = require("url");
var fs = require("fs");
var redis = null;
// sets up redis connection
// flushes redis when running on heroku (files aren't kept between pushes)
function connect_redis() {
logging.log("connecting to redis...");
// parse redis env
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() {
logging.log("Redis connection established.");
if(process.env.HEROKU) {
logging.log("Running on heroku, flushing redis");
redis.flushall();
}
});
redis.on("error", function (err) {
logging.error(err);
});
redis.on("end", function () {
logging.warn("Redis connection lost!");
});
}
// sets the date of the face file belonging to +skin_hash+ to now
// the helms file is ignored because we only need 1 file to read/write from
function update_file_date(rid, skin_hash) {
if (skin_hash) {
var path = config.faces_dir + skin_hash + ".png";
fs.exists(path, function(exists) {
if (exists) {
var date = new Date();
fs.utimes(path, date, date, function(err){
if (err) {
logging.error(rid + "Error: " + err.stack);
}
});
} else {
logging.error(rid + "tried to update " + path + " date, but it does not exist");
}
});
}
}
var exp = {};
// returns the redis instance
exp.get_redis = function() {
return redis;
};
// updates the redis instance's server_info object
// callback contains 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);
});
};
// sets the timestamp for +userId+ and its face file's (+hash+) date to the current time
// if +temp+ is true, the timestamp is set so that the record will be outdated after 60 seconds
// these 60 seconds match the duration of Mojang's rate limit ban
// +callback+ contains error
exp.update_timestamp = function(rid, userId, hash, temp, callback) {
logging.log(rid + "cache: updating timestamp");
sub = temp ? (config.local_cache_time - 60) : 0;
var time = new Date().getTime() - sub;
// store userId in lower case if not null
userId = userId && userId.toLowerCase();
redis.hmset(userId, "t", time, function(err) {
callback(err);
});
update_file_date(rid, hash);
};
// create the key +userId+, store +skin_hash+, +cape_hash+ and time
// if either +skin_hash+ or +cape_hash+ are undefined, they will not be stored
// this feature can be used to write both cape and skin at separate times
// +callback+ contans error
exp.save_hash = function(rid, userId, skin_hash, cape_hash, callback) {
logging.log(rid + "cache: saving skin:" + skin_hash + " cape:" + cape_hash);
var time = new Date().getTime();
// store shorter null byte instead of "null"
skin_hash = (skin_hash === null ? "" : skin_hash);
cape_hash = (cape_hash === null ? "" : cape_hash);
// store userId in lower case if not null
userId = userId && userId.toLowerCase();
if (skin_hash === undefined) {
redis.hmset(userId, "c", cape_hash, "t", time, function(err){
callback(err);
});
} else if (cape_hash === undefined) {
redis.hmset(userId, "s", skin_hash, "t", time, function(err){
callback(err);
});
} else {
redis.hmset(userId, "s", skin_hash, "c", cape_hash, "t", time, function(err){
callback(err);
});
}
};
// removes the hash for +userId+ from the cache
exp.remove_hash = function(rid, userId) {
logging.log(rid + "cache: deleting hash");
redis.del(userId.toLowerCase(), "h", "t");
};
// get a details object for +userId+
// {skin: "0123456789abcdef", cape: "gs1gds1g5d1g5ds1", time: 1414881524512}
// +callback+ contains error, details
// details is null when userId not cached
exp.get_details = function(userId, callback) {
// get userId in lower case if not null
userId = userId && userId.toLowerCase();
redis.hgetall(userId, function(err, data) {
var details = null;
if (data) {
details = {
skin: data.s === "" ? null : data.s,
cape: data.c === "" ? null : data.c,
time: Number(data.t)
};
}
callback(err, details);
});
};
connect_redis();
module.exports = exp;

100
modules/cleaner.js Normal file
View File

@ -0,0 +1,100 @@
var logging = require("./logging");
var config = require("./config");
var cache = require("./cache");
var df = require("node-df");
var fs = require("fs");
var redis = cache.get_redis();
var exp = {};
// compares redis' used_memory with cleaning_redis_limit
// callback contains error, true|false
function should_clean_redis(callback) {
cache.info(function(err, info) {
if (err) {
callback(err, false);
} else {
try {
//logging.debug(info.toString());
logging.debug("used mem:" + info.used_memory);
var used = parseInt(info.used_memory) / 1024;
logging.log("RedisCleaner: " + used + "KB used");
callback(err, used >= config.cleaning_redis_limit);
} catch(e) {
callback(e, false);
}
}
});
}
// uses `df` to get the available fisk space
// callback contains error, true|false
function should_clean_disk(callback) {
df({
file: __dirname + "/../" + config.faces_dir,
prefixMultiplier: "KiB",
isDisplayPrefixMultiplier: false,
precision: 2
}, function (err, response) {
if (err) {
callback(err, false);
} else {
var available = response[0].available;
logging.log("DiskCleaner: " + available + "KB available");
callback(err, available < config.cleaning_disk_limit);
}
});
}
// check if redis limit reached, then flush redis
// check if disk limit reached, then delete images
exp.run = function() {
should_clean_redis(function(err, clean) {
if (err) {
logging.error("Failed to run RedisCleaner");
logging.error(err);
} else if (clean) {
logging.warn("RedisCleaner: Redis limit reached! flushing now");
redis.flushall();
} else {
logging.log("RedisCleaner: Nothing to clean");
}
});
should_clean_disk(function(err, clean) {
if (err) {
logging.error("Failed to run DiskCleaner");
logging.error(err);
} else if (clean) {
logging.warn("DiskCleaner: Disk limit reached! Cleaning images now");
var facesdir = __dirname + "/../" + config.faces_dir;
var helmdir = __dirname + "/../" + config.helms_dir;
var renderdir = __dirname + "/../" + config.renders_dir;
var skindir = __dirname + "/../" + config.skins_dir;
fs.readdir(facesdir, function (err, files) {
for (var i = 0, l = Math.min(files.length, config.cleaning_amount); i < l; i++) {
var filename = files[i];
if (filename[0] !== ".") {
fs.unlink(facesdir + filename, nil);
fs.unlink(helmdir + filename, nil);
fs.unlink(skindir + filename, nil);
}
}
});
fs.readdir(renderdir, function (err, files) {
for (var j = 0, l = Math.min(files.length, config.cleaning_amount); j < l; j++) {
var filename = files[j];
if (filename[0] !== ".") {
fs.unlink(renderdir + filename, nil);
}
}
});
} else {
logging.log("DiskCleaner: Nothing to clean");
}
});
};
function nil () {}
module.exports = exp;

25
modules/config.example.js Normal file
View File

@ -0,0 +1,25 @@
var config = {
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
min_scale: 1, // for 3D renders
max_scale: 10, // for 3D renders; too big values might lead to slow response time or DoS
default_scale: 6, // for 3D renders; scale to be used when no scale given
local_cache_time: 1200, // seconds until we will check if the image changed. should be > 60 to prevent mojang 429 response
browser_cache_time: 3600, // seconds until browser will request image again
cleaning_interval: 1800, // seconds interval: deleting images if disk size at limit
cleaning_disk_limit: 10240, // min allowed available KB on disk to trigger cleaning
cleaning_redis_limit: 24576, // max allowed used KB on redis to trigger redis flush
cleaning_amount: 50000, // amount of avatar (and their helm) files to clean
http_timeout: 1000, // ms until connection to mojang is dropped
debug_enabled: false, // enables logging.debug
faces_dir: "images/faces/", // directory where faces are kept. should have trailing "/"
helms_dir: "images/helms/", // directory where helms are kept. should have trailing "/"
skins_dir: "images/skins/", // directory where skins are kept. should have trailing "/"
renders_dir: "images/renders/", // Directory where rendered skins are kept. should have trailing "/"
capes_dir: "images/capes/", // directory where capes are kept. should have trailing "/"
clusters: 1, // We recommend not using multiple clusters YET, see issue #80
log_time: true, // set to false if you use an external logger that provides timestamps
};
module.exports = config;

365
modules/helpers.js Normal file
View File

@ -0,0 +1,365 @@
var networking = require("./networking");
var logging = require("./logging");
var config = require("./config");
var cache = require("./cache");
var skins = require("./skins");
var renders = require("./renders");
var fs = require("fs");
// 0098cb60-fa8e-427c-b299-793cbd302c9a
var valid_user_id = /^([0-9a-f-A-F-]{32,36}|[a-zA-Z0-9_]{1,16})$/; // uuid|username
var hash_pattern = /[0-9a-f]+$/;
// gets the hash from the textures.minecraft.net +url+
function get_hash(url) {
return hash_pattern.exec(url)[0].toLowerCase();
}
function store_skin(rid, userId, profile, details, callback) {
networking.get_skin_url(rid, userId, profile, function(err, url) {
if (!err && url) {
var skin_hash = get_hash(url);
if (details && details.skin === skin_hash) {
cache.update_timestamp(rid, userId, skin_hash, false, function(err) {
callback(err, skin_hash);
});
} else {
logging.log(rid + "new skin hash: " + skin_hash);
var facepath = __dirname + "/../" + config.faces_dir + skin_hash + ".png";
var helmpath = __dirname + "/../" + config.helms_dir + skin_hash + ".png";
fs.exists(facepath, function(exists) {
if (exists) {
logging.log(rid + "skin already exists, not downloading");
callback(null, skin_hash);
} else {
networking.get_from(rid, url, function(img, response, err1) {
if (err1 || !img) {
callback(err1, null);
} else {
skins.extract_face(img, facepath, function(err2) {
if (err2) {
logging.error(rid + err2.stack);
callback(err2, null);
} else {
logging.debug(rid + "face extracted");
skins.extract_helm(rid, facepath, img, helmpath, function(err3) {
logging.debug(rid + "helm extracted");
logging.debug(rid + helmpath);
callback(err3, skin_hash);
});
}
});
}
});
}
});
}
} else {
callback(err, null);
}
});
}
function store_cape(rid, userId, profile, details, callback) {
networking.get_cape_url(rid, userId, profile, function(err, url) {
if (!err && url) {
var cape_hash = get_hash(url);
if (details && details.cape === cape_hash) {
cache.update_timestamp(rid, userId, cape_hash, false, function(err) {
callback(err, cape_hash);
});
} else {
logging.log(rid + "new cape hash: " + cape_hash);
var capepath = __dirname + "/../" + config.capes_dir + cape_hash + ".png";
fs.exists(capepath, function(exists) {
if (exists) {
logging.log(rid + "cape already exists, not downloading");
callback(null, cape_hash);
} else {
networking.get_from(rid, url, function(img, response, err) {
if (err || !img) {
logging.error(rid + err.stack);
callback(err, null);
} else {
skins.save_image(img, capepath, function(err) {
logging.debug(rid + "cape saved");
callback(err, cape_hash);
});
}
});
}
});
}
} else {
callback(err, null);
}
});
}
// used by store_images to queue simultaneous requests for identical userId
// the first request has to be completed until all others are continued
var currently_running = [];
// calls back all queued requests that match userId and type
function callback_for(userId, type, err, hash) {
var req_count = 0;
for (var i = 0; i < currently_running.length; i++) {
var current = currently_running[i];
if (current.userid === userId && current.type === type) {
req_count++;
if (req_count !== 1) {
// otherwise this would show up on single/first requests, too
logging.debug(current.rid + "queued " + type + " request continued");
}
currently_running.splice(i, 1); // remove from array
current.callback(err, hash);
i--;
}
}
if (req_count > 1) {
logging.debug(req_count + " simultaneous requests for " + userId);
}
}
// returns true if any object in +arr+ has +value+ as +property+
function deep_property_check(arr, property, value) {
for (var i = 0; i < arr.length; i++) {
if (arr[i][property] === value) {
return true;
}
}
return false;
}
// downloads the images for +userId+ while checking the cache
// status based on +details+. +type+ specifies which
// image type should be called back on
// +callback+ contains error, image hash
function store_images(rid, userId, details, type, callback) {
var is_uuid = userId.length > 16;
var new_hash = {
rid: rid,
userid: userId,
type: type,
callback: callback
};
if (!deep_property_check(currently_running, "userid", userId)) {
currently_running.push(new_hash);
networking.get_profile(rid, (is_uuid ? userId : null), function(err, profile) {
if (err || (is_uuid && !profile)) {
// error or uuid without profile
if (!err && !profile) {
// no error, but uuid without profile
cache.save_hash(rid, userId, null, null, function(cache_err) {
// we have no profile, so we have neither skin nor cape
callback_for(userId, "skin", cache_err, null);
callback_for(userId, "cape", cache_err, null);
});
} else {
// an error occured, not caching. we can try in 60 seconds
callback_for(userId, type, err, null);
}
} else {
// no error and we have a profile (if it's a uuid)
store_skin(rid, userId, profile, details, function(err, skin_hash) {
if (err && !skin_hash) {
// an error occured, not caching. we can try in 60 seconds
callback_for(userId, "skin", err, null);
} else {
cache.save_hash(rid, userId, skin_hash, null, function(cache_err) {
callback_for(userId, "skin", (err || cache_err), skin_hash);
});
}
});
store_cape(rid, userId, profile, details, function(err, cape_hash) {
if (err && !cape_hash) {
// an error occured, not caching. we can try in 60 seconds
callback_for(userId, "cape", (err || cache_err), cape_hash);
} else {
cache.save_hash(rid, userId, undefined, cape_hash, function(cache_err) {
callback_for(userId, "cape", (err || cache_err), cape_hash);
});
}
});
}
});
} else {
logging.log(rid + "ID already being processed, adding to queue");
currently_running.push(new_hash);
}
}
var exp = {};
// returns true if the +userId+ is a valid userId or username
// the userId may be not exist, however
exp.id_valid = function(userId) {
return valid_user_id.test(userId);
};
// decides whether to get a +type+ image for +userId+ from disk or to download it
// callback contains error, status, hash
// the status gives information about how the image was received
// -1: "error"
// 0: "none" - cached as null
// 1: "cached" - found on disk
// 2: "downloaded" - profile downloaded, skin downloaded from mojang servers
// 3: "checked" - profile re-downloaded (was too old), but it has either not changed or has no skin
exp.get_image_hash = function(rid, userId, type, callback) {
cache.get_details(userId, function(err, details) {
var cached_hash = (details !== null) ? (type === "skin" ? details.skin : details.cape) : null;
if (err) {
callback(err, -1, null);
} else {
if (details && details[type] !== undefined && details.time + config.local_cache_time * 1000 >= new Date().getTime()) {
// use cached image
logging.log(rid + "userId cached & recently updated");
callback(null, (cached_hash ? 1 : 0), cached_hash);
} else {
// download image
if (details) {
logging.log(rid + "userId cached, but too old");
} else {
logging.log(rid + "userId not cached");
}
store_images(rid, userId, details, type, function(err, new_hash) {
if (err) {
// we might have a cached hash although an error occured
// (e.g. Mojang servers not reachable, using outdated hash)
cache.update_timestamp(rid, userId, cached_hash, true, function(err2) {
callback(err2 || err, -1, details && cached_hash);
});
} else {
var status = details && (cached_hash === new_hash) ? 3 : 2;
logging.debug(rid + "cached hash: " + (details && cached_hash));
logging.log(rid + "new hash: " + new_hash);
callback(null, status, new_hash);
}
});
}
}
});
};
// handles requests for +userId+ avatars with +size+
// callback contains error, status, image buffer, skin hash
// image is the user's face+helm when helm is true, or the face otherwise
// for status, see get_image_hash
exp.get_avatar = function(rid, userId, helm, size, callback) {
exp.get_image_hash(rid, userId, "skin", function(err, status, skin_hash) {
if (skin_hash) {
var facepath = __dirname + "/../" + config.faces_dir + skin_hash + ".png";
var helmpath = __dirname + "/../" + config.helms_dir + skin_hash + ".png";
var filepath = facepath;
fs.exists(helmpath, function(exists) {
if (helm && exists) {
filepath = helmpath;
}
skins.resize_img(filepath, size, function(img_err, image) {
if (img_err) {
callback(img_err, -1, null, skin_hash);
} else {
callback(err, (err ? -1 : status), image, skin_hash);
}
});
});
} else {
// hash is null when userId has no skin
callback(err, status, null, null);
}
});
};
// handles requests for +userId+ skins
// callback contains error, skin hash, image buffer
exp.get_skin = function(rid, userId, callback) {
exp.get_image_hash(rid, userId, "skin", function(err, status, skin_hash) {
var skinpath = __dirname + "/../" + config.skins_dir + skin_hash + ".png";
fs.exists(skinpath, function(exists) {
if (exists) {
logging.log(rid + "skin already exists, not downloading");
skins.open_skin(rid, skinpath, function(err, img) {
callback(err, skin_hash, img);
});
} else {
networking.save_texture(rid, skin_hash, skinpath, function(err, response, img) {
callback(err, skin_hash, img);
});
}
});
});
};
function get_type(helm, body) {
var text = body ? "body" : "head";
return helm ? text + "helm" : text;
}
// handles creations of 3D renders
// callback contains error, skin hash, image buffer
exp.get_render = function(rid, userId, scale, helm, body, callback) {
exp.get_skin(rid, userId, function(err, skin_hash, img) {
if (!skin_hash) {
callback(err, -1, skin_hash, null);
return;
}
var renderpath = __dirname + "/../" + config.renders_dir + skin_hash + "-" + scale + "-" + get_type(helm, body) + ".png";
fs.exists(renderpath, function(exists) {
if (exists) {
renders.open_render(rid, renderpath, function(err, img) {
callback(err, 1, skin_hash, img);
});
return;
} else {
if (!img) {
callback(err, 0, skin_hash, null);
return;
}
renders.draw_model(rid, img, scale, helm, body, function(err, img) {
if (err) {
callback(err, -1, skin_hash, null);
} else if (!img) {
callback(null, 0, skin_hash, null);
} else {
fs.writeFile(renderpath, img, "binary", function(err) {
if (err) {
logging.error(rid + err.stack);
}
callback(null, 2, skin_hash, img);
});
}
});
}
});
});
};
// handles requests for +userId+ capes
// callback contains error, cape hash, image buffer
exp.get_cape = function(rid, userId, callback) {
exp.get_image_hash(rid, userId, "cape", function(err, status, cape_hash) {
if (!cape_hash) {
callback(err, null, null);
return;
}
var capepath = __dirname + "/../" + config.capes_dir + cape_hash + ".png";
fs.exists(capepath, function(exists) {
if (exists) {
logging.log(rid + "cape already exists, not downloading");
skins.open_skin(rid, capepath, function(err, img) {
callback(err, cape_hash, img);
});
} else {
networking.save_texture(rid, cape_hash, capepath, function(err, response, img) {
if (response && response.statusCode === 404) {
callback(err, cape_hash, null);
} else {
callback(err, cape_hash, img);
}
});
}
});
});
};
module.exports = exp;

45
modules/logging.js Normal file
View File

@ -0,0 +1,45 @@
var cluster = require("cluster");
var config = require("./config");
var exp = {};
function split_args(args) {
var text = "";
for (var i = 0, l = args.length; i < l; i++) {
if (i > 0) {
text += " " + args[i];
} else {
text += args[i];
}
}
return text;
}
function log(level, args, logger) {
logger = logger || console.log;
var time = config.log_time ? new Date().toISOString() + " " : "";
var clid = (cluster.worker && cluster.worker.id || "M");
var lines = split_args(args).split("\n");
for (var i = 0, l = lines.length; i < l; i++) {
logger(time + clid + " " + level + ": " + lines[i]);
}
}
exp.log = function() {
log(" INFO", arguments);
};
exp.warn = function() {
log(" WARN", arguments, console.warn);
};
exp.error = function() {
log("ERROR", arguments, console.error);
};
if (config.debug_enabled || process.env.DEBUG === "true") {
exp.debug = function() {
log("DEBUG", arguments);
};
} else {
exp.debug = function(){};
}
module.exports = exp;

179
modules/networking.js Normal file
View File

@ -0,0 +1,179 @@
var http_code = require("http").STATUS_CODES;
var logging = require("./logging");
var request = require("request");
var config = require("./config");
var fs = require("fs");
var session_url = "https://sessionserver.mojang.com/session/minecraft/profile/";
var skins_url = "https://skins.minecraft.net/MinecraftSkins/";
var capes_url = "https://skins.minecraft.net/MinecraftCloaks/";
var textures_url = "http://textures.minecraft.net/texture/";
var mojang_urls = [skins_url, capes_url];
var exp = {};
function extract_url(profile, property) {
var url = null;
if (profile && profile.properties) {
profile.properties.forEach(function(prop) {
if (prop.name === "textures") {
var json = new Buffer(prop.value, "base64").toString();
var props = JSON.parse(json);
url = props && props.textures && props.textures[property] && props.textures[property].url || null;
}
});
}
return url;
}
// exracts the skin url of a +profile+ object
// returns null when no url found (user has no skin)
exp.extract_skin_url = function(profile) {
return extract_url(profile, 'SKIN');
};
// exracts the cape url of a +profile+ object
// returns null when no url found (user has no cape)
exp.extract_cape_url = function(profile) {
return extract_url(profile, 'CAPE');
};
// makes a GET request to the +url+
// +options+ hash includes these options:
// encoding (string), default is to return a buffer
// +callback+ contains the body, response,
// and error buffer. get_from helper method is available
exp.get_from_options = function(rid, url, options, callback) {
request.get({
url: url,
headers: {
"User-Agent": "https://crafatar.com"
},
timeout: config.http_timeout,
followRedirect: false,
encoding: (options.encoding || null),
}, function(error, response, body) {
// log url + code + description
var code = response && response.statusCode;
if (!error) {
var logfunc = code && code < 405 ? logging.log : logging.warn;
logfunc(rid + url + " " + code + " " + http_code[code]);
}
// 200 or 301 depending on content type
if (!error && (code === 200 || code === 301)) {
// response received successfully
callback(body, response, null);
} else if (error) {
logging.error(error);
callback(body || null, response, error);
} else if (code === 404 || code === 204) {
// page does not exist
callback(null, response, null);
} else if (code === 429) {
// Too Many Requests exception - code 429
// cause error so the image will not be cached
callback(body || null, response, (error || "TooManyRequests"));
} else {
logging.error(rid + " Unknown reply:");
logging.error(rid + JSON.stringify(response));
callback(body || null, response, error);
}
});
};
// helper method for get_from_options, no options required
exp.get_from = function(rid, url, callback) {
exp.get_from_options(rid, url, {}, function(body, response, err) {
callback(body, response, err);
});
};
// make a request to skins.miencraft.net
// the skin url is taken from the HTTP redirect
// type reference is above
exp.get_username_url = function(rid, name, type, callback) {
exp.get_from(rid, mojang_urls[type] + name + ".png", function(body, response, err) {
if (!err) {
callback(err, response ? (response.statusCode === 404 ? null : response.headers.location) : null);
} else {
callback(err, null);
}
});
};
// gets the URL for a skin/cape from the profile
// +type+ specifies which to retrieve
exp.get_uuid_url = function(profile, type, callback) {
var url = null;
if (type === 0) {
url = exp.extract_skin_url(profile);
} else if (type === 1) {
url = exp.extract_cape_url(profile);
}
callback(url || null);
};
// make a request to sessionserver for +uuid+
// +callback+ contains error, profile
exp.get_profile = function(rid, uuid, callback) {
if (!uuid) {
callback(null, null);
} else {
exp.get_from_options(rid, session_url + uuid, { encoding: "utf8" }, function(body, response, err) {
callback(err || null, (body !== null ? JSON.parse(body) : null));
});
}
};
// get the skin URL for +userId+
// +profile+ is used if +userId+ is a uuid
exp.get_skin_url = function(rid, userId, profile, callback) {
get_url(rid, userId, profile, 0, function(err, url) {
callback(err, url);
});
};
// get the cape URL for +userId+
// +profile+ is used if +userId+ is a uuid
exp.get_cape_url = function(rid, userId, profile, callback) {
get_url(rid, userId, profile, 1, function(err, url) {
callback(err, url);
});
};
function get_url(rid, userId, profile, type, callback) {
if (userId.length <= 16) {
//username
exp.get_username_url(rid, userId, type, function(err, url) {
callback(err, url || null);
});
} else {
exp.get_uuid_url(profile, type, function(url) {
callback(null, url || null);
});
}
}
exp.save_texture = function(rid, tex_hash, outpath, callback) {
if (tex_hash) {
var textureurl = textures_url + tex_hash;
exp.get_from(rid, textureurl, function(img, response, err) {
if (err) {
logging.error(rid + "error while downloading texture");
callback(err, response, null);
} else {
fs.writeFile(outpath, img, "binary", function(err) {
if (err) {
logging.error(rid + "error: " + err.stack);
}
callback(err, response, img);
});
}
});
} else {
callback(null, null, null);
}
};
module.exports = exp;

198
modules/renders.js Normal file
View File

@ -0,0 +1,198 @@
// Skin locations are based on the work of Confuser, with 1.8 updates by Jake0oo0
// https://github.com/confuser/serverless-mc-skin-viewer
// Permission to use & distribute https://github.com/confuser/serverless-mc-skin-viewer/blob/master/LICENSE
var logging = require("./logging");
var fs = require("fs");
var Canvas = require("canvas");
var Image = Canvas.Image;
var exp = {};
// draws the helmet on to the +skin_canvas+
// using the skin from the +model_ctx+ at the +scale+
exp.draw_helmet = function(skin_canvas, model_ctx, scale) {
//Helmet - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 40*scale, 8*scale, 8*scale, 8*scale, 10*scale, 13/1.2*scale, 8*scale, 8*scale);
//Helmet - Right
model_ctx.setTransform(1,0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 32*scale, 8*scale, 8*scale, 8*scale, 2*scale, 3/1.2*scale, 8*scale, 8*scale);
//Helmet - Top
model_ctx.setTransform(-1,0.5,1,0.5,0,0);
model_ctx.scale(-1,1);
model_ctx.drawImage(skin_canvas, 40*scale, 0, 8*scale, 8*scale, -3*scale, 5*scale, 8*scale, 8*scale);
};
// draws the head on to the +skin_canvas+
// using the skin from the +model_ctx+ at the +scale+
exp.draw_head = function(skin_canvas, model_ctx, scale) {
//Head - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 8*scale, 8*scale, 8*scale, 8*scale, 10*scale, 13/1.2*scale, 8*scale, 8*scale);
//Head - Right
model_ctx.setTransform(1,0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 0, 8*scale, 8*scale, 8*scale, 2*scale, 3/1.2*scale, 8*scale, 8*scale);
//Head - Top
model_ctx.setTransform(-1,0.5,1,0.5,0,0);
model_ctx.scale(-1,1);
model_ctx.drawImage(skin_canvas, 8*scale, 0, 8*scale, 8*scale, -3*scale, 5*scale, 8*scale, 8*scale);
};
// draws the body on to the +skin_canvas+
// using the skin from the +model_ctx+ at the +scale+
// parts are labeled as if drawn from the skin's POV
exp.draw_body = function(rid, skin_canvas, model_ctx, scale) {
if (skin_canvas.height === 32 * scale) {
logging.debug(rid + "uses old skin format");
//Left Leg
//Left Leg - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.scale(-1,1);
model_ctx.drawImage(skin_canvas, 4*scale, 20*scale, 4*scale, 12*scale, -16*scale, 34.4/1.2*scale, 4*scale, 12*scale);
//Right Leg
//Right Leg - Right
model_ctx.setTransform(1,0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 0*scale, 20*scale, 4*scale, 12*scale, 4*scale, 26.4/1.2*scale, 4*scale, 12*scale);
//Right Leg - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 4*scale, 20*scale, 4*scale, 12*scale, 8*scale, 34.4/1.2*scale, 4*scale, 12*scale);
//Arm Left
//Arm Left - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.scale(-1,1);
model_ctx.drawImage(skin_canvas, 44*scale, 20*scale, 4*scale, 12*scale, -20*scale, 20/1.2*scale, 4*scale, 12*scale);
//Arm Left - Top
model_ctx.setTransform(-1,0.5,1,0.5,0,0);
model_ctx.drawImage(skin_canvas, 44*scale, 16*scale, 4*scale, 4*scale, 0, 16*scale, 4*scale, 4*scale);
//Body
//Body - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 20*scale, 20*scale, 8*scale, 12*scale, 8*scale, 20/1.2*scale, 8*scale, 12*scale);
//Arm Right
//Arm Right - Right
model_ctx.setTransform(1,0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 40*scale, 20*scale, 4*scale, 12*scale, 0, 16/1.2*scale, 4*scale, 12*scale);
//Arm Right - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 44*scale, 20*scale, 4*scale, 12*scale, 4*scale, 20/1.2*scale, 4*scale, 12*scale);
//Arm Right - Top
model_ctx.setTransform(-1,0.5,1,0.5,0,0);
model_ctx.scale(-1,1);
model_ctx.drawImage(skin_canvas, 44*scale, 16*scale, 4*scale, 4*scale, -16*scale, 16*scale, 4*scale, 4*scale);
} else {
logging.debug(rid + "uses new skin format");
//Left Leg
//Left Leg - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 20*scale, 52*scale, 4*scale, 12*scale, 12*scale, 34.4/1.2*scale, 4*scale, 12*scale);
//Right Leg
//Right Leg - Right
model_ctx.setTransform(1,0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 0, 20*scale, 4*scale, 12*scale, 4*scale, 26.4/1.2*scale, 4*scale, 12*scale);
//Right Leg - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 4*scale, 20*scale, 4*scale, 12*scale, 8*scale, 34.4/1.2*scale, 4*scale, 12*scale);
//Arm Left
//Arm Left - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 36*scale, 52*scale, 4*scale, 12*scale, 16*scale, 20/1.2*scale, 4*scale, 12*scale);
//Arm Left - Top
model_ctx.setTransform(-1,0.5,1,0.5,0,0);
model_ctx.drawImage(skin_canvas, 36*scale, 48*scale, 4*scale, 4*scale, 0, 16*scale, 4*scale, 4*scale);
//Body
//Body - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 20*scale, 20*scale, 8*scale, 12*scale, 8*scale, 20/1.2*scale, 8*scale, 12*scale);
//Arm Right
//Arm Right - Right
model_ctx.setTransform(1,0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 40*scale, 20*scale, 4*scale, 12*scale, 0, 16/1.2*scale, 4*scale, 12*scale);
//Arm Right - Front
model_ctx.setTransform(1,-0.5,0,1.2,0,0);
model_ctx.drawImage(skin_canvas, 44*scale, 20*scale, 4*scale, 12*scale, 4*scale, 20/1.2*scale, 4*scale, 12*scale);
//Arm Right - Top
model_ctx.setTransform(-1,0.5,1,0.5,0,0);
model_ctx.scale(-1,1);
model_ctx.drawImage(skin_canvas, 44*scale, 16*scale, 4*scale, 4*scale, -16*scale, 16*scale, 4*scale, 4*scale);
}
};
// sets up the necessary components to draw the skin model
// uses the +img+ skin with options of drawing
// the +helm+ and the +body+
// callback contains error, image buffer
exp.draw_model = function(rid, img, scale, helm, body, callback) {
var image = new Image();
image.onerror = function(err) {
logging.error(rid + "render error: " + err.stack);
callback(err, null);
};
image.onload = function() {
var width = 64 * scale;
var original_height = (image.height === 32 ? 32 : 64);
var height = original_height * scale;
var model_canvas = new Canvas(20 * scale, (body ? 44.8 : 17.6) * scale);
var skin_canvas = new Canvas(width, height);
var model_ctx = model_canvas.getContext("2d");
var skin_ctx = skin_canvas.getContext("2d");
skin_ctx.drawImage(image,0,0,64,original_height);
//Scale it
scale_image(skin_ctx.getImageData(0,0,64,original_height), skin_ctx, 0, 0, scale);
if (body) {
exp.draw_body(rid, skin_canvas, model_ctx, scale);
}
exp.draw_head(skin_canvas, model_ctx, scale);
if (helm) {
exp.draw_helmet(skin_canvas, model_ctx, scale);
}
model_canvas.toBuffer(function(err, buf){
if (err) {
logging.error(rid + "error creating buffer: " + err);
}
callback(err, buf);
});
};
image.src = img;
};
// helper method to open a render from +renderpath+
// callback contains error, image buffer
exp.open_render = function(rid, renderpath, callback) {
fs.readFile(renderpath, function (err, buf) {
if (err) {
logging.error(rid + "error while opening skin file: " + err);
}
callback(err, buf);
});
};
// scales an image from the +imagedata+ onto the +context+
// scaled by a factor of +scale+ with options +d_x+ and +d_y+
function scale_image(imageData, context, d_x, d_y, scale) {
var width = imageData.width;
var height = imageData.height;
context.clearRect(0,0,width,height); //Clear the spot where it originated from
for(var y = 0; y < height; y++) { // height original
for(var x = 0; x < width; x++) { // width original
//Gets original colour, then makes a scaled square of the same colour
var index = (x + y * width) * 4;
context.fillStyle = "rgba(" + imageData.data[index+0] + "," + imageData.data[index+1] + "," + imageData.data[index+2] + "," + imageData.data[index+3] + ")";
context.fillRect(d_x + x*scale, d_y + y*scale, scale, scale);
}
}
}
module.exports = exp;

126
modules/skins.js Normal file
View File

@ -0,0 +1,126 @@
var logging = require("./logging");
var lwip = require("lwip");
var fs = require("fs");
var exp = {};
// extracts the face from an image +buffer+
// result is saved to a file called +outname+
// +callback+ contains error
exp.extract_face = function(buffer, outname, callback) {
lwip.open(buffer, "png", function(err, image) {
if (err) {
callback(err);
} else {
image.batch()
.crop(8, 8, 15, 15) // face
.writeFile(outname, function(err) {
if (err) {
callback(err);
} else {
callback(null);
}
});
}
});
};
// extracts the helm from an image +buffer+ and lays it over a +facefile+
// +facefile+ is the filename of an image produced by extract_face
// result is saved to a file called +outname+
// +callback+ contains error
exp.extract_helm = function(rid, facefile, buffer, outname, callback) {
lwip.open(buffer, "png", function(err, skin_img) {
if (err) {
callback(err);
} else {
lwip.open(facefile, function(err, face_img) {
if (err) {
callback(err);
} else {
face_img.toBuffer("png", { compression: "none" }, function(err, face_buffer) {
skin_img.crop(40, 8, 47, 15, function(err, helm_img) {
if (err) {
callback(err);
} else {
face_img.paste(0, 0, helm_img, function(err, face_helm_img) {
if (err) {
callback(err);
} else {
face_helm_img.toBuffer("png", {compression: "none"}, function(err, face_helm_buffer) {
if (face_helm_buffer.toString() !== face_buffer.toString()) {
face_helm_img.writeFile(outname, function(err) {
callback(err);
});
} else {
logging.log(rid + "helm img == face img, not storing!");
callback(null);
}
});
}
});
}
});
});
}
});
}
});
};
// resizes the image file +inname+ to +size+ by +size+ pixels
// +callback+ contains error, image buffer
exp.resize_img = function(inname, size, callback) {
lwip.open(inname, function(err, image) {
if (err) {
callback(err, null);
} else {
image.batch()
.resize(size, size, "nearest-neighbor") // nearest-neighbor doesn't blur
.toBuffer("png", function(err, buffer) {
callback(null, buffer);
});
}
});
};
// returns "alex" or "steve" calculated by the +userId+
exp.default_skin = function(userId) {
if (Number("0x" + userId[31]) % 2 === 0) {
return "alex";
} else {
return "steve";
}
};
// helper method for opening a skin file from +skinpath+
// callback contains error, image buffer
exp.open_skin = function(rid, skinpath, callback) {
fs.readFile(skinpath, function(err, buf) {
if (err) {
logging.error(rid + "error while opening skin file: " + err);
callback(err, null);
} else {
callback(null, buf);
}
});
};
exp.save_image = function(buffer, outpath, callback) {
lwip.open(buffer, "png", function(err, image) {
if (err) {
callback(err);
} else {
image.batch()
.writeFile(outpath, function(err) {
if (err) {
callback(err);
} else {
callback(null);
}
});
}
});
};
module.exports = exp;

2392
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,47 @@
{ {
"name": "crafatar", "name": "crafatar",
"version": "2.1.5", "version": "1.0.0",
"private": true, "private": true,
"author": "Jake0oo0",
"description": "A Minecraft avatar service with support for avatars, 1.8 skins, and even 3D renders!",
"contributors": [
{
"name": "jomo"
}
],
"repository": {
"type": "git",
"url": "https://github.com/crafatar/crafatar"
},
"issues": {
"url": "https://github.com/crafatar/crafatar/issues"
},
"keywords": [
"minecraft",
"avatar"
],
"scripts": { "scripts": {
"start": "node www.js", "postinstall": "cp 'modules/config.example.js' 'modules/config.js'",
"test": "mocha" "start": "forever -l logs/log.log -o logs/out.log -e logs/error.log -p ./ -a --minUptime 8000 --spinSleepTime 1500 bin/www.js",
"test": "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" "iojs": "1.3.x"
}, },
"dependencies": { "dependencies": {
"@randy.tarampi/lwip": "^1.3.1", "canvas": "crafatar/node-canvas",
"canvas": "^2.6.1", "forever": "0.14.1",
"crc": "^3.8.0", "jade": "~1.9.1",
"ejs": "^3.1.5", "lwip": "0.0.6",
"mime": "^2.4.6", "mime": "1.3.4",
"redis": "^3.0.2", "node-df": "0.1.1",
"request": "^2.88.2", "redis": "0.12.1",
"toobusy-js": "^0.5.1" "request": "^2.51.0"
}, },
"devDependencies": { "devDependencies": {
"mocha": "^7.2.0" "coveralls": "^2.11.2",
"istanbul": "^0.3.2",
"mocha": "2.1.0",
"mocha-lcov-reporter": "0.0.1"
} }
} }

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 B

BIN
public/images/akliz.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

View File

Before

Width:  |  Height:  |  Size: 150 B

After

Width:  |  Height:  |  Size: 150 B

View File

Before

Width:  |  Height:  |  Size: 997 B

After

Width:  |  Height:  |  Size: 997 B

View File

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 222 B

View File

Before

Width:  |  Height:  |  Size: 835 B

After

Width:  |  Height:  |  Size: 835 B

BIN
public/images/twitter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

View File

@ -13,6 +13,11 @@ a {
color: #00B7FF; color: #00B7FF;
} }
a.anchor {
position: relative;
top: -50px;
}
a.forkme { a.forkme {
top: 0; top: 0;
right: 0; right: 0;
@ -20,14 +25,14 @@ a.forkme {
position: fixed; position: fixed;
display: inline-block; display: inline-block;
background: #008000; background: #008000;
box-shadow: 0 0 5px #000;
color: #fff; color: #fff;
font-weight: bold; font-weight: bold;
padding: 3px 100px; padding: 3px 40px;
border: 2px solid #006400; border: 2px solid #006400;
-webkit-transform: rotate(45deg) translate(108px, -46px); -webkit-transform: rotate(45deg) translate(65px);
transform: rotate(45deg) translate(108px, -46px); transform: rotate(45deg) translate(65px);
} }
a.forkme:hover { a.forkme:hover {
color: #ddd; color: #ddd;
text-decoration: none; text-decoration: none;
@ -35,120 +40,60 @@ a.forkme:hover {
a.sponsor { a.sponsor {
position: fixed; position: fixed;
width: 48px;
height: 48px;
right: 0px; right: 0px;
top: 0px; top: 0px;
margin: 5px; height: 40px;
width: 40px;
z-index: 1041;
margin: 5px 10px;
} }
.sponsor img { .container > .navbar-header {
width: 100%;
height: 100%;
}
a.sponsor-item {
color: #aa7100 !important;
font-weight: initial;
background: #fff3de;
border-color: #fcd794;
}
.sponsor-item:hover {
background: #fff8ec !important;
}
#quote-wrapper {
line-height: 9.5em;
}
#quote {
display: inline-block; display: inline-block;
margin: inherit;
}
a.navbar-brand.twitter {
color: #55acee;
font-size: 16px;
}
a.navbar-brand.twitter:before {
content: "";
background: url("/images/twitter.png");
display: inline-block;
height: 16px;
width: 16px;
vertical-align: middle; vertical-align: middle;
line-height: initial;
background: #d4e7ff;
border-color: #94cbfc;
} }
#quote:hover { mark.green {
background: #dcedff;
}
.alert {
font-size: 1rem;
}
#documentation .row {
background: #eee;
border-radius: 0.25rem;
}
#documentation .row .col-md-2 {
text-align: center;
}
#documentation .row > div {
padding: 15px;
}
#try input {
width: 100%;
background: #fff;
border: 1px solid #ddd;
padding: 0.3em;
line-height: 1.5em;
margin: 0px;
}
img.tryit {
-webkit-filter: drop-shadow(0px 0px 6px);
filter: drop-shadow(0px 0px 6px);
}
mark {
background: inherit; background: inherit;
color: #008000;
font-weight: bold; font-weight: bold;
padding: 0; padding: 0;
} }
mark.green { thead {
color: #080; font-weight: bold;
} }
mark.blue {
color: #08f;
}
span[title] {
cursor: help;
text-decoration: underline dotted;
}
.row { .row {
margin-right: auto; margin-right: auto;
margin-left: auto; margin-left: auto;
} }
h1, h2, h3, h4, h6 { h1, h2, h3, h4, h5, h6 {
font-weight: 200; color: #333;
} font-weight: normal;
h1 {
font-size: 4rem;
}
h2 {
margin-top: 2em;
} }
h3 { h3 {
font-size: 1.3rem;
margin-top: 2em; margin-top: 2em;
} }
code { h4 {
word-wrap: break-word; margin-top: 1em;
} }
.code { .code {
@ -165,88 +110,185 @@ code {
position: relative; position: relative;
} }
.jumbotron { .code .example {
padding: 1em 0 3em; cursor: text;
}
.code .example:hover {
color: #000;
text-decoration: underline;
}
.preview-background {
background: #eee;
height: 220px;
}
.code .example-wrapper .preview, .code .preview-placeholder {
display: none;
left: 0;
right: 0;
position: absolute;
bottom: -240px;
padding-left: 10px;
padding-top: 200px;
background-position: 10px center;
background-repeat: no-repeat;
font-size: 14px;
font-family: "Helvetica Neue", Arial, sans-serif;
font-weight: 300;
color: #666;
}
.code .preview-placeholder {
display: block;
font-weight: bold;
}
.code .preview-placeholder:hover {
/* fixes glitchy blinking */
display: block !important;
}
.code:hover .preview-placeholder {
display: none;
}
.code .example-wrapper .preview i {
color: #aaa;
}
.code .example-wrapper:hover .preview {
display: block;
}
#avatar-example-1:hover .preview {
background-image: url("/avatars/jeb_");
}
#avatar-example-2:hover .preview {
background-image: url("/avatars/jeb_?helm");
}
#avatar-example-3:hover .preview {
background-image: url("/avatars/jeb_?size=128");
}
#avatar-example-4:hover .preview {
background-image: url("/avatars/853c80ef3c3749fdaa49938b674adae6");
}
#avatar-example-5:hover .preview {
background-image: url("/avatars/0?default=alex");
}
#avatar-example-6:hover .preview {
background-image: url("/avatars/0?default=https%3A%2F%2Fi.imgur.com%2FocJVWAc.png");
}
#render-example-1:hover .preview {
background-image: url("/renders/body/jeb_?helm&scale=4");
}
#render-example-2:hover .preview {
background-image: url("/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=8");
}
#skin-example-1:hover .preview {
background-image: url("/skins/jeb_");
}
#skin-example-2:hover .preview {
background-image: url("/skins/0?default=alex");
}
#cape-example-1:hover .preview {
background-image: url("/capes/Dinnerbone");
}
#cape-example-2:hover .preview {
background-image: url("/capes/md_5");
}
img.preload {
/*
preload hover images
browsers don't load 0x0 images
*/
position: fixed;
top: -9999px;
left: -9999px;
} }
.jumbotron img { .jumbotron img {
margin: 5px; margin: 5px;
} }
#avatar-wrapper { .avatar-wrapper {
height: 64px; height: 64px;
overflow: hidden; overflow: hidden;
font-size: 0;
} }
.avatar { .avatar {
width: 64px; width: 64px;
height: 64px; height: 64px;
display: inline-block; display: inline-block;
margin-right: 6px; margin-right: 0.5em;
} }
.avatar.jomo {background-image: url("/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=64")} .avatar.jomo {background-image: url("/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=64")}
.avatar.jomo:hover {background-image: url("/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=64&overlay")} .avatar.jomo:hover {background-image: url("/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=64&helm")}
.avatar.jake_0 {background-image: url("/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=64")} .avatar.jake_0 {background-image: url("/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=64")}
.avatar.jake_0:hover {background-image: url("/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=64&overlay")} .avatar.jake_0:hover {background-image: url("/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=64&helm")}
.avatar.sk89q {background-image: url("/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=64")} .avatar.sk89q {background-image: url("/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=64")}
.avatar.sk89q:hover {background-image: url("/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=64&overlay")} .avatar.sk89q:hover {background-image: url("/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=64&helm")}
.avatar.md_5 {background-image: url("/avatars/af74a02d19cb445bb07f6866a861f783?size=64")} .avatar.md_5 {background-image: url("/avatars/af74a02d19cb445bb07f6866a861f783?size=64")}
.avatar.md_5:hover {background-image: url("/avatars/af74a02d19cb445bb07f6866a861f783?size=64&overlay")} .avatar.md_5:hover {background-image: url("/avatars/af74a02d19cb445bb07f6866a861f783?size=64&helm")}
.avatar.jeb {background-image: url("/avatars/853c80ef3c3749fdaa49938b674adae6?size=64")} .avatar.jeb {background-image: url("/avatars/853c80ef3c3749fdaa49938b674adae6?size=64")}
.avatar.jeb:hover {background-image: url("/avatars/853c80ef3c3749fdaa49938b674adae6?size=64&overlay")} .avatar.jeb:hover {background-image: url("/avatars/853c80ef3c3749fdaa49938b674adae6?size=64&helm")}
.avatar.notch {background-image: url("/avatars/069a79f444e94726a5befca90e38aaf5?size=64")} .avatar.notch {background-image: url("/avatars/069a79f444e94726a5befca90e38aaf5?size=64")}
.avatar.notch:hover {background-image: url("/avatars/069a79f444e94726a5befca90e38aaf5?size=64&overlay")} /* Notch fucked up his helm */
.avatar.dinnerbone {background-image: url("/avatars/61699b2ed3274a019f1e0ea8c3f06bc6?size=64")} .avatar.dinnerbone {background-image: url("/avatars/61699b2ed3274a019f1e0ea8c3f06bc6?size=64")}
.avatar.dinnerbone:hover {background-image: url("/avatars/61699b2ed3274a019f1e0ea8c3f06bc6?size=64&overlay")} .avatar.dinnerbone:hover {background-image: url("/avatars/61699b2ed3274a019f1e0ea8c3f06bc6?size=64&helm")}
.avatar.ez {background-image: url("/avatars/7d043c7389524696bfba571c05b6aec0?size=64")} .avatar.ez {background-image: url("/avatars/7d043c7389524696bfba571c05b6aec0?size=64")}
.avatar.ez:hover {background-image: url("/avatars/7d043c7389524696bfba571c05b6aec0?size=64&overlay")} .avatar.ez:hover {background-image: url("/avatars/7d043c7389524696bfba571c05b6aec0?size=64&helm")}
.avatar.grumm {background-image: url("/avatars/e6b5c088068044df9e1b9bf11792291b?size=64")} .avatar.grumm {background-image: url("/avatars/e6b5c088068044df9e1b9bf11792291b?size=64")}
.avatar.grumm:hover {background-image: url("/avatars/e6b5c088068044df9e1b9bf11792291b?size=64&overlay")} .avatar.grumm:hover {background-image: url("/avatars/e6b5c088068044df9e1b9bf11792291b?size=64&helm")}
.avatar.themogmimer {background-image: url("/avatars/1c1bd09a6a0f4928a7914102a35d2670?size=64")} .avatar.themogmimer {background-image: url("/avatars/1c1bd09a6a0f4928a7914102a35d2670?size=64")}
.avatar.themogmimer:hover {background-image: url("/avatars/1c1bd09a6a0f4928a7914102a35d2670?size=64&overlay")} .avatar.themogmimer:hover {background-image: url("/avatars/1c1bd09a6a0f4928a7914102a35d2670?size=64&helm")}
.avatar.marc {background-image: url("/avatars/b05881186e75410db2db4d3066b223f7?size=64")} .avatar.marc {background-image: url("/avatars/b05881186e75410db2db4d3066b223f7?size=64")}
.avatar.marc:hover {background-image: url("/avatars/b05881186e75410db2db4d3066b223f7?size=64&overlay")} .avatar.marc:hover {background-image: url("/avatars/b05881186e75410db2db4d3066b223f7?size=64&helm")}
.avatar.searge {background-image: url("/avatars/696a82ce41f44b51aa31b8709b8686f0?size=64")} .avatar.searge {background-image: url("/avatars/696a82ce41f44b51aa31b8709b8686f0?size=64")}
.avatar.searge:hover {background-image: url("/avatars/696a82ce41f44b51aa31b8709b8686f0?size=64&overlay")} .avatar.searge:hover {background-image: url("/avatars/696a82ce41f44b51aa31b8709b8686f0?size=64&helm")}
.avatar.xlson {background-image: url("/avatars/b9583ca43e64488a9c8c4ab27e482255?size=64")} .avatar.xlson {background-image: url("/avatars/b9583ca43e64488a9c8c4ab27e482255?size=64")}
.avatar.xlson:hover {background-image: url("/avatars/b9583ca43e64488a9c8c4ab27e482255?size=64&overlay")} .avatar.xlson:hover {background-image: url("/avatars/b9583ca43e64488a9c8c4ab27e482255?size=64&helm")}
.avatar.aikar {background-image: url("/avatars/23c0b72e6a3f4390897f9ec328eef972?size=64")} .avatar.minecraftchick {background-image: url("/avatars/c9b54008fd8047428b238787b5f2401c?size=64")}
.avatar.aikar:hover {background-image: url("/avatars/23c0b72e6a3f4390897f9ec328eef972?size=64&overlay")} .avatar.minecraftchick:hover {background-image: url("/avatars/c9b54008fd8047428b238787b5f2401c?size=64&helm")}
.avatar.ammar2 {background-image: url("/avatars/98bde7ac1cdc4027a8e94b3ed31558c1?size=64")} .avatar.kappe {background-image: url("/avatars/d8f9a4340f2d415f9acfcd70341c75ec?size=64")}
.avatar.ammar2:hover {background-image: url("/avatars/98bde7ac1cdc4027a8e94b3ed31558c1?size=64&overlay")} .avatar.kappe:hover {background-image: url("/avatars/d8f9a4340f2d415f9acfcd70341c75ec?size=64&helm")}
.avatar.krisjelbring {background-image: url("/avatars/7125ba8b1c864508b92bb5c042ccfe2b?size=64")} .avatar.krisjelbring {background-image: url("/avatars/7125ba8b1c864508b92bb5c042ccfe2b?size=64")}
.avatar.krisjelbring:hover {background-image: url("/avatars/7125ba8b1c864508b92bb5c042ccfe2b?size=64&overlay")} .avatar.krisjelbring:hover {background-image: url("/avatars/7125ba8b1c864508b92bb5c042ccfe2b?size=64&helm")}
.avatar.thinkofdeath {background-image: url("/avatars/4566e69fc90748ee8d71d7ba5aa00d20?size=64")} .avatar.thinkofdeath {background-image: url("/avatars/4566e69fc90748ee8d71d7ba5aa00d20?size=64")}
.avatar.thinkofdeath:hover {background-image: url("/avatars/4566e69fc90748ee8d71d7ba5aa00d20?size=64&overlay")} .avatar.thinkofdeath:hover {background-image: url("/avatars/4566e69fc90748ee8d71d7ba5aa00d20?size=64&helm")}
.avatar.evilseph {background-image: url("/avatars/020242a17b9441799eff511eea1221da?size=64")} .avatar.evilseph {background-image: url("/avatars/020242a17b9441799eff511eea1221da?size=64")}
.avatar.evilseph:hover {background-image: url("/avatars/020242a17b9441799eff511eea1221da?size=64&overlay")} .avatar.evilseph:hover {background-image: url("/avatars/020242a17b9441799eff511eea1221da?size=64&helm")}
.avatar.mollstam {background-image: url("/avatars/9769ecf6331448f3ace67ae06cec64a3?size=64")} .avatar.mollstam {background-image: url("/avatars/9769ecf6331448f3ace67ae06cec64a3?size=64")}
.avatar.mollstam:hover {background-image: url("/avatars/9769ecf6331448f3ace67ae06cec64a3?size=64&overlay")} .avatar.mollstam:hover {background-image: url("/avatars/9769ecf6331448f3ace67ae06cec64a3?size=64&helm")}
.avatar.mollstam {background-image: url("/avatars/f8cdb6839e9043eea81939f85d9c5d69?size=64")} .avatar.mollstam {background-image: url("/avatars/f8cdb6839e9043eea81939f85d9c5d69?size=64")}
.avatar.mollstam:hover {background-image: url("/avatars/f8cdb6839e9043eea81939f85d9c5d69?size=64&overlay")} .avatar.mollstam:hover {background-image: url("/avatars/f8cdb6839e9043eea81939f85d9c5d69?size=64&helm")}
.avatar.flipped { .avatar.flipped {
-webkit-transform: rotate(180deg); -webkit-transform: rotate(180deg);
transform: rotate(180deg); transform: rotate(180deg);
} }

113
routes/avatars.js Normal file
View File

@ -0,0 +1,113 @@
var logging = require("../modules/logging");
var helpers = require("../modules/helpers");
var config = require("../modules/config");
var skins = require("../modules/skins");
var cache = require("../modules/cache");
var human_status = {
0: "none",
1: "cached",
2: "downloaded",
3: "checked",
"-1": "error"
};
// GET avatar request
module.exports = function(req, res) {
var start = new Date();
var userId = (req.url.path_list[2] || "").split(".")[0];
var size = parseInt(req.url.query.size) || config.default_size;
var def = req.url.query.default;
var helm = req.url.query.hasOwnProperty("helm");
var etag = null;
var rid = req.id;
// Prevent app from crashing/freezing
if (size < config.min_size || size > config.max_size) {
// "Unprocessable Entity", valid request, but semantically erroneous:
// https://tools.ietf.org/html/rfc4918#page-78
res.writeHead(422, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("Invalid Size");
return;
} else if (!helpers.id_valid(userId)) {
res.writeHead(422, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("Invalid ID");
return;
}
// strip dashes
userId = userId.replace(/-/g, "");
logging.log(rid + "userid: " + userId);
try {
helpers.get_avatar(rid, userId, helm, size, function(err, status, image, hash) {
logging.log(rid + "storage type: " + human_status[status]);
if (err) {
logging.error(rid + err);
if (err.code === "ENOENT") {
// no such file
cache.remove_hash(rid, userId);
}
}
etag = image && hash && hash.substr(0, 32) || "none";
var matches = req.headers["if-none-match"] === '"' + etag + '"';
if (image) {
var http_status = 200;
if (matches) {
http_status = 304;
} else if (err) {
http_status = 503;
}
logging.debug(rid + "etag: " + req.headers["if-none-match"]);
logging.debug(rid + "matches: " + matches);
sendimage(rid, http_status, status, image);
} else {
handle_default(rid, 404, status, userId);
}
});
} catch(e) {
logging.error(rid + "error: " + e.stack);
handle_default(rid, 500, -1, userId);
}
function handle_default(rid, http_status, img_status, userId) {
if (def && def !== "steve" && def !== "alex") {
logging.log(rid + "status: 301");
res.writeHead(301, {
"Cache-Control": "max-age=" + config.browser_cache_time + ", public",
"Response-Time": new Date() - start,
"X-Storage-Type": human_status[img_status],
"X-Request-ID": rid,
"Access-Control-Allow-Origin": "*",
"Location": def
});
res.end();
} else {
def = def || skins.default_skin(userId);
skins.resize_img("public/images/" + def + ".png", size, function(err, image) {
sendimage(rid, http_status, img_status, image);
});
}
}
function sendimage(rid, http_status, img_status, image) {
logging.log(rid + "status: " + http_status);
res.writeHead(http_status, {
"Content-Type": "image/png",
"Cache-Control": "max-age=" + config.browser_cache_time + ", public",
"Response-Time": new Date() - start,
"X-Storage-Type": human_status[img_status],
"X-Request-ID": rid,
"Access-Control-Allow-Origin": "*",
"Etag": '"' + etag + '"'
});
res.end(http_status === 304 ? null : image);
}
};

86
routes/capes.js Normal file
View File

@ -0,0 +1,86 @@
var logging = require("../modules/logging");
var helpers = require("../modules/helpers");
var config = require("../modules/config");
var cache = require("../modules/cache");
var human_status = {
0: "none",
1: "cached",
2: "downloaded",
3: "checked",
"-1": "error"
};
// GET cape request
module.exports = function(req, res) {
var start = new Date();
var userId = (req.url.pathname.split("/")[2] || "").split(".")[0];
var etag = null;
var rid = req.id;
if (!helpers.id_valid(userId)) {
res.writeHead(422, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("Invalid ID");
return;
}
// strip dashes
userId = userId.replace(/-/g, "");
logging.log(rid + "userid: " + userId);
try {
helpers.get_cape(rid, userId, function(err, status, image, hash) {
logging.log(rid + "storage type: " + human_status[status]);
if (err) {
logging.error(rid + err);
if (err.code === "ENOENT") {
// no such file
cache.remove_hash(rid, userId);
}
}
etag = hash && hash.substr(0, 32) || "none";
var matches = req.headers["if-none-match"] === '"' + etag + '"';
if (image) {
var http_status = 200;
if (matches) {
http_status = 304;
} else if (err) {
http_status = 503;
}
logging.debug(rid + "etag: " + req.headers["if-none-match"]);
logging.debug(rid + "matches: " + matches);
logging.log(rid + "status: " + http_status);
sendimage(rid, http_status, status, image);
} else {
res.writeHead(404, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("404 not found");
}
});
} catch(e) {
logging.error(rid + "error:" + e.stack);
res.writeHead(500, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("500 server error");
}
function sendimage(rid, http_status, img_status, image) {
res.writeHead(http_status, {
"Content-Type": "image/png",
"Cache-Control": "max-age=" + config.browser_cache_time + ", public",
"Response-Time": new Date() - start,
"X-Storage-Type": human_status[img_status],
"X-Request-ID": rid,
"Access-Control-Allow-Origin": "*",
"Etag": '"' + etag + '"'
});
res.end(http_status === 304 ? null : image);
}
};

18
routes/index.js Normal file
View File

@ -0,0 +1,18 @@
var config = require("../modules/config");
var jade = require("jade");
// compile jade
var index = jade.compileFile(__dirname + "/../views/index.jade");
module.exports = function(req, res) {
var html = index({
title: "Crafatar",
domain: "https://" + req.headers.host,
config: config
});
res.writeHead(200, {
"Content-Length": Buffer.byteLength(html, "UTF-8"),
"Content-Type": "text/html; charset=utf-8"
});
res.end(html);
};

142
routes/renders.js Normal file
View File

@ -0,0 +1,142 @@
var logging = require("../modules/logging");
var helpers = require("../modules/helpers");
var config = require("../modules/config");
var cache = require("../modules/cache");
var skins = require("../modules/skins");
var renders = require("../modules/renders");
var fs = require("fs");
var human_status = {
0: "none",
1: "cached",
2: "downloaded",
3: "checked",
"-1": "error"
};
// valid types: head, body
// helmet is query param
// TODO: The Type logic should be two separate GET functions once response methods are extracted
// GET render request
module.exports = function(req, res) {
var start = new Date();
var raw_type = (req.url.path_list[2] || "");
var rid = req.id;
// validate type
if (raw_type !== "body" && raw_type !== "head") {
res.writeHead(422, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("Invalid Render Type");
return;
}
var body = raw_type === "body";
var userId = (req.url.path_list[3] || "").split(".")[0];
var def = req.url.query.default;
var scale = parseInt(req.url.query.scale) || config.default_scale;
var helm = req.url.query.hasOwnProperty("helm");
var etag = null;
if (scale < config.min_scale || scale > config.max_scale) {
res.writeHead(422, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("422 Invalid Scale");
return;
} else if (!helpers.id_valid(userId)) {
res.writeHead(422, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("422 Invalid ID");
return;
}
// strip dashes
userId = userId.replace(/-/g, "");
logging.log(rid + "userId: " + userId);
try {
helpers.get_render(rid, userId, scale, helm, body, function(err, status, hash, image) {
logging.log(rid + "storage type: " + human_status[status]);
if (err) {
logging.error(rid + err);
if (err.code === "ENOENT") {
// no such file
cache.remove_hash(rid, userId);
}
}
etag = hash && hash.substr(0, 32) || "none";
var matches = req.headers["if-none-match"] === '"' + etag + '"';
if (image) {
var http_status = 200;
if (matches) {
http_status = 304;
} else if (err) {
http_status = 503;
}
logging.debug(rid + "etag: " + req.headers["if-none-match"]);
logging.debug(rid + "matches: " + matches);
sendimage(rid, http_status, status, image);
} else {
logging.log(rid + "image not found, using default.");
handle_default(rid, 404, status, userId);
}
});
} catch(e) {
logging.error(rid + "error: " + e.stack);
handle_default(rid, 500, -1, userId);
}
// default alex/steve images can be rendered, but
// custom images will not be
function handle_default(rid, http_status, img_status, userId) {
if (def && def !== "steve" && def !== "alex") {
logging.log(rid + "status: 301");
res.writeHead(301, {
"Cache-Control": "max-age=" + config.browser_cache_time + ", public",
"Response-Time": new Date() - start,
"X-Storage-Type": human_status[img_status],
"X-Request-ID": rid,
"Access-Control-Allow-Origin": "*",
"Location": def
});
res.end();
} else {
def = def || skins.default_skin(userId);
fs.readFile("public/images/" + def + "_skin.png", function (err, buf) {
if (err) {
// errored while loading the default image, continuing with null image
logging.error(rid + "error loading default render image: " + err);
}
// we render the default skins, but not custom images
renders.draw_model(rid, buf, scale, helm, body, function(err, def_img) {
if (err) {
logging.error(rid + "error while rendering default image: " + err);
}
sendimage(rid, http_status, img_status, def_img);
});
});
}
}
function sendimage(rid, http_status, img_status, image) {
logging.log(rid + "status: " + http_status);
res.writeHead(http_status, {
"Content-Type": "image/png",
"Cache-Control": "max-age=" + config.browser_cache_time + ", public",
"Response-Time": new Date() - start,
"X-Storage-Type": human_status[img_status],
"X-Request-ID": rid,
"Access-Control-Allow-Origin": "*",
"Etag": '"' + etag + '"'
});
res.end(http_status === 304 ? null : image);
}
};

89
routes/skins.js Normal file
View File

@ -0,0 +1,89 @@
var logging = require("../modules/logging");
var helpers = require("../modules/helpers");
var config = require("../modules/config");
var skins = require("../modules/skins");
var lwip = require("lwip");
// GET skin request
module.exports = function(req, res) {
var start = new Date();
var userId = (req.url.path_list[2] || "").split(".")[0];
var def = req.url.query.default;
var etag = null;
var rid = req.id;
if (!helpers.id_valid(userId)) {
res.writeHead(422, {
"Content-Type": "text/plain",
"Response-Time": new Date() - start
});
res.end("Invalid ID");
return;
}
// strip dashes
userId = userId.replace(/-/g, "");
logging.log(rid + "userid: " + userId);
try {
helpers.get_skin(rid, userId, function(err, hash, image) {
if (err) {
logging.error(rid + err);
}
etag = hash && hash.substr(0, 32) || "none";
var matches = req.headers["if-none-match"] === '"' + etag + '"';
if (image) {
var http_status = 200;
if (matches) {
http_status = 304;
} else if (err) {
http_status = 503;
}
logging.debug(rid + "etag: " + req.headers["if-none-match"]);
logging.debug(rid + "matches: " + matches);
sendimage(rid, http_status, image);
} else {
handle_default(rid, 404, userId);
}
});
} catch(e) {
logging.error(rid + "error: " + e.stack);
handle_default(rid, 500, userId);
}
function handle_default(rid, http_status, userId) {
if (def && def !== "steve" && def !== "alex") {
logging.log(rid + "status: 301");
res.writeHead(301, {
"Cache-Control": "max-age=" + config.browser_cache_time + ", public",
"Response-Time": new Date() - start,
"X-Storage-Type": "downloaded",
"X-Request-ID": rid,
"Access-Control-Allow-Origin": "*",
"Location": def
});
res.end();
} else {
def = def || skins.default_skin(userId);
lwip.open("public/images/" + def + "_skin.png", function(err, image) {
image.toBuffer("png", function(err, buffer) {
sendimage(rid, http_status, buffer);
});
});
}
}
function sendimage(rid, http_status, image) {
logging.log(rid + "status: " + http_status);
res.writeHead(http_status, {
"Content-Type": "image/png",
"Cache-Control": "max-age=" + config.browser_cache_time + ", public",
"Response-Time": new Date() - start,
"X-Storage-Type": "downloaded",
"X-Request-ID": rid,
"Access-Control-Allow-Origin": "*",
"Etag": '"' + etag + '"'
});
res.end(http_status === 304 ? null : image);
}
};

113
server.js Normal file
View File

@ -0,0 +1,113 @@
#!/usr/bin/env node
var logging = require("./modules/logging");
var querystring = require("querystring");
var config = require("./modules/config");
var http = require("http");
var mime = require("mime");
var url = require("url");
var fs = require("fs");
var server = null;
var routes = {
index: require("./routes/index"),
avatars: require("./routes/avatars"),
skins: require("./routes/skins"),
renders: require("./routes/renders"),
capes: require("./routes/capes")
};
function asset_request(req, res) {
var filename = __dirname + "/public/" + req.url.path_list.join("/");
fs.exists(filename, function(exists) {
if (exists) {
res.writeHead(200, { "Content-type" : mime.lookup(filename) });
fs.createReadStream(filename).pipe(res);
} else {
res.writeHead(404, {
"Content-type" : "text/plain"
});
res.end("Not Found");
}
});
}
function requestHandler(req, res) {
var request = req;
request.url = url.parse(req.url, true);
request.url.query = request.url.query || {};
// remove trailing and double slashes + other junk
var path_list = request.url.pathname.split("/");
for (var i = 0; i < path_list.length; i++) {
// URL decode
path_list[i] = querystring.unescape(path_list[i]);
}
request.url.path_list = path_list;
// generate 12 character random string
request.id = Math.random().toString(36).substring(2,14) + " ";
var local_path = request.url.path_list[1];
logging.log(request.id + request.method + " " + request.url.href);
if (request.method === "GET" || request.method === "HEAD") {
try {
switch (local_path) {
case "":
routes.index(request, res);
break;
case "avatars":
routes.avatars(request, res);
break;
case "skins":
routes.skins(request, res);
break;
case "renders":
routes.renders(request, res);
break;
case "capes":
routes.capes(request, res);
break;
default:
asset_request(request, res);
}
} catch(e) {
var error = JSON.stringify(req.headers) + "\n" + e.stack;
logging.error(request.id + "Error: " + error);
res.writeHead(500, {
"Content-Type": "text/plain"
});
res.end(config.debug_enabled ? error : "Internal Server Error");
}
} else {
res.writeHead(405, {
"Content-Type": "text/plain"
});
res.end("Method Not Allowed");
}
}
var exp = {};
exp.boot = function(callback) {
var port = process.env.PORT || 3000;
var bind_ip = process.env.BIND || "0.0.0.0";
logging.log("Server running on http://" + bind_ip + ":" + port + "/");
server = http.createServer(requestHandler).listen(port, bind_ip, function() {
if (callback) {
callback();
}
});
};
exp.close = function(callback) {
server.close(function() {
callback();
});
};
module.exports = exp;
if (require.main === module) {
logging.error("Please use 'npm start' or 'bin/www.js'");
process.exit(1);
}

18
test/benchmark.sh Executable file
View File

@ -0,0 +1,18 @@
#!/usr/bin/env bash
host="$1"
if [ -z "$host" ]; then
echo "Usage: $0 <host uri> > benchmark.txt 2>&1"
exit 1
fi
# insert newline after uuids
id_file="$(echo | cat 'uuids.txt' - 'usernames.txt')"
mapfile ids <<< $id_file
bench() {
for id in $ids; do
curl -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" "$host/avatars/$id?helm"
done
}
time bench

View File

@ -1,55 +1,17 @@
#!/usr/bin/env bash #!/bin/bash
host="$1"
hostname="crafatar.com" if [ -z "$host" ]; then
async="true" echo "Usage: $0 <host>"
random="false"
interval="0.1"
usage() {
echo "Usage: $0 [-s | -r | -i <interval> | -h <hostname>]... <host uri>" >&2
exit 1 exit 1
} fi
dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
get_ids() { rm -f "$dir/../skins/"*.png || exit 1
local shuf for uuid in `cat "$dir/uuids.txt"`; do
if [ "$random" = "true" ]; then uuid=`echo "$uuid" | tr -d "\r"`
while true; do uuid -v 4; done size=$(( ((RANDOM<<15)|RANDOM) % 514 - 1 )) # random number from -1 to 513
else helm=""
# `brew install coreutils` on OS X for gshuf if [ "$(( ((RANDOM<<15)|RANDOM) % 2 ))" -eq "1" ]; then
shuf=$(command -v shuf gshuf) helm="&helm"
# randomize ids
$shuf < uuids.txt
fi fi
} curl -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" "http://$host/avatars/$uuid?size=$size$helm"
done
bulk() {
trap return INT # return from this function on Ctrl+C
get_ids | while read id; do
if [ "$async" = "false" ]; then
curl -H "Host: $hostname" -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" -- "$host/avatars/$id?overlay"
else
curl -H "Host: $hostname" -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" -- "$host/avatars/$id?overlay" &
sleep "$interval"
fi
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

File diff suppressed because it is too large Load Diff

View File

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

383
views/index.jade Normal file
View File

@ -0,0 +1,383 @@
extends layout
block content
.jumbotron
.container
h1 Crafatar
p A blazing fast API for Minecraft faces!
.avatar-wrapper
.avatar.jomo(title="jomo's avatar")
.avatar.jake_0(title="jake_0's avatar")
.avatar.sk89q(title="sk89q's avatar")
.avatar.md_5(title="md_5's avatar")
.avatar.notch(title="notch's avatar")
.avatar.jeb(title="jeb's avatar")
.avatar.dinnerbone.flipped(title="dinnerbone's avatar")
.avatar.ez(title="ez' avatar")
.avatar.grumm.flipped(title="grumm's avatar")
.avatar.themogmimer(title="themogmimer's avatar")
.avatar.searge(title="searge's avatar")
.avatar.xlson(title="xlson's avatar")
.avatar.krisjelbring(title="krisjelbring's avatar")
.avatar.minecraftchick(title="minecraftchick's avatar")
.avatar.kappe(title="kappe's avatar")
.avatar.marc(title="marc's avatar")
.avatar.mollstam(title="mollstam's avatar")
.avatar.evilseph(title="evilseph's avatar")
.avatar.thinkofdeath(title="thinkofdeath's avatar")
.container
section(id="documentation")
h2 Documentation
.row
section
a(id="avatars", class="anchor")
a(href="#avatars")
h3 Avatars
| Replace
mark.green userid
| with a Mojang <b>UUID</b> or <b>username</b> to get the related head. All images are PNGs.
.code
| #{domain}/avatars/
mark.green userid
section
a(id="avatar-parameters" class="anchor")
a(href="#avatar-parameters")
h4 Avatar Parameters
table(class="table table-striped")
thead
tr
td parameter
td type
td default
td description
tbody
tr
td size
td integer
td #{config.default_size}
td The size of the image in pixels, #{config.min_size} - #{config.max_size}.
tr
td default
td string
td
| The standard value is calculated based on the UUID (even = alex, odd = steve).<br>
| Usernames always default to steve.
td
| The image to be served when the userid has no skin (404).<br>
| Valid options are
a(href="/avatars/0?default=steve") steve
| ,
a(href="/avatars/0?default=alex") alex
| , or a custom URL.
tr
td helm
td null
td
td Apply the "second" layer (hat) to the avatar.
section
a(id="avatar-examples", class="anchor")
a(href="#avatar-examples")
h4 Avatar Examples
.code
#avatar-example-1.example-wrapper
.example #{domain}/avatars/jeb_
p.preview Jeb's avatar
#avatar-example-2.example-wrapper
.example #{domain}/avatars/jeb_?helm
p.preview Jeb's avatar with helm
#avatar-example-3.example-wrapper
.example #{domain}/avatars/jeb_?size=128
p.preview Jeb's avatar, 128 × 128
#avatar-example-4.example-wrapper
.example #{domain}/avatars/853c80ef3c3749fdaa49938b674adae6
p.preview Jeb's avatar by UUID
#avatar-example-5.example-wrapper
.example #{domain}/avatars/jeb_?default=alex
p.preview Jeb's avatar, or fall back to alex <i>(this example assumes jeb_ does not exist)</i>
#avatar-example-6.example-wrapper
.example #{domain}/avatars/jeb_?default=https://i.imgur.com/ocJVWAc.png
p.preview
| Jeb's avatar, or fall back to a custom image <i>(this example assumes jeb_ does not exist)</i>
p.preview-placeholder
| Hover over the examples for a preview!
.preview-background
section
a(id="renders" class="anchor")
a(href="#renders")
h3 3D Renders
p
| Crafatar also provides support for 3D renders of Minecraft skins.<br>
| Please note that <b>this feature is currently beta</b>!<br>
| Replace
mark.green userid
| with a Mojang <b>UUID</b> or <b>username</b> to get a render of the skin.
| The <b>head</b> render type returns a render of the skin's head.
span.code
| #{domain}/renders/head/
mark.green userid
| The <b>body</b> render returns a render of the entire skin.
span.code
| #{domain}/renders/body/
mark.green userid
section
a(id="render-parameters" class="anchor")
a(href="#render-parameters")
h4 Render Parameters
table(class="table table-striped")
thead
tr
td parameter
td type
td default
td description
tbody
tr
td scale
td integer
td #{config.default_scale}. The actual size differs between the type of render.
td The scale factor of the image #{config.min_scale} - #{config.max_scale}.
tr
td helm
td null
td
td Apply the "second" layer (hat) to the avatar.
section
a(id="render-examples", class="anchor")
a(href="#render-examples")
h4 Render Examples
.code
#render-example-1.example-wrapper
.example #{domain}/renders/body/jeb_?helm&amp;scale=4
p.preview Jeb's body, with helmet, scale 4
#render-example-2.example-wrapper
.example #{domain}/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=8
p.preview Jeb's head, by UUID, scale 8
p.preview-placeholder
| Hover over the examples for a preview!
.preview-background
section
a(id="skins" class="anchor")
a(href="#skins")
h3 Skins
p
| You can also get the full skin file of a player.<br>
| Replace
mark.green userid
| with a Mojang <b>UUID</b> or <b>username</b> to get the related skin.<br>
| The user's skin is returned, or the default image is served.<br>
| You can use the default parameter here as well.
span.code
| #{domain}/skins/
mark.green userid
section
a(id="skin-parameters" class="anchor")
a(href="#skin-parameters")
h4 Skin Parameters
table(class="table table-striped")
thead
tr
td parameter
td type
td default
td description
tbody
tr
td default
td string
td
| The standard value is calculated based on the UUID (even = alex, odd = steve).<br>
| Usernames always default to steve.
td
| The image to be served when the userid has no skin (404).<br>
| Valid options are
a(href="/skins/0?default=steve") steve
| ,
a(href="/skins/0?default=alex") alex
| , or a custom URL.
section
a(id="skin-examples", class="anchor")
a(href="#skin-examples")
h4 Skin Examples
.code
#skin-example-1.example-wrapper
.example #{domain}/skins/jeb_
p.preview Jeb's skin
#skin-example-2.example-wrapper
.example #{domain}/skins/jeb_?default=alex
p.preview Jeb's skin, or fall back to alex <i>(this example assumes jeb_ does not exist)</i>
p.preview-placeholder
| Hover over the examples for a preview!
.preview-background
section
a(id="capes" class="anchor")
a(href="#capes")
h3 Capes
p
| A cape endpoint is also available to get the active cape of a user.<br>
| Replace
mark.green userid
| with a Mojang <b>UUID</b> or <b>username</b> to get the related cape.<br>
| The user's cape is returned, otherwise a 404 is thrown.<br>
.code
| #{domain}/skins/
mark.green userid
section
a(id="cape-examples", class="anchor")
a(href="#cape-examples")
h4 Cape Examples
.code
#cape-example-1.example-wrapper
.example #{domain}/capes/Dinnerbone
p.preview Dinnerbone's Cape <i>Mojang capes are not transparent...</i>
#cape-example-2.example-wrapper
.example #{domain}/capes/md_5
p.preview md_5's Cape
p.preview-placeholder
| Hover over the examples for a preview!
.preview-background
section
a(id="meta" class="anchor")
a(href="#meta")
h2 Meta
section
a(id="meta-http-headers" class="anchor")
a(href="#meta-http-headers")
h3 HTTP Headers
p
| Responses come with these HTTP headers, useful for debugging.<br>
| Please note that these headers are cached by CloudFlare <small>(CF-Cache-Status: HIT)</small>.
section
a(id="meta-response-time" class="anchor")
a(href="#meta-response-time")
h4 Response-Time
p The time, in milliseconds, it took Crafatar to process the request.
section
a(id="meta-x-storage-type" class="anchor")
a(href="#meta-x-storage-type")
h4 X-Storage-Type
ul
li <b>none</b>: No external requests. Cached: User has no skin.
li <b>cached</b>: No external requests. Skin cached and stored locally.
li
| <b>checked</b>: 1 external request. Skin cached, checked for updates, no skin downloaded.<br>
| This happens either when the user removed their skin or when it didn't change.
li <b>downloaded</b>: 2 external requests. Skin changed or unknown, downloaded.
li
| <b>error</b>: This can happen, for example, when Mojang's servers are down.<br>
| If possible, an outdated image is served instead.
section
a(id="meta-x-request-id" class="anchor")
a(href="#meta-x-request-id")
h4 X-Request-ID
p
| 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.
section
a(id="meta-about-usernames" class="anchor")
a(href="#meta-about-usernames")
h3 About Usernames
p
| We strongly advise you to use UUIDs instead of usernames in production.<br>
| Usernames are deprecated by Mojang and you should only use usernames for testing.<br>
| You don't have to change anything when using UUIDs and someone changes their Username.<br>
| Malformed usernames are rejected.
section
a(id="meta-about-uuids" class="anchor")
a(href="#meta-about-uuids")
h3 About UUIDs
p
| UUIDs may use the blank or dashed format.<br>
| Malformed UUIDs are rejected.
section
a(id="meta-about-caching" class="anchor")
a(href="#meta-about-caching")
h3 About Caching
p
| Crafatar caches skins for #{config.local_cache_time/60} minutes before checking for skin changes.<br>
| Images are cached in your browser for #{config.browser_cache_time/60} minutes until a new request to Crafatar is made.<br>
| When you changed your skin you can try clearing your browser cache to see the change faster.
section
a(id="contact" class="anchor")
a(href="#contact")
h2 Contact
ul
li Follow us on twitter <a href="https://twitter.com/crafatar" target="_blank">@crafatar</a>
li Open an issue <a href="https://github.com/crafatar/crafatar/issues" target="_blank">on GitHub</a>
li <a href="https://webchat.esper.net/?channels=crafatar" target="_blank">Join us</a> in #crafatar on irc.esper.net
footer
hr
p(class="pull-right") Copyright Crafatar #{new Date().getFullYear()}
// preload hover images
img.preload(src="/avatars/jeb_", alt="preloaded image")
img.preload(src="/avatars/853c80ef3c3749fdaa49938b674adae6", alt="preloaded image")
img.preload(src="/avatars/0?default=alex", alt="preloaded image")
img.preload(src="/avatars/0?default=https%3A%2F%2Fi.imgur.com%2FocJVWAc.png", alt="preloaded image")
img.preload(src="/skins/0?default=alex", alt="preloaded image")
img.preload(src="/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=8", alt="preloaded image")
img.preload(src="/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=64", alt="preloaded image")
img.preload(src="/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=64", alt="preloaded image")
img.preload(src="/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=64", alt="preloaded image")
img.preload(src="/avatars/af74a02d19cb445bb07f6866a861f783?size=64", alt="preloaded image")
img.preload(src="/avatars/853c80ef3c3749fdaa49938b674adae6?size=64", alt="preloaded image")
img.preload(src="/avatars/069a79f444e94726a5befca90e38aaf5?size=64", alt="preloaded image")
img.preload(src="/avatars/61699b2ed3274a019f1e0ea8c3f06bc6?size=64", alt="preloaded image")
img.preload(src="/avatars/7d043c7389524696bfba571c05b6aec0?size=64", alt="preloaded image")
img.preload(src="/avatars/e6b5c088068044df9e1b9bf11792291b?size=64", alt="preloaded image")
img.preload(src="/avatars/1c1bd09a6a0f4928a7914102a35d2670?size=64", alt="preloaded image")
img.preload(src="/avatars/b05881186e75410db2db4d3066b223f7?size=64", alt="preloaded image")
img.preload(src="/avatars/jeb_?size=128", alt="preloaded image")
img.preload(src="/avatars/696a82ce41f44b51aa31b8709b8686f0?size=64", alt="preloaded image")
img.preload(src="/avatars/b9583ca43e64488a9c8c4ab27e482255?size=64", alt="preloaded image")
img.preload(src="/avatars/c9b54008fd8047428b238787b5f2401c?size=64", alt="preloaded image")
img.preload(src="/avatars/d8f9a4340f2d415f9acfcd70341c75ec?size=64", alt="preloaded image")
img.preload(src="/avatars/7125ba8b1c864508b92bb5c042ccfe2b?size=64", alt="preloaded image")
img.preload(src="/avatars/4566e69fc90748ee8d71d7ba5aa00d20?size=64", alt="preloaded image")
img.preload(src="/avatars/020242a17b9441799eff511eea1221da?size=64", alt="preloaded image")
img.preload(src="/avatars/9769ecf6331448f3ace67ae06cec64a3?size=64", alt="preloaded image")
img.preload(src="/avatars/f8cdb6839e9043eea81939f85d9c5d69?size=64", alt="preloaded image")
img.preload(src="/avatars/jeb_?helm", alt="preloaded image")
img.preload(src="/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/0ea8eca3dbf647cc9d1ac64551ca975c?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/af74a02d19cb445bb07f6866a861f783?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/853c80ef3c3749fdaa49938b674adae6?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/61699b2ed3274a019f1e0ea8c3f06bc6?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/7d043c7389524696bfba571c05b6aec0?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/e6b5c088068044df9e1b9bf11792291b?size=64&helm", alt="preloaded image")
img.preload(src="/renders/body/jeb_?helm&scale=4", alt="preloaded image")
img.preload(src="/avatars/1c1bd09a6a0f4928a7914102a35d2670?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/b05881186e75410db2db4d3066b223f7?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/696a82ce41f44b51aa31b8709b8686f0?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/b9583ca43e64488a9c8c4ab27e482255?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/c9b54008fd8047428b238787b5f2401c?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/d8f9a4340f2d415f9acfcd70341c75ec?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/7125ba8b1c864508b92bb5c042ccfe2b?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/4566e69fc90748ee8d71d7ba5aa00d20?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/020242a17b9441799eff511eea1221da?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/9769ecf6331448f3ace67ae06cec64a3?size=64&helm", alt="preloaded image")
img.preload(src="/avatars/f8cdb6839e9043eea81939f85d9c5d69?size=64&helm", alt="preloaded image")
img.preload(src="/skins/jeb_", alt="preloaded image")

21
views/layout.jade Normal file
View File

@ -0,0 +1,21 @@
doctype html
html
head
title= title
link(rel="icon", sizes="16x16", type="image/png", href="/favicon.png")
link(href="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.1/css/bootstrap.min.css", rel="stylesheet")
link(rel="stylesheet", href="/stylesheets/style.css")
meta(name="description", content="Crafatar is a blazing fast Minecraft avatar API with support for avatars, 1.8 skins, and even 3D renders!")
meta(name="keywords", content="minecraft, avatar, renders, skins, uuid, username")
meta(name="viewport", content="initial-scale=1,maximum-scale=1")
body
a.forkme(href="https://github.com/crafatar/crafatar", target="_blank") Fork me on GitHub
a.sponsor(href="https://akliz.net/crafatar", target="_blank", title="Crafatar is sponsored by Akliz")
img(src="/images/akliz.png", alt="Akliz")
.navbar.navbar-default.navbar-fixed-top
.container
.navbar-header
a.navbar-brand(href="/") Crafatar
.navbar-header
a.navbar-brand.twitter(href="https://twitter.com/Crafatar", target="_blank") @crafatar
block content

12
www.js
View File

@ -1,12 +0,0 @@
var networking = require("./lib/networking");
var logging = require("./lib/logging");
var config = require("./config");
process.on("uncaughtException", function(err) {
logging.error("uncaughtException", err.stack || err.toString());
process.exit(1);
});
setInterval(networking.resetCounter, 1000);
require("./lib/server.js").boot();