Преглед на файлове

Merge pull request #52 from Jake0oo0/capes

Capes Support + Network changes - Initial commits from @navarr
Jake преди 10 години
родител
ревизия
74f1618aa3
променени са 19 файла, в които са добавени 622 реда и са изтрити 259 реда
  1. 1 0
      .gitignore
  2. 1 1
      README.md
  3. 0 0
      images/capes/.gitkeep
  4. 10 7
      modules/cache.js
  5. 2 3
      modules/cleaner.js
  6. 6 5
      modules/config.example.js
  7. 206 92
      modules/helpers.js
  8. 134 106
      modules/networking.js
  9. 2 2
      modules/renders.js
  10. 25 6
      modules/skins.js
  11. 3 3
      package.json
  12. 8 1
      public/stylesheets/style.css
  13. 3 3
      routes/avatars.js
  14. 79 0
      routes/capes.js
  15. 4 4
      routes/renders.js
  16. 2 2
      routes/skins.js
  17. 6 3
      server.js
  18. 102 21
      test/test.js
  19. 28 0
      views/index.jade

+ 1 - 0
.gitignore

@@ -5,3 +5,4 @@ node_modules/
 *.rdb
 coverage/
 modules/config.js
+undefined*.png

+ 1 - 1
README.md

@@ -1,4 +1,4 @@
-# Crafatar [![travis](https://img.shields.io/travis/Jake0oo0/crafatar.svg?style=flat)](https://travis-ci.org/Jake0oo0/crafatar/) [![Coverage Status](https://img.shields.io/coveralls/Jake0oo0/crafatar.svg?style=flat)](https://coveralls.io/r/Jake0oo0/crafatar)
+# Crafatar [![travis](https://img.shields.io/travis/Jake0oo0/crafatar.svg?style=flat)](https://travis-ci.org/Jake0oo0/crafatar/) [![Coverage Status](https://img.shields.io/coveralls/Jake0oo0/crafatar.svg?style=flat)](https://coveralls.io/r/Jake0oo0/crafatar) [![Code Climate](https://codeclimate.com/github/Jake0oo0/crafatar/badges/gpa.svg)](https://codeclimate.com/github/Jake0oo0/crafatar)
 
 https://crafatar.com
 

+ 0 - 0
images/capes/.gitkeep


+ 10 - 7
modules/cache.js

