Compare commits

...

382 Commits

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

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

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

if a texture hash is cached but outdated, the cache ttl will be bumped
as if the request succeeded, in order to lower requests in the near future
2020-03-29 07:43:23 +02:00
jomo
d967db3ad4 use environment variables for configuration 2020-03-29 07:32:39 +02:00
jomo
d81e2777d2 delete unused function 2020-03-28 23:38:20 +01:00
jomo
ea1ae64283 add 403 to expected response codes 2020-03-28 23:37:08 +01:00
jomo
5eb5b6fa5e bump version to 2.1.1 2020-03-24 19:26:58 +01:00
jomo
e6373002a2 update readme 2020-03-24 19:26:34 +01:00
jomo
424a4ab93b remove notice from website 2020-03-24 18:51:20 +01:00
jomo
16948de18d don't warn about closed connections 2020-03-24 18:50:58 +01:00
jomo
8e08e02272 update .travis.yml, package.json and LICENSE 2020-03-24 18:50:38 +01:00
jomo
c975cc793b remove cleaner.js 2020-03-24 18:49:42 +01:00
jomo
b7c6f7dae1 bump version to 2.1.0 2020-03-21 12:44:58 +01:00
jomo
cdc8c99a22 add Dockerfile, update README 2020-03-21 12:44:58 +01:00
jomo
b3a9793b87 add note about rate limit 2020-03-21 12:10:13 +01:00
jomo
168457dfd9 update to node 12 2020-03-21 01:50:50 +01:00
jomo
1816b18b12 update deps 2018-02-16 18:45:56 +01:00
jomo
1a280b0fd7 semver bump 2018-02-16 18:04:43 +01:00
jomo
dff58c66e7 drop support for usernames
Mojang has disabled their legacy skins API:
https://twitter.com/MojangSupport/status/964511258601865216

With their API rate limits, it's now practially impossible
for us to support usernames.

Fixes #142. The default parameter allows using:

- UUID
- URL
- MHF_Alex
- MHF_Steve
- Alex
- Steve

Contrary to UUIDs, using alex/steve doesn't redirect
and instead provides the skin from a locally stored file.
2018-02-16 18:01:41 +01:00
jomo
a187fb26d4 Merge pull request #246 from InventivetalentDev/patch-1
Fix README links
2017-08-02 14:02:12 +02:00
Haylee Schäfer
ccc4e114c7 Fix README links
Fixed the readme links to properly display just the title, and not the raw markdown
2017-08-02 13:57:22 +02:00
jomo
4fdbfb442b use pajk-lwip as a temporary workaround for EyalAr/lwip#297 2017-07-13 13:03:32 +02:00
jomo
13386c96eb update to node 6.9.11
https://nodejs.org/en/blog/vulnerability/july-2017-security-releases/
2017-07-13 13:01:51 +02:00
jomo
a25e01922e fix cape test
jeb_ no longer has a cape:

{
  "id": "853c80ef3c3749fdaa49938b674adae6",
  "name": "jeb_",
  "properties": [
    {
      "name": "textures",
      "value": "eyJ0aW1lc3RhbXAiOjE0OTk5MDMzNDY3NTQsInByb2ZpbGVJZCI6Ijg1M2M4MGVmM2MzNzQ5ZmRhYTQ5OTM4YjY3NGFkYWU2IiwicHJvZmlsZU5hbWUiOiJqZWJfIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2E4NDZiODI5NjM5MjRjYjEzMjExMTIyNDg5MjYzOTQxZDE0MDM2ODlmOTAxNTExMjBkNTIzNGJlNGE3M2ZiIn19fQ=="
    }
  ]
}

{
  "timestamp": 1499903346754,
  "profileId": "853c80ef3c3749fdaa49938b674adae6",
  "profileName": "jeb_",
  "textures": {
    "SKIN": {
      "url": "http://textures.minecraft.net/texture/a846b82963924cb13211122489263941d1403689f90151120d5234be4a73fb"
    }
  }
}
2017-07-13 12:49:09 +02:00
jomo
3130bdf9e9 update dependencies 2017-05-14 04:20:21 +02:00
jomo
305ed1c65f update sponsor info 2017-02-12 18:31:48 +01:00
jomo
a65cc63ec8 remove alex faces from website
kappe and minecraftchick decided to delete their skins
welcome @aikar and @ammaraskar!
2016-11-08 01:07:18 +01:00
jomo
4a2226be12 update Crafatar Tools & Plugins 2016-11-08 00:41:37 +01:00
jomo
3b7b42a2f6 update Popular Crafatar users
- NameMC now has its own interactive renders, so long, and thanks for all the fish!
- PlayMindCrack has shut down
2016-11-08 00:36:46 +01:00
jomo
24cfc03811 print 'Not found' on invalid path 2016-11-08 00:21:33 +01:00
jomo
c02d3d33e9 don't respond with 304 on error when debugging is enabled 2016-11-07 04:08:19 +01:00
jomo
f1f3ba6709 use response.js for all responses
results in:
1) less duplicated code
2) default response headers being used at all times
3) *all* requests being logged properly

- adds documentation for result.code
- allows using result.code to override HTTP 500
- uses response.js for too-busy, server error, method not allowed
2016-11-07 03:59:07 +01:00
jomo
6f1c414a4a accept ESOCKETTIMEDOUT as possible timeout error
seems to be a race condition which one is thrown (?)
2016-11-04 16:48:13 +01:00
jomo
deeaee1af6 use node LTS 6.9.1 2016-11-04 16:32:23 +01:00
jomo
57115202d2 don't rely on hasOwnProperty to exist
from MDN:
JavaScript does not protect the property name hasOwnProperty; thus, if the possibility exists that an object might have a property with this name, it is necessary to use an external hasOwnProperty to get correct results
2016-11-04 16:19:12 +01:00
jomo
c6c203fc5e remove IRC from README
none of us was idling on esper anyway, and they delete your account after 30d of inactivity ¯\_(ツ)_/¯
2016-11-03 21:56:25 +01:00
jomo
f7b8fd4e8c fix rate-limiting tests
Mojang is now rate-limiting calls to their sessionserver case-insensitive.

This fix skips network-based tests for an upper-cased UUID which are previously
run with the same lower-cased UUID
2016-11-03 21:52:00 +01:00
jomo
f0b73b34d1 test code style: add trailing commas 2016-11-03 21:26:35 +01:00
jomo
1d9176711f get rid of some test log spam 2016-11-03 21:25:13 +01:00
jomo
688a34029c wait for redis connection before running tests 2016-11-03 21:19:28 +01:00
jomo
cbe2b25835 add test for empty username 2016-11-03 21:14:32 +01:00
jomo
b2d38d1093 Merge remote-tracking branch 'origin/greenkeeper-istanbul-0.4.5' 2016-11-03 21:07:08 +01:00
jomo
aeded46d86 Merge remote-tracking branch 'origin/greenkeeper-canvas-1.6.2' 2016-11-03 21:05:36 +01:00
jomo
8a10807168 Merge remote-tracking branch 'origin/greenkeeper-coveralls-2.11.14' 2016-11-03 21:04:45 +01:00
jomo
91a8facdc1 Merge remote-tracking branch 'origin/greenkeeper-crc-3.4.1' 2016-11-03 21:04:34 +01:00
jomo
42f0da9281 Merge remote-tracking branch 'origin/greenkeeper-ejs-2.5.2' 2016-11-03 21:03:58 +01:00
jomo
508ea87ff7 Merge remote-tracking branch 'origin/greenkeeper-mocha-3.1.2' 2016-11-03 21:03:49 +01:00
jomo
c0857bd622 Merge remote-tracking branch 'origin/greenkeeper-redis-2.6.3' 2016-11-03 21:03:36 +01:00
jomo
b5eb1a275e Merge remote-tracking branch 'origin/greenkeeper-toobusy-js-0.5.1' 2016-11-03 21:02:27 +01:00
greenkeeperio-bot
f668fee480 chore(package): update request to version 2.78.0
https://greenkeeper.io/
2016-11-03 14:46:59 +01:00
greenkeeperio-bot
96aa543c72 chore(package): update redis to version 2.6.3
https://greenkeeper.io/
2016-10-31 21:15:01 +01:00
greenkeeperio-bot
40b7e76d77 chore(package): update canvas to version 1.6.2
https://greenkeeper.io/
2016-10-30 20:35:41 +01:00
greenkeeperio-bot
5f179e6ec8 chore(package): update crc to version 3.4.1
https://greenkeeper.io/
2016-10-28 20:49:47 +02:00
greenkeeperio-bot
c3dc4edb94 chore(package): update mocha to version 3.1.2
https://greenkeeper.io/
2016-10-11 08:04:38 +02:00
greenkeeperio-bot
75c3cc9e1a chore(package): update coveralls to version 2.11.14
https://greenkeeper.io/
2016-09-19 19:23:03 +02:00
greenkeeperio-bot
c2e6c8589d chore(package): update ejs to version 2.5.2
https://greenkeeper.io/
2016-09-07 16:50:58 +02:00
greenkeeperio-bot
fe665459f5 chore(package): update istanbul to version 0.4.5
https://greenkeeper.io/
2016-08-21 22:11:50 +02:00
greenkeeperio-bot
b33cb76219 chore(package): update toobusy-js to version 0.5.1
https://greenkeeper.io/
2016-07-07 15:50:56 +02:00
jomo
23948afae5 catch ExtremelyRare™ 502 response from CloudFront
happens occasionally when they can't reach the mojang upstream server
2016-07-05 01:04:34 +02:00
jomo
6c132f5c51 fix Shotbow server name 2016-04-24 12:18:11 +02:00
jomo
fca7d0d753 Merge branch 'master' of github.com:crafatar/crafatar 2016-03-26 22:03:00 +01:00
jomo
159060df77 fix typo in Crafatar users 2016-03-26 22:02:56 +01:00
jomo
7570b15320 Merge pull request #165 from crafatar/greenkeeper-redis-2.5.2
Update redis to version 2.5.2 🚀
2016-03-17 00:12:11 +01:00
greenkeeperio-bot
a86a097562 chore(package): update redis to version 2.5.2
http://greenkeeper.io/
2016-03-17 00:08:50 +01:00
jomo
aae4dbbbf6 Merge branch 'master' of github.com:crafatar/crafatar 2016-03-13 22:18:24 +01:00
jomo
9ed7fde061 update to 4.4.0 LTS
I don't think nodejs understood the idea of 'LTS'
2016-03-13 22:18:15 +01:00
jomo
d163109a34 Merge pull request #162 from crafatar/greenkeeper-mocha-lcov-reporter-1.2.0
Update mocha-lcov-reporter to version 1.2.0 🚀
2016-02-21 18:22:51 +01:00
greenkeeperio-bot
d50a2d79eb chore(package): update mocha-lcov-reporter to version 1.2.0
http://greenkeeper.io/
2016-02-21 11:53:31 +01:00
jomo
5a18fa155e Merge branch 'master' of github.com:crafatar/crafatar 2016-02-19 19:41:30 +01:00
jomo
96b277b806 add missing documentation 2016-02-19 19:24:27 +01:00
jomo
32577778b2 Merge pull request #161 from crafatar/greenkeeper-mocha-lcov-reporter-1.1.0
Update mocha-lcov-reporter to version 1.1.0 🚀
2016-02-17 21:58:34 +01:00
greenkeeperio-bot
21fbf3f8d7 chore(package): update mocha-lcov-reporter to version 1.1.0
http://greenkeeper.io/
2016-02-17 20:34:39 +01:00
jomo
fe16821cb7 update nodejs to 4.3.0 LTS 2016-02-14 20:41:42 +01:00
jomo
3620a63d14 fs.exists is deprecated, switch to fs.access 2016-02-14 20:17:09 +01:00
jomo
22ecc6f8aa make User-Agent RFC1945 compliant
This is the product name without the optional '/' + version.
The parens are a comment, the '+' preceding the URL is common practice

