Pārlūkot izejas kodu

Merge branch 'master' of github.com:Jake0oo0/crafatar

Jake 10 gadi atpakaļ
vecāks
revīzija
480385559d
16 mainītis faili ar 401 papildinājumiem un 148 dzēšanām
  1. 1 0
      .gitignore
  2. 3 2
      .travis.yml
  3. 1 1
      Procfile
  4. 5 0
      README.md
  5. 1 1
      app.js
  6. 10 9
      modules/cache.js
  7. 13 0
      modules/config.example.js
  8. 3 2
      modules/config.js
  9. 35 58
      modules/helpers.js
  10. 16 0
      modules/logging.js
  11. 88 27
      modules/networking.js
  12. 5 0
      public/stylesheets/style.css
  13. 60 12
      routes/avatars.js
  14. 3 1
      routes/index.js
  15. 63 14
      test/test.js
  16. 94 21
      views/index.jade

+ 1 - 0
.gitignore

@@ -4,3 +4,4 @@ node_modules/
 .DS_Store
 *.rdb
 coverage/
+modules/config.js

+ 3 - 2
.travis.yml

@@ -1,11 +1,12 @@
 language: node_js
 node_js:
   - "0.10"
+before_script:
+  - cp "modules/config.example.js" "modules/config.js"
 notifications:
   irc:
     channels:
       - "irc.esper.net#spongy"
     skip_join: true
 services:
-  - redis-server
-skip_join: true
+  - redis-server

+ 1 - 1
Procfile

@@ -1 +1 @@
-web: npm start
+web: cp "modules/config.example.js" "modules/config.js" && npm start

+ 5 - 0
README.md

