2
0
Эх сурвалжийг харах

Merge branch 'http-response'

jomo 10 жил өмнө
parent
commit
9c59d302c9

+ 2 - 2
.travis.yml

@@ -1,6 +1,6 @@
 language: node_js
 node_js:
-  - "iojs-v1.6.0"
+  - iojs-v2.0
 before_install:
   - sudo apt-get install libcairo2-dev libjpeg8-dev libpango1.0-dev libgif-dev build-essential g++
 script:
@@ -8,7 +8,7 @@ script:
 notifications:
   irc:
     channels:
-      - "irc.esper.net#crafatar"
+      - irc.esper.net#crafatar
     skip_join: true
 services:
   - redis-server

+ 3 - 3
lib/cache.js

@@ -98,7 +98,7 @@ exp.info = function(callback) {
 // these 60 seconds match the duration of Mojang's rate limit ban
 // callback: error
 exp.update_timestamp = function(rid, userId, hash, temp, callback) {
-  logging.log(rid, "cache: updating timestamp");
+  logging.debug(rid, "updating cache timestamp");
   var sub = temp ? (config.local_cache_time - 60) : 0;
   var time = Date.now() - sub;
   // store userId in lower case if not null
@@ -114,7 +114,7 @@ exp.update_timestamp = function(rid, userId, hash, temp, callback) {
 // 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);
+  logging.debug(rid, "caching skin:" + skin_hash + " cape:" + cape_hash);
   var time = Date.now();
   // store shorter null byte instead of "null"
   skin_hash = (skin_hash === null ? "" : skin_hash);
@@ -138,7 +138,7 @@ exp.save_hash = function(rid, userId, skin_hash, cape_hash, callback) {
 
 // removes the hash for +userId+ from the cache
 exp.remove_hash = function(rid, userId) {
-  logging.log(rid, "cache: deleting hash");
+  logging.log(rid, "deleting hash from cache");
   redis.del(userId.toLowerCase(), "h", "t");
 };
 

+ 28 - 30
lib/helpers.js

@@ -177,7 +177,7 @@ function store_images(rid, userId, cache_details, type, callback) {
             // an error occured, not caching. we can try in 60 seconds
             callback_for(userId, "skin", store_err, null);
           } else {
-            cache.save_hash(rid, userId, skin_hash, null, function(cache_err) {
+            cache.save_hash(rid, userId, skin_hash, undefined, function(cache_err) {
               callback_for(userId, "skin", (store_err || cache_err), skin_hash);
             });
           }
@@ -210,12 +210,7 @@ exp.id_valid = function(userId) {
 
 // decides whether to get a +type+ image for +userId+ from disk or to download it
 // callback: 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
+// 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;
@@ -286,23 +281,26 @@ exp.get_avatar = function(rid, userId, helm, size, callback) {
 };
 
 // handles requests for +userId+ skins
-// callback: error, skin hash, image buffer
+// callback: error, skin hash, status, image buffer
 exp.get_skin = function(rid, userId, callback) {
   exp.get_image_hash(rid, userId, "skin", function(err, status, skin_hash) {
-    // FIXME: err is not used / handled
-    var skinpath = path.join(__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(skin_err, img) {
-          callback(skin_err, skin_hash, img);
-        });
-      } else {
-        networking.save_texture(rid, skin_hash, skinpath, function(net_err, response, img) {
-          callback(net_err, skin_hash, img);
-        });
-      }
-    });
+    if (skin_hash) {
+      var skinpath = path.join(__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(skin_err, img) {
+            callback(skin_err || err, skin_hash, status, img);
+          });
+        } else {
+          networking.save_texture(rid, skin_hash, skinpath, function(net_err, response, img) {
+            callback(net_err || err, skin_hash, status, img);
+          });
+        }
+      });
+    } else {
+      callback(err, null, status, null);
+    }
   });
 };
 
@@ -317,9 +315,9 @@ function get_type(helm, body) {
 // handles creations of 3D renders
 // callback: 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) {
+  exp.get_skin(rid, userId, function(err, skin_hash, status, img) {
     if (!skin_hash) {
-      callback(err, -1, skin_hash, null);
+      callback(err, status, skin_hash, null);
       return;
     }
     var renderpath = path.join(__dirname, "..", config.renders_dir, [skin_hash, scale, get_type(helm, body)].join("-") + ".png");
@@ -344,7 +342,7 @@ exp.get_render = function(rid, userId, scale, helm, body, callback) {
               if (fs_err) {
                 logging.error(rid, fs_err.stack);
               }
-              callback(null, 2, skin_hash, img);
+              callback(null, 2, skin_hash, drawn_img);
             });
           }
         });
@@ -354,11 +352,11 @@ exp.get_render = function(rid, userId, scale, helm, body, callback) {
 };
 
 // handles requests for +userId+ capes
-// callback: error, cape hash, image buffer
+// 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) {
     if (!cape_hash) {
-      callback(err, null, null);
+      callback(err, null, status, null);
       return;
     }
     var capepath = path.join(__dirname, "..", config.capes_dir, cape_hash + ".png");
@@ -366,14 +364,14 @@ exp.get_cape = function(rid, userId, callback) {
       if (exists) {
         logging.log(rid, "cape already exists, not downloading");
         skins.open_skin(rid, capepath, function(skin_err, img) {
-          callback(skin_err, cape_hash, 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, null);
+            callback(net_err, cape_hash, status, null);
           } else {
-            callback(net_err, cape_hash, img);
+            callback(net_err, cape_hash, status, img);
           }
         });
       }

+ 90 - 0
lib/response.js