https://tools.ietf.org/html/rfc1945#section-10.15
2016-02-14 17:37:41 +01:00
jomo
0940b50f2c networking.save_texture should return image buffer, not lwip image object
This led to a crash when a cape or skin was not stored on disk.
The function caled skins.save_image and returned that function's lwip image object instead of the expected buffer.
skins.save_image also no longer returns the image object because it's not used anywhere
2016-02-14 17:04:33 +01:00
jomo
ab781772c7 Merge pull request #160 from crafatar/greenkeeper-canvas-1.3.10
canvas@1.3.10
2016-02-07 21:10:14 +01:00
jomo
6594200500 Mojang updated all capes, fix tests 2016-02-07 21:06:06 +01:00
greenkeeperio-bot
1ec936ee31 chore(package): update canvas to version 1.3.10
http://greenkeeper.io/
2016-02-07 11:03:01 +01:00
jomo
183e8cfa9c fix tests for f088c27012c0c49ad47538373d083311dccaf7d9 2016-02-03 03:00:03 +01:00
jomo
f088c27012 use '201 Created' when 'status' is 'downloaded' 2016-02-03 02:49:43 +01:00
jomo
f2dda3b939 check for transparency in hat transparency-bounding-box for avatars, fixes #117 2016-02-02 23:57:40 +01:00
jomo
f920811405 Merge pull request #159 from crafatar/greenkeeper-request-2.69.0
Update request to version 2.69.0 🚀
2016-01-27 23:41:02 +01:00
greenkeeperio-bot
ee0473971a chore(package): update request to version 2.69.0
http://greenkeeper.io/
2016-01-27 20:06:53 +01:00
jomo
f50d48df51 Merge pull request #158 from crafatar/greenkeeper-request-2.68.0
Update request to version 2.68.0 🚀
2016-01-27 18:51:40 +01:00
greenkeeperio-bot
88389f3b54 chore(package): update request to version 2.68.0
http://greenkeeper.io/
2016-01-27 17:53:06 +01:00
jomo
dea197093d Merge pull request #157 from crafatar/greenkeeper-canvas-1.3.9
canvas@1.3.9 breaks build 🚨
2016-01-27 10:21:54 +01:00
greenkeeperio-bot
3692b52145 chore(package): update canvas to version 1.3.9
http://greenkeeper.io/
2016-01-27 09:15:24 +01:00
jomo
6638fb7f0e Merge pull request #156 from crafatar/greenkeeper-mocha-2.4.1
Update mocha to version 2.4.1 🚀
2016-01-26 23:53:12 +01:00
greenkeeperio-bot
965abead3e chore(package): update mocha to version 2.4.1
http://greenkeeper.io/
2016-01-26 20:37:56 +01:00
jomo
0fb17e5e5e update README about renders 2016-01-23 06:30:58 +01:00
jomo
29fa734148 remove 'public' part from Cache-Control, not required 2016-01-23 06:26:41 +01:00
jomo
5654d51eec renders are no longer beta 2016-01-23 06:24:28 +01:00
jomo
c9f04e470b update crafatar users list 2016-01-21 23:23:04 +01:00
jomo
c39019074b use cache lookup for username skin types
fixes steve-model renders for usernames on first request after uuid request was made before
2016-01-21 22:12:23 +01:00
jomo
4468b55b4f username request shouldn't set skin model
if it's set to slim (by a uuid request before), that would have been overwritten and set to not slim
2016-01-21 21:54:46 +01:00
jomo
4f667cc99f remove transparency from avatar, fixes #129 2016-01-20 23:44:33 +01:00
jomo
875bb7c14a add C++11 compiler to travis 2016-01-20 03:19:47 +01:00
jomo
4e884b5497 use nodejs v4.2.4 LTS, fixes #149 2016-01-20 02:29:17 +01:00
jomo
d8e9282ee7 updat dependencies 2016-01-20 02:17:55 +01:00
jomo
6a5967dfba downstream caches shouldn't cache server errors, fixes #136 2016-01-20 02:00:10 +01:00
jomo
e7242ce773 respond 304 on server error, fixes #135 2016-01-20 01:50:30 +01:00
jomo
9ccb0151bc don't search/replace username in path
fixes faulty URLs when part of the path is used as username (e.g. 'avatars')
2016-01-20 01:28:54 +01:00
jomo
e8877c427a Merge branch 'renders-new' 2016-01-20 01:27:42 +01:00
jomo
74ba828701 add test for uuid -> username skin type update 2016-01-20 01:17:36 +01:00
jomo
8367c1e519 mhf_alex should default to mhf_alex if skin not accessible
We only use the 'hard stored' mhf_alex skin when it's part of the 'default' query parameter.
In the rare event that mhf_alex is requested but the skin is not accessible, we would fall back to 'mhf_steve' because it's the default for usernames.
This commit adds the special case to use the stored version of the 'mhf_alex' skin when it's not accessible otherwise
2016-01-20 00:56:20 +01:00
jomo
b9f6a21942 ignore case when checking for mhf_alex or mhf_steve 2016-01-20 00:12:53 +01:00
jomo
5b1ad851ef mhf_alex is always slim 2016-01-19 23:08:56 +01:00
jomo
fcdb03173a Use UUID's profileName response to update username model type, see #125 2016-01-13 02:15:47 +01:00
jomo
1f0f696151 Make sure 'slim' model is correctly checked
The 'textures.SKIN.metadata.model' field seems to be only present if set to 'slim', so currently this change has no effect
however, if the field is returned in other cases (in future), we need to make sure it acually reports 'slim'
2016-01-11 07:37:28 +01:00
Jake
2934b22da2 Crafatar Team 2015-12-31 23:10:04 -06:00
Jake
79512c5f90 ...Happy New Year? :party: 2015-12-31 23:09:44 -06:00
jomo
1144b6755a always use crc32 for etag, much more reliable than mojang skin hash
had to make quite a few changes to tests to prevent them from failing
also, etag is now only sent with a 200 response, as defined in RFC7232
2015-12-16 00:47:51 +01:00
jomo
caeb9a52fe verbose logging on travis 2015-12-15 21:09:13 +01:00
jomo
5cb20b1105 add more crc values to tests 2015-12-14 01:59:32 +01:00
jomo
7d02138c1e solve merge conflicts 2015-12-14 01:51:49 +01:00
jomo
3c21a59c94 add support for slim renders, fixes #125, adjust tests 2015-12-13 14:08:59 +01:00
jomo
7eed1fa09b clean up renders again
- improved readability a lot
- now applies overlays directly to underlying skin part *before* transforming
2015-11-23 21:58:47 +01:00
jomo
6e6eff5299 add back missing helpers.js 2015-11-23 03:45:17 +01:00
jomo
67f317aeac add overlay 'logic', deciding which overlays to render; tweak position & size +/- to get around anti-aliasing 'cracks'; remove transparency from base model
fixes #32
fixes #112
fixes #117
fixes #153
2015-11-23 03:21:58 +01:00
jomo
6e500d8652 fix HTML
from https://validator.w3.org:
> Document checking completed. No errors or warnings to show.
2015-11-22 07:22:56 +01:00
jomo
4627aecd17 clean up
uses 'parts' object instead of tons of variables
2015-11-22 05:33:48 +01:00
jomo
25c4912db9 add back new (1.8) skin support 2015-11-22 05:05:47 +01:00
jomo
abdacdc713 fix rendering of slim arms 2015-11-22 04:18:32 +01:00
jomo
82727cb24d add back head support 2015-11-22 03:28:09 +01:00
jomo
cb25872498 rewrite renders
- renders are now fully isometric
- used position -0.5 and size +1 at some places to fix #32
- does not support overlay yet
- does not support left/right arms/legs
- does not support slim renders yet
- currently only renders full body, not head
2015-11-22 03:15:41 +01:00
jomo
4302a95f6c clarify size is only used for avatars. Related: crafatar/setup#5 2015-11-15 20:06:40 +01:00
jomo
fb0c70d648 return HTTPERROR on 429 or 5xx, fixes #151
otherwise 429 or 5xx would be overwriting cached value with null for $config minutes
2015-10-21 01:02:57 +02:00
jomo
d307aec221 rename helm to overlay, fixes #127 2015-10-18 15:11:17 +02:00
jomo
e8ab044d91 s/Famous/Popular 2015-10-18 04:24:35 +02:00
jomo
36cb9f6b67 fix famous users list bottom 2015-10-17 23:22:56 +02:00
jomo
1e979042e6 fix avatar spacing in header 2015-10-17 23:20:40 +02:00
jomo
815e9c5ae9 word-foo 2015-10-17 22:49:02 +02:00
jomo
08d8211468 add irc:// link 2015-10-17 22:14:41 +02:00
jomo
8a59757345 fix word-wrap 2015-10-17 22:11:48 +02:00
jomo
c915c6699f add CloudFlare caching notice 2015-10-17 22:02:13 +02:00
jomo
44a6a4961d add famous crafatar users + tools & plugins 2015-10-17 21:54:11 +02:00
jomo
3f06992cc1 fix slogan inconstency 2015-10-17 20:43:22 +02:00
jomo
ab64f56c26 s/parameters/modifiers 2015-10-17 20:41:06 +02:00
jomo
503e9eae4a Notice about renders being beta / remade 2015-10-17 20:40:18 +02:00
jomo
863402fb97 add try-it input & logic 2015-10-17 20:31:09 +02:00
jomo
c6f4b038b2 mojang down *may* affect crafatar requests, not definitely does 2015-10-17 18:04:43 +02:00
jomo
8626ed212a don't use same user for skins & capes 2015-10-17 03:10:42 +02:00
jomo
65e145925c some meta tag stuff 2015-10-17 03:05:00 +02:00
jomo
ddf2cb24de clean up some headings 2015-10-17 02:34:46 +02:00
jomo
8d651ca5dc encourage attribution 2015-10-17 02:31:28 +02:00
jomo
f0055e7b0a major html rewrite
- show single sample image with endpoint
- list accepted parameters
- link to details below
2015-10-17 02:24:46 +02:00
jomo
4a6902adac don't place heading (block) inside links (inline) 2015-10-16 23:05:29 +02:00
jomo
70900a249b shuffle avatars in website header 2015-10-15 01:46:14 +02:00
jomo
882b0753bc detect & show mojang issues 2015-10-15 01:32:32 +02:00
jomo
f69eb85c58 fix anchors 2015-10-15 01:03:49 +02:00
jomo
18d054e664 remove all usernames from examples 2015-10-15 00:57:48 +02:00
jomo
e844c05dd2 add danger notice about username lookups
- linked to more info further down with detailed description
- removed all mentions of usernames where applicable
2015-10-15 00:52:50 +02:00
jomo
9d46c1c768 delete navbar, tiny style changes 2015-10-15 00:13:26 +02:00
jomo
f0df78e6a9 allow editing index html when debug_enabled = true 2015-10-14 23:42:27 +02:00
jomo
a3cbedb859 update to bootstrap 4 alpha
- there's no official CDN for bootstrap 4 yet
- fixed breaking change in bootstrap's navbar
2015-10-14 23:41:51 +02:00
jomo
bf1e26d2c5 get rid of jade/haml, use ejs instead 2015-10-14 01:12:30 +02:00
jomo
b0f50cbed0 print base64 encoded body if CRC does not match 2015-10-13 00:51:03 +02:00
jomo
8b2ccf3368 add new CRC checksums
updated OS X + cairo, so obviously the checksums change, right? right??
2015-10-13 00:50:25 +02:00
jomo
01ce06cd22 Move Installation to wiki 2015-10-07 01:10:41 +02:00
jomo
7714e0e0ef add case sensitive default URL tests, so 06caf589abfc4f7a552714558ac5f78abddeeabb won't happen again 2015-09-30 21:06:16 +02:00
jomo
06caf589ab don't lowercase default URLs 2015-09-30 17:00:52 +02:00
jomo
83defa6885 make default parameter case insensitive, add missing docs to renders
See #142
2015-09-30 00:52:51 +02:00
jomo
ecfec6a407 use MHF_Steve and MHF_Alex instead of steve and alex in default parameter
See #142 (not fixed by this commit!)
Basically, this just adds mhf_steve and mhf_alex as special cases for the default parameter only
2015-09-30 00:38:32 +02:00
jomo
fd4fb0764c return & use lwip-stripped image in skins.save_image
no need to pass along (possibly) bulky or broken images!
see #147
2015-09-29 23:32:16 +02:00
jomo
6fbfd6c355 update dependencies 2015-09-26 16:22:32 +02:00
jomo
c8d74d47be avoid reserved property names (+ test), fixes #145 2015-09-25 19:24:56 +02:00
jomo
5e1e7f3701 update dependencies 2015-09-23 00:29:50 +02:00
jomo
5e7d116364 readme IRC badge improvement
the server is now in the badge label and the channel is shown on hover
2015-09-22 21:37:55 +02:00
jomo
1ecf3c0122 add 504 to expected return codes, don't cache unexpected responses 2015-09-21 22:21:14 +02:00
jomo
cc2840ae4b log error if http code not available 2015-09-20 22:02:37 +02:00
jomo
d49f7279b3 log response ID first for access log
also made sure 'headers' is defined before it's used
2015-09-20 21:45:37 +02:00
jomo
a15cb20144 TooManyRequestsException shouldn't actually throw an error
all other errors thrown here are network issues, this is not.
2015-09-20 21:43:12 +02:00
jomo
06895cdd81 add TooManyRequestsException to silent_errors 2015-09-20 21:35:46 +02:00
jomo
9cdca6acda don't throw strings 2015-09-20 21:28:43 +02:00
jomo
26f6b089ef log errors only once, fixes #140
also made sure some (network) errors have level 'WARN'
these are printed without stacktrace
2015-09-20 21:17:13 +02:00
jomo
b97087c099 catch HTTP 500/503 and empty response, fixes #141 2015-09-20 20:58:34 +02:00
jomo
750d741308 log when cleaner has nothing to do 2015-09-17 01:51:11 +02:00
jomo
7e77142b29 add link to crafatar/setup 2015-09-15 04:38:24 +02:00
jomo
8ddc300a11 #138 bump http_timeout to 2 seconds
we run into the timeout quite frequently, even on a fast network.
2s should be long enough for mojang to reply
2015-09-06 03:40:17 +02:00
jomo
6a630f23b9 add new test CRCs for @6d12ed6 2015-09-06 00:47:17 +02:00
jomo
6d12ed685b enhance renders by using binary transparency
this is a temporary fix for #32.
it doesn't solve the problem, but it makes the renders much less worse.