@@ -82,7 +82,7 @@ exp.info = function(callback) {
     });
     obj.versions = [];
     if( obj.redis_version ){
-      obj.redis_version.split(".").forEach(function (num) {
+      obj.redis_version.split(".").forEach(function(num) {
         obj.versions.push(+num);
       });
     }
@@ -103,14 +103,16 @@ exp.update_timestamp = function(uuid, hash) {
 };
 
 // create the key +uuid+, store +hash+ and time
-exp.save_hash = function(uuid, hash) {
+exp.save_hash = function(uuid, skin, cape) {
   logging.log(uuid + " cache: saving hash");
+  logging.log("skin:" + skin + " cape:" + cape);
   var time = new Date().getTime();
   // store shorter null byte instead of "null"
-  hash = hash || ".";
+  skin = skin || ".";
+  cape = cape || ".";
   // store uuid in lower case if not null
   uuid = uuid && uuid.toLowerCase();
-  redis.hmset(uuid, "h", hash, "t", time);
+  redis.hmset(uuid, "s", skin, "c", cape, "t", time);
 };
 
 exp.remove_hash = function(uuid) {
@@ -119,7 +121,7 @@ exp.remove_hash = function(uuid) {
 };
 
 // get a details object for +uuid+
-// {hash: "0123456789abcdef", time: 1414881524512}
+// {skin: "0123456789abcdef", cape: "gs1gds1g5d1g5ds1", time: 1414881524512}
 // null when uuid unkown
 exp.get_details = function(uuid, callback) {
   // get uuid in lower case if not null
@@ -128,7 +130,8 @@ exp.get_details = function(uuid, callback) {
     var details = null;
     if (data) {
       details = {
-        hash: (data.h == "." ? null : data.h),
+        skin: (data.s === "." ? null : data.s),
+        cape: (data.c === "." ? null : data.c),
         time: Number(data.t)
       };
     }
@@ -137,4 +140,4 @@ exp.get_details = function(uuid, callback) {
 };
 
 connect_redis();
-module.exports = exp;
+module.exports = exp;

+ 2 - 3
modules/cleaner.js

@@ -15,7 +15,7 @@ function should_clean_redis(callback) {
       callback(err, false);
     } else {
       try {
-        logging.debug(info);
+        //logging.debug(info.toString());
         logging.debug("used mem:" + info.used_memory);
         var used = parseInt(info.used_memory) / 1024;
         logging.log("RedisCleaner: " + used + "KB used");
@@ -71,7 +71,6 @@ exp.run = function() {
       var helmdir = __dirname + "/../" + config.helms_dir;
       var renderdir = __dirname + "/../" + config.renders_dir;
       var skindir = __dirname + "/../" + config.skins_dir;
-
       fs.readdir(facesdir, function (err, files) {
         for (var i = 0, l = Math.min(files.length, config.cleaning_amount); i < l; i++) {
           var filename = files[i];
@@ -98,4 +97,4 @@ exp.run = function() {
 
 function nil () {}
 
-module.exports = exp;
+module.exports = exp;

+ 6 - 5
modules/config.example.js

@@ -2,6 +2,9 @@ var config = {
   min_size: 1,                   // for avatars
   max_size: 512,                 // for avatars; too big values might lead to slow response time or DoS
   default_size: 160,             // for avatars; size to be used when no size given
+  min_scale: 1,                  // for renders
+  max_scale: 10,                 // for renders; too big values might lead to slow response time or DoS
+  default_scale: 6,              // for renders; scale to be used when no scale given
   local_cache_time: 1200,        // seconds until we will check if the image changed. should be > 60 to prevent mojang 429 response
   browser_cache_time: 3600,      // seconds until browser will request image again
   cleaning_interval: 1800,       // seconds interval: deleting images if disk size at limit
@@ -9,14 +12,12 @@ var config = {
   cleaning_redis_limit: 24576,   // max allowed used KB on redis to trigger redis flush
   cleaning_amount: 50000,        // amount of avatar (and their helm) files to clean
   http_timeout: 1000,            // ms until connection to mojang is dropped
+  debug_enabled: false,          // enables logging.debug
   faces_dir: "images/faces/",    // directory where faces are kept. should have trailing "/"
   helms_dir: "images/helms/",    // directory where helms are kept. should have trailing "/"
   skins_dir: "images/skins/",    // directory where skins are kept. should have trailing "/"
   renders_dir: "images/renders/",// Directory where rendered skins are kept. should have trailing "/"
-  debug_enabled: false,          // enables logging.debug
-  min_scale: 1,                  // for renders
-  max_scale: 10,                 // for renders; too big values might lead to slow response time or DoS
-  default_scale: 6               // for renders; scale to be used when no scale given
+  capes_dir: "images/capes/",    // directory where capes are kept. should have trailing "/"
 };
 
-module.exports = config;
+module.exports = config;

+ 206 - 92
modules/helpers.js

@@ -10,73 +10,138 @@ var fs = require("fs");
 var valid_uuid = /^([0-9a-f-A-F-]{32,36}|[a-zA-Z0-9_]{1,16})$/; // uuid|username
 var hash_pattern = /[0-9a-f]+$/;
 
+// gets the hash from the textures.minecraft.net +url+
 function get_hash(url) {
   return hash_pattern.exec(url)[0].toLowerCase();
 }
 
-// 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 skin_url for +uuid+
-  networking.get_skin_url(uuid, function(err, skin_url) {
-    if (err) {
-      callback(err, null);
+function store_skin(uuid, profile, details, callback) {
+  networking.get_skin_url(uuid, profile, function(url) {
+    if (url) {
+      var hash = get_hash(url);
+      if (details && details.skin === hash) {
+        cache.update_timestamp(uuid, hash);
+        callback(null, hash);
+      } else {
+        logging.log(uuid + " new skin hash: " + hash);
+        var facepath = __dirname + "/../" + config.faces_dir + hash + ".png";
+        var helmpath = __dirname + "/../" + config.helms_dir + hash + ".png"
+        fs.exists(facepath, function(exists) {
+          if (exists) {
+            logging.log(uuid + " skin already exists, not downloading");
+            callback(null, hash);
+          } else {
+            networking.get_from(url, function(img, response, err) {
+              if (err || !img) {
+                callback(err, null);
+              } else {
+                skins.extract_face(img, facepath, function(err) {
+                  if (err) {
+                    logging.error(err);
+                    callback(err, null);
+                  } else {
+                    logging.log(uuid + " face extracted");
+                    skins.extract_helm(uuid, facepath, img, helmpath, function(err) {
+                      logging.log(uuid + " helm extracted");
+                      logging.debug(helmpath);
+                      callback(err, hash);
+                    });
+                  }
+                });
+              }
+            });
+          }
+        });
+      }
     } else {
-      if (skin_url) {
-        logging.log(uuid + " " + skin_url);
-        // set file paths
-        var hash = get_hash(skin_url);
-        if (details && details.hash == hash) {
-          // hash hasn't changed
-          logging.log(uuid + " hash has not changed");
-          cache.update_timestamp(uuid, hash);
-          callback(null, hash);
-        } else {
-          // hash has changed
-          logging.log(uuid + " new hash: " + hash);
-          var facepath = __dirname + "/../" + config.faces_dir + hash + ".png";
-          var helmpath = __dirname + "/../" + config.helms_dir + hash + ".png";
-
-          fs.exists(facepath, function (exists) {
-            if (exists) {
-              logging.log(uuid + " Avatar already exists, not downloading");
-              cache.save_hash(uuid, hash);
-              callback(null, hash);
-            } else {
-              // download skin
-              networking.get_skin(skin_url, uuid, function(err, img) {
-                if (err || !img) {
-                  callback(err, null);
-                } else {
-                  // extract face / helm
-                  skins.extract_face(img, facepath, function(err) {
-                    if (err) {
-                      callback(err);
-                    } else {
-                      logging.log(uuid + " face extracted");
-                      logging.debug(uuid + " " + facepath);
-                      skins.extract_helm(uuid, facepath, img, helmpath, function(err) {
-                        logging.log(uuid + " helm extracted");
-                        logging.debug(uuid + " " + helmpath);
-                        cache.save_hash(uuid, hash);
-                        callback(err, hash);
-                      });
-                    }
-                  });
-                }
-              });
-            }
-          });
-        }
+      callback(null, null);
+    }
+  });
+}
+
+function store_cape(uuid, profile, details, callback) {
+  networking.get_cape_url(uuid, profile, function(url) {
+    if (url) {
+      var hash = get_hash(url);
+      if (details && details.cape === hash) {
+        cache.update_timestamp(uuid, hash);
+        callback(null, hash);
       } else {
-        // profile found, but has no skin
-        cache.save_hash(uuid, null);
-        callback(null, null);
+        logging.log(uuid + " new cape hash: " + hash);
+        var capepath = __dirname + "/../" + config.capes_dir + hash + ".png";
+        fs.exists(capepath, function(exists) {
+          if (exists) {
+            logging.log(uuid + " cape already exists, not downloading");
+            callback(null, hash);
+          } else {
+            networking.get_from(url, function(img, response, err) {
+              if (err || !img) {
+                logging.error(err);
+                callback(err, null);
+              } else {
+                skins.save_image(img, capepath, function(err) {
+                  logging.log(uuid + " cape saved");
+                  callback(err, hash);              
+                });
+              }
+            });
+          }
+        });
       }
+    } else {
+      callback(null, null);
     }
   });
 }
 
+// downloads the images for +uuid+ while checking the cache
+// status based on +details+. +whichhash+ specifies which
+// image is more important, and should be called back on
+// +callback+ contains the error buffer and image hash
+var currently_running = [];
+function callback_for(uuid, which, err, cape_hash, skin_hash) {
+  for (var i = 0; i < currently_running.length; i++) {
+    if (currently_running[i] && currently_running[i].uuid === uuid && (currently_running[i].which === which || which === null)) {
+      var will_call = currently_running[i];
+      will_call.callback(err, will_call.which === 'skin' ? skin_hash : cape_hash);
+      //remove_from_array(currently_running, i);
+      delete(currently_running[i]);
+    }
+  }
+}
+
+function array_has_hash(arr, property, value) {
+  for (var i = 0; i < arr.length; i++) {
+    if (arr[i] && arr[i][property] === value) {
+      return true;
+    }
+  }
+  return false;
+}
+
+function store_images(uuid, details, whichhash, callback) {
+  var isUUID = uuid.length > 16;
+  var new_hash = { 'uuid': uuid, 'which': whichhash, 'callback': callback };
+  if (!array_has_hash(currently_running, 'uuid', uuid)) {
+    currently_running.push(new_hash);
+    networking.get_profile((isUUID ? uuid : null), function(err, profile) {
+      if (err || (isUUID && !profile)) {
+        callback_for(uuid, err, null, null);
+      } else {
+        store_skin(uuid, profile, details, function(err, skin_hash) {
+          cache.save_hash(uuid, skin_hash, null);
+          callback_for(uuid, 'skin', err, null, skin_hash);
+          store_cape(uuid, profile, details, function(err, cape_hash) {
+            cache.save_hash(uuid, skin_hash, cape_hash);
+            callback_for(uuid, 'cape', err, cape_hash, skin_hash);
+          });
+        });
+      }
+    });
+  } else {
+    currently_running.push(new_hash);
+  }
+}
 
 var exp = {};
 
@@ -86,7 +151,6 @@ 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
@@ -95,29 +159,27 @@ exp.uuid_valid = function(uuid) {
 //   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) {
+exp.get_image_hash = function(uuid, raw_type, callback) {
   cache.get_details(uuid, function(err, details) {
+    var type = (details !== null ? (raw_type === "skin" ? details.skin : details.cape) : null);
     if (err) {
       callback(err, -1, null);
     } else {
       if (details && details.time + config.local_cache_time * 1000 >= new Date().getTime()) {
-        // uuid known + recently updated
         logging.log(uuid + " uuid cached & recently updated");
-        callback(null, (details.hash ? 1 : 0), details.hash);
+        callback(null, (type ? 1 : 0), type);
       } else {
         if (details) {
           logging.log(uuid + " uuid cached, but too old");
         } else {
           logging.log(uuid + " uuid not cached");
         }
-        store_images(uuid, details, function(err, hash) {
+        store_images(uuid, details, raw_type, function(err, hash) {
           if (err) {
-            callback(err, -1, details && details.hash);
+            callback(err, -1, details && type);
           } else {
-            // skin is only checked (3) when uuid known AND hash didn't change
-            // in all other cases the skin is downloaded (2)
-            var status = details && (details.hash == hash) ? 3 : 2;
-            logging.debug(uuid + " old hash: " + (details && details.hash));
+            var status = details && (type === hash) ? 3 : 2;
+            logging.debug(uuid + " old hash: " + (details && type));
             logging.log(uuid + " hash: " + hash);
             callback(null, status, hash);
           }
@@ -133,17 +195,16 @@ exp.get_image_hash = function(uuid, callback) {
 // 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) {
-  exp.get_image_hash(uuid, function(err, status, hash) {
+  logging.log("request: " + uuid);
+  exp.get_image_hash(uuid, "skin", function(err, status, hash) {
     if (hash) {
       var facepath = __dirname + "/../" + config.faces_dir + hash + ".png";
       var helmpath = __dirname + "/../" + config.helms_dir + hash + ".png";
       var filepath = facepath;
-
-      fs.exists(helmpath, function (exists) {
+      fs.exists(helmpath, function(exists) {
         if (helm && exists) {
           filepath = helmpath;
         }
-
         skins.resize_img(filepath, size, function(img_err, result) {
           if (img_err) {
             callback(img_err, -1, null, hash);
@@ -164,7 +225,8 @@ exp.get_avatar = function(uuid, helm, size, callback) {
 // handles requests for +uuid+ skins
 // callback contains error, hash, image buffer
 exp.get_skin = function(uuid, callback) {
-  exp.get_image_hash(uuid, function(err, status, hash) {
+  logging.log(uuid + " skin request");
+  exp.get_image_hash(uuid, 'skin', function(err, status, hash) {
     var skinpath = __dirname + "/../" + config.skins_dir + hash + ".png";
     fs.exists(skinpath, function (exists) {
       if (exists) {
@@ -173,7 +235,7 @@ exp.get_skin = function(uuid, callback) {
           callback(err, hash, img);
         });
       } else {
-        networking.save_skin(uuid, hash, skinpath, function(err, img) {
+        networking.save_texture(uuid, hash, skinpath, function(err, img) {
           callback(err, hash, img);
         });
       }
@@ -183,7 +245,7 @@ exp.get_skin = function(uuid, callback) {
 
 function get_type(helm, body) {
   var text = body ? "body" : "head";
-  return helm ? text+"helm" : text;
+  return helm ? text + "helm" : text;
 }
 
 // handles creations of skin renders
@@ -195,33 +257,85 @@ exp.get_render = function(uuid, scale, helm, body, callback) {
       return;
     }
     var renderpath = __dirname + "/../" + config.renders_dir + hash + "-" + scale + "-" + get_type(helm, body) + ".png";
-    fs.exists(renderpath, function (exists) {
+    fs.exists(renderpath, function(exists) {
       if (exists) {
         renders.open_render(uuid, renderpath, function(err, img) {
           callback(err, 1, hash, img);
         });
         return;
+      } else {
+        if (!img) {
+          callback(err, 0, hash, null);
+          return;
+        }
+        renders.draw_model(uuid, img, scale, helm, body, function(err, img) {
+          if (err) {
+            callback(err, -1, hash, null);
+          } else if (!img) {
+            callback(null, 0, hash, null);
+          } else {
+            fs.writeFile(renderpath, img, "binary", function(err) {
+              if (err) {
+                logging.log(err);
+              }
+              callback(null, 2, hash, img);
+            });
+          }
+        });
       }
-      if (!img) {
-        callback(err, 0, hash, null);
-        return;
+    });
+  });
+};
+
+
+// handles requests for +uuid+ skins
+// callback contains error, hash, image buffer
+exp.get_skin = function(uuid, callback) {
+  logging.log(uuid + " skin request");
+  exp.get_image_hash(uuid, "skin", function(err, status, hash) {
+    var skinpath = __dirname + "/../" + config.skins_dir + hash + ".png";
+    fs.exists(skinpath, function(exists) {
+      if (exists) {
+        logging.log("skin already exists, not downloading");
+        skins.open_skin(uuid, skinpath, function(err, img) {
+          callback(err, hash, img);
+        });
+      } else {
+        networking.save_texture(uuid, hash, skinpath, function(err, response, img) {
+          callback(err, hash, img);
+        });
+      }
+    });
+  });
+};
+
+// handles requests for +uuid+ capes
+// callback contains error, hash, image buffer
+exp.get_cape = function(uuid, callback) {
+  logging.log(uuid + " cape request");
+  exp.get_image_hash(uuid, "cape", function(err, status, hash) {
+    if (!hash) {
+      callback(err, null, null);
+      return;
+    }
+    var capepath = __dirname + "/../" + config.capes_path + hash + ".png";
+    fs.exists(capepath, function(exists) {
+      if (exists) {
+        logging.log("cape already exists, not downloading");
+        skins.open_skin(uuid, capepath, function(err, img) {
+          callback(err, hash, img);
+        });
+      } else {
+        networking.save_texture(uuid, hash, capepath, function(err, response, img) {
+          if (response && response.statusCode === 404) {
+            callback(err, hash, null);
+          } else {
+            callback(err, hash, img);
+          }
+        });
       }
-      renders.draw_model(uuid, img, scale, helm, body, function(err, img) {
-        if (err) {
-          callback(err, -1, hash, null);
-        } else if (!img) {
-          callback(null, 0, hash, null);
-        } else {
-          fs.writeFile(renderpath, img, "binary", function(err){
-            if (err) {
-              logging.log(uuid + " error: " + err);
-            }
-            callback(null, 2, hash, img);
-          });
-        }
-      });
     });
   });
 };
 
-module.exports = exp;
+module.exports = exp;

+ 134 - 106
modules/networking.js

@@ -1,165 +1,193 @@
 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 capes_url = "https://skins.minecraft.net/MinecraftCloaks/";
 
-// exracts the skin url of a +profile+ object
-// returns null when no url found (user has no skin)
-function extract_skin_url(profile) {
+var exp = {};
+
+function extract_url(profile, property) {
   var url = null;
   if (profile && profile.properties) {
     profile.properties.forEach(function(prop) {
-      if (prop.name == "textures") {
-        var json = Buffer(prop.value, "base64").toString();
+      if (prop.name === "textures") {
+        var json = new Buffer(prop.value, "base64").toString();
         var props = JSON.parse(json);
-        url = props && props.textures && props.textures.SKIN && props.textures.SKIN.url || null;
+        url = props && props.textures && props.textures[property] && props.textures[property].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) {
+// exracts the skin url of a +profile+ object
+// returns null when no url found (user has no skin)
+exp.extract_skin_url = function(profile) {
+  return extract_url(profile, 'SKIN');
+};
+
+// exracts the cape url of a +profile+ object
+// returns null when no url found (user has no cape)
+exp.extract_cape_url = function(profile) {
+  return extract_url(profile, 'CAPE');
+};
+
+// makes a GET request to the +url+
+// +options+ hash includes various options for
+// encoding and timeouts, defaults are already
+// specified. +callback+ contains the body, response,
+// and error buffer. get_from helper method is available
+exp.get_from_options = function(url, options, callback) {
   request.get({
-    url: skins_url + name + ".png",
+    url: url,
     headers: {
       "User-Agent": "https://crafatar.com"
     },
-    timeout: config.http_timeout,
-    followRedirect: false
+    timeout: (options.timeout || config.http_timeout),
+    encoding: (options.encoding || null),
+    followRedirect: (options.folow_redirect || false),
+    maxAttempts: (options.max_attempts || 2)
   }, function(error, response, body) {
-    if (!error && response.statusCode == 301) {
-      // skin_url received successfully
-      logging.log(name + " skin url received");
-      callback(null, response.headers.location);
+    // 200 or 301 depending on content type
+    if (!error && (response.statusCode === 200 || response.statusCode === 301)) {
+      // response received successfully
+      logging.log(url + " url received");
+      callback(body, response, null);
     } else if (error) {
-      callback(error, null);
-    } else if (response.statusCode == 404) {
-      // skin (or user) doesn't exist
-      logging.log(name + " has no skin");
-      callback(null, null);
-    } else if (response.statusCode == 429) {
-      // Too Many Requests
-      // Never got this, seems like skins aren't limited
-      logging.warn(name + body || "Too many requests");
-      callback(null, null);
+      callback(body || null, response, error);
+    } else if (response.statusCode === 404) {
+      // page does not exist
+      logging.log(url + " url does not exist");
+      callback(null, response, null);
+    } else if (response.statusCode === 429) {
+      // Too Many Requests exception - code 429
+      logging.warn(body || "Too many requests");
+      callback(body || null, response, error);
     } else {
-      logging.error(name + " Unknown error:");
-      logging.error(name + " " + response);
-      callback(body || "Unknown error", null);
+      logging.error(url + " Unknown error:");
+      //logging.error(response);
+      callback(body || null, response, error);
     }
   });
 };
 
-// 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,
-    headers: {
-      "User-Agent": "https://crafatar.com"
-    },
-    timeout: config.http_timeout // ms
-  }, function (error, response, body) {
-    if (!error && response.statusCode == 200) {
-      // profile downloaded successfully
-      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)
-      logging.log(uuid + " uuid does not exist");
-      callback(null, null);
-    } else if (response.statusCode == 429) {
-      // Too Many Requests
-      callback(body || "Too many requests", null);
+// helper method for get_from_options, no options required
+exp.get_from = function(url, callback) {
+  exp.get_from_options(url, {}, function(body, response, err) {
+    callback(body, response, err);
+  });
+};
+
+// specifies which numbers identify what url
+var mojang_url_types = {
+  1: skins_url,
+  2: capes_url
+};
+
+// make a request to skins.miencraft.net
+// the skin url is taken from the HTTP redirect
+// type reference is above
+exp.get_username_url = function(name, type, callback) {
+  exp.get_from(mojang_url_types[type] + name + ".png", function(body, response, err) {
+    if (!err) {
+      callback(err, response ? (response.statusCode === 404 ? null : response.headers.location) : null);
     } else {
-      logging.error(uuid + " Unknown error:");
-      logging.error(uuid + " " + response);
-      callback(body || "Unknown error", null);
+      callback(err, null);
     }
   });
 };
 
-var exp = {};
+// gets the URL for a skin/cape from the profile
+// +type+ specifies which to retrieve
+exp.get_uuid_url = function(profile, type, callback) {
+  var url = null;
+  if (type === 1) {
+    url = exp.extract_skin_url(profile);
+  } else if (type === 2) {
+    url = exp.extract_cape_url(profile);
+  }
+  callback(url || null);
+};
+
+// make a request to sessionserver
+// profile is returned as json
+exp.get_profile = function(uuid, callback) {
+  if (!uuid) {
+    callback(null, null);
+  } else {
+    exp.get_from_options(session_url + uuid, { encoding: "utf8" }, function(body, response, err) {
+      callback(err || null, (body !== null ? JSON.parse(body) : null));
+    }); 
+  }
+};
 
-// download skin_url for +uuid+ (name or uuid)
-// callback contains error, skin_url
-exp.get_skin_url = function(uuid, callback) {
+// +uuid+ is likely a username and if so
+// +uuid+ is used to get the url, otherwise
+// +profile+ will be used to get the url
+exp.get_skin_url = function(uuid, profile, callback) {
+  getUrl(uuid, profile, 1, function(url) {
+    callback(url);
+  });
+};
+
+// +uuid+ is likely a username and if so
+// +uuid+ is used to get the url, otherwise
+// +profile+ will be used to get the url
+exp.get_cape_url = function(uuid, profile, callback) {
+  getUrl(uuid, profile, 2, function(url) {
+    callback(url);
+  });
+};
+
+function getUrl(uuid, profile, type, callback) {
   if (uuid.length <= 16) {
-    get_username_url(uuid, function(err, url) {
-      callback(err, url);
+    //username
+    exp.get_username_url(uuid, type, function(err, url) {
+      callback(url || null);
     });
   } else {
-    get_uuid_url(uuid, function(err, url) {
-      callback(err, url);
+    exp.get_uuid_url(profile, type, function(url) {
+      callback(url || null);
     });
   }
-};
+}
 
 // downloads skin file from +url+
 // callback contains error, image
 exp.get_skin = function(url, uuid, callback) {
-  request.get({
-    url: url,
-    headers: {
-      "User-Agent": "https://crafatar.com"
-    },
-    encoding: null, // encoding must be null so we get a buffer
-    timeout: config.http_timeout // ms
-  }, function (error, response, body) {
-    if (!error && response.statusCode == 200) {
-      // skin downloaded successfully
-      logging.log(uuid + " downloaded skin");
-      logging.debug(uuid + " " + url);
-      callback(null, body);
-    } else {
-      if (error) {
-        logging.error(uuid + " error downloading '" + url + "': " + error);
-      } else if (response.statusCode == 404) {
-        logging.warn(uuid + " texture not found (404): " + url);
-      } else if (response.statusCode == 429) {
-        // Too Many Requests
-        // Never got this, seems like textures aren't limited
-        logging.warn(uuid + " too many requests for " + url);
-        logging.warn(uuid + " " + body);
-      } else {
-        logging.error(uuid + " unknown error for " + url);
-        logging.error(uuid + " " + response);
-        logging.error(uuid + " " + body);
-        error = "unknown error"; // Error needs to be set, otherwise null in callback
-      }
-      callback(error, null);
-    }
+  exp.get_from(url, function(body, response, err) {
+    callback(body, err);
   });
 };
 
-exp.save_skin = function(uuid, hash, outpath, callback) {
+exp.save_texture = function(uuid, hash, outpath, callback) {
   if (hash) {
-    var skinurl = "http://textures.minecraft.net/texture/" + hash;
-    exp.get_skin(skinurl, uuid, function(err, img) {
+    var textureurl = "http://textures.minecraft.net/texture/" + hash;
+    exp.get_from(textureurl, function(img, response, err) {
       if (err) {
-        logging.error(uuid + " error while downloading skin");
-        callback(err, null);
+        logging.error(uuid + "error while downloading texture");
+        callback(err, response, null);
       } else {
-        fs.writeFile(outpath, img, "binary", function(err){
+        fs.writeFile(outpath, img, "binary", function(err) {
           if (err) {
             logging.log(uuid + " error: " + err);
           }
-          callback(null, img);
+          callback(err, response, img);
         });
       }
     });
   } else {
-    callback(null, null);
+    callback(null, null, null);
   }
 };
 
-module.exports = exp;
+exp.get_cape = function(url, callback) {
+  exp.get_from(url, function(body, response, err) {
+    callback(err, body);
+  });
+};
+
+module.exports = exp;

+ 2 - 2
modules/renders.js

@@ -140,7 +140,7 @@ exp.draw_model = function(uuid, img, scale, helm, body, callback) {
 
   image.onload = function() {
     var width = 64 * scale;
-    var original_height = (image.height == 32 ? 32 : 64);
+    var original_height = (image.height === 32 ? 32 : 64);
     var height = original_height * scale;
     var model_canvas = new Canvas(20 * scale, (body ? 44.8 : 17.6) * scale);
     var skin_canvas = new Canvas(width, height);
@@ -199,4 +199,4 @@ function scale_image(imageData, context, d_x, d_y, scale) {
   }
 }
 
-module.exports = exp;
+module.exports = exp;

+ 25 - 6
modules/skins.js

@@ -62,10 +62,10 @@ exp.extract_helm = function(uuid, facefile, buffer, outname, callback) {
               }
             });
           });
-        }
-      });
-    }
-  });
+}
+});
+}
+});
 };
 
 // resizes the image file +inname+ to +size+ by +size+ pixels
@@ -96,11 +96,30 @@ exp.default_skin = function(uuid) {
 // helper method for opening a skin file from +skinpath+
 // callback contains error, image buffer
 exp.open_skin = function(uuid, skinpath, callback) {
-  fs.readFile(skinpath, function (err, buf) {
+  fs.readFile(skinpath, function(err, buf) {
     if (err) {
       logging.error(uuid + " error while opening skin file: " + err);
+      callback(err, null)
+    } else {
+      callback(null, buf);
+    }
+  });
+};
+
+exp.save_image = function(buffer, outpath, callback) {
+  lwip.open(buffer, "png", function(err, image) {
+    if (err) {
+      callback(err);
+    } else {
+      image.batch()
+      .writeFile(outpath, function(err) {
+        if (err) {
+          callback(err);
+        } else {
+          callback(null);
+        }
+      });
     }
-    callback(err, buf);
   });
 };
 

+ 3 - 3
package.json

@@ -29,10 +29,10 @@
     "canvas": "1.1.6",
     "jade": "~1.8.2",
     "lwip": "0.0.6",
-    "redis": "0.12.1",
-    "request": "2.51.0",
+    "mime": "1.2.11",
     "node-df": "0.1.1",
-    "mime": "1.2.11"
+    "redis": "0.12.1",
+    "request": "^2.51.0"
   },
   "devDependencies": {
     "coveralls": "^2.11.2",

+ 8 - 1
public/stylesheets/style.css

@@ -195,6 +195,13 @@ h4 {
   background-image: url("/skins/0?default=alex");
 }
 
+#cape-example-1:hover .preview {
+  background-image: url("/capes/Dinnerbone");
+}
+#cape-example-2:hover .preview {
+  background-image: url("/capes/md_5");
+}
+
 img.preload {
   /*
     preload hover images
@@ -284,4 +291,4 @@ img.preload {
 .avatar.flipped {
   -webkit-transform: rotate(180deg);
   transform: rotate(180deg);
-}
+}

+ 3 - 3
routes/avatars.js

@@ -54,7 +54,7 @@ module.exports = function(req, res) {
         }
       }
       etag = image && hash && hash.substr(0, 32) || "none";
-      var matches = req.headers["if-none-match"] == '"' + etag + '"';
+      var matches = req.headers["if-none-match"] === '"' + etag + '"';
       if (image) {
         var http_status = 200;
         if (matches) {
@@ -103,6 +103,6 @@ module.exports = function(req, res) {
       "Access-Control-Allow-Origin": "*",
       "Etag": '"' + etag + '"'
     });
-    res.end(http_status == 304 ? null : image);
+    res.end(http_status === 304 ? null : image);
   }
-};
+};

+ 79 - 0
routes/capes.js

@@ -0,0 +1,79 @@
+var logging = require("../modules/logging");
+var helpers = require("../modules/helpers");
+var config = require("../modules/config");
+
+var human_status = {
+  0: "none",
+  1: "cached",
+  2: "downloaded",
+  3: "checked",
+  "-1": "error"
+};
+
+// GET cape request
+module.exports = function(req, res) {
+  var start = new Date();
+  var uuid = (req.url.pathname.split("/")[2] || "").split(".")[0];
+  var etag = null;
+
+  if (!helpers.uuid_valid(uuid)) {
+    res.writeHead(422, {
+      "Content-Type": "text/plain",
+      "Response-Time": new Date() - start
+    });
+    res.end("Invalid ID");
+    return;
+  }
+
+  // strip dashes
+  uuid = uuid.replace(/-/g, "");
+
+  try {
+    helpers.get_cape(uuid, function(err, status, image, hash) {
+      logging.log(uuid + " - " + human_status[status]);
+      if (err) {
+        logging.error(uuid + " " + err);
+      }
+      etag = hash && hash.substr(0, 32) || "none";
+      var matches = req.headers["if-none-match"] === '"' + etag + '"';
+      if (image) {
+        var http_status = 200;
+        if (matches) {
+          http_status = 304;
+        } else if (err) {
+          http_status = 503;
+        }
+        logging.debug("Etag: " + req.headers["if-none-match"]);
+        logging.debug("matches: " + matches);
+        logging.log("status: " + http_status);
+        sendimage(http_status, status, image);
+      } else {
+        res.writeHead(404, {
+          "Content-Type": "text/plain",
+          "Response-Time": new Date() - start
+        });
+        res.end("404 not found");
+      }
+    });
+  } catch(e) {
+    logging.error(uuid + " error:");
+    logging.error(e);
+    res.writeHead(500, {
+      "Content-Type": "text/plain",
+      "Response-Time": new Date() - start
+    });
+    res.end("500 server error");
+  }
+
+  function sendimage(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],
+      "Access-Control-Allow-Origin": "*",
+      "Etag": '"' + etag + '"'
+    });
+    res.end(http_status === 304 ? null : image);
+  }
+};

+ 4 - 4
routes/renders.js

@@ -23,7 +23,7 @@ module.exports = function(req, res) {
   var raw_type = (req.url.path_list[2] || "");
 
   // validate type
-  if (raw_type != "body" && raw_type != "head") {
+  if (raw_type !== "body" && raw_type !== "head") {
     res.writeHead(422, {
       "Content-Type": "text/plain",
       "Response-Time": new Date() - start
@@ -32,7 +32,7 @@ module.exports = function(req, res) {
     return;
   }
 
-  var body = raw_type == "body";
+  var body = raw_type === "body";
   var uuid = (req.url.path_list[3] || "").split(".")[0];
   var def = req.url.query.default;
   var scale = parseInt(req.url.query.scale) || config.default_scale;
@@ -65,7 +65,7 @@ module.exports = function(req, res) {
         logging.error(uuid + " " + err);
       }
       etag = hash && hash.substr(0, 32) || "none";
-      var matches = req.headers["if-none-match"] == '"' + etag + '"';
+      var matches = req.headers["if-none-match"] === '"' + etag + '"';
       if (image) {
         var http_status = 200;
         if (matches) {
@@ -128,6 +128,6 @@ module.exports = function(req, res) {
       "Access-Control-Allow-Origin": "*",
       "Etag": '"' + etag + '"'
     });
-    res.end(http_status == 304 ? null : image);
+    res.end(http_status === 304 ? null : image);
   }
 };

+ 2 - 2
routes/skins.js

@@ -31,7 +31,7 @@ module.exports = function(req, res) {
         logging.error(uuid + " " + err);
       }
       etag = hash && hash.substr(0, 32) || "none";
-      var matches = req.headers["if-none-match"] == '"' + etag + '"';
+      var matches = req.headers["if-none-match"] === '"' + etag + '"';
       if (image) {
         var http_status = 200;
         if (matches) {
@@ -82,6 +82,6 @@ module.exports = function(req, res) {
       "Access-Control-Allow-Origin": "*",
       "Etag": '"' + etag + '"'
     });
-    res.end(http_status == 304 ? null : image);
+    res.end(http_status === 304 ? null : image);
   }
 };

+ 6 - 3
server.js

@@ -5,7 +5,6 @@ var config = require("./modules/config");
 var clean = require("./modules/cleaner");
 var http = require("http");
 var mime = require("mime");
-var path = require("path");
 var url = require("url");
 var fs = require("fs");
 
@@ -13,7 +12,8 @@ var routes = {
   index: require("./routes/index"),
   avatars: require("./routes/avatars"),
   skins: require("./routes/skins"),
-  renders: require("./routes/renders")
+  renders: require("./routes/renders"),
+  capes: require("./routes/capes")
 };
 
 function asset_request(req, res) {
@@ -37,7 +37,7 @@ function requestHandler(req, res) {
   request.url.query = request.url.query || {};
 
   // remove trailing and double slashes + other junk
-  var path_list = path.resolve(request.url.pathname).split("/");
+  var path_list = request.url.pathname.split("/");
   for (var i = 0; i < path_list.length; i++) {
     // URL decode
     path_list[i] = querystring.unescape(path_list[i]);
@@ -61,6 +61,9 @@ function requestHandler(req, res) {
         case "renders":
           routes.renders(request, res);
           break;
+        case "capes":
+          routes.capes(request, res);
+          break;
         default:
           asset_request(request, res);
       }

+ 102 - 21
test/test.js

@@ -8,12 +8,13 @@ var config = require("../modules/config");
 var skins = require("../modules/skins");
 var cache = require("../modules/cache");
 var renders = require("../modules/renders");
+var cleaner = require("../modules/cleaner")
 
 // we don't want tests to fail because of slow internet
 config.http_timeout *= 3;
 
 // no spam
-logging.log = function(){};
+logging.log = function() {};
 
 var uuids = fs.readFileSync("test/uuids.txt").toString().split(/\r?\n/);
 var names = fs.readFileSync("test/usernames.txt").toString().split(/\r?\n/);
@@ -22,10 +23,14 @@ var names = fs.readFileSync("test/usernames.txt").toString().split(/\r?\n/);
 var uuid = uuids[Math.round(Math.random() * (uuids.length - 1))];
 var name = names[Math.round(Math.random() * (names.length - 1))];
 
+function getRandomInt(min, max) {
+  return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
 var ids = [
   uuid.toLowerCase(),
-  uuid.toUpperCase(),
   name.toLowerCase(),
+  uuid.toUpperCase(),
   name.toUpperCase()
 ];
 
@@ -35,6 +40,7 @@ describe("Crafatar", function() {
 
   before(function() {
     cache.get_redis().flushall();
+    cleaner.run();
   });
 
   describe("UUID/username", function() {
@@ -79,13 +85,14 @@ describe("Crafatar", function() {
       done();
     });
     it("should not exist (uuid)", function(done) {
-      networking.get_skin_url("00000000000000000000000000000000", function(err, profile) {
-        assert.strictEqual(err, null);
+      var number = getRandomInt(0, 9).toString();
+      networking.get_profile(Array(33).join(number), function(err, profile) {
+        assert.strictEqual(profile, null);
         done();
       });
     });
     it("should not exist (username)", function(done) {
-      networking.get_skin_url("Steve", function(err, profile) {
+      networking.get_username_url("Steve", 1, function(err, profile) {
         assert.strictEqual(err, null);
         done();
       });
@@ -99,10 +106,13 @@ describe("Crafatar", function() {
     var steven_uuid = "b8ffc3d37dbf48278f69475f6690aabd";
 
     it("uuid's account should exist, but skin should not", function(done) {
-      helpers.get_avatar(alex_uuid, false, 160, function(err, status, image) {
-        assert.strictEqual(status, 2);
-        done();
-      });
+      networking.get_profile(alex_uuid, function(err, profile) {
+        assert.notStrictEqual(profile, null);
+        networking.get_uuid_url(profile, 1, function(url) {
+          assert.strictEqual(url, null);
+          done();
+        });
+      })
     });
     it("odd UUID should default to Alex", function(done) {
       assert.strictEqual(skins.default_skin(alex_uuid), "alex");
@@ -117,7 +127,7 @@ describe("Crafatar", function() {
     it("should time out on uuid info download", function(done) {
       var original_timeout = config.http_timeout;
       config.http_timeout = 1;
-      networking.get_skin_url("069a79f444e94726a5befca90e38aaf5", function(err, skin_url) {
+      networking.get_profile("069a79f444e94726a5befca90e38aaf5", function(err, profile) {
         assert.strictEqual(err.code, "ETIMEDOUT");
         config.http_timeout = original_timeout;
         done();
@@ -126,7 +136,7 @@ describe("Crafatar", function() {
     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) {
+      networking.get_username_url("redstone_sheep", 1, function(err, url) {
         assert.strictEqual(err.code, "ETIMEDOUT");
         config.http_timeout = original_timeout;
         done();
@@ -135,15 +145,15 @@ describe("Crafatar", function() {
     it("should time out on skin download", function(done) {
       var original_timeout = config.http_timeout;
       config.http_timeout = 1;
-      networking.get_skin("http://textures.minecraft.net/texture/477be35554684c28bdeee4cf11c591d3c88afb77e0b98da893fd7bc318c65184", uuid, function(err, img) {
-        assert.strictEqual(err.code, "ETIMEDOUT");
+      networking.get_from("http://textures.minecraft.net/texture/477be35554684c28bdeee4cf11c591d3c88afb77e0b98da893fd7bc318c65184", function(body, res, error) {
+        assert.strictEqual(error.code, "ETIMEDOUT");
         config.http_timeout = original_timeout;
         done();
       });
     });
     it("should not find the skin", function(done) {
       assert.doesNotThrow(function() {
-        networking.get_skin("http://textures.minecraft.net/texture/this-does-not-exist", uuid, function(err, img) {
+        networking.get_from("http://textures.minecraft.net/texture/this-does-not-exist", function(img, response, err) {
           assert.strictEqual(err, null); // no error here, but it shouldn't throw exceptions
           done();
         });
@@ -155,8 +165,73 @@ describe("Crafatar", function() {
       });
       done();
     });
+    it("should not find the file", function(done) {
+      skins.open_skin("TestUUID", 'non/existant/path', function(err, img) {
+        assert.notStrictEqual(err, null);
+        done();
+      });
+    });
+  });
+
+  // we have to make sure that we test both a 32x64 and 64x64 skin
+  describe("Networking: Render", function() {
+    it("should not fail (username, 32x64 skin)", function(done) {
+      helpers.get_render("md_5", 6, true, true, function(err, hash, img) {
+        assert.strictEqual(err, null);
+        done();
+      });
+    });
+    it("should not fail (username, 64x64 skin)", function(done) {
+      helpers.get_render("Jake0oo0", 6, true, true, function(err, hash, img) {
+        assert.strictEqual(err, null);
+        done();
+      });
+    });
+  });
+
+  describe("Networking: Cape", function() {
+    it("should not fail (guaranteed cape)", function(done) {
+      helpers.get_cape("Dinnerbone", function(err, hash, img) {
+        assert.strictEqual(err, null);
+        done();
+      });
+    });
+    it("should already exist", function(done) {
+      before(function() {
+        cache.get_redis().flushall();
+      });
+      helpers.get_cape("Dinnerbone", function(err, hash, img) {
+        assert.strictEqual(err, null);
+        done();
+      });
+    });
+    it("should not be found", function(done) {
+      helpers.get_cape("Jake0oo0", function(err, hash, img) {
+        assert.strictEqual(img, null);
+        done();
+      });
+    });
   });
 
+  describe("Networking: Skin", function() {
+    it("should not fail", function(done) {
+      helpers.get_cape("Jake0oo0", function(err, hash, img) {
+        assert.strictEqual(err, null);
+        done();
+      });
+    });
+    it("should already exist", function(done) {
+      before(function() {
+        cache.get_redis().flushall();
+      });
+      helpers.get_cape("Jake0oo0", function(err, hash, img) {
+        assert.strictEqual(err, null);
+        done();
+      });
+    });
+  });
+
+
   // DRY with uuid and username tests
   for (var i in ids) {
     var id = ids[i];
@@ -206,17 +281,23 @@ describe("Crafatar", function() {
       });
 
       describe("Networking: Render", function() {
-        it("should not fail (username, 64x64 skin)", function(done) {
-          helpers.get_render("Jake0oo0", 6, true, true, function(err, hash, img) {
+        it("should not fail (full body)", function(done) {
+          helpers.get_render(id, 6, true, true, function(err, hash, img) {
+            assert.strictEqual(err, null);
+            done();
+          });
+        });
+        it("should not fail (only head)", function(done) {
+          helpers.get_render(id, 6, true, false, function(err, hash, img) {
             assert.strictEqual(err, null);
             done();
           });
         });
       });
 
-      describe("Networking: Render", function() {
-        it("should not fail (username, 32x64 skin)", function(done) {
-          helpers.get_render("md_5", 6, true, true, function(err, hash, img) {
+      describe("Networking: Cape", function() {
+        it("should not fail (possible cape)", function(done) {
+          helpers.get_cape(id, function(err, hash, img) {
             assert.strictEqual(err, null);
             done();
           });
@@ -231,8 +312,8 @@ describe("Crafatar", function() {
 
         if (id_type == "uuid") {
           it("uuid should be rate limited", function(done) {
-            helpers.get_avatar(id, false, 160, function(err, status, image) {
-              assert.strictEqual(JSON.parse(err).error, "TooManyRequestsException");
+            networking.get_profile(id, function(err, profile) {
+              assert.strictEqual(profile.error, "TooManyRequestsException");
               done();
             });
           });

+ 28 - 0
views/index.jade

@@ -220,6 +220,34 @@ block content
                 | Hover over the examples for a preview!
             .preview-background
 
+        section
+          a(id="capes" class="anchor")
+          a(href="#capes")
+            h3 Capes
+          p
+            | A cape endpoint is also available to get the active cape of a user.<br>
+            | Replace
+            mark.green  id
+            |  with a Mojang <b>UUID</b> or <b>username</b> to get the related cape.<br>
+            | The user's cape is returned, otherwise a 404 is thrown.<br>
+            .code
+              | #{domain}/skins/
+              mark.green id
+
+          section
+            a(id="cape-examples", class="anchor")
+            a(href="#cape-examples")
+              h4 Cape Examples
+            .code
+              #cape-example-1.example-wrapper
+                .example #{domain}/capes/Dinnerbone
+                p.preview Dinnerbone's Cape <i>Mojang capes are not transparent...</i>
+              #cape-example-2.example-wrapper
+                .example #{domain}/capes/md_5
+                p.preview md_5's Cape
+              p.preview-placeholder
+                | Hover over the examples for a preview!
+            .preview-background
 
         section
           a(id="meta" class="anchor")