@@ -0,0 +1,90 @@
+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. 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
+};
+
+
+// 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
+module.exports = function(request, response, result) {
+
+  response.on("close", function() {
+    logging.warn(request.id, "Connection closed");
+  });
+
+  response.on("finish", function() {
+    logging.log(request.id, response.statusCode, "(" + (human_status[result.status] || "-") + ")");
+  });
+
+  response.on("error", function(err) {
+    logging.error(request.id, err);
+  });
+
+  // These headers are the same for every response
+  var headers = {
+    "Content-Type": (result.body && result.type) || "text/plain",
+    "Cache-Control": "max-age=" + config.browser_cache_time + ", public",
+    "Response-Time": Date.now() - request.start,
+    "X-Request-ID": request.id,
+    "Access-Control-Allow-Origin": "*"
+  };
+
+  if (result.err) {
+    logging.error(result.err);
+    logging.error(result.err.stack);
+    result.status = -1;
+  }
+
+  if (result.status !== undefined && result.status !== null) {
+    headers["X-Storage-Type"] = human_status[result.status];
+  }
+
+  if (result.body) {
+    // use Mojang's image hash if available
+    // use crc32 as a hash function otherwise
+    var etag = result.hash && result.hash.substr(0, 10) || crc(result.body);
+    headers.Etag = "\"" + etag + "\"";
+
+    // handle etag caching
+    var incoming_etag = request.headers["if-none-match"];
+    if (incoming_etag && incoming_etag === headers.Etag) {
+      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(422, headers);
+    response.end(result.body);
+  } else if (result.status === -1) {
+    response.writeHead(500, headers);
+    response.end(result.body);
+  } else {
+    response.writeHead(result.body ? 200 : 404, headers);
+    response.end(result.body);
+  }
+};

+ 42 - 71
lib/routes/avatars.js

@@ -3,108 +3,79 @@ var helpers = require("../helpers");
 var config = require("../config");
 var skins = require("../skins");
 var cache = require("../cache");
+var path = require("path");
 
-var human_status = {
-  0: "none",
-  1: "cached",
-  2: "downloaded",
-  3: "checked",
-  "-1": "error"
-};
+function handle_default(img_status, userId, size, def, err, callback) {
+  if (def && def !== "steve" && def !== "alex") {
+    callback({
+      status: img_status,
+      redirect: def,
+      err: err
+    });
+  } else {
+    def = def || skins.default_skin(userId);
+    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, res) {
-  var start = new Date();
-  var userId = (req.url.path_list[2] || "").split(".")[0];
+module.exports = function(req, callback) {
+  var userId = (req.url.path_list[1] || "").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;
-
-  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);
-  }
-
-  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);
-      });
-    }
-  }
 
   // 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
+    callback({
+      status: -2,
+      body: "Invalid Size"
     });