in combination with #134 this will hopefully lead to fixing the problem entirely
2015-09-06 00:22:57 +02:00
jomo
fe12901f41 update app.json
Also fixed our description in package.json
2015-09-03 00:26:25 +02:00
jomo
47a978df6c add image dir to git 2015-09-02 23:57:08 +02:00
jomo
90022d9e6c revert 4d949362beeaf432201f576d83518dd0bb0351a8 2015-09-02 23:50:20 +02:00
jomo
b4ae89832a add missing 'cache' import in routes/skins 2015-09-02 23:22:52 +02:00
jomo
841eb39f05 remove obsolete imports 2015-08-31 05:41:18 +02:00
jomo
3a61e15abf various networking.js improvements
- cleaned up some messy if/else code, replaced with nicely readable switch/case
- catch JSON.parse errors
2015-08-31 00:10:35 +02:00
jomo
ccc7314ea0 fix 'Permission denied' on travis 2015-08-30 23:19:45 +02:00
jomo
5f0e16897d travis 💩 N°2 2015-08-30 22:24:35 +02:00
jomo
5aaf075f94 travis 💩 2015-08-30 22:21:01 +02:00
jomo
52098ca2f8 hotfix for #139 2015-08-30 22:12:43 +02:00
jomo
2e738f8b40 use crafatar/node-df
until adriano-di-giovanni/node-df#3 is merged
fixes #4
2015-08-30 05:42:28 +02:00
jomo
755cc74170 don't update file dates
this was originally implemented because we wanted to delete the oldest images on disk
where 'oldest' means not *used* for the longest time

that's not useful and was never actually implemented, so we don't need this
2015-08-30 04:48:50 +02:00
jomo
78f2f2027f cleaner: be less verbose 2015-08-30 04:29:56 +02:00
jomo
5f703eda70 check disk/redis every 10 minutes 2015-08-30 03:58:57 +02:00
jomo
6746459c8d delete cape images when cleaning 2015-08-30 03:57:29 +02:00
jomo
e56f300d3e use EPHEMERAL_STORAGE instead of HEROKU env
afaik you can have persistent storage on heroku, at least via addons
and heroku is probably not the only environment where one has a temporary file system
2015-08-30 02:11:35 +02:00
jomo
ac9cd93c8e 512MB free disk space is better than 10MB 2015-08-29 22:42:02 +02:00
jomo
3156423209 fix typo 2015-08-22 18:30:29 +02:00
jomo
a928952dc4 don't force image directories to be relative 2015-08-20 01:52:01 +02:00
jomo
9ed431d7ad remove images/ (see @4d94936) 2015-08-18 01:33:00 +02:00
jomo
4d949362be change default config to use /var/lib/crafatr/images
I think this makes more sense, especially when you run multiple instances
so they can use the same image cache instead of each version relying on their own
2015-08-18 01:11:38 +02:00
jomo
b460260bae remove logs/
should be up to the user where to put log files
and to make sure that directory actually exists

currently nothing in this code base expects
logs/ to be present.
2015-08-18 01:08:01 +02:00
jomo
0b58b3a4d1 add final log before shutting down on SIGTERM 2015-08-16 22:27:22 +02:00
jomo
85e7b4b571 remove clusters
clusters aren't supported, see #80
until we actually use clusters, having a main AND a single worker cluster just makes things more difficult
2015-08-16 22:18:17 +02:00
jomo
72708ca590 remove forever, update dependencies
forever should be used externally to start crafatar. it's not an internal dependency
2015-08-16 22:14:26 +02:00
jomo
79da225b9f gracefully shut down on SIGTERM
this will close the server, i.e. all new connections will be dropped
while existing connections are able to complete within 30 seconds
otherwise they are dropped and the server force quits
2015-08-16 22:11:08 +02:00
jomo
49b4ae1a6e exit on process error
From the iojs docs:

> An unhandled exception means your application - and by extension io.js itself -
> is in an undefined state. Blindly resuming means anything could happen.
>
> Think of resuming as pulling the power cord when you are upgrading your system.
> Nine out of ten times nothing happens - but the 10th time, your system is bust.
>
> uncaughtException should be used to perform synchronous cleanup before shutting
> down the process. It is not safe to resume normal operation after
> uncaughtException. If you do use it, restart your application after every
> unhandled exception!
2015-08-16 06:17:38 +02:00
jomo
d6a9f7c71a add JS to check mojang's server status
does CORS request to status.mojang.com/check and figures out if 'uuid', 'name' or 'both' are affected
not doing anything yet
2015-07-28 23:58:48 +02:00
jomo
442dee0280 don't print all this shit when we receive a 500 2015-07-27 20:06:16 +02:00
jomo
6213344090 lots of rendering improvements
- we don't need to resize images. canvas can do that for us
- we don't need to use `scale(-1, 1)` to draw flipped
- most of the old/new skin format shares the same code
- we can draw the skin image directly on the canvas
2015-07-23 11:13:24 +02:00
jomo
3edf89e884 Merge branch 'master' of github.com:crafatar/crafatar 2015-07-22 23:24:30 +02:00
jomo
c2d2644bbe make travis fast attempt 2
See @fc5ab113db93842cf27b5d94bd98c597a7d0f133 for attempt 1
2015-07-22 23:24:26 +02:00
jomo
256edd622f https 2015-07-21 10:06:01 +02:00
jomo
fd647c5953 3D renders are still beta
@Jake0oo0 fyi

We haven't changed anything on our 3D renders since we first introduced them.
They still have rendering glitches, they still don't render the jacket layer,
they still don't support alex-type skins.

Telling people this is no longer beta (@2515575) doesn't seem like a good idea.
2015-07-20 22:28:06 +02:00
Jake
db72d05680 Dependency update 2015-07-18 00:58:27 -05:00
Jake
7f4fa0dea2 Merge branch 'master' of github.com:crafatar/crafatar 2015-07-18 00:46:31 -05:00
Jake
2515575f25 Renders are no longer in beta, encode default url 2015-07-18 00:46:24 -05:00
jomo
607dcaf6e5 use status -2 for 404s
human_status (response.js) defines code -2 as 'user error'. 404 is definitely a user error, so using that makes sense.
eventually we should change the whole status code thing with #120
2015-07-17 10:09:34 +02:00
jomo
fc5ab113db Merge branch 'master' of github.com:crafatar/crafatar 2015-07-17 01:37:35 +02:00
jomo
d0689c9e3b change two more logs to debug 2015-07-17 01:37:26 +02:00
Jake
c2e2a98b82 Make 'too busy' an error in logs 2015-07-16 17:52:16 -05:00
Jake
b1cdf61e4b Change invalid request path to be a 404 rather than 422, implement status id in response module 2015-07-16 17:48:48 -05:00
Jake
d71d99fa9a Make a vast majority of logs debug, make it so that essential info is in one line 2015-07-16 17:42:41 -05:00
Jake
7e8c65fb33 Add tests for invalid URL paths, closes #123 2015-07-16 17:31:20 -05:00
Jake
f4e31eab4f Prevent twitter logo from overlapping Crafaatar title on mobile 2015-07-16 17:15:03 -05:00
Jake
4ed0b75c9d More meta tags 2015-07-16 17:09:54 -05:00
jomo
5de8c77ffb fix docker-options 2015-07-14 01:14:23 +02:00
jomo
53e571bd6e renew dokku installation instructions
+ some minor readme changes
2015-07-14 00:45:58 +02:00
jomo
73af64ef2b add IRC badge 2015-07-12 17:36:59 +02:00
jomo
44cff242d7 update dependencies 2015-06-29 15:42:45 +02:00
jomo
72840433cb use proper URL encoding in tests 2015-06-25 21:55:23 +02:00
jomo
d56b10955e add tests for uuid defaults, #115 2015-06-25 21:46:14 +02:00
jomo
8c022e5cb6 'useranme' 2015-06-25 21:34:15 +02:00
jomo
f9b7ffb5b7 fix redirection test 2015-06-25 21:33:47 +02:00
jomo
3e4f150262 add missing import N°2 2015-06-25 21:24:26 +02:00
jomo
fa8e719de3 add missing import 2015-06-25 21:22:54 +02:00
jomo
34f94bf9b5 get rid of redundant config names #124 2015-06-25 21:19:50 +02:00
jomo
20b6fd55aa clean up config comments, fix postinstall. #124 2015-06-25 20:44:38 +02:00
jomo
2d71d2954e clean up gitignore 2015-06-25 20:35:54 +02:00
Jake
e9071b123c Handle several unhandled errors - this is becoming a mess and we should really use a package to clean this up - closes #113 2015-06-25 13:26:27 -05:00
Jake
1f4e5430b7 Remove config 2015-06-25 13:21:03 -05:00
Jake
0153640422 Ignore config.js 2015-06-25 13:20:51 -05:00
Jake
a2e0edc491 Namespace and move config to root directory, closes #124 2015-06-25 13:12:08 -05:00
Jake
2eb1112c3b Fix render defaults 2015-06-24 15:14:28 -05:00
Jake
d2ab7b87ad Fix tests with new default code 2015-06-24 15:08:05 -05:00
Jake
8c39d0c017 Add support for userIds as defaults, ccloses #115 2015-06-24 01:23:22 -05:00
Jake
f1fd92f1cf Disallow additional paths, closes #123 2015-06-24 00:50:38 -05:00
Jake
58a2f0662d capaaacittty 2015-06-22 23:34:35 -05:00
Jake
7b5a871c5a Use lwip 0.0.7 from npm 2015-06-06 10:32:24 -05:00
jomo
f36cfa7898 skip helm for images without alpha channel
hotfix for #117
2015-05-28 01:55:05 +02:00
jomo
c54e3020e9 shut up travis 2015-05-28 01:35:39 +02:00
jomo
288657107e actually store the skin in store_skin, fixes #108; pipe skins & capes through lwip before saving, fixes #121 2015-05-28 01:32:29 +02:00
jomo
679e72759a fix test for TooManyRequests 2015-05-28 01:05:24 +02:00
jomo
0ec97e9ba1 add alternative checksum for mojang capes 2015-05-28 00:52:26 +02:00
jomo
11f580bb95 fix TooManyRequests test 2015-05-28 00:45:48 +02:00
jomo
01049cb34d rewrite request queue, fixes #118 2015-05-28 00:45:20 +02:00
jomo
189698bed9 add alternative checksums for steve & alex
using the latest version of lwip, the images don't have an alpha channel anymore