@@ -11,10 +11,15 @@ Image manipulation is done by [lwip](https://github.com/EyalAr/lwip)
 
 See the [API Usage](https://crafatar.com)
 
+## Contact
+
+You can [join us](https://webchat.esper.net/?channels=spongy) in #spongy on irc.esper.net.
+
 ## Install
 
 * Clone the repository
 * `npm install`
 * `redis-server`
+* `cp "modules/config.example.js" "modules/config.js"`
 * `npm start`
 * Access [http://localhost:3000](http://localhost:3000)

+ 1 - 1
app.js

@@ -20,7 +20,7 @@ app.use(cookieParser());
 app.use(express.static(path.join(__dirname, 'public')));
 
 app.use('/', routes);
-app.use('/avatars', avatars);
+app.use('/', avatars);
 
 
 // catch 404 and forward to error handler

+ 10 - 9
modules/cache.js

@@ -1,3 +1,4 @@
+var logging = require('./logging');
 var config = require("./config");
 var redis = null;
 var fs = require("fs");
@@ -5,7 +6,7 @@ var fs = require("fs");
 // sets up redis connection
 // flushes redis when running on heroku (files aren't kept between pushes)
 function connect_redis() {
-  console.log("connecting to redis...");
+  logging.log("connecting to redis...");
   if (process.env.REDISCLOUD_URL) {
     var redisURL = require("url").parse(process.env.REDISCLOUD_URL);
     redis = require("redis").createClient(redisURL.port, redisURL.hostname);
@@ -14,17 +15,17 @@ function connect_redis() {
     redis = require("redis").createClient();
   }
   redis.on("ready", function() {
-    console.log("Redis connection established.");
+    logging.log("Redis connection established.");
     if(process.env.HEROKU) {
-      console.log("Running on heroku, flushing redis");
+      logging.log("Running on heroku, flushing redis");
       redis.flushall();
     }
   });
   redis.on("error", function (err) {
-    console.error(err);
+    logging.error(err);
   });
   redis.on("end", function () {
-    console.warn("Redis connection lost!");
+    logging.warn("Redis connection lost!");
   });
 }
 
@@ -38,11 +39,11 @@ function update_file_date(hash) {
         var date = new Date();
         fs.utimes(path, date, date, function(err){
           if (err) {
-            console.error(err);
+            logging.error(err);
           }
         });
       } else {
-        console.error("Tried to update " + path + " date, but it doesn't exist");
+        logging.error("Tried to update " + path + " date, but it doesn't exist");
       }
     });
   }
@@ -56,7 +57,7 @@ exp.get_redis = function() {
 
 // sets the timestamp for +uuid+ and its face file's date to now
 exp.update_timestamp = function(uuid, hash) {
-  console.log(uuid + " cache: updating timestamp");
+  logging.log(uuid + " cache: updating timestamp");
   var time = new Date().getTime();
   redis.hmset(uuid, "t", time);
   update_file_date(hash);
@@ -64,7 +65,7 @@ exp.update_timestamp = function(uuid, hash) {
 
 // create the key +uuid+, store +hash+ and time
 exp.save_hash = function(uuid, hash) {
-  console.log(uuid + " cache: saving hash");
+  logging.log(uuid + " cache: saving hash");
   var time = new Date().getTime();
   redis.hmset(uuid, "h", hash, "t", time);
 };

+ 13 - 0
modules/config.example.js

@@ -0,0 +1,13 @@
+var config = {
+  min_size: 1,               // < 1 will (obviously) cause crash
+  max_size: 512,             // too big values might lead to slow response time or DoS
+  default_size: 160,         // size to be used when no size given
+  local_cache_time: 3600,    // 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
+  http_timeout: 1000,        // ms until connection to mojang is dropped
+  faces_dir: 'skins/faces/', // directory where faces are kept. should have trailing '/'
+  helms_dir: 'skins/helms/', // directory where helms are kept. should have trailing '/'
+  debug_enabled: false       // enables logging.debug
+};
+
+module.exports = config;

+ 3 - 2
modules/config.js

@@ -4,9 +4,10 @@ var config = {
   default_size: 160,         // size to be used when no size given
   local_cache_time: 3600,    // 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
-  http_timeout: 1000,        // ms until connection to mojang is dropped
+  http_timeout: 3000,        // ms until connection to mojang is dropped
   faces_dir: 'skins/faces/', // directory where faces are kept. should have trailing '/'
-  helms_dir: 'skins/helms/'  // directory where helms are kept. should have trailing '/'
+  helms_dir: 'skins/helms/', // directory where helms are kept. should have trailing '/'
+  debug_enabled: true        // enables logging.debug
 };
 
 module.exports = config;

+ 35 - 58
modules/helpers.js

@@ -1,11 +1,12 @@
 var networking = require('./networking');
+var logging = require('./logging');
 var config = require('./config');
 var cache = require('./cache');
 var skins = require('./skins');
 
 // 0098cb60-fa8e-427c-b299-793cbd302c9a
 var valid_uuid = /^([0-9a-f-]{32,36}|[a-zA-Z0-9_]{1,16})$/; // uuid|username
-var hash_pattern = /([^\/]+)(?=\.\w{0,16}$)|((?:[a-z][a-z]*[0-9]+[a-z0-9]*))/;
+var hash_pattern = /[0-9a-f]+$/;
 
 function get_hash(url) {
   return hash_pattern.exec(url)[0].toLowerCase();
@@ -14,40 +15,27 @@ function get_hash(url) {
 // requests skin for +uuid+ and extracts face/helm if image hash in +details+ changed
 // callback contains error, image hash
 function store_images(uuid, details, callback) {
-  // get profile for +uuid+
-  networking.get_profile(uuid, function(err, profile) {
-    if (err === 0) {
-      // uuid does not exist
-      cache.save_hash(uuid, null);
-      callback(null, null);
-    } else if (err) {
+  // get skin_url for +uuid+
+  networking.get_skin_url(uuid, function(err, skin_url) {
+    if (err) {
       callback(err, null);
     } else {
-      var skinurl = null;
-
-      // Username handling
-      if (uuid.length <= 16) {
-        skinurl = "https://skins.minecraft.net/MinecraftSkins/" + uuid + ".png";
-        console.log(uuid + " is a username");
-      } else {
-        skinurl = skin_url(profile);
-      }
-      if (skinurl) {
-        console.log(uuid + " " + skinurl);
+      if (skin_url) {
+        logging.log(uuid + " " + skin_url);
         // set file paths
-        var hash = get_hash(skinurl);
+        var hash = get_hash(skin_url);
         if (details && details.hash == hash) {
           // hash hasn't changed
-          console.log(uuid + " hash has not changed");
+          logging.log(uuid + " hash has not changed");
           cache.update_timestamp(uuid, hash);
           callback(null, hash);
         } else {
           // hash has changed
-          console.log(uuid + " new hash: " + hash);
+          logging.log(uuid + " new hash: " + hash);
           var facepath = __dirname + '/../' + config.faces_dir + hash + ".png";
           var helmpath = __dirname + '/../' + config.helms_dir + hash + ".png";
           // download skin, extract face/helm
-          networking.skin_file(skinurl, facepath, helmpath, function(err) {
+          networking.skin_file(skin_url, facepath, helmpath, function(err) {
             if (err) {
               callback(err, null);
             } else {
@@ -65,69 +53,58 @@ function store_images(uuid, details, callback) {
   });
 }
 
-// exracts the skin url of a +profile+ object
-// returns null when no url found (user has no skin)
-function skin_url(profile) {
-  var url = null;
-  if (profile && profile.properties) {
-    profile.properties.forEach(function(prop) {
-      if (prop.name == 'textures') {
-        var json = Buffer(prop.value, 'base64').toString();
-        var props = JSON.parse(json);
-        url = props && props.textures && props.textures.SKIN && props.textures.SKIN.url || null;
-      }
-    });
-  }
-  return url;
-}
+
+var exp = {};
+
+// returns true if the +uuid+ is a valid uuid or username
+// the uuid may be not exist, however
+exp.uuid_valid = function(uuid) {
+  return valid_uuid.test(uuid);
+};
+
 
 // decides whether to get an image from disk or to download it
 // callback contains error, status, hash
 // the status gives information about how the image was received
-//  -1: error
-//   0: cached as null
-//   1: found on disk
-//   2: profile requested/found, skin downloaded from mojang servers
-//   3: profile requested/found, but it has not changed or no skin
-function get_image_hash(uuid, callback) {
+//  -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(uuid, callback) {
   cache.get_details(uuid, function(err, details) {
     if (err) {
       callback(err, -1, null);
     } else {
       if (details && details.time + config.local_cache_time * 1000 >= new Date().getTime()) {
         // uuid known + recently updated
-        console.log(uuid + " uuid known & recently updated");
+        logging.log(uuid + " uuid known & recently updated");
         callback(null, (details.hash ? 1 : 0), details.hash);
       } else {
-        console.log(uuid + " uuid not known or too old");
+        logging.log(uuid + " uuid not known or too old");
         store_images(uuid, details, function(err, hash) {
           if (err) {
             callback(err, -1, details && details.hash);
           } else {
-            console.log(uuid + " hash: " + hash);
-            callback(null, (hash != (details && details.hash) ? 2 : 3), hash);
+            logging.log(uuid + " hash: " + hash);
+            var oldhash = details && details.hash;
+            var status = hash !== oldhash ? 2 : 3;
+            callback(null, status, hash);
           }
         });
       }
     }
   });
-}
-
-var exp = {};
-
-// returns true if the +uuid+ is a valid uuid or username
-// the uuid may be not exist, however
-exp.uuid_valid = function(uuid) {
-  return valid_uuid.test(uuid);
 };
 
+
 // handles requests for +uuid+ images with +size+
 // callback contains error, status, image buffer
 // 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(uuid, helm, size, callback) {
-  console.log("\nrequest: " + uuid);
-  get_image_hash(uuid, function(err, status, hash) {
+  logging.log("\nrequest: " + uuid);
+  exp.get_image_hash(uuid, function(err, status, hash) {
     if (hash) {
       var filepath = __dirname + '/../' + (helm ? config.helms_dir : config.faces_dir) + hash + ".png";
       skins.resize_img(filepath, size, function(img_err, result) {

+ 16 - 0
modules/logging.js

@@ -0,0 +1,16 @@
+var config = require("./config");
+
+var exp = {};
+
+function debug() {
+  if (config.debug_enabled) {
+    console.log(Array.prototype.slice.call(arguments).join(" "));
+  }
+}
+
+exp.log = console.log;
+exp.warn = console.warn;
+exp.error = console.error;
+exp.debug = debug;
+
+module.exports = exp;

+ 88 - 27
modules/networking.js

@@ -1,54 +1,115 @@
+var logging = require('./logging');
 var request = require('request');
 var config = require('./config');
 var skins = require('./skins');
 var fs = require("fs");
 
 var session_url = "https://sessionserver.mojang.com/session/minecraft/profile/";
+var skins_url = "https://skins.minecraft.net/MinecraftSkins/";
 
-var exp = {};
-
-// download the Mojang profile for +uuid+
-// callback contains error, profile object
-exp.get_profile = function(uuid, callback) {
-  if (uuid.length <= 16) {
-    callback(null, null);
-    return;
+// exracts the skin url of a +profile+ object
+// returns null when no url found (user has no skin)
+function extract_skin_url(profile) {
+  var url = null;
+  if (profile && profile.properties) {
+    profile.properties.forEach(function(prop) {
+      if (prop.name == 'textures') {
+        var json = Buffer(prop.value, 'base64').toString();
+        var props = JSON.parse(json);
+        url = props && props.textures && props.textures.SKIN && props.textures.SKIN.url || null;
+      }
+    });
   }
+  return url;
+}
+
+// make a request to skins.miencraft.net
+// the skin url is taken from the HTTP redirect
+var get_username_url = function(name, callback) {
+  request.get({
+    url: skins_url + name + ".png",
+    timeout: config.http_timeout,
+    followRedirect: false
+  }, function(error, response, body) {
+    if (!error && response.statusCode == 301) {
+      // skin_url received successfully
+      logging.log(name + " skin url received");
+      callback(null, response.headers.location);
+    } else if (error) {
+      callback(error, null);
+    } else if (response.statusCode == 404) {
+      // skin doesn't exist
+      logging.log(name + " has no skin");
+      callback(0, null);
+    } else if (response.statusCode == 429) {
+      // Too Many Requests
+      // Never got this, seems like skins aren't limited
+      logging.warn(name + " Too many requests");
+      logging.warn(body);
+      callback(null, null);
+    } else {
+      logging.error(name + " Unknown error:");
+      logging.error(response);
+      logging.error(body);
+      callback(null, null);
+    }
+  });
+};
+
+// make a request to sessionserver
+// the skin_url is taken from the profile
+var get_uuid_url = function(uuid, callback) {
   request.get({
     url: session_url + uuid,
     timeout: config.http_timeout // ms
   }, function (error, response, body) {
     if (!error && response.statusCode == 200) {
       // profile downloaded successfully
-      console.log(uuid + " profile downloaded");
-      callback(null, JSON.parse(body));
+      logging.log(uuid + " profile downloaded");
+      callback(null, extract_skin_url(JSON.parse(body)));
     } else if (error) {
       callback(error, null);
     } else if (response.statusCode == 204 || response.statusCode == 404) {
       // we get 204 No Content when UUID doesn't exist (including 404 in case they change that)
-      console.log(uuid + " uuid does not exist");
+      logging.log(uuid + " uuid does not exist");
       callback(0, null);
     } else if (response.statusCode == 429) {
       // Too Many Requests
-      console.warn(uuid + " Too many requests");
-      console.warn(body);
+      logging.warn(uuid + " Too many requests");
+      logging.warn(body);
       callback(null, null);
     } else {
-      console.error(uuid + " Unknown error:");
-      console.error(response);
-      console.error(body);
+      logging.error(uuid + " Unknown error:");
+      logging.error(response);
+      logging.error(body);
       callback(null, null);
     }
   });
 };
 
+var exp = {};
+
+// download skin_url for +uuid+ (name or uuid)
+// callback contains error, skin_url
+exp.get_skin_url = function(uuid, callback) {
+  if (uuid.length <= 16) {
+    get_username_url(uuid, function(err, url) {
+      callback(err, url);
+    });
+  } else {
+    get_uuid_url(uuid, function(err, url) {
+      callback(err, url);
+    });
+  }
+};
+
 // downloads skin file from +url+
 // stores face image as +facename+
 // stores helm image as +helmname+
 // callback contains error
 exp.skin_file = function(url, facename, helmname, callback) {
   if (fs.existsSync(facename) && fs.existsSync(facename)) {
-    console.log("Images already exist, not downloading.");
+    logging.log("Images already exist, not downloading.");
     callback(null);
     return;
   }
@@ -59,32 +120,32 @@ exp.skin_file = function(url, facename, helmname, callback) {
   }, function (error, response, body) {
     if (!error && response.statusCode == 200) {
       // skin downloaded successfully
-      console.log(url + " skin downloaded");
+      logging.log(url + " skin downloaded");
       skins.extract_face(body, facename, function(err) {
         if (err) {
           callback(err);
         } else {
-          console.log(facename + " face extracted");
+          logging.log(facename + " face extracted");
           skins.extract_helm(facename, body, helmname, function(err) {
-            console.log(helmname + " helm extracted.");
+            logging.log(helmname + " helm extracted.");
             callback(err);
           });
         }
       });
     } else {
       if (error) {
-        console.error("Error downloading '" + url + "': " + error);
+        logging.error("Error downloading '" + url + "': " + error);
       } else if (response.statusCode == 404) {
-        console.warn("texture not found (404): " + url);
+        logging.warn("texture not found (404): " + url);
       } else if (response.statusCode == 429) {
         // Too Many Requests
         // Never got this, seems like textures aren't limited
-        console.warn("too many requests for " + url);
-        console.warn(body);
+        logging.warn("too many requests for " + url);
+        logging.warn(body);
       } else {
-        console.error("unknown error for " + url);
-        console.error(response);
-        console.error(body);
+        logging.error("unknown error for " + url);
+        logging.error(response);
+        logging.error(body);
         error = "unknown error"; // Error needs to be set, otherwise null in callback
       }
       callback(error);

+ 5 - 0
public/stylesheets/style.css

@@ -13,6 +13,11 @@ a {
   color: #00B7FF;
 }
 
+a.anchor {
+  position: relative;
+  top: -50px;
+}
+
 a.forkme {
   top: 0;
   right: 0;

+ 60 - 12
routes/avatars.js

@@ -1,3 +1,5 @@
+var networking = require('../modules/networking');
+var logging = require('../modules/logging');
 var helpers = require('../modules/helpers');
 var router = require('express').Router();
 var config = require('../modules/config');
@@ -6,13 +8,51 @@ var skins = require('../modules/skins');
 var human_status = {
   0: "none",
   1: "cached",
-  2: "checked",
-  3: "downloaded",
+  2: "downloaded",
+  3: "checked",
   "-1": "error"
 };
 
+router.get('/skins/:uuid.:ext?', function(req, res) {
+  var uuid = req.params.uuid;
+  var start = new Date();
+
+  if (!helpers.uuid_valid(uuid)) {
+    res.status(422).send("422 Invalid UUID");
+    return;
+  }
+  // strip dashes
+  uuid = uuid.replace(/-/g, "");
+  try {
+    helpers.get_image_hash(uuid, function(err, status, hash) {
+      if (hash) {
+        res.writeHead(301, {
+          'Location': "http://textures.minecraft.net/texture/" + hash,
+          'Cache-Control': 'max-age=' + config.browser_cache_time + ', public',
+          'Response-Time': new Date() - start,
+          'X-Storage-Type': human_status[status]
+        });
+        res.end();
+      } else if (!err) {
+        res.writeHead(404, {
+          'Cache-Control': 'max-age=' + config.browser_cache_time + ', public',
+          'Response-Time': new Date() - start,
+          'X-Storage-Type': human_status[status]
+        });
+        res.end("404 Not found");
+      } else {
+        res.status(500).send("500 Internal server error");
+      }
+    });
+  } catch(e) {
+    logging.error("Error!");
+    logging.error(e);
+    res.status(500).send("500 Internal server error");
+  }
+});
+
 /* GET avatar request. */
-router.get('/:uuid.:ext?', function(req, res) {
+router.get('/avatars/:uuid.:ext?', function(req, res) {
   var uuid = req.params.uuid;
   var size = req.query.size || config.default_size;
   var def = req.query.default;
@@ -35,9 +75,9 @@ router.get('/:uuid.:ext?', function(req, res) {
 
   try {
     helpers.get_avatar(uuid, helm, size, function(err, status, image) {
-      console.log(uuid + " - " + human_status[status]);
+      logging.log(uuid + " - " + human_status[status]);
       if (err) {
-        console.error(err);
+        logging.error(err);
       }
       if (image) {
         sendimage(err ? 503 : 200, status, image);
@@ -46,18 +86,26 @@ router.get('/:uuid.:ext?', function(req, res) {
       }
     });
   } catch(e) {
-    console.error("Error!");
-    console.error(e);
+    logging.error("Error!");
+    logging.error(e);
     handle_default(500, status);
   }
 
   function handle_default(http_status, img_status) {
-    if (def != "steve" && def != "alex") {
-      def = skins.default_skin(uuid);
+    if (def && def != "steve" && def != "alex") {
+      res.writeHead(301, {
+        'Cache-Control': 'max-age=' + config.browser_cache_time + ', public',
+        'Response-Time': new Date() - start,
+        'X-Storage-Type': human_status[img_status],
+        'Location': def
+      });
+      res.end();
+    } else {
+      def = def || skins.default_skin(uuid);
+      skins.resize_img("public/images/" + def + ".png", size, function(err, image) {
+        sendimage(http_status, img_status, image);
+      });
     }
-    skins.resize_img("public/images/" + def + ".png", size, function(err, image) {
-      sendimage(http_status, img_status, image);
-    });
   }
 
   function sendimage(http_status, img_status, image) {

+ 3 - 1
routes/index.js

@@ -1,11 +1,13 @@
 var express = require('express');
+var config = require('../modules/config');
 var router = express.Router();
 
 /* GET home page. */
 router.get('/', function(req, res) {
   res.render('index', {
     title: 'Crafatar',
-    domain: "https://" + req.headers.host
+    domain: "https://" + req.headers.host,
+    config: config
   });
 });
 

+ 63 - 14
test/test.js

@@ -3,6 +3,7 @@ var fs = require('fs');
 
 var networking = require('../modules/networking');
 var helpers = require('../modules/helpers');
+var logging = require('../modules/logging');
 var config = require('../modules/config');
 var skins = require('../modules/skins');
 var cache = require("../modules/cache");
@@ -10,18 +11,21 @@ var cache = require("../modules/cache");
 // we don't want tests to fail because of slow internet
 config.http_timeout = 3000;
 
+// no spam
+logging.log = function(){};
+
 var uuids = fs.readFileSync('test/uuids.txt').toString().split("\n");
 var usernames = fs.readFileSync('test/usernames.txt').toString().split("\n");
 // Get a random UUID + username in order to prevent rate limiting
 var uuid = uuids[Math.round(Math.random() * (uuids.length - 1))];
 var username = usernames[Math.round(Math.random() * (usernames.length - 1))];
 
-describe('UUID/username', function() {
+describe('Crafatar', function() {
   before(function() {
     cache.get_redis().flushall();
   });
 
-  describe('UUID', function() {
+  describe('UUID/username', function() {
     it("should be an invalid uuid", function(done) {
       assert.strictEqual(helpers.uuid_valid("g098cb60fa8e427cb299793cbd302c9a"), false);
       done();
@@ -58,43 +62,51 @@ describe('UUID/username', function() {
       assert.strictEqual(helpers.uuid_valid("a"), true);
       done();
     });
-    it("should not exist", function(done) {
-      networking.get_profile("00000000000000000000000000000000", function(err, profile) {
+    it("should not exist (uuid)", function(done) {
+      networking.get_skin_url("00000000000000000000000000000000", function(err, profile) {
+        assert.strictEqual(err, 0);
+        done();
+      });
+    });
+    it("should not exist (username)", function(done) {
+      networking.get_skin_url("Steve", function(err, profile) {
         assert.strictEqual(err, 0);
         done();
       });
     });
   });
 
-  describe('Avatar', function() {
+  describe('Networking: Avatar', function() {
     it("should be downloaded (uuid)", function(done) {
       helpers.get_avatar(uuid, false, 160, function(err, status, image) {
         assert.strictEqual(status, 2);
         done();
       });
     });
-    it("should be local (uuid)", function(done) {
+    it("should be cached (uuid)", function(done) {
       helpers.get_avatar(uuid, false, 160, function(err, status, image) {
         assert.strictEqual(status, 1);
         done();
       });
     });
+    /* We can't test this because of mojang's rate limits :(
     it("should be checked (uuid)", function(done) {
       var original_cache_time = config.local_cache_time;
       config.local_cache_time = 0;
       helpers.get_avatar(uuid, false, 160, function(err, status, image) {
-        assert.strictEqual(status, 2);
+        assert.strictEqual(status, 3);
         config.local_cache_time = original_cache_time;
         done();
       });
     });
+    */
     it("should be downloaded (username)", function(done) {
       helpers.get_avatar(username, false, 160, function(err, status, image) {
         assert.strictEqual(status, 2);
         done();
       });
     });
-    it("should be local (username)", function(done) {
+    it("should be cached (username)", function(done) {
       helpers.get_avatar(username, false, 160, function(err, status, image) {
         assert.strictEqual(status, 1);
         done();
@@ -104,7 +116,7 @@ describe('UUID/username', function() {
       var original_cache_time = config.local_cache_time;
       config.local_cache_time = 0;
       helpers.get_avatar(username, false, 160, function(err, status, image) {
-        assert.strictEqual(status, 2);
+        assert.strictEqual(status, 3);
         config.local_cache_time = original_cache_time;
         done();
       });
@@ -116,6 +128,18 @@ describe('UUID/username', function() {
         done();
       });
     });
+    it("should already have the files / not download", function(done) {
+      assert.doesNotThrow(function() {
+        fs.openSync("face.png", "w");
+        fs.openSync("helm.png", "w");
+        networking.skin_file("http://textures.minecraft.net/texture/477be35554684c28bdeee4cf11c591d3c88afb77e0b98da893fd7bc318c65184", "face.png", "helm.png", function(err) {
+          assert.strictEqual(err, null); // no error here, but it shouldn't throw exceptions
+          fs.unlinkSync("face.png");
+          fs.unlinkSync("helm.png");
+          done();
+        });
+      });
+    });
     it("should default to Alex", function(done) {
       assert.strictEqual(skins.default_skin("ec561538f3fd461daff5086b22154bce"), "alex");
       done();
@@ -126,7 +150,7 @@ describe('UUID/username', function() {
     });
   });
 
-  describe('Mojang Errors', function() {
+  describe('Errors', function() {
     before(function() {
       cache.get_redis().flushall();
     });
@@ -136,21 +160,46 @@ describe('UUID/username', function() {
         done();
       });
     });
-    it("should time out on profile download", function(done) {
+    it("should time out on uuid info download", function(done) {
+      var original_timeout = config.http_timeout;
       config.http_timeout = 1;
-      networking.get_profile("069a79f444e94726a5befca90e38aaf5", function(err, profile) {
+      networking.get_skin_url("069a79f444e94726a5befca90e38aaf5", function(err, skin_url) {
         assert.strictEqual(err.code, "ETIMEDOUT");
-        config.http_timeout = 3000;
+        config.http_timeout = original_timeout;
+        done();
+      });
+    });
+    it("should time out on username info download", function(done) {
+      var original_timeout = config.http_timeout;
+      config.http_timeout = 1;
+      networking.get_skin_url("redstone_sheep", function(err, skin_url) {
+        assert.strictEqual(err.code, "ETIMEDOUT");
+        config.http_timeout = original_timeout;
         done();
       });
     });
     it("should time out on skin download", function(done) {
+      var original_timeout = config.http_timeout;
       config.http_timeout = 1;
       networking.skin_file("http://textures.minecraft.net/texture/477be35554684c28bdeee4cf11c591d3c88afb77e0b98da893fd7bc318c65184", "face.png", "helm.png", function(err) {
         assert.strictEqual(err.code, "ETIMEDOUT");
-        config.http_timeout = 3000;
+        config.http_timeout = original_timeout;
         done();
       });
     });
+    it("should not find the skin", function(done) {
+      assert.doesNotThrow(function() {
+        networking.skin_file("http://textures.minecraft.net/texture/this-does-not-exist", "face.png", "helm.png", function(err) {
+          assert.strictEqual(err, null); // no error here, but it shouldn't throw exceptions
+          done();
+        });
+      });
+    });
+    it("should handle file updates on invalid files", function(done) {
+      assert.doesNotThrow(function() {
+        cache.update_timestamp("0123456789abcdef0123456789abcdef", "invalid-file.png");
+      });
+      done();
+    });
   });
 });

+ 94 - 21
views/index.jade

@@ -4,55 +4,128 @@ block content
   .container(style= "margin-top: 70px;")
     .row
       .col-md-10
-        h1 Crafatar
+        a(id="crafatar", class="anchor")
+        a(href="#crafatar")
+          h1 Crafatar
         hr
         p Welcome to Crafatar, an API for Minecraft's faces!
 
         hr
-        h2 Documentation
+        a(id="documentation", class="anchor")
+        a(href="#documentation")
+          h2 Documentation
 
-        h3 Endpoint
+        a(id="avatars", class="anchor")
+        a(href="#avatars")
+          h3 Avatars
         p
           | Replace 
-          mark.green uuid
-          |  with a Mojang UUID to get the related head. All images are PNGs.
+          mark.green id
+          |  with a Mojang UUID or username to get the related head. All images are PNGs.
         .code
           | &lt;img src="#{domain}/avatars/
-          mark.green uuid
+          mark.green id
           | "&gt;
 
-        h3 Parameters
-        h4 size
-        p The size of the image in pixels, 1 - 512. <br> Default is 160.
-        h4 default
-        p The image to be returned when the uuid has no skin (404). <br> Valid options are 
+        a(id="parameters", class="anchor")
+        a(href="#parameters")
+          h3 Parameters
+        a(id="size", class="anchor")
+        a(href="#size")
+          h4 size
+        p The size of the image in pixels, #{config.min_size} - #{config.max_size}. <br> Default is #{config.default_size}.
+        a(id="default", class="anchor")
+        a(href="#default")
+          h4 default
+        p
+          | The image to be returned when the id has no skin (404). <br> Valid options are 
           a(href="/avatars/00000000000000000000000000000000?default=steve") steve
           |  or 
           a(href="/avatars/00000000000000000000000000000000?default=alex") alex
-          | .<br> The standard value is calculated based on the UUID (even = alex, odd = steve)
-        h4 helm
+          | .<br> A URL is also accepted. <br>
+          | The standard value is calculated based on the id (even = alex, odd = steve)
+        a(id="helm", class="anchor")
+        a(href="#helm")
+          h4 helm
         p Get an avatar with the second (helmet) layer applied. <br> The content of this parameter is ignored
 
-        h3 HTTP headers
-        p Images will come with these HTTP headers, useful for debugging.
-        h4 Response-Time
+        a(id="skins", class="anchor")
+        a(href="#skins")
+          h3 Skins
+        p
+          | You can also get the full skin file from name or id. <br>
+          | Replace 
+          mark.green id 
+          | with a Mojang UUID or username to get the related skin.
+          | You are redirected to the textures URL, or a 404 is returned.
+        .code
+          | &lt;img src="#{domain}/skins/
+          mark.green id
+          | "&gt;
+
+        a(id="http-headers", class="anchor")
+        a(href="#http-headers")
+          h3 HTTP headers
+        p Responses come with these HTTP headers, useful for debugging.
+        a(id="response-time", class="anchor")
+        a(href="#response-time")
+          h4 Response-Time
         p The time, in milliseconds, it took Crafatar to process the request.
-        h4 X-Storage-Type
+        a(id="x-storage-type", class="anchor")
+        a(href="#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 has no skin or it didn't change.
+          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. If possible, an outdated image will be served instead.
+          li
+            | <b>error</b>: This can happen, for example, when Mojang's servers are down.<br>
+            | If possible, an outdated image is be served instead.
+
+        a(id="about-usernames", class="anchor")
+        a(href="#about-usernames")
+          h3 About usernames
+        p
+          | We strongly advise you to use UUIDs instead of usernames in production.
+          | Usernames are deprecated by Mojang and you should only use usernames for testing.<br>
+          | Invalid usernames are rejected and a 422 is returned.
+
+        a(id="about-uuids", class="anchor")
+        a(href="#about-uuids")
+          h3 About UUIDs
+        p
+          | UUIDs may use the raw or dashed format.<br>
+          | Invalid UUIDs are rejected and a 422 is returned.
+
+        a(id="about-caching", class="anchor")
+        a(href="#about-caching")
+          h3 About caching
+        p
+          | Crafatar caches keeps skins for #{config.local_cache_time} seconds until they are checked for changes.<br>
+          | Images should be cached in browsers for #{config.browser_cache_time} seconds until a new request to Crafatar is made.
 
-        h3 Examples
+        a(id="examples", class="anchor")
+        a(href="#examples")
+          h3 Examples
         p Get jeb_'s avatar, 160 × 160 pixels
         .code &lt;img src="#{domain}/avatars/853c80ef3c3749fdaa49938b674adae6"&gt;
         p Get jeb_'s avatar, 64 × 64 pixels
         .code &lt;img src="#{domain}/avatars/853c80ef3c3749fdaa49938b674adae6?size=64"&gt;
         p Get jeb_'s helmet avatar, 64 × 64 pixels
         .code &lt;img src="#{domain}/avatars/853c80ef3c3749fdaa49938b674adae6?size=64&amp;helm"&gt;
+        p Get jeb_'s avatar or fall back to steve
+        .code &lt;img src="#{domain}/avatars/853c80ef3c3749fdaa49938b674adae6?default=steve"&gt;
+        p Get jeb_'s avatar or fall back to a custom image
+        .code &lt;img src="#{domain}/avatars/853c80ef3c3749fdaa49938b674adae6?default=https%3A%2F%2Fi.imgur.com%2FozszMZV.png"&gt;
+        p Get jeb_'s avatar by username, 160 x 160 pixels
+        .code &lt;img src="#{domain}/avatars/jeb_"&gt;
+        p Get jeb_'s skin
+        .code &lt;img src="#{domain}/skins/853c80ef3c3749fdaa49938b674adae6"&gt;
+        p Get jeb_'s skin by username
+        .code &lt;img src="#{domain}/skins/jeb_"&gt;
       .col-md-2.center
         .sideface.redstone_sheep(title="redstone_sheep")
         .sideface.Jake0oo0(title="Jake0oo0")