-    res.end("Invalid Size");
     return;
   } else if (!helpers.id_valid(userId)) {
-    res.writeHead(422, {
-      "Content-Type": "text/plain",
-      "Response-Time": new Date() - start
+    callback({
+      status: -2,
+      body: "Invalid UserID"
     });
-    res.end("Invalid ID");
     return;
   }
 
   // strip dashes
   userId = userId.replace(/-/g, "");
-  logging.debug(rid, "userid:", userId);
 
   try {
-    helpers.get_avatar(rid, userId, helm, size, function(err, status, image, hash) {
-      logging.log(rid, "storage type:", human_status[status]);
+    helpers.get_avatar(req.id, userId, helm, size, function(err, status, image, hash) {
       if (err) {
-        logging.error(rid, err);
+        logging.error(req.id, err);
         if (err.code === "ENOENT") {
           // no such file
-          cache.remove_hash(rid, userId);
+          cache.remove_hash(req.id, userId);
         }
       }
-      etag = image && hash && hash.substr(0, 32) || "none";
-      var matches = req.headers["if-none-match"] === '"' + etag + '"';
       if (image) {
-        var http_status = 200;
-        if (err) {
-          http_status = 503;
-        }
-        logging.debug(rid, "etag:", req.headers["if-none-match"]);
-        logging.debug(rid, "matches:", matches);
-        sendimage(rid, matches ? 304 : http_status, status, image);
+        callback({
+          status: status,
+          body: image,
+          type: "image/png",
+          err: err,
+          hash: hash
+        });
       } else {
-        handle_default(rid, matches ? 304 : 200, status, userId);
+        handle_default(status, userId, size, def, err, callback);
       }
     });
   } catch(e) {
-    logging.error(rid, "error:", e.stack);
-    handle_default(rid, 500, -1, userId);
+    logging.error(req.id, "error:", e.stack);
+    handle_default(-1, userId, size, def, e, callback);
   }
 };

+ 17 - 62
lib/routes/capes.js

@@ -1,52 +1,26 @@
 var logging = require("../logging");
 var helpers = require("../helpers");
-var config = require("../config");
 var cache = require("../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();
+module.exports = function(req, callback) {
   var userId = (req.url.pathname.split("/")[2] || "").split(".")[0];
-  var etag = null;
+  var def = req.url.query.default;
   var rid = req.id;
 
-  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);
-  }
-
   if (!helpers.id_valid(userId)) {
-    res.writeHead(422, {
-      "Content-Type": "text/plain",
-      "Response-Time": new Date() - start
+    callback({
+      status: -2,
+      body: "Invalid UserID"
     });
-    res.end("Invalid ID");
     return;
   }
 
   // strip dashes
   userId = userId.replace(/-/g, "");
-  logging.debug(rid, "userid:", userId);
 
   try {
-    helpers.get_cape(rid, userId, function(err, status, image, hash) {
-      logging.log(rid, "storage type:", human_status[status]);
+    helpers.get_cape(rid, userId, function(err, hash, status, image) {
       if (err) {
         logging.error(rid, err);
         if (err.code === "ENOENT") {
@@ -54,38 +28,19 @@ module.exports = function(req, res) {
           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 (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, matches ? 304 : http_status, status, image);
-      } else if (matches) {
-        res.writeHead(304, {
-          "Etag": '"' + etag + '"',
-          "Response-Time": new Date() - start
-        });
-        res.end();
-      } else {
-        res.writeHead(404, {
-          "Content-Type": "text/plain",
-          "Etag": '"' + etag + '"',
-          "Response-Time": new Date() - start
-        });
-        res.end("404 not found");
-      }
+      callback({
+        status: status,
+        body: image,
+        type: image ? "image/png" : undefined,
+        redirect: image ? undefined : def,
+        hash: hash,
+        err: err
+      });
     });
   } catch(e) {
-    logging.error(rid, "error:" + e.stack);
-    res.writeHead(500, {
-      "Content-Type": "text/plain",
-      "Response-Time": new Date() - start
+    callback({
+      status: -1,
+      err: e
     });
-    res.end("500 server error");
   }
 };

+ 5 - 6
lib/routes/index.js

@@ -3,17 +3,16 @@ var path = require("path");
 var jade = require("jade");
 
 // compile jade
-var index = jade.compileFile(path.join(__dirname, "../views/index.jade"));
+var index = jade.compileFile(path.join(__dirname, "..", "views", "index.jade"));
 
-module.exports = function(req, res) {
+module.exports = function(req, callback) {
   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"
+  callback({
+    body: html,
+    type: "text/html; charset=utf-8"
   });
-  res.end(html);
 };

+ 51 - 88
lib/routes/renders.js

@@ -1,115 +1,80 @@
 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 renders = require("../renders");
+var path = require("path");
 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
+// default alex/steve images can be rendered, but
+// custom images will not be
+function handle_default(rid, scale, helm, body, img_status, userId, size, def, err, callback) {
+  if (def && def !== "steve" && def !== "alex") {
+    callback({
+      status: img_status,
+      redirect: def,
+      err: err
+    });
+  } else {
+    def = def || skins.default_skin(userId);
+    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, helm, body, function(render_err, def_img) {
+        callback({
+          status: img_status,
+          body: def_img,
+          type: "image/png",
+          hash: def,
+          err: render_err || fs_err || err
+        });
+      });
     });
-    res.end("Invalid Render Type");
-    return;
   }
+}
 
+// 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[3] || "").split(".")[0];
+  var userId = (req.url.path_list[2] || "").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;
 
-  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 + '"'
+  // validate type
+  if (raw_type !== "body" && raw_type !== "head") {
+    callback({
+      status: -2,
+      body: "Invalid Render Type"
     });
-    res.end(http_status === 304 ? null : image);
-  }
-
-  // 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(render_err, def_img) {
-          if (render_err) {
-            logging.error(rid, "error while rendering default image:", render_err);
-          }
-          sendimage(rid, http_status, img_status, def_img);
-        });
-      });
-    }
+    return;
   }
 
   if (scale < config.min_scale || scale > config.max_scale) {
-    res.writeHead(422, {
-      "Content-Type": "text/plain",
-      "Response-Time": new Date() - start
+    callback({
+      status: -2,
+      body: "Invalid Scale"
     });
-    res.end("422 Invalid Scale");
     return;
   } else if (!helpers.id_valid(userId)) {
-    res.writeHead(422, {
-      "Content-Type": "text/plain",
-      "Response-Time": new Date() - start
+    callback({
+      status: -2,
+      body: "Invalid UserID"
     });
-    res.end("422 Invalid ID");
     return;
   }
 
   // strip dashes
   userId = userId.replace(/-/g, "");
-  logging.debug(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") {
@@ -117,23 +82,21 @@ module.exports = function(req, res) {
           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 (err) {
-          http_status = 503;
-        }
-        logging.debug(rid, "etag:", req.headers["if-none-match"]);
-        logging.debug(rid, "matches:", matches);
-        sendimage(rid, matches ? 304 : http_status, status, image);
+        callback({
+          status: status,
+          body: image,
+          type: "image/png",
+          hash: hash,
+          err: err
+        });
       } else {
         logging.log(rid, "image not found, using default.");
-        handle_default(rid, matches ? 304 : 200, status, userId);
+        handle_default(rid, scale, helm, body, status, userId, scale, def, err, callback);
       }
     });
   } catch(e) {
     logging.error(rid, "error:", e.stack);
-    handle_default(rid, 500, -1, userId);
+    handle_default(rid, scale, helm, body, -1, userId, scale, def, e, callback);
   }
 };

+ 49 - 60
lib/routes/skins.js

@@ -1,90 +1,79 @@
 var logging = require("../logging");
 var helpers = require("../helpers");
-var config = require("../config");
 var skins = require("../skins");
 var path = require("path");
 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;
-
-  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 + '"'
+function handle_default(img_status, userId, def, err, callback) {
+  if (def && def !== "steve" && def !== "alex") {
+    callback({
+      status: img_status,
+      redirect: def,
+      err: err
     });
-    res.end(http_status === 304 ? null : image);
-  }
-
-  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(path.join(__dirname, "..", "public", "images", def + "_skin.png"), function(err, image) {
-        // FIXME: err is not handled
+  } else {
+    def = def || skins.default_skin(userId);
+    lwip.open(path.join(__dirname, "..", "public", "images", def + "_skin.png"), function(lwip_err, image) {
+      if (image) {
         image.toBuffer("png", function(buf_err, buffer) {
-          // FIXME: buf_err is not handled
-          sendimage(rid, http_status, 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.query.default;
+  var rid = req.id;
 
   if (!helpers.id_valid(userId)) {
-    res.writeHead(422, {
-      "Content-Type": "text/plain",
-      "Response-Time": new Date() - start
+    callback({
+      status: -2,
+      body: "Invalid UserID"
     });
-    res.end("Invalid ID");
     return;
   }
 
   // strip dashes
   userId = userId.replace(/-/g, "");
-  logging.debug(rid, "userid:", userId);
 
   try {
-    helpers.get_skin(rid, userId, function(err, hash, image) {
+    helpers.get_skin(rid, userId, function(err, hash, status, image) {
       if (err) {
-        logging.error(rid, err);
+        logging.error(req.id, err);
+        if (err.code === "ENOENT") {
+          // no such file
+          cache.remove_hash(req.id, userId);
+        }
       }
-      etag = hash && hash.substr(0, 32) || "none";
-      var matches = req.headers["if-none-match"] === '"' + etag + '"';
       if (image) {
-        var http_status = 200;
-        if (err) {
-          http_status = 503;
-        }
-        logging.debug(rid, "etag:", req.headers["if-none-match"]);
-        logging.debug(rid, "matches:", matches);
-        sendimage(rid, matches ? 304 : http_status, image);
+        callback({
+          status: status,
+          body: image,
+          type: "image/png",
+          hash: hash,
+          err: err
+        });
       } else {
-        handle_default(rid, 200, userId);
+        handle_default(2, userId, def, err, callback);
       }
     });
   } catch(e) {
     logging.error(rid, "error:", e.stack);
-    handle_default(rid, 500, userId);
+    handle_default(-1, userId, def, e, callback);
   }
 };

+ 55 - 28
lib/server.js

@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 var logging = require("./logging");
 var querystring = require("querystring");
+var response = require("./response");
 var config = require("./config");
 var http = require("http");
 var mime = require("mime");
@@ -17,63 +18,89 @@ var routes = {
   capes: require("./routes/capes")
 };
 
-function asset_request(req, res) {
+// serves assets from lib/public
+function asset_request(req, callback) {
   var filename = path.join(__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"
+      fs.readFile(filename, function(err, data) {
+        callback({
+          body: data,
+          type: mime.lookup(filename),
+          err: err
+        });
       });
-      res.end("Not Found");
+    } else {
+      callback({});
     }
   });
 }
 
-function requestHandler(req, res) {
-  var request = req;
-  request.url = url.parse(req.url, true);
-  request.url.query = request.url.query || {};
+// generates a 12 character random string
+function request_id() {
+  return Math.random().toString(36).substring(2, 14);
+}
 
-  // remove trailing and double slashes + other junk
-  var path_list = request.url.pathname.split("/");
-  for (var i = 0; i < path_list.length; i++) {
+// splits a URL path into an Array
+// the path is resolved and decoded
+function path_list(pathname) {
+  // remove double and trailing slashes
+  pathname = pathname.replace(/\/\/+/g, "/").replace(/(.)\/$/, "$1");
+  var list = pathname.split("/");
+  list.shift();
+  for (var i = 0; i < list.length; i++) {
     // URL decode
-    path_list[i] = querystring.unescape(path_list[i]);
+    list[i] = querystring.unescape(list[i]);
   }
-  request.url.path_list = path_list;
+  return list;
+}
+
+function requestHandler(req, res) {
+  req.url = url.parse(req.url, true);
+  req.url.query = req.url.query || {};
+  req.url.path_list = path_list(req.url.pathname);
 
-  // generate 12 character random string
-  request.id = Math.random().toString(36).substring(2, 14);
+  req.id = request_id();
+  req.start = Date.now();
 
-  var local_path = request.url.path_list[1];
-  logging.log(request.id, request.method, request.url.href);
-  if (request.method === "GET" || request.method === "HEAD") {
+  var local_path = req.url.path_list[0];
+  logging.log(req.id, req.method, req.url.href);
+  if (req.method === "GET" || req.method === "HEAD") {
     try {
       switch (local_path) {
         case "":
-        routes.index(request, res);
+        routes.index(req, function(result) {
+          response(req, res, result);
+        });
         break;
         case "avatars":
-        routes.avatars(request, res);
+        routes.avatars(req, function(result) {
+          response(req, res, result);
+        });
         break;
         case "skins":
-        routes.skins(request, res);
+        routes.skins(req, function(result) {
+          response(req, res, result);
+        });
         break;
         case "renders":
-        routes.renders(request, res);
+        routes.renders(req, function(result) {
+          response(req, res, result);
+        });
         break;
         case "capes":
-        routes.capes(request, res);
+        routes.capes(req, function(result) {
+          response(req, res, result);
+        });
         break;
         default:
-        asset_request(request, res);
+        asset_request(req, function(result) {
+          response(req, res, result);
+        });
       }
     } catch(e) {
       var error = JSON.stringify(req.headers) + "\n" + e.stack;
-      logging.error(request.id + "Error:", error);
+      logging.error(req.id + "Error:", error);
       res.writeHead(500, {
         "Content-Type": "text/plain"
       });

+ 44 - 38
lib/views/index.jade

@@ -279,16 +279,20 @@ block content
               a(id="meta-x-storage-type" class="anchor")
               a(href="#meta-x-storage-type")
                 h4 X-Storage-Type
+              p Details about how the requested image was stored on the server
               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>downloaded</b>: 2 external requests. First request or skin changed, skin 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.
+                  | <b>server error</b>: This can happen, for example, when Mojang's servers are down.<br>
+                  | If possible, a cached image is served instead.
+                li
+                  | <b>user error</b>: You have done something wrong, such as requesting a malformed userid.<br>
+                  | Check the response body for details.
             section
               a(id="meta-x-request-id" class="anchor")
               a(href="#meta-x-request-id")
@@ -340,52 +344,54 @@ block content
 
 
   // preload hover images
-  img.preload(src="/avatars/jeb_", alt="preloaded image")
-  img.preload(src="/avatars/853c80ef3c3749fdaa49938b674adae6", alt="preloaded image")
+  img.preload(src="/avatars/020242a17b9441799eff511eea1221da?size=64", alt="preloaded image")
+  img.preload(src="/avatars/020242a17b9441799eff511eea1221da?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/069a79f444e94726a5befca90e38aaf5?size=64", 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/0ea8eca3dbf647cc9d1ac64551ca975c?size=64&helm", 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/1c1bd09a6a0f4928a7914102a35d2670?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=64", alt="preloaded image")
+  img.preload(src="/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/4566e69fc90748ee8d71d7ba5aa00d20?size=64", alt="preloaded image")
+  img.preload(src="/avatars/4566e69fc90748ee8d71d7ba5aa00d20?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/61699b2ed3274a019f1e0ea8c3f06bc6?size=64", alt="preloaded image")
+  img.preload(src="/avatars/61699b2ed3274a019f1e0ea8c3f06bc6?size=64&helm", 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/696a82ce41f44b51aa31b8709b8686f0?size=64&helm", 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/7125ba8b1c864508b92bb5c042ccfe2b?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/7d043c7389524696bfba571c05b6aec0?size=64", alt="preloaded image")
+  img.preload(src="/avatars/7d043c7389524696bfba571c05b6aec0?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/853c80ef3c3749fdaa49938b674adae6", alt="preloaded image")
+  img.preload(src="/avatars/853c80ef3c3749fdaa49938b674adae6?size=64", alt="preloaded image")
+  img.preload(src="/avatars/853c80ef3c3749fdaa49938b674adae6?size=64&helm", 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/9769ecf6331448f3ace67ae06cec64a3?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/ae795aa86327408e92ab25c8a59f3ba1?size=64", 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", 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", 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", alt="preloaded image")
   img.preload(src="/avatars/b9583ca43e64488a9c8c4ab27e482255?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/c9b54008fd8047428b238787b5f2401c?size=64", alt="preloaded image")
   img.preload(src="/avatars/c9b54008fd8047428b238787b5f2401c?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/d8f9a4340f2d415f9acfcd70341c75ec?size=64", 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/e6b5c088068044df9e1b9bf11792291b?size=64", alt="preloaded image")
+  img.preload(src="/avatars/e6b5c088068044df9e1b9bf11792291b?size=64&helm", alt="preloaded image")
+  img.preload(src="/avatars/f8cdb6839e9043eea81939f85d9c5d69?size=64", alt="preloaded image")
   img.preload(src="/avatars/f8cdb6839e9043eea81939f85d9c5d69?size=64&helm", alt="preloaded image")
-  img.preload(src="/skins/jeb_", alt="preloaded image")
+  img.preload(src="/avatars/jeb_", alt="preloaded image")
+  img.preload(src="/avatars/jeb_?helm", alt="preloaded image")
+  img.preload(src="/avatars/jeb_?size=128", alt="preloaded image")
+  img.preload(src="/capes/Dinnerbone", alt="preloaded image")
+  img.preload(src="/capes/md_5", alt="preloaded image")
+  img.preload(src="/renders/body/jeb_?helm&scale=4", alt="preloaded image")
+  img.preload(src="/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=8", alt="preloaded image")
+  img.preload(src="/skins/0?default=alex", alt="preloaded image")
+  img.preload(src="/skins/jeb_", alt="preloaded image")

+ 4 - 3
package.json

@@ -27,17 +27,18 @@
     "test-travis": "istanbul cover ./node_modules/mocha/bin/_mocha --report lcovonly -- -R spec && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js && rm -rf ./coverage"
   },
   "engines": {
-    "iojs": "1.6.x"
+    "iojs": "2.0.x"
   },
   "dependencies": {
     "canvas": "crafatar/node-canvas",
     "forever": "0.14.1",
     "jade": "~1.9.1",
-    "lwip": "0.0.6",
+    "lwip": "crafatar/lwip",
     "mime": "1.3.4",
     "node-df": "0.1.1",
     "redis": "0.12.1",
-    "request": "^2.51.0"
+    "request": "^2.51.0",
+    "crc": "3.2.1"
   },
   "devDependencies": {
     "coveralls": "^2.11.2",

+ 434 - 81
test/test.js

@@ -1,21 +1,22 @@
-var assert = require("assert");
-var fs = require("fs");
 var networking = require("../lib/networking");
 var helpers = require("../lib/helpers");
 var logging = require("../lib/logging");
+var cleaner = require("../lib/cleaner");
+var request = require("request");
 var config = require("../lib/config");
+var server = require("../lib/server");
+var assert = require("assert");
 var skins = require("../lib/skins");
 var cache = require("../lib/cache");
-var server = require("../lib/server");
-var cleaner = require("../lib/cleaner");
-var request = require("request");
+var crc = require("crc").crc32;
+var fs = require("fs");
 
 // we don't want tests to fail because of slow internet
 config.http_timeout *= 3;
 
 // no spam
 if (process.env.VERBOSE_TEST !== "true") {
-  logging.log = function() {};
+  logging.log = logging.debug = logging.warn = logging.error = function() {};
 }
 
 var uuids = fs.readFileSync("test/uuids.txt").toString().split(/\r?\n/);
@@ -140,6 +141,10 @@ describe("Crafatar", function() {
         });
       });
     });
+    it("Username should default to Steve", function(done) {
+      assert.strictEqual(skins.default_skin("TestUser"), "steve");
+      done();
+    });
     for (var a in alex_ids) {
       var alex_id = alex_ids[a];
       (function(alex_id) {
@@ -211,39 +216,435 @@ describe("Crafatar", function() {
   });
 
   describe("Server", function() {
+
+    // throws Exception when default headers are not in res.headers
+    function assert_headers(res) {
+      assert(res.headers["content-type"]);
+      assert("" + res.headers["response-time"]);
+      assert(res.headers["x-request-id"]);
+      assert.equal(res.headers["access-control-allow-origin"], "*");
+      assert.equal(res.headers["cache-control"], "max-age=" + config.browser_cache_time + ", public");
+    }
+
+    // throws Exception when +url+ is requested with +etag+
+    // and it does not return 304 without a body
+    function assert_cache(url, etag, callback) {
+      request.get(url, {
+        headers: {
+          "If-None-Match": etag
+        }
+      }, function(error, res, body) {
+        assert.ifError(error);
+        assert.ifError(body);
+        assert.equal(res.statusCode, 304);
+        assert(res.headers["etag"]);
+        assert_headers(res);
+        callback();
+      });
+    }
+
     before(function(done) {
       server.boot(function() {
         done();
       });
     });
 
-    // Test the home page
-    it("should return a 200 (home page)", function(done) {
-      request.get("http://localhost:3000", function(error, res, body) {
-        assert.equal(200, res.statusCode);
+    it("should return 405 Method Not Allowed for POST", function(done) {
+      request.post("http://localhost:3000", function(error, res, body) {
+        assert.ifError(error);
+        assert.strictEqual(res.statusCode, 405);
         done();
       });
     });
 
-    it("should return a 200 (asset request)", function(done) {
-      request.get("http://localhost:3000/stylesheets/style.css", function(error, res, body) {
-        assert.equal(200, res.statusCode);
-        done();
+    it("should return correct HTTP response for home page", function(done) {
+      var url = "http://localhost:3000";
+      request.get(url, function(error, res, body) {
+        assert.ifError(error);
+        assert.strictEqual(res.statusCode, 200);
+        assert_headers(res);
+        assert(res.headers["etag"]);
+        assert.strictEqual(res.headers["content-type"], "text/html; charset=utf-8");
+        assert.strictEqual(res.headers["etag"], "\"" + crc(body) + "\"");
+        assert(body);
+
+        assert_cache(url, res.headers["etag"], function() {
+          done();
+        });
       });
     });
 
-    // invalid method, we only allow GET and HEAD requests
-    it("should return a 405 (invalid method)", function(done) {
-      request.post("http://localhost:3000", function(error, res, body) {
-        assert.equal(405, res.statusCode);
-        done();
+    it("should return correct HTTP response for assets", function(done) {
+      var url = "http://localhost:3000/stylesheets/style.css";
+      request.get(url, function(error, res, body) {
+        assert.ifError(error);
+        assert.strictEqual(res.statusCode, 200);
+        assert_headers(res);
+        assert(res.headers["etag"]);
+        assert.strictEqual(res.headers["content-type"], "text/css");
+        assert.strictEqual(res.headers["etag"], "\"" + crc(body) + "\"");
+        assert(body);
+
+        assert_cache(url, res.headers["etag"], function() {
+          done();
+        });
       });
     });
 
+    var server_tests = {
+      "avatar with existing username": {
+        url: "http://localhost:3000/avatars/jeb_?size=16",
+        etag: '"a846b82963"',
+        crc32: 1623808067
+      },
+      "avatar with not existing username": {
+        url: "http://localhost:3000/avatars/0?size=16",
+        etag: '"steve"',
+        crc32: 2416827277
+      },
+      "avatar with not existing username defaulting to alex": {
+        url: "http://localhost:3000/avatars/0?size=16&default=alex",
+        etag: '"alex"',
+        crc32: 862751081
+      },
+      "avatar with not existing username defaulting to url": {
+        url: "http://localhost:3000/avatars/0?size=16&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "helm avatar with existing username": {
+        url: "http://localhost:3000/avatars/jeb_?size=16&helm",
+        etag: '"a846b82963"',
+        crc32: 646871998
+      },
+      "helm avatar with not existing username": {
+        url: "http://localhost:3000/avatars/0?size=16&helm",
+        etag: '"steve"',
+        crc32: 2416827277
+      },
+      "helm avatar with not existing username defaulting to alex": {
+        url: "http://localhost:3000/avatars/0?size=16&helm&default=alex",
+        etag: '"alex"',
+        crc32: 862751081
+      },
+      "helm avatar with not existing username defaulting to url": {
+        url: "http://localhost:3000/avatars/0?size=16&helm&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "avatar with existing uuid": {
+        url: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
+        etag: '"a846b82963"',
+        crc32: 1623808067
+      },
+      "avatar with not existing uuid": {
+        url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16",
+        etag: '"steve"',
+        crc32: 2416827277
+      },
+      "avatar with not existing uuid defaulting to alex": {
+        url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=alex",
+        etag: '"alex"',
+        crc32: 862751081
+      },
+      "avatar with not existing uuid defaulting to url": {
+        url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "helm avatar with existing uuid": {
+        url: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16&helm",
+        etag: '"a846b82963"',
+        crc32: 646871998
+      },
+      "helm avatar with not existing uuid": {
+        url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&helm",
+        etag: '"steve"',
+        crc32: 2416827277
+      },
+      "helm avatar with not existing uuid defaulting to alex": {
+        url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&helm&default=alex",
+        etag: '"alex"',
+        crc32: 862751081
+      },
+      "helm avatar with not existing uuid defaulting to url": {
+        url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&helm&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "cape with existing username": {
+        url: "http://localhost:3000/capes/jeb_",
+        etag: '"3f688e0e69"',
+        crc32: 989800403
+      },
+      "cape with not existing username": {
+        url: "http://localhost:3000/capes/0",
+        crc32: 0
+      },
+      "cape with not existing username defaulting to url": {
+        url: "http://localhost:3000/capes/0?default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "cape with existing uuid": {
+        url: "http://localhost:3000/capes/853c80ef3c3749fdaa49938b674adae6",
+        etag: '"3f688e0e69"',
+        crc32: 989800403
+      },
+      "cape with not existing uuid": {
+        url: "http://localhost:3000/capes/00000000000000000000000000000000",
+        crc32: 0
+      },
+      "cape with not existing uuid defaulting to url": {
+        url: "http://localhost:3000/capes/00000000000000000000000000000000?default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "skin with existing username": {
+        url: "http://localhost:3000/skins/jeb_",
+        etag: '"a846b82963"',
+        crc32: 110922424
+      },
+      "skin with not existing username": {
+        url: "http://localhost:3000/skins/0",
+        etag: '"steve"',
+        crc32: 981937087
+      },
+      "skin with not existing username defaulting to alex": {
+        url: "http://localhost:3000/skins/0?default=alex",
+        etag: '"alex"',
+        crc32: 2298915739
+      },
+      "skin with not existing username defaulting to url": {
+        url: "http://localhost:3000/skins/0?default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "skin with existing uuid": {
+        url: "http://localhost:3000/skins/853c80ef3c3749fdaa49938b674adae6",
+        etag: '"a846b82963"',
+        crc32: 110922424
+      },
+      "skin with not existing uuid": {
+        url: "http://localhost:3000/skins/00000000000000000000000000000000",
+        etag: '"steve"',
+        crc32: 981937087
+      },
+      "skin with not existing uuid defaulting to alex": {
+        url: "http://localhost:3000/skins/00000000000000000000000000000000?default=alex",
+        etag: '"alex"',
+        crc32: 2298915739
+      },
+      "skin with not existing uuid defaulting to url": {
+        url: "http://localhost:3000/skins/00000000000000000000000000000000?default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "head render with existing username": {
+        url: "http://localhost:3000/renders/head/jeb_?scale=2",
+        etag: '"a846b82963"',
+        crc32: [353633671, 370672768]
+      },
+      "head render with not existing username": {
+        url: "http://localhost:3000/renders/head/0?scale=2",
+        etag: '"steve"',
+        crc32: [883439147, 433083528]
+      },
+      "head render with not existing username defaulting to alex": {
+        url: "http://localhost:3000/renders/head/0?scale=2&default=alex",
+        etag: '"alex"',
+        crc32: [1240086237, 1108800327]
+      },
+      "head render with not existing username defaulting to url": {
+        url: "http://localhost:3000/renders/head/0?scale=2&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "helm head render with existing username": {
+        url: "http://localhost:3000/renders/head/jeb_?scale=2&helm",
+        etag: '"a846b82963"',
+        crc32: [3456497067, 3490318764]
+      },
+      "helm head render with not existing username": {
+        url: "http://localhost:3000/renders/head/0?scale=2&helm",
+        etag: '"steve"',
+        crc32: [1858563554, 2647471936]
+      },
+      "helm head render with not existing username defaulting to alex": {
+        url: "http://localhost:3000/renders/head/0?scale=2&helm&default=alex",
+        etag: '"alex"',
+        crc32: [2963161105, 1769904825]
+      },
+      "helm head render with not existing username defaulting to url": {
+        url: "http://localhost:3000/renders/head/0?scale=2&helm&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "head render with existing uuid": {
+        url: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2",
+        etag: '"a846b82963"',
+        crc32: [353633671, 370672768]
+      },
+      "head render with not existing uuid": {
+        url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2",
+        etag: '"steve"',
+        crc32: [883439147, 433083528]
+      },
+      "head render with not existing uuid defaulting to alex": {
+        url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=alex",
+        etag: '"alex"',
+        crc32: [1240086237, 1108800327]
+      },
+      "head render with not existing uuid defaulting to url": {
+        url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "helm head render with existing uuid": {
+        url: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2&helm",
+        etag: '"a846b82963"',
+        crc32: [3456497067, 3490318764]
+      },
+      "helm head render with not existing uuid": {
+        url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&helm",
+        etag: '"steve"',
+        crc32: [1858563554, 2647471936]
+      },
+      "helm head render with not existing uuid defaulting to alex": {
+        url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&helm&default=alex",
+        etag: '"alex"',
+        crc32: [2963161105, 1769904825]
+      },
+      "helm head render with not existing uuid defaulting to url": {
+        url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&helm&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "body render with existing username": {
+        url: "http://localhost:3000/renders/body/jeb_?scale=2",
+        etag: '"a846b82963"',
+        crc32: [1291941229, 2628108474]
+      },
+      "body render with not existing username": {
+        url: "http://localhost:3000/renders/body/0?scale=2",
+        etag: '"steve"',
+        crc32: [2652947188, 2115706574]
+      },
+      "body render with not existing username defaulting to alex": {
+        url: "http://localhost:3000/renders/body/0?scale=2&default=alex",
+        etag: '"alex"',
+        crc32: [407932087, 2516216042]
+      },
+      "body render with not existing username defaulting to url": {
+        url: "http://localhost:3000/renders/body/0?scale=2&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "helm body render with existing username": {
+        url: "http://localhost:3000/renders/body/jeb_?scale=2&helm",
+        etag: '"a846b82963"',
+        crc32: [3556188297, 4269754007]
+      },
+      "helm body render with not existing username": {
+        url: "http://localhost:3000/renders/body/0?scale=2&helm",
+        etag: '"steve"',
+        crc32: [272191039, 542896675]
+      },
+      "helm body render with not existing username defaulting to alex": {
+        url: "http://localhost:3000/renders/body/0?scale=2&helm&default=alex",
+        etag: '"alex"',
+        crc32: [737759773, 66512449]
+      },
+      "helm body render with not existing username defaulting to url": {
+        url: "http://localhost:3000/renders/body/0?scale=2&helm&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "body render with existing uuid": {
+        url: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2",
+        etag: '"a846b82963"',
+        crc32: [1291941229, 2628108474]
+      },
+      "body render with not existing uuid": {
+        url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2",
+        etag: '"steve"',
+        crc32: [2652947188, 2115706574]
+      },
+      "body render with not existing uuid defaulting to alex": {
+        url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=alex",
+        etag: '"alex"',
+        crc32: [407932087, 2516216042]
+      },
+      "body render with not existing uuid defaulting to url": {
+        url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+      "helm body render with existing uuid": {
+        url: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2&helm",
+        etag: '"a846b82963"',
+        crc32: [3556188297, 4269754007]
+      },
+      "helm body render with not existing uuid": {
+        url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&helm",
+        etag: '"steve"',
+        crc32: [272191039, 542896675]
+      },
+      "helm body render with not existing uuid defaulting to alex": {
+        url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&helm&default=alex",
+        etag: '"alex"',
+        crc32: [737759773, 66512449]
+      },
+      "helm body render with not existing uuid defaulting to url": {
+        url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&helm&default=http://example.com",
+        crc32: 0,
+        redirect: "http://example.com"
+      },
+    };
+
+    for (var description in server_tests) {
+      var location = server_tests[description];
+      (function(location) {
+        it("should return correct HTTP response for " + description, function(done) {
+          request.get(location.url, {followRedirect: false, encoding: null}, function(error, res, body) {
+            assert.ifError(error);
+            assert_headers(res);
+            assert(res.headers["x-storage-type"]);
+            assert.strictEqual(res.headers["etag"], location.etag);
+            var matches = false;
+            if (location.crc32 instanceof Array) {
+              for (var i = 0; i < location.crc32.length; i++) {
+                if (location.crc32[i] === crc(body)) {
+                  matches = true;
+                  break;
+                }
+              }
+            } else {
+              matches = (location.crc32 === crc(body));
+            }
+            assert.ok(matches);
+            assert.strictEqual(res.headers["location"], location.redirect);
+            if (location.etag === undefined) {
+              assert.strictEqual(res.statusCode, location.redirect ? 307 : 404);
+              assert.strictEqual(res.headers["content-type"], "text/plain");
+              done();
+            } else {
+              assert(res.headers["etag"]);
+              assert.strictEqual(res.headers["content-type"], "image/png");
+              assert.strictEqual(res.statusCode, 200);
+              assert_cache(location.url, res.headers["etag"], function() {
+                done();
+              });
+            }
+          });
+        });
+      }(location));
+    }
+
     it("should return a 422 (invalid size)", function(done) {
       var size = config.max_size + 1;
       request.get("http://localhost:3000/avatars/Jake_0?size=" + size, function(error, res, body) {
-        assert.equal(422, res.statusCode);
+        assert.strictEqual(res.statusCode, 422);
         done();
       });
     });
@@ -251,74 +652,26 @@ describe("Crafatar", function() {
     it("should return a 422 (invalid scale)", function(done) {
       var scale = config.max_scale + 1;
       request.get("http://localhost:3000/renders/head/Jake_0?scale=" + scale, function(error, res, body) {
-        assert.equal(422, res.statusCode);
-        done();
-      });
-    });
-
-    // no default images for capes, should 404
-    it("should return a 404 (no cape)", function(done) {
-      request.get("http://localhost:3000/capes/Jake_0", function(error, res, body) {
-        assert.equal(404, res.statusCode);
+        assert.strictEqual(res.statusCode, 422);
         done();
       });
     });
 
     it("should return a 422 (invalid render type)", function(done) {
-      request.get("http://localhost:3000/renders/side/Jake_0", function(error, res, body) {
-        assert.equal(422, res.statusCode);
+      request.get("http://localhost:3000/renders/invalid/Jake_0", function(error, res, body) {
+        assert.strictEqual(res.statusCode, 422);
         done();
       });
     });
 
-    // testing all paths for valid inputs
-    var locations = ["avatars", "skins", "renders/head"];
+    // testing all paths for Invalid UserID
+    var locations = ["avatars", "skins", "capes", "renders/body", "renders/head"];
     for (var l in locations) {
       var location = locations[l];
       (function(location) {
-        it("should return a 200 (valid input " + location + ")", function(done) {
-          request.get("http://localhost:3000/" + location + "/Jake_0", function(error, res, body) {
-            assert.equal(200, res.statusCode);
-            done();
-          });
-        });
         it("should return a 422 (invalid id " + location + ")", function(done) {
           request.get("http://localhost:3000/" + location + "/thisisaninvaliduuid", function(error, res, body) {
-            assert.equal(422, res.statusCode);
-            done();
-          });
-        });
-      })(location);
-    }
-
-    // testing all paths for invalid id formats
-    locations = ["avatars", "capes", "skins", "renders/head"];
-    for (l in locations) {
-      var location = locations[l];
-      (function(location) {
-        it("should return a 422 (invalid id " + location + ")", function(done) {
-          request.get("http://localhost:3000/" + location + "/thisisaninvaliduuid", function(error, res, body) {
-            assert.equal(422, res.statusCode);
-            done();
-          });
-        });
-      })(location);
-    }
-
-    //testing all paths for default images
-    locations = ["avatars", "skins", "renders/head"];
-    for (l in locations) {
-      var location = locations[l];
-      (function(location) {
-        it("should return a 200 (default steve image " + location + ")", function(done) {
-          request.get("http://localhost:3000/" + location + "/invalidjsvns?default=steve", function(error, res, body) {
-            assert.equal(200, res.statusCode);
-            done();
-          });
-        });
-        it("should return a 200 (default external image " + location + ")", function(done) {
-          request.get("http://localhost:3000/" + location + "/invalidjsvns?default=https%3A%2F%2Fi.imgur.com%2FocJVWAc.png", function(error, res, body) {
-            assert.equal(200, res.statusCode);
+            assert.strictEqual(res.statusCode, 422);
             done();
           });
         });
@@ -350,7 +703,7 @@ describe("Crafatar", function() {
 
   describe("Networking: Cape", function() {
     it("should not fail (guaranteed cape)", function(done) {
-      helpers.get_cape(rid, "Dinnerbone", function(err, hash, img) {
+      helpers.get_cape(rid, "Dinnerbone", function(err, hash, status, img) {
         assert.strictEqual(err, null);
         done();
       });
@@ -359,13 +712,13 @@ describe("Crafatar", function() {
       before(function() {
         cache.get_redis().flushall();
       });
-      helpers.get_cape(rid, "Dinnerbone", function(err, hash, img) {
+      helpers.get_cape(rid, "Dinnerbone", function(err, hash, status, img) {
         assert.strictEqual(err, null);
         done();
       });
     });
     it("should not be found", function(done) {
-      helpers.get_cape(rid, "Jake_0", function(err, hash, img) {
+      helpers.get_cape(rid, "Jake_0", function(err, hash, status, img) {
         assert.strictEqual(img, null);
         done();
       });
@@ -374,7 +727,7 @@ describe("Crafatar", function() {
 
   describe("Networking: Skin", function() {
     it("should not fail", function(done) {
-      helpers.get_cape(rid, "Jake_0", function(err, hash, img) {
+      helpers.get_cape(rid, "Jake_0", function(err, hash, status, img) {
         assert.strictEqual(err, null);
         done();
       });
@@ -383,7 +736,7 @@ describe("Crafatar", function() {
       before(function() {
         cache.get_redis().flushall();
       });
-      helpers.get_cape(rid, "Jake_0", function(err, hash, img) {
+      helpers.get_cape(rid, "Jake_0", function(err, hash, status, img) {
         assert.strictEqual(err, null);
         done();
       });
@@ -432,7 +785,7 @@ describe("Crafatar", function() {
 
       describe("Networking: Skin", function() {
         it("should not fail (uuid)", function(done) {
-          helpers.get_skin(rid, id, function(err, hash, img) {
+          helpers.get_skin(rid, id, function(err, hash, status, img) {
             assert.strictEqual(err, null);
             done();
           });
@@ -456,7 +809,7 @@ describe("Crafatar", function() {
 
       describe("Networking: Cape", function() {
         it("should not fail (possible cape)", function(done) {
-          helpers.get_cape(rid, id, function(err, hash, img) {
+          helpers.get_cape(rid, id, function(err, hash, status, img) {
             assert.strictEqual(err, null);
             done();
           });