apparently before @94322f3 npm thought 'crafatar/lwip' means git tag v0.0.5
and after that commit, using 'EyalAr/lwip' it noticed there's a v0.0.6

...
...
...

wat.
2015-05-27 23:28:54 +02:00
jomo
7f8da3abe2 lint tests 2015-05-27 22:35:59 +02:00
jomo
94322f3a77 update dependencies
also using ~ operator, i.e. allow patch-level changes
2015-05-27 19:02:09 +02:00
jomo
51b7d19cb0 add 'Contributions welcome' notice to readme 2015-05-27 01:04:36 +02:00
jomo
bb4de15ff2 no yoda
usually, '10 === finished' would be considered yoda
but in this case, with this naming of variables
it makes a lot more sense this way
2015-05-26 22:21:07 +02:00
jomo
c93ffa5a79 add test for simultaneous requests 2015-05-26 22:16:33 +02:00
jomo
5ef3c4f59c add test for URL encoding 2015-05-26 21:53:33 +02:00
jomo
fd6fd0f1bd fix bulk.sh script 2015-05-25 14:13:09 +02:00
jomo
add4d1abac allow some latency 2015-05-24 17:22:47 +02:00
jomo
b7929d06d3 fix alt tag in readme 2015-05-24 17:02:27 +02:00
jomo
9c5e5ff9a7 shuffle ids for bulk.sh
this requires gshuf (coreutils) to be installed on OS X
2015-05-24 16:55:04 +02:00
jomo
9a8dcbdff1 Revert "add missing @ for twitter username"
This reverts commit 48ce2f9267e43bfcd00c79ab601c28348f01d3b3.

Actually looks better without the @. It has a leading Larry anyways
2015-05-24 15:53:11 +02:00
jomo
e1f1fe7c57 add toobusy 503 response 2015-05-24 13:34:49 +02:00
jomo
6f94af7a4a log response time 2015-05-07 01:06:56 +02:00
jomo
fd18c70bec catch uncaughtException 2015-05-06 23:24:21 +02:00
jomo
9c59d302c9 Merge branch 'http-response' 2015-05-06 22:36:22 +02:00
jomo
6273e3bcc8 adjust 422 messages 2015-05-06 22:13:28 +02:00
jomo
00f6c28cfc add tests for 422 / invalid xy / user error 2015-05-06 22:11:19 +02:00
jomo
d27eb0049f update to iojs 2.0 2015-05-06 22:00:54 +02:00
jomo
d1e174405a fix wrong URL in test 2015-05-06 21:59:44 +02:00
jomo
15afa940f0 update lwip for iojs 2.0.0 2015-05-06 21:50:57 +02:00
jomo
46d10fdc81 remove unnecessary double check 2015-05-06 21:07:43 +02:00
jomo
ed25d30ff0 remove unnecessary log 2015-05-06 21:07:19 +02:00
jomo
9f04fbc136 allow multiple checksums for tests, fixes #119
As discussed in #119, the rendered image can be slightly different in different environments
2015-05-06 21:06:09 +02:00
jomo
67e635dbbb remove unnecessary quotes in .travis.yml
I just want travis to run these tests again
2015-05-03 23:33:23 +02:00
jomo
deeb72770e fix iojs version in .travis.yml (again) 2015-05-03 23:19:29 +02:00
jomo
f48a9e66cf Revert "travis dependency 💩"
This reverts commit b2d31c5b7e34282e96c7fc774aebc81c728fd7ba.
2015-05-03 23:15:52 +02:00
jomo
d8303eb1a6 revert b2d31c5..2d1876b
this wasn't a cairo issue, no need to slow down travis
less recent version of cairo works just as well
2015-05-03 23:09:32 +02:00
jomo
b039e97fa7 fix test crc32 values 2015-05-03 23:06:21 +02:00
jomo
6e41423b7a set encoding: null in test HTTP requests
from the 'request' docs:

encoding: Encoding to be used on setEncoding of response data. If null, the body is returned as a Buffer. Anything else (including the default value of undefined) will be passed as the encoding parameter to toString() (meaning this is effectively utf8 by default).
2015-05-03 22:46:03 +02:00
jomo
189d1c7010 show travis status for master branch
so we don't display 'build|failing' for broken PRs or branches
2015-04-28 13:41:16 +02:00
jomo
2d1876b6b7 add libexpat1-dev dependency 2015-04-26 02:37:37 +02:00
jomo
89a80eeaad add expat dependency 2015-04-26 02:31:17 +02:00
jomo
14769f6f36 💩 2015-04-26 02:21:06 +02:00
jomo
b2d31c5b7e travis dependency 💩 2015-04-26 02:10:47 +02:00
jomo
aeffc8fc51 update travis' iojs version 2015-04-25 23:01:46 +02:00
jomo
4286b35775 support redirection for capes 2015-04-25 22:59:33 +02:00
jomo
5c53694f9d no need to defined undefined 2015-04-25 22:51:22 +02:00
jomo
23080370b2 add test for redirection 2015-04-25 22:50:31 +02:00
jomo
d981b34d0b compact code for cape tests 2015-04-25 21:25:43 +02:00
jomo
8ad35aeea2 rewrite http tests, check http headers, body, and caching
also disabled *all* logs in tests unless VERBOSE_TEST=true
2015-04-25 21:22:16 +02:00
Jake
02092aec08 Add test for username defaulting to steve 2015-04-25 09:09:22 -05:00
jomo
efd9c0ccee don't remove trailing slash for root path 2015-04-25 15:55:59 +02:00
jomo
79ab296f1f get rid of path.resolve
This doesn't handle '..' and other things that
path.resolve does, but if you're passing weird
things to a URL, your fault.
2015-04-25 15:49:47 +02:00
jomo
22ea5a7e27 use iojs 1.8.x 2015-04-25 14:43:56 +02:00
jomo
1464ec81f4 update calls to changed function helpers.get_cape() 2015-04-23 23:42:20 +02:00
jomo
34adbd32d7 be more clear in docs 2015-04-22 00:35:39 +02:00
jomo
c8dad9dfbb invalidate cache when skin file is gone 2015-04-22 00:31:37 +02:00
jomo
3cdcccde57 improve logging 2015-04-22 00:26:10 +02:00
jomo
7ca43e3cd9 add description for X-Storage-Type, remove debug log 2015-04-22 00:23:48 +02:00
jomo
cf1119e2cb update preload images on index 2015-04-22 00:14:18 +02:00
jomo
69f0ee23be use new response module for assets
unfortunately we can't use stream pipes because we need to generate
a hash of the content for the Etag.

I think proper caching (i.e. Etag) is very important
2015-04-22 00:06:10 +02:00
jomo
2e66e5c794 add missing status for empty capes 2015-04-21 23:46:07 +02:00
jomo
244f90c4c7 fix bug with skin caching that cached capes as non-existent
when using cache.save_hash, an `undefined` skin or cape hash means "do not touch"
while `null` means "overwrite as non-existent"

this was sent to redis after calling /capes/Notch for the first time:

  "hmset" "notch" "c" "3f688e0e699b3d9fe448b5bb50a3a288f9c589762b3dae8308842122dcb81" "t" "1429651244222"
  "hmset" "notch" "s" "a116e69a845e227f7ca1fdde8c357c8c821ebd4ba619382ea4a1f87d4ae94" "c" "" "t" "1429651244235"

as you can see, the first request stores the c(ape) but does not touch the s(kin), whereas the second request
sets the s(kin) and replaces the c(ape) value
2015-04-21 23:34:14 +02:00
jomo
13169be774 use new response module for capes + more
- made sure that get_cape returns a status
- response.js returns 404 if body is empty
- 'X-Storage-Type: undefined' is no longer returned when status is `null`
2015-04-21 23:27:10 +02:00
jomo
8971e3c02b use new response module for renders + bug fixes 2015-04-21 00:57:14 +02:00
jomo
a947b02c87 use new response module for skins 2015-04-20 23:18:27 +02:00
jomo
0b0882e63d use new response module for index 2015-04-20 22:19:57 +02:00
jomo
8dc55442b1 remove debug log 2015-04-20 01:12:16 +02:00
jomo
d025a3004d remove always-empty first entry in req.url.path_list
rhyme pro 😎
2015-04-20 01:10:39 +02:00
jomo
0b687d8f8e correct comment about result.type 2015-04-20 00:52:08 +02:00
jomo
71a13c3bab add missing src dependency 2015-04-20 00:46:03 +02:00
jomo
3cbf73b0d7 create new response module & use it for avatars 2015-04-20 00:41:11 +02:00
jomo
fce58722c8 start work on #45 2015-04-12 19:53:34 +02:00
jomo
9d2fe0c454 some logging fixes 2015-04-06 03:32:13 +02:00
jomo
f6c4e62846 fix path in error message 2015-04-06 03:27:28 +02:00
jomo
67fd75b91e fix logo in readme 2015-04-06 03:25:15 +02:00
jomo
82170c1d09 be more clear about 'examples' on website 2015-04-06 03:08:49 +02:00
jomo
e28451f8e7 remove debug log 2015-04-06 03:07:29 +02:00
jomo
b84a65fd8e restructure directories
www.js is our 'main' file, it's now at the project's root instead of server.js
routes, views, assets are now in lib, too
2015-04-06 03:06:38 +02:00
jomo
a3a77962b3 flat-square badges in readme 2015-04-05 23:20:15 +02:00
jomo
a22454e6e4 Date.now() is faster (and shorter) than new Date().getTime() 2015-03-30 23:56:44 +02:00
jomo
9e3ef8087e always use etag, fixes #94 2015-03-28 01:51:59 +01:00
jomo
eeb1939235 remove redundant dashes in render filenames 2015-03-27 23:25:08 +01:00
jomo
4dec71c75e only report coverage to coveralls from travis
this has the nice side effect that `npm test` effectively only runs `mocha` and has proper line numbers in stack traces
2015-03-27 23:14:30 +01:00
jomo
af03fb63f8 ❤️ eslint 2015-03-27 23:12:44 +01:00
jomo
7520957b93 Use path.join() instead of + to create paths 2015-03-27 21:29:36 +01:00
jomo
48c2a55995 cache_err wouldn't be defined 2015-03-27 21:10:13 +01:00
jomo
2780ae05d3 move www.js to lib
it's not a binary, why is this in bin?
2015-03-27 09:50:54 +01:00
jomo
2cb197a717 Merge branch 'master' of github.com:crafatar/crafatar 2015-03-22 03:09:12 +01:00
jomo
0326000b42 iojs v1.6 2015-03-20 09:17:55 +01:00
jomo
d4b0a335c4 return 200 for default avatars/renders/skins, fixes #111 2015-03-18 01:11:49 +01:00
jomo
13360430d9 join logs using , instead of + " " + 2015-03-15 01:57:19 +01:00
jomo
8e8ff1fe39 fix capes endpoint doc 2015-03-14 17:32:49 +01:00
jomo
a294a7c57f add 128px logo, polish README 2015-03-14 17:31:35 +01:00
jomo
0b97aeee8d better style for hover previews 2015-03-14 06:56:45 +01:00
jomo
4c7da4940e remove benchmark.sh, improve bulk.sh 2015-03-14 06:37:16 +01:00
jomo
dfaa79b9c7 use iojs 1.5.x, see iojs/io.js#1103 + iojs/io.js#1075 2015-03-14 01:23:16 +01:00
jomo
94fe61e076 add CORS info to website, fixes #110 2015-03-07 20:00:06 +01:00
jomo
abaf3f77aa add & clean up documentation
also a small improvement for URL error logging, variable naming, and argument joining
2015-03-01 18:58:21 +01:00
jomo
912b1b2ba7 add space between badges 2015-02-27 18:59:16 +01:00
jomo
a49a1c7649 iojs v1.4.1 2015-02-27 18:55:44 +01:00
jomo
48092d0422 add inch-ci for doc quality tracking 2015-02-27 18:53:47 +01:00
jomo
f984b20344 mv modules/ lib/
that's what all the cool kids do
2015-02-27 18:49:39 +01:00
jomo
2972e818c7 complete that sentence 2015-02-27 01:08:39 +01:00
jomo
9c8df06c6b proper handling of steve/alex
it's more complex than just even/odd UUID
2015-02-27 01:07:09 +01:00
65 changed files with 5697 additions and 2815 deletions

View File

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

6
.dockerignore Normal file
View File

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

View File

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

6
.gitignore vendored
View File

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

View File

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

35
Dockerfile Normal file
View File

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

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015 Jake0oo0
Copyright (c) 2020 Crafatar Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

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

100
README.md
View File

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

View File

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

View File

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

69
config.js Normal file
View File

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

116
lib/cache.js Normal file
View File

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

402
lib/helpers.js Normal file
View File

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

47
lib/logging.js Normal file
View File

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

201
lib/networking.js Normal file
View File

@ -0,0 +1,201 @@
var logging = require("./logging");
var request = require("request");
var config = require("../config");
var skins = require("./skins");
var http = require("http");
require("./object-patch");
var session_url = config.endpoints.session_url;
var textures_url = config.endpoints.textures_url;
// count requests made to session_url in the last 1000ms
var session_requests = [];
var exp = {};
// returns the amount of outgoing session requests made in the last 1000ms
function req_count() {
var index = session_requests.findIndex((i) => i >= Date.now() - 1000);
if (index >= 0) {
return session_requests.length - index;
} else {
return 0;
}
}
// deletes all entries in session_requests, should be called every 1000ms
exp.resetCounter = function() {
var count = req_count();
if (count) {
var logfunc = count >= config.server.sessions_rate_limit ? logging.warn : logging.debug;
logfunc('Clearing old session requests (count was ' + count + ')');
session_requests.splice(0, session_requests.length - count);
} else {
session_requests = []
}
}
// performs a GET request to the +url+
// +options+ object includes these options:
// encoding (string), default is to return a buffer
// callback: the body, response,
// and error buffer. get_from helper method is available
exp.get_from_options = function(rid, url, options, callback) {
var is_session_req = config.server.sessions_rate_limit && url.startsWith(session_url);
// This is to prevent being blocked by CloudFront for exceeding the rate limit
if (is_session_req && req_count() >= config.server.sessions_rate_limit) {
var e = new Error("Skipped, rate limit exceeded");
e.name = "HTTP";
e.code = "RATELIMIT";
var response = new http.IncomingMessage();
response.statusCode = 403;
callback(null, response, e);
} else {
is_session_req && session_requests.push(Date.now());
request.get({
url: url,
headers: {
"User-Agent": "Crafatar (+https://crafatar.com)"
},
timeout: config.server.http_timeout,
followRedirect: false,
encoding: options.encoding || null,
}, function(error, response, body) {
// log url + code + description
var code = response && response.statusCode;
var logfunc = code && (code < 400 || code === 404) ? logging.debug : logging.warn;
logfunc(rid, url, code || error && error.code, http.STATUS_CODES[code]);
// not necessarily used
var e = new Error(code);
e.name = "HTTP";
e.code = "HTTPERROR";
switch (code) {
case 200:
case 301:
case 302: // never seen, but mojang might use it in future
case 307: // never seen, but mojang might use it in future
case 308: // never seen, but mojang might use it in future
// these are okay
break;
case 204: // no content, used like 404 by mojang. making sure it really has no content
case 404:
// can be cached as null
body = null;
break;
case 403: // Blocked by CloudFront :(
case 429: // this shouldn't usually happen, but occasionally does
case 500:
case 502: // CloudFront can't reach mojang origin
case 503:
case 504:
// we don't want to cache this
error = error || e;
body = null;
break;
default:
if (!error) {
// Probably 500 or the likes
logging.error(rid, "Unexpected response:", code, body);
}
error = error || e;
body = null;
break;
}
if (body && !body.length) {
// empty response
body = null;
}
callback(body, response, error);
});
}
};
// helper method for get_from_options, no options required
exp.get_from = function(rid, url, callback) {
exp.get_from_options(rid, url, {}, function(body, response, err) {
callback(body, response, err);
});
};
// gets the URL for a skin/cape from the profile
// +type+ "SKIN" or "CAPE", specifies which to retrieve
// callback: url, slim
exp.get_uuid_info = function(profile, type, callback) {
var properties = Object.get(profile, "properties") || [];
properties.forEach(function(prop) {
if (prop.name === "textures") {
var json = new Buffer.from(prop.value, "base64").toString();
profile = JSON.parse(json);
}
});
var url = Object.get(profile, "textures." + type + ".url");
var slim;
if (type === "SKIN") {
slim = Object.get(profile, "textures.SKIN.metadata.model") === "slim";
}
callback(null, url || null, !!slim);
};
// make a request to sessionserver for +uuid+
// callback: error, profile
exp.get_profile = function(rid, uuid, callback) {
exp.get_from_options(rid, session_url + uuid, { encoding: "utf8" }, function(body, response, err) {
try {
body = body ? JSON.parse(body) : null;
callback(err || null, body);
} catch(e) {
if (e instanceof SyntaxError) {
logging.warn(rid, "Failed to parse JSON", e);
logging.debug(rid, body);
callback(err || null, null);
} else {
throw e;
}
}
});
};
// get the skin URL and type for +userId+
// +profile+ is used if +userId+ is a uuid
// callback: error, url, slim
exp.get_skin_info = function(rid, userId, profile, callback) {
exp.get_uuid_info(profile, "SKIN", callback);
};
// get the cape URL for +userId+
// +profile+ is used if +userId+ is a uuid
exp.get_cape_url = function(rid, userId, profile, callback) {
exp.get_uuid_info(profile, "CAPE", callback);
};
// download the +tex_hash+ image from the texture server
// and save it in the +outpath+ file
// callback: error, response, image buffer
exp.save_texture = function(rid, tex_hash, outpath, callback) {
if (tex_hash) {
var textureurl = textures_url + tex_hash;
exp.get_from(rid, textureurl, function(img, response, err) {
if (err) {
callback(err, response, null);
} else {
skins.save_image(img, outpath, function(img_err) {
callback(img_err, response, img);
});
}
});
} else {
callback(null, null, null);
}
};
module.exports = exp;

22
lib/object-patch.js Normal file
View File

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

BIN
lib/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
lib/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 150 B

After

Width:  |  Height:  |  Size: 150 B

View File

Before

Width:  |  Height:  |  Size: 997 B

After

Width:  |  Height:  |  Size: 997 B

View File

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 222 B

View File

Before

Width:  |  Height:  |  Size: 835 B

After

Width:  |  Height:  |  Size: 835 B

View File

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

BIN
lib/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

File diff suppressed because one or more lines are too long

View File

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

260
lib/renders.js Normal file
View File

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

118
lib/response.js Normal file
View File

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

111
lib/routes/avatars.js Normal file
View File

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

53
lib/routes/capes.js Normal file
View File

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

34
lib/routes/index.js Normal file
View File

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

127
lib/routes/renders.js Normal file
View File

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

109
lib/routes/skins.js Normal file
View File

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

180
lib/server.js Normal file
View File

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

175
lib/skins.js Normal file
View File

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

324
lib/views/index.html.ejs Normal file
View File

@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Crafatar A blazing fast API for Minecraft faces!</title>
<meta charset="utf-8">
<link rel="icon" type="image/png" href="/favicon.png">
<link rel="stylesheet" href="/stylesheets/bootstrap.min.css">
<link rel="stylesheet" href="/stylesheets/style.css">
<meta name="description" content="A blazing fast API for Minecraft faces with support for avatars, skins, and 3D renders!">
<meta name="keywords" content="minecraft, avatar, renders, skins, uuid">
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<meta name="copyright" content="Crafatar">
<meta name="language" content="en-US">
<meta name="robots" content="index">
<meta property="og:title" content="Crafatar">
<meta property="og:type" content="website">
<meta property="og:url" content="<%= domain %>">
<meta property="og:image" content="<%= domain %>/logo.png">
<meta property="og:description" content="blazing fast API for Minecraft faces with support for avatars, skins, and 3D renders!">
<meta property="og:determiner" content="a">
<meta property="og:locale" content="en_US">
<meta name="twitter:card" content="summary">
<meta name="twitter:creator" content="@Crafatar">
<script src="/javascript/crafatar.js"></script>
</head>
<body lang="en-US">
<a href="https://github.com/crafatar/crafatar" target="_blank" class="forkme">Fork me on GitHub</a>
<% if (config.sponsor.top_right) { %>
<%- config.sponsor.top_right %>
<% } %>
<div class="jumbotron">
<div class="container">
<h1>Crafatar</h1>
<h2>A blazing fast API for Minecraft faces!</h2>
<div id="avatar-wrapper">
<%# These are shuffled by JS %>
<div title="jomo's avatar" class="avatar jomo"></div>
<div title="jake_0's avatar" class="avatar jake_0"></div>
<div title="sk89q's avatar" class="avatar sk89q"></div>
<div title="md_5's avatar" class="avatar md_5"></div>
<div title="notch's avatar" class="avatar notch"></div>
<div title="jeb_'s avatar" class="avatar jeb"></div>
<div title="dinnerbone's avatar" class="avatar dinnerbone flipped"></div>
<div title="ez' avatar" class="avatar ez"></div>
<div title="grumm's avatar" class="avatar grumm flipped"></div>
<div title="themogmimer's avatar" class="avatar themogmimer"></div>
<div title="searge's avatar" class="avatar searge"></div>
<div title="xlson's avatar" class="avatar xlson"></div>
<div title="krisjelbring's avatar" class="avatar krisjelbring"></div>
<div title="aikar's avatar" class="avatar aikar"></div>
<div title="ammar2's avatar" class="avatar ammar2"></div>
<div title="marc's avatar" class="avatar marc"></div>
<div title="mollstam's avatar" class="avatar mollstam"></div>
<div title="evilseph's avatar" class="avatar evilseph"></div>
<div title="thinkofdeath's avatar" class="avatar thinkofdeath"></div>
</div>
</div>
</div>
<div class="container row">
<div class="col-md-9">
<section id="documentation">
<div id="alerts">
</div>
<section id="try">
<h2><a href="#try">Try it</a></h2>
<form id="tryit" action="#">
<div class="row">
<div class="col-md-11">
<input id="tryname" type="text" placeholder="Enter valid UUID">
</div>
<div class="col-md-1">
<input type="submit" value="Go!">
</div>
</div>
</form>
<p>You can use <a rel="nofollow" target="_blank" href="https://minecraftuuid.com">minecraftuuid.com</a> to find the UUID of a username.</p>
</section>
<section id="avatars">
<h2><a href="#avatars">Avatars</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/avatars/$?size=100" src="/avatars/853c80ef3c3749fdaa49938b674adae6?size=100" alt="avatar">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/avatars/<mark class="green">uuid</mark>
</div>
<p>Accepted <a href="#meta-parameters">modifiers</a>: <i><b>size</b>, <b>overlay</b>, <b>default</b></i>.</p>
</div>
</div>
</section>
<section id="head-renders">
<h2><a href="#head-renders">Head Renders</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/renders/head/$" src="/renders/head/853c80ef3c3749fdaa49938b674adae6" alt="head">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/renders/head/<mark class="green">uuid</mark>
</div>
<p>
Accepted <a href="#meta-parameters">modifiers</a>: <i><b>scale</b>, <b>overlay</b>, <b>default</b></i>.
</p>
</div>
</div>
</section>
<section id="body-renders">
<h2><a href="#body-renders">Body Renders</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/renders/body/$" src="/renders/body/853c80ef3c3749fdaa49938b674adae6" alt="body">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/renders/body/<mark class="green">uuid</mark>
</div>
<p>
Accepted <a href="#meta-parameters">modifiers</a>: <i><b>scale</b>, <b>overlay</b>, <b>default</b></i>.
</p>
</div>
</div>
</section>
<section id="skins">
<h2><a href="#skins">Skins</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/skins/$" src="/skins/853c80ef3c3749fdaa49938b674adae6" alt="skin">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/skins/<mark class="green">uuid</mark>
</div>
<p>Accepted <a href="#meta-parameters">modifiers</a>: <i><b>default</b></i>.</p>
</div>
</div>
</section>
<section id="capes">
<h2><a href="#capes">Capes</a></h2>
<div class="row">
<div class="col-md-2">
<img class="tryit" data-src="/capes/$?default=853c80ef3c3749fdaa49938b674adae6" src="/capes/069a79f444e94726a5befca90e38aaf5?default=853c80ef3c3749fdaa49938b674adae6" alt="cape">
</div>
<div class="col-md-10">
<div class="code">
<%= domain %>/capes/<mark class="green">uuid</mark>
</div>
<p>Accepted <a href="#meta-parameters">modifiers</a>: <i><b>default</b></i>.</p>
</div>
</div>
</section>
<hr>
<section id="meta">
<h2><a href="#meta">Meta</a></h2>
<p>
You can append <code>.png</code> or any other file extension to the URL path if you like to, but all images are PNG.
</p>
<section id="meta-attribution">
<h3><a href="#meta-attribution">Attribution</a></h3>
<p>
Attribution is not required, but it is <strong>encouraged</strong>.<br>
If you want to show some support for this (free!) service, place a notice like this somewhere:
<span class="code">
Thank you to &lt;a href="https://crafatar.com"&gt;Crafatar&lt;/a&gt; for providing avatars.
</span>
</p>
</section>
<section id="meta-parameters">
<h3><a href="#meta-parameters">URL Parameters</a></h3>
<p>
You can tweak images using <a href="https://en.wikipedia.org/wiki/Query_string" target="_blank">query string parameters</a>.<br>
Example: <code><%= domain %>/avatars/853c80ef3c3749fdaa49938b674adae6<mark class="blue">?</mark><mark class="green">size=4</mark><mark class="blue">&</mark><mark class="green">default=MHF_Steve</mark><mark class="blue">&</mark><mark class="green">overlay</mark></code>
</p>
<ul>
<li><b>size</b>: The size for avatars in pixels. <code><%= config.avatars.min_size %> - <%= config.avatars.max_size %></code>
<li><b>scale</b>: The scale factor for renders. <code><%= config.renders.min_scale %> - <%= config.renders.max_scale %></code>
<li><b>overlay</b>: Apply the <span title="Also known as 'hat' or 'jacket' or 'helm'">overlay</span> to the avatar. Presence of this parameter implies <code>true</code>. This option was previously known as <code>helm</code>.
<li>
<b>default</b>: The fallback to be used when the requested image cannot be served. You can use a <span title="Make sure to properly percent-encode this!">custom URL</span>, any <mark class="green">uuid</mark>, or <code>MHF_Steve</code>/<code>MHF_Alex</code>.<br>
The option defaults to either <code>MHF_Steve</code> or <code>MHF_Alex</code>, depending on Minecraft's default for the requested UUID.
</ul>
</section>
<section id="meta-uuids">
<h3><a href="#meta-uuids">About UUIDs</a></h3>
<p>UUIDs may be any valid Mojang UUID in the blank or dashed format.</p>
<p>Malformed UUIDs are rejected.</p>
</section>
<section id="meta-usernames">
<h3><a href="#meta-usernames">About Usernames</a></h3>
<p>
By <a href="https://twitter.com/MojangSupport/status/964511258601865216" target="_blank">disabling</a> a legacy API in 2018, Mojang has made it practically impossible for Crafatar to support usernames. Please use UUIDs instead!
</p>
<p>All usernames are rejected.</p>
</section>
<section id="meta-caching">
<h3><a href="#meta-caching">About Caching</a></h3>
<p>
Crafatar checks for skin updates every <%= config.caching.local / 60 %> minutes.<br>
Images are also cached in your browser for <%= config.caching.browser / 60 %> minutes unless you clear your browser cache.
<% if (config.caching.cloudflare) { %>
<br>In addition, <span title="A CDN and caching proxy">Cloudflare</span> may cache images as long as your browser would.
<% } %>
</p>
<p>After changing your Minecraft skin, you can try clearing your browser cache to see the change faster.</p>
</section>
<section id="meta-cors">
<h3><a href="#meta-cors">CORS</a></h3>
<p>Crafatar supports Cross-Origin Resource Sharing, so you can make AJAX request from other sites!</p>
</section>
<section id="meta-http-headers">
<h3><a href="#meta-http-headers">HTTP Headers</a></h3>
<p>
Crafatar always replies with a <code>200 OK</code> status code when the requested user's skin/cape was found. This is also used in some rare cases when Mojang servers are having issues and the image couldn't be checked for changes, but Crafatar still had a cached version.
<% if (config.caching.cloudflare) { %>
<code>500 Server Error</code> is used when no skin/cape was found because of Mojang or Crafatar server issues.
<% } else { %>
<code>502 Bad Gateway</code> and <code>500 Server Error</code> are used when no skin/cape was found because of Mojang or Crafatar server issues.
<% } %>
</p>
<p>
Note that requests are usually answered with an image (with Steve/Alex skin), even if an error occured!
</p>
<p>
Responses come with some HTTP headers that are useful for debugging.
<% if (config.caching.cloudflare) { %>
<br>Please note that these headers may be cached by <span title="A CDN and caching proxy">Cloudflare</span>.
<% } %>
</p>
<ul>
<li>
<b>Warning</b>: When using a cached image after an error occured. One of:
<ul>
<li><code>110 Crafatar "Response is Stale"</code></li>
<li><code>111 Crafatar "Revalidation Failed"</code></li>
</ul>
<li>
<b>X-Storage-Type</b>: Details about how the requested image was stored on the server
<ul>
<li><b>none</b>: No external requests. Player has no skin (cached)</li>
<li><b>cached</b>: No external requests. (skin cached)</li>
<li><b>checked</b>: Requested skin details, skin cached. (1 external request)<br>
This happens either when the user removed their skin or when it didn't change.</li>
<li><b>downloaded</b>: Requested skin details, skin downloaded. (2 external requests)</li>
<li><b>server error</b>: This can happen, for example, when Mojang's servers are down.</li>
<li><b>server error;cached</b>: Same as server error, but a cached skin was available.</li>
<li><b>user error</b>: You have done something wrong, such as requesting a malformed uuid.<br>
Check the response body for details.</li>
</ul>
<li>
<b>X-Request-ID</b>: The internal ID assigned to this request.<br>
If you think something is wrong with your request, please contact us and provide this ID.
<li>
<b>Response-Time</b>: How long it took Crafatar to answer the request, in ms.
</ul>
</section>
</section>
</div>
<div class="col-md-3">
<h4>Popular Crafatar users</h4>
<div class="list-group">
<a rel="nofollow" href="https://hypixel.net" target="_blank" class="list-group-item">Hypixel</a>
<a rel="nofollow" href="https://mineplex.com" target="_blank" class="list-group-item">Mineplex</a>
<a rel="nofollow" href="https://hivemc.com" target="_blank" class="list-group-item">The Hive</a>
<a rel="nofollow" href="https://www.technicpack.net" target="_blank" class="list-group-item">Technic Pack</a>
<a rel="nofollow" href="https://namemc.com" target="_blank" class="list-group-item">NameMC</a>
<a rel="nofollow" href="https://mcuuid.net/" target="_blank" class="list-group-item">MCUUID</a>
<a href="https://github.com/crafatar/crafatar/wiki/Who-uses-crafatar%3F" target="_blank" class="list-group-item">and many more…</a>
</div>
<hr>
<h4>Quotes</h4>
<div id="quote-wrapper" class="list-group">
<a id="quote" rel="nofollow" target="_blank" class="list-group-item"></a>
</div>
<p>See <a rel="nofollow" href="https://github.com/crafatar/crafatar/wiki/What-people-say-about-Crafatar" target="_blank">all quotes</a>.</p>
<hr>
<h4>Crafatar Tools & Plugins</h4>
<div class="list-group">
<a rel="nofollow" href="https://github.com/DiscordSRV/DiscordSRV#readme" target="_blank" class="list-group-item">DiscordSRV</a>
<a rel="nofollow" href="https://github.com/the-obsidian/discourse-minecraft-avatar" target="_blank" class="list-group-item">Discourse Minecraft Avatar</a>
<a rel="nofollow" href="https://xenforo.com/community/resources/associationmc.3232/" target="_blank" class="list-group-item">AssociationMc <i>(XenForo)</i></a>
<a rel="nofollow" href="https://open.vanillaforums.com/addon/crafatar-plugin" target="_blank" class="list-group-item">Crafatar Avatars <i>(Vanilla)</i></a>
<a rel="nofollow" href="https://www.spigotmc.org/resources/picture-login.4514/" target="_blank" class="list-group-item">Picture Login <i>(Spigot/Bukkit)</i></a>
<a href="https://github.com/crafatar/crafatar/wiki/Who-uses-crafatar%3F#other-services-using-crafatar" target="_blank" class="list-group-item">and many more…</a>
</div>
<hr>
<h4>Contact</h4>
<div class="list-group">
<a class="list-group-item" href="https://twitter.com/crafatar" target="_blank">@crafatar on Twitter</a>
<a class="list-group-item" href="https://github.com/crafatar/crafatar/issues" target="_blank">Issue tracker</a>
</div>
<% if (config.sponsor.sidebar) { %>
<hr>
<%- config.sponsor.sidebar %>
<% } %>
</div>
</div>
<footer id="footer">
<hr>
<div class="container row">
<p class="pull-right">Copyright Crafatar <%= new Date().getFullYear() %></p>
</div>
</footer>
</body>
</html>

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2392
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 B

View File

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

View File

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

View File

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

View File

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

View File

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

113
server.js
View File

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

View File

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

View File

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

View File

@ -1,54 +1,74 @@
var assert = require("assert");
var fs = require("fs");
var networking = require("../modules/networking");
var helpers = require("../modules/helpers");
var logging = require("../modules/logging");
var config = require("../modules/config");
var skins = require("../modules/skins");
var cache = require("../modules/cache");
var renders = require("../modules/renders");
var server = require("../server");
var cleaner = require("../modules/cleaner");
var request = require("request");
// we don't want tests to fail because of slow internet
config.http_timeout *= 3;
/* globals describe, it, before, after */
/* eslint no-loop-func:0 guard-for-in:0 */
// no spam
var logging = require("../lib/logging");
if (process.env.VERBOSE_TEST !== "true") {
logging.log = function() {};
logging.log = logging.debug = logging.warn = logging.error = function() {};
}
var networking = require("../lib/networking");
var helpers = require("../lib/helpers");
var request = require("request");
var config = require("../config");
var server = require("../lib/server");
var assert = require("assert");
var skins = require("../lib/skins");
var cache = require("../lib/cache");
var crc = require("crc").crc32;
var fs = require("fs");
// we don't want tests to fail because of slow internet
config.server.http_timeout *= 3;
var uuids = fs.readFileSync("test/uuids.txt").toString().split(/\r?\n/);
var names = fs.readFileSync("test/usernames.txt").toString().split(/\r?\n/);
// Get a random UUID + name in order to prevent rate limiting
// Get a random UUIDto prevent rate limiting
var uuid = uuids[Math.round(Math.random() * (uuids.length - 1))];
var name = names[Math.round(Math.random() * (names.length - 1))];
var rid = "TestReqID: ";
// Let's hope these will never be assigned
var steve_ids = [
"fffffff0" + "fffffff0" + "fffffff0" + "fffffff0",
"fffffff0" + "fffffff0" + "fffffff1" + "fffffff1",
"fffffff0" + "fffffff1" + "fffffff0" + "fffffff1",
"fffffff0" + "fffffff1" + "fffffff1" + "fffffff0",
"fffffff1" + "fffffff0" + "fffffff0" + "fffffff1",
"fffffff1" + "fffffff0" + "fffffff1" + "fffffff0",
"fffffff1" + "fffffff1" + "fffffff0" + "fffffff0",
"fffffff1" + "fffffff1" + "fffffff1" + "fffffff1",
];
// Let's hope these will never be assigned
var alex_ids = [
"fffffff0" + "fffffff0" + "fffffff0" + "fffffff1",
"fffffff0" + "fffffff0" + "fffffff1" + "fffffff0",
"fffffff0" + "fffffff1" + "fffffff0" + "fffffff0",
"fffffff0" + "fffffff1" + "fffffff1" + "fffffff1",
"fffffff1" + "fffffff0" + "fffffff0" + "fffffff0",
"fffffff1" + "fffffff0" + "fffffff1" + "fffffff1",
"fffffff1" + "fffffff1" + "fffffff0" + "fffffff1",
"fffffff1" + "fffffff1" + "fffffff1" + "fffffff0",
];
// generates a 12 character random string
function rid() {
return Math.random().toString(36).substring(2, 14);
}
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
var ids = [
uuid.toLowerCase(),
name.toLowerCase(),
name.toUpperCase(),
uuid.toUpperCase(),
];
describe("Crafatar", function() {
// we might have to make 2 HTTP requests
this.timeout(config.http_timeout * 2 + 50);
this.timeout(config.server.http_timeout * 2 + 50);
before(function() {
cache.get_redis().flushall();
// cause I don't know how big hard drives are these days
config.cleaning_disk_limit = Infinity;
config.cleaning_redis_limit = Infinity;
cleaner.run();
before(function(done) {
console.log("Flushing and waiting for redis ...");
cache.get_redis().flushall(function() {
console.log("Redis flushed!");
done();
});
});
describe("UUID/username", function() {
@ -60,18 +80,6 @@ describe("Crafatar", function() {
assert.strictEqual(helpers.id_valid(""), false);
done();
});
it("non-alphanumeric username is invalid", function(done) {
assert.strictEqual(helpers.id_valid("usernäme"), false);
done();
});
it("dashed username is invalid", function(done) {
assert.strictEqual(helpers.id_valid("user-name"), false);
done();
});
it(">16 length username is invalid", function(done) {
assert.strictEqual(helpers.id_valid("ThisNameIsTooLong"), false);
done();
});
it("lowercase uuid is valid", function(done) {
assert.strictEqual(helpers.id_valid("0098cb60fa8e427cb299793cbd302c9a"), true);
done();
@ -80,224 +88,507 @@ describe("Crafatar", function() {
assert.strictEqual(helpers.id_valid("1DCEF164FF0A47F2B9A691385C774EE7"), true);
done();
});
it("dashed uuid is valid", function(done) {
assert.strictEqual(helpers.id_valid("0098cb60-fa8e-427c-b299-793cbd302c9a"), true);
it("dashed uuid is not valid", function(done) {
assert.strictEqual(helpers.id_valid("0098cb60-fa8e-427c-b299-793cbd302c9a"), false);
done();
});
it("16 chars, underscored, capital, numbered username is valid", function(done) {
assert.strictEqual(helpers.id_valid("__niceUs3rname__"), true);
it("username is invalid", function(done) {
assert.strictEqual(helpers.id_valid("__niceUs3rname__"), false);
done();
});
it("1 char username is valid", function(done) {
assert.strictEqual(helpers.id_valid("a"), true);
it("username alex is invalid", function(done) {
assert.strictEqual(helpers.id_valid("alex"), false);
done();
});
it("username mhf_alex is invalid", function(done) {
assert.strictEqual(helpers.id_valid("mhf_alex"), false);
done();
});
it("username steve is invalid", function(done) {
assert.strictEqual(helpers.id_valid("steve"), false);
done();
});
it("username mhf_steve is invalid", function(done) {
assert.strictEqual(helpers.id_valid("mhf_steve"), false);
done();
});
it(">16 length username is invalid", function(done) {
assert.strictEqual(helpers.id_valid("ThisNameIsTooLong"), false);
done();
});
it("should not exist (uuid)", function(done) {
var number = getRandomInt(0, 9).toString();
networking.get_profile(rid, Array(33).join(number), function(err, profile) {
networking.get_profile(rid(), Array(33).join(number), function(err, profile) {
assert.ifError(err);
assert.strictEqual(profile, null);
done();
});
});
it("should not exist (username)", function(done) {
networking.get_username_url(rid, "Steve", 0, function(err, profile) {
assert.strictEqual(err, null);
done();
});
});
});
describe("Avatar", function() {
// profile "Alex" - hoping it'll never have a skin
var alex_uuid = "ec561538f3fd461daff5086b22154bce";
// profile "Steven" (Steve doesn't exist) - hoping it'll never have a skin
var steven_uuid = "b8ffc3d37dbf48278f69475f6690aabd";
it("uuid's account should exist, but skin should not", function(done) {
networking.get_profile(rid, alex_uuid, function(err, profile) {
assert.notStrictEqual(profile, null);
networking.get_uuid_url(profile, 1, function(url) {
assert.strictEqual(url, null);
for (var a in alex_ids) {
var alexid = alex_ids[a];
(function(alex_id) {
it("UUID " + alex_id + " should default to MHF_Alex", function(done) {
assert.strictEqual(skins.default_skin(alex_id), "mhf_alex");
done();
});
});
});
it("odd UUID should default to Alex", function(done) {
assert.strictEqual(skins.default_skin(alex_uuid), "alex");
done();
});
it("even UUID should default to Steve", function(done) {
assert.strictEqual(skins.default_skin(steven_uuid), "steve");
}(alexid));
}
for (var s in steve_ids) {
var steveid = steve_ids[s];
(function(steve_id) {
it("UUID " + steve_id + " should default to MHF_Steve", function(done) {
assert.strictEqual(skins.default_skin(steve_id), "mhf_steve");
done();
});
}(steveid));
}
});
describe("Errors", function() {
it("should time out on uuid info download", function(done) {
var original_timeout = config.http_timeout;
config.http_timeout = 1;
networking.get_profile(rid, "069a79f444e94726a5befca90e38aaf5", function(err, profile) {
assert.strictEqual(err.code, "ETIMEDOUT");
config.http_timeout = original_timeout;
done();
});
});
it("should time out on username info download", function(done) {
var original_timeout = config.http_timeout;
config.http_timeout = 1;
networking.get_username_url(rid, "jomo", 0, function(err, url) {
assert.strictEqual(err.code, "ETIMEDOUT");
config.http_timeout = original_timeout;
var original_timeout = config.server.http_timeout;
config.server.http_timeout = 1;
networking.get_profile(rid(), "069a79f444e94726a5befca90e38aaf5", function(err, profile) {
assert.notStrictEqual(["ETIMEDOUT", "ESOCKETTIMEDOUT"].indexOf(err.code), -1);
config.server.http_timeout = original_timeout;
done();
});
});
it("should time out on skin download", function(done) {
var original_timeout = config.http_timeout;
config.http_timeout = 1;
networking.get_from(rid, "http://textures.minecraft.net/texture/477be35554684c28bdeee4cf11c591d3c88afb77e0b98da893fd7bc318c65184", function(body, res, error) {
assert.strictEqual(error.code, "ETIMEDOUT");
config.http_timeout = original_timeout;
config.server.http_timeout = 1;
networking.get_from(rid(), config.endpoints.textures_url + "477be35554684c28bdeee4cf11c591d3c88afb77e0b98da893fd7bc318c65184", function(body, res, error) {
assert.notStrictEqual(["ETIMEDOUT", "ESOCKETTIMEDOUT"].indexOf(error.code), -1);
config.server.http_timeout = original_timeout;
done();
});
});
it("should not find the skin", function(done) {
assert.doesNotThrow(function() {
networking.get_from(rid, "http://textures.minecraft.net/texture/this-does-not-exist", function(img, response, err) {
networking.get_from(rid(), config.endpoints.textures_url + "this-does-not-exist", function(img, response, err) {
assert.strictEqual(err, null); // no error here, but it shouldn't throw exceptions
done();
});
});
});
it("should ignore file updates on invalid files", function(done) {
assert.doesNotThrow(function() {
cache.update_timestamp(rid, "0123456789abcdef0123456789abcdef", "invalid-file.png", false, function(err) {
done();
});
});
});
it("should not find the file", function(done) {
skins.open_skin(rid, "non/existent/path", function(err, img) {
assert.notStrictEqual(err, null);
skins.open_skin(rid(), "non/existent/path", function(err, img) {
assert(err);
done();
});
});
});
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.caching.browser);
}
// 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.strictEqual(body, '');
assert.equal(res.statusCode, 304);
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);
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();
});
});
// invalid method, we only allow GET and HEAD requests
it("should return a 405 (invalid method)", function(done) {
it("should return 405 Method Not Allowed for POST", function(done) {
request.post("http://localhost:3000", function(error, res, body) {
assert.equal(405, res.statusCode);
assert.ifError(error);
assert.strictEqual(res.statusCode, 405);
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();
});
});
});
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();
});
});
});
it("should return correct HTTP response for URL encoded URLs", function(done) {
var url = "http://localhost:3000/%61%76%61%74%61%72%73/%61%65%37%39%35%61%61%38%36%33%32%37%34%30%38%65%39%32%61%62%32%35%63%38%61%35%39%66%33%62%61%31"; // avatars/ae795aa86327408e92ab25c8a59f3ba1
request.get(url, function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 200);
assert_headers(res);
assert(res.headers.etag);
assert.strictEqual(res.headers["content-type"], "image/png");
assert(body);
done();
});
});
it("should not fail on simultaneous requests", function(done) {
var url = "http://localhost:3000/avatars/696a82ce41f44b51aa31b8709b8686f0";
// 10 requests at once
var requests = 10;
var finished = 0;
function partDone() {
finished++;
if (requests === finished) {
done();
}
}
function req() {
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"], "image/png");
assert(body);
partDone();
});
}
// make simultanous requests
for (var k = 0; k < requests; k++) {
req(k);
}
});
var server_tests = {
"avatar with existing uuid": {
url: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
crc32: [4264176600],
},
"avatar with existing dashed uuid": {
url: "http://localhost:3000/avatars/853c80ef-3c37-49fd-aa49938b674adae6?size=16",
crc32: [4264176600],
},
"avatar with non-existent uuid": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16",
crc32: [3348154329],
},
"avatar with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=mhf_alex",
crc32: [73899130],
},
"avatar with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0],
redirect: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
},
"avatar with non-existent uuid defaulting to url": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
crc32: [0],
redirect: "http://example.com/CaseSensitive",
},
"overlay avatar with existing uuid": {
url: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16&overlay",
crc32: [575355728],
},
"overlay avatar with non-existent uuid": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay",
crc32: [3348154329],
},
"overlay avatar with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay&default=mhf_alex",
crc32: [73899130],
},
"overlay avatar with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0],
redirect: "http://localhost:3000/avatars/853c80ef3c3749fdaa49938b674adae6?size=16",
},
"overlay avatar with non-existent uuid defaulting to url": {
url: "http://localhost:3000/avatars/00000000000000000000000000000000?size=16&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
crc32: [0],
redirect: "http://example.com/CaseSensitive",
},
"cape with existing uuid": {
url: "http://localhost:3000/capes/853c80ef3c3749fdaa49938b674adae6",
crc32: [985789174, 2099310578],
},
"cape with non-existent uuid": {
url: "http://localhost:3000/capes/00000000000000000000000000000000",
crc32: [0],
},
"cape with non-existent uuid defaulting to url": {
url: "http://localhost:3000/capes/00000000000000000000000000000000?default=http%3A%2F%2Fexample.com%2FCaseSensitive",
crc32: [0],
redirect: "http://example.com/CaseSensitive",
},
"skin with existing uuid": {
url: "http://localhost:3000/skins/853c80ef3c3749fdaa49938b674adae6",
crc32: [1759176487],
},
"skin with non-existent uuid": {
url: "http://localhost:3000/skins/00000000000000000000000000000000",
crc32: [1853029228],
},
"skin with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/skins/00000000000000000000000000000000?default=mhf_alex",
crc32: [427506205],
},
"skin with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/skins/00000000000000000000000000000000?size=16&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0],
redirect: "http://localhost:3000/skins/853c80ef3c3749fdaa49938b674adae6?size=16",
},
"skin with non-existent uuid defaulting to url": {
url: "http://localhost:3000/skins/00000000000000000000000000000000?default=http%3A%2F%2Fexample.com%2FCaseSensitive",
crc32: [0],
redirect: "http://example.com/CaseSensitive",
},
"head render with existing uuid": {
url: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2",
crc32: [1168786201],
},
"head render with non-existent uuid": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2",
crc32: [3800926063],
},
"head render with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=mhf_alex",
crc32: [4027858557],
},
"head render with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0],
redirect: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2",
},
"head render with non-existent uuid defaulting to url": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
crc32: [0],
redirect: "http://example.com/CaseSensitive",
},
"overlay head render with existing uuid": {
url: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay",
crc32: [2880579826],
},
"overlay head render with non-existent uuid": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&overlay",
crc32: [3800926063],
},
"overlay head render with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&overlay&default=mhf_alex",
crc32: [4027858557],
},
"overlay head with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&overlay&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0],
redirect: "http://localhost:3000/renders/head/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay=",
},
"overlay head render with non-existent uuid defaulting to url": {
url: "http://localhost:3000/renders/head/00000000000000000000000000000000?scale=2&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
crc32: [0],
redirect: "http://example.com/CaseSensitive",
},
"body render with existing uuid": {
url: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2",
crc32: [1144887125],
},
"body render with non-existent uuid": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2",
crc32: [996962526],
},
"body render with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=mhf_alex",
crc32: [4280894468],
},
"body render with non-existent uuid defaulting to uuid": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=853c80ef3c3749fdaa49938b674adae6",
crc32: [0],
redirect: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2",
},
"body render with non-existent uuid defaulting to url": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
crc32: [0],
redirect: "http://example.com/CaseSensitive",
},
"overlay body render with existing uuid": {
url: "http://localhost:3000/renders/body/853c80ef3c3749fdaa49938b674adae6?scale=2&overlay",
crc32: [1107696668],
},
"overlay body render with non-existent uuid": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay",
crc32: [996962526],
},
"overlay body render with non-existent uuid defaulting to mhf_alex": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay&default=mhf_alex",
crc32: [4280894468],
},
"overlay body render with non-existent uuid defaulting to url": {
url: "http://localhost:3000/renders/body/00000000000000000000000000000000?scale=2&overlay&default=http%3A%2F%2Fexample.com%2FCaseSensitive",
crc32: [0],
redirect: "http://example.com/CaseSensitive",
},
};
for (var description in server_tests) {
var loc = 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"]);
var hash = crc(body);
var matches = false;
for (var c = 0; c < location.crc32.length; c++) {
if (location.crc32[c] === hash) {
matches = true;
break;
}
}
try {
assert(matches);
} catch(e) {
throw new Error(hash + " != " + location.crc32 + " | " + body.toString("base64"));
}
assert.strictEqual(res.headers.location, location.redirect);
if (location.crc32[0] === 0) {
assert.strictEqual(res.statusCode, location.redirect ? 307 : 404);
assert.ifError(res.headers.etag); // etag must not be present on non-200
assert.strictEqual(res.headers["content-type"], "text/plain");
done();
} else {
assert.strictEqual(res.headers["content-type"], "image/png");
assert.strictEqual(res.statusCode, 200);
assert(res.headers.etag);
assert.strictEqual(res.headers.etag, '"' + hash + '"');
assert_cache(location.url, res.headers.etag, function() {
done();
});
}
});
});
}(loc));
}
it("should return 304 on server error", function(done) {
var original_debug = config.server.debug_enabled;
var original_timeout = config.server.http_timeout;
config.server.debug_enabled = false;
config.server.http_timeout = 1;
request.get({url: "http://localhost:3000/avatars/0000000000000000000000000000000f", headers: {"If-None-Match": '"some-etag"'}}, function(error, res, body) {
assert.ifError(error);
assert.strictEqual(body, '');
assert.strictEqual(res.statusCode, 304);
config.server.debug_enabled = original_debug;
config.server.http_timeout = original_timeout;
done();
});
});
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);
var size = config.avatars.max_size + 1;
request.get("http://localhost:3000/avatars/2d5aa9cdaeb049189930461fc9b91cc5?size=" + size, function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 422);
done();
});
});
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);
var scale = config.renders.max_scale + 1;
request.get("http://localhost:3000/renders/head/2d5aa9cdaeb049189930461fc9b91cc5?scale=" + scale, function(error, res, body) {
assert.ifError(error);
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/2d5aa9cdaeb049189930461fc9b91cc5", function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 422);
done();
});
});
// testing all paths for valid inputs
var locations = ["avatars", "skins", "renders/head"];
// testing all paths for Invalid UUID
var locations = ["avatars", "skins", "capes", "renders/body", "renders/head"];
for (var l in locations) {
var location = locations[l];
loc = 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) {
it("should return a 422 (invalid uuid " + location + ")", function(done) {
request.get("http://localhost:3000/" + location + "/thisisaninvaliduuid", function(error, res, body) {
assert.equal(422, res.statusCode);
assert.ifError(error);
assert.strictEqual(res.statusCode, 422);
done();
});
});
})(location);
it("should return a 404 (invalid path " + location + ")", function(done) {
request.get("http://localhost:3000/" + location + "/853c80ef3c3749fdaa49938b674adae6/invalid", function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 404);
done();
});
});
}(loc));
}
// 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);
it("should return /public resources", function(done) {
request.get("http://localhost:3000/javascript/crafatar.js", function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 200);
done();
});
});
})(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 404 (default steve image " + location + ")", function(done) {
request.get("http://localhost:3000/" + location + "/invalidjsvns?default=steve", function(error, res, body) {
assert.equal(404, res.statusCode);
it("should not allow path traversal on /public", function(done) {
request.get("http://localhost:3000/../server.js", function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 404);
done();
});
});
it("should 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);
done();
});
});
})(location);
}
after(function(done) {
server.close(function() {
it("should not allow encoded path traversal on /public", function(done) {
request.get("http://localhost:3000/%2E%2E/server.js", function(error, res, body) {
assert.ifError(error);
assert.strictEqual(res.statusCode, 404);
done();
});
});
@ -305,14 +596,14 @@ describe("Crafatar", function() {
// 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(rid, "md_5", 6, true, true, function(err, hash, img) {
it("should not fail (uuid, 32x64 skin)", function(done) {
helpers.get_render(rid(), "af74a02d19cb445bb07f6866a861f783", 6, true, true, function(err, hash, img) {
assert.strictEqual(err, null);
done();
});
});
it("should not fail (username, 64x64 skin)", function(done) {
helpers.get_render(rid, "Jake_0", 6, true, true, function(err, hash, img) {
it("should not fail (uuid, 64x64 skin)", function(done) {
helpers.get_render(rid(), "2d5aa9cdaeb049189930461fc9b91cc5", 6, true, true, function(err, hash, img) {
assert.strictEqual(err, null);
done();
});
@ -321,7 +612,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(), "61699b2ed3274a019f1e0ea8c3f06bc6", function(err, hash, status, img) {
assert.strictEqual(err, null);
done();
});
@ -330,13 +621,14 @@ describe("Crafatar", function() {
before(function() {
cache.get_redis().flushall();
});
helpers.get_cape(rid, "Dinnerbone", function(err, hash, img) {
helpers.get_cape(rid(), "61699b2ed3274a019f1e0ea8c3f06bc6", 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(), "2d5aa9cdaeb049189930461fc9b91cc5", function(err, hash, status, img) {
assert.ifError(err);
assert.strictEqual(img, null);
done();
});
@ -345,7 +637,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(), "2d5aa9cdaeb049189930461fc9b91cc5", function(err, hash, status, img) {
assert.strictEqual(err, null);
done();
});
@ -354,7 +646,7 @@ describe("Crafatar", function() {
before(function() {
cache.get_redis().flushall();
});
helpers.get_cape(rid, "Jake_0", function(err, hash, img) {
helpers.get_cape(rid(), "2d5aa9cdaeb049189930461fc9b91cc5", function(err, hash, status, img) {
assert.strictEqual(err, null);
done();
});
@ -362,48 +654,30 @@ describe("Crafatar", function() {
});
// DRY with uuid and username tests
for (var i in ids) {
var id = ids[i];
var id_type = id.length > 16 ? "uuid" : "name";
// needs an anonymous function because id and id_type aren't constant
(function(id, id_type) {
describe("Networking: Avatar", function() {
before(function() {
cache.get_redis().flushall();
console.log("\n\nRunning tests with " + id_type + " '" + id + "'\n\n");
});
it("should be downloaded", function(done) {
helpers.get_avatar(rid, id, false, 160, function(err, status, image) {
helpers.get_avatar(rid(), uuid, false, 160, function(err, status, image) {
assert.ifError(err);
assert.strictEqual(status, 2);
done();
});
});
it("should be cached", function(done) {
helpers.get_avatar(rid, id, false, 160, function(err, status, image) {
helpers.get_avatar(rid(), uuid, false, 160, function(err, status, image) {
assert.ifError(err);
assert.strictEqual(status === 0 || status === 1, true);
done();
});
});
if (id.length > 16) {
console.log("can't run 'checked' test due to Mojang's rate limits :(");
} else {
it("should be checked", function(done) {
var original_cache_time = config.local_cache_time;
config.local_cache_time = 0;
helpers.get_avatar(rid, id, false, 160, function(err, status, image) {
assert.strictEqual(status, 3);
config.local_cache_time = original_cache_time;
done();
});
});
}
});
describe("Networking: Skin", function() {
it("should not fail (uuid)", function(done) {
helpers.get_skin(rid, id, function(err, hash, img) {
helpers.get_skin(rid(), uuid, function(err, hash, status, img) {
assert.strictEqual(err, null);
done();
});
@ -412,14 +686,14 @@ describe("Crafatar", function() {
describe("Networking: Render", function() {
it("should not fail (full body)", function(done) {
helpers.get_render(rid, id, 6, true, true, function(err, hash, img) {
assert.strictEqual(err, null);
helpers.get_render(rid(), uuid, 6, true, true, function(err, hash, img) {
assert.ifError(err);
done();
});
});
it("should not fail (only head)", function(done) {
helpers.get_render(rid, id, 6, true, false, function(err, hash, img) {
assert.strictEqual(err, null);
helpers.get_render(rid(), uuid, 6, true, false, function(err, hash, img) {
assert.ifError(err);
done();
});
});
@ -427,8 +701,8 @@ describe("Crafatar", function() {
describe("Networking: Cape", function() {
it("should not fail (possible cape)", function(done) {
helpers.get_cape(rid, id, function(err, hash, img) {
assert.strictEqual(err, null);
helpers.get_cape(rid(), uuid, function(err, hash, status, img) {
assert.ifError(err);
done();
});
});
@ -440,22 +714,34 @@ describe("Crafatar", function() {
cache.get_redis().flushall();
});
if (id_type == "uuid") {
it("uuid should be rate limited", function(done) {
networking.get_profile(rid, id, function(err, profile) {
assert.strictEqual(profile.error, "TooManyRequestsException");
// Mojang has changed its rate limiting, so we no longer expect to hit the rate limit
// it("uuid SHOULD be rate limited", function(done) {
// networking.get_profile(rid(), uuid, function() {
// networking.get_profile(rid(), uuid, function(err, profile) {
// assert.strictEqual(err.toString(), "HTTP: 429");
// assert.strictEqual(profile, null);
// done();
// });
// });
// });
it("CloudFront rate limit is handled", function(done) {
var original_rate_limit = config.server.sessions_rate_limit;
config.server.sessions_rate_limit = 1;
networking.get_profile(rid(), uuid, function() {
networking.get_profile(rid(), uuid, function(err, profile) {
assert.strictEqual(err.code, "RATELIMIT");
config.server.sessions_rate_limit = original_rate_limit;
done();
});
});
} else {
it("username should NOT be rate limited (username)", function(done) {
helpers.get_avatar(rid, id, false, 160, function(err, status, image) {
assert.strictEqual(err, null);
});
});
after(function(done) {
server.close(function() {
cache.get_redis().quit();
done();
});
});
}
});
})(id, id_type);
}
});

View File

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

View File

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

12
www.js Normal file
View File

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