Compare commits
382 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e2a23ccbb | ||
| 41690f84c7 | |||
|
|
d6293cc73d | ||
|
|
c155c8d098 | ||
|
|
bba004acc7 | ||
|
|
9cb32a843f | ||
|
|
e44ebda56f | ||
|
|
fb4d24de6b | ||
|
|
59f27f0769 | ||
|
|
019ca37037 | ||
|
|
56765488e0 | ||
|
|
1328f98746 | ||
|
|
ef4b2f8005 | ||
|
|
fe5ce6b688 | ||
|
|
a6e8e6b0f9 | ||
|
|
29955a1765 | ||
|
|
265a98d404 | ||
|
|
624bf0e338 | ||
|
|
db565f86c8 | ||
|
|
0d2fe02cbc | ||
|
|
e69b3f38fb | ||
|
|
22309efba9 | ||
|
|
3bd76ad918 | ||
|
|
22448c098b | ||
|
|
7ad6f85aec | ||
|
|
b87be6f9f3 | ||
|
|
e0233f2899 | ||
|
|
14cbcae60c | ||
|
|
eae7745758 | ||
|
|
7f95a34e29 | ||
|
|
15a4f17560 | ||
|
|
d967db3ad4 | ||
|
|
d81e2777d2 | ||
|
|
ea1ae64283 | ||
|
|
5eb5b6fa5e | ||
|
|
e6373002a2 | ||
|
|
424a4ab93b | ||
|
|
16948de18d | ||
|
|
8e08e02272 | ||
|
|
c975cc793b | ||
|
|
b7c6f7dae1 | ||
|
|
cdc8c99a22 | ||
|
|
b3a9793b87 | ||
|
|
168457dfd9 | ||
|
|
1816b18b12 | ||
|
|
1a280b0fd7 | ||
|
|
dff58c66e7 | ||
|
|
a187fb26d4 | ||
|
|
ccc4e114c7 | ||
|
|
4fdbfb442b | ||
|
|
13386c96eb | ||
|
|
a25e01922e | ||
|
|
3130bdf9e9 | ||
|
|
305ed1c65f | ||
|
|
a65cc63ec8 | ||
|
|
4a2226be12 | ||
|
|
3b7b42a2f6 | ||
|
|
24cfc03811 | ||
|
|
c02d3d33e9 | ||
|
|
f1f3ba6709 | ||
|
|
6f1c414a4a | ||
|
|
deeaee1af6 | ||
|
|
57115202d2 | ||
|
|
c6c203fc5e | ||
|
|
f7b8fd4e8c | ||
|
|
f0b73b34d1 | ||
|
|
1d9176711f | ||
|
|
688a34029c | ||
|
|
cbe2b25835 | ||
|
|
b2d38d1093 | ||
|
|
aeded46d86 | ||
|
|
8a10807168 | ||
|
|
91a8facdc1 | ||
|
|
42f0da9281 | ||
|
|
508ea87ff7 | ||
|
|
c0857bd622 | ||
|
|
b5eb1a275e | ||
|
|
f668fee480 | ||
|
|
96aa543c72 | ||
|
|
40b7e76d77 | ||
|
|
5f179e6ec8 | ||
|
|
c3dc4edb94 | ||
|
|
75c3cc9e1a | ||
|
|
c2e6c8589d | ||
|
|
fe665459f5 | ||
|
|
b33cb76219 | ||
|
|
23948afae5 | ||
|
|
6c132f5c51 | ||
|
|
fca7d0d753 | ||
|
|
159060df77 | ||
|
|
7570b15320 | ||
|
|
a86a097562 | ||
|
|
aae4dbbbf6 | ||
|
|
9ed7fde061 | ||
|
|
d163109a34 | ||
|
|
d50a2d79eb | ||
|
|
5a18fa155e | ||
|
|
96b277b806 | ||
|
|
32577778b2 | ||
|
|
21fbf3f8d7 | ||
|
|
fe16821cb7 | ||
|
|
3620a63d14 | ||
|
|
22ecc6f8aa | ||
|
|
0940b50f2c | ||
|
|
ab781772c7 | ||
|
|
6594200500 | ||
|
|
1ec936ee31 | ||
|
|
183e8cfa9c | ||
|
|
f088c27012 | ||
|
|
f2dda3b939 | ||
|
|
f920811405 | ||
|
|
ee0473971a | ||
|
|
f50d48df51 | ||
|
|
88389f3b54 | ||
|
|
dea197093d | ||
|
|
3692b52145 | ||
|
|
6638fb7f0e | ||
|
|
965abead3e | ||
|
|
0fb17e5e5e | ||
|
|
29fa734148 | ||
|
|
5654d51eec | ||
|
|
c9f04e470b | ||
|
|
c39019074b | ||
|
|
4468b55b4f | ||
|
|
4f667cc99f | ||
|
|
875bb7c14a | ||
|
|
4e884b5497 | ||
|
|
d8e9282ee7 | ||
|
|
6a5967dfba | ||
|
|
e7242ce773 | ||
|
|
9ccb0151bc | ||
|
|
e8877c427a | ||
|
|
74ba828701 | ||
|
|
8367c1e519 | ||
|
|
b9f6a21942 | ||
|
|
5b1ad851ef | ||
|
|
fcdb03173a | ||
|
|
1f0f696151 | ||
|
|
2934b22da2 | ||
|
|
79512c5f90 | ||
|
|
1144b6755a | ||
|
|
caeb9a52fe | ||
|
|
5cb20b1105 | ||
|
|
7d02138c1e | ||
|
|
3c21a59c94 | ||
|
|
7eed1fa09b | ||
|
|
6e6eff5299 | ||
|
|
67f317aeac | ||
|
|
6e500d8652 | ||
|
|
4627aecd17 | ||
|
|
25c4912db9 | ||
|
|
abdacdc713 | ||
|
|
82727cb24d | ||
|
|
cb25872498 | ||
|
|
4302a95f6c | ||
|
|
fb0c70d648 | ||
|
|
d307aec221 | ||
|
|
e8ab044d91 | ||
|
|
36cb9f6b67 | ||
|
|
1e979042e6 | ||
|
|
815e9c5ae9 | ||
|
|
08d8211468 | ||
|
|
8a59757345 | ||
|
|
c915c6699f | ||
|
|
44a6a4961d | ||
|
|
3f06992cc1 | ||
|
|
ab64f56c26 | ||
|
|
503e9eae4a | ||
|
|
863402fb97 | ||
|
|
c6f4b038b2 | ||
|
|
8626ed212a | ||
|
|
65e145925c | ||
|
|
ddf2cb24de | ||
|
|
8d651ca5dc | ||
|
|
f0055e7b0a | ||
|
|
4a6902adac | ||
|
|
70900a249b | ||
|
|
882b0753bc | ||
|
|
f69eb85c58 | ||
|
|
18d054e664 | ||
|
|
e844c05dd2 | ||
|
|
9d46c1c768 | ||
|
|
f0df78e6a9 | ||
|
|
a3cbedb859 | ||
|
|
bf1e26d2c5 | ||
|
|
b0f50cbed0 | ||
|
|
8b2ccf3368 | ||
|
|
01ce06cd22 | ||
|
|
7714e0e0ef | ||
|
|
06caf589ab | ||
|
|
83defa6885 | ||
|
|
ecfec6a407 | ||
|
|
fd4fb0764c | ||
|
|
6fbfd6c355 | ||
|
|
c8d74d47be | ||
|
|
5e1e7f3701 | ||
|
|
5e7d116364 | ||
|
|
1ecf3c0122 | ||
|
|
cc2840ae4b | ||
|
|
d49f7279b3 | ||
|
|
a15cb20144 | ||
|
|
06895cdd81 | ||
|
|
9cdca6acda | ||
|
|
26f6b089ef | ||
|
|
b97087c099 | ||
|
|
750d741308 | ||
|
|
7e77142b29 | ||
|
|
8ddc300a11 | ||
|
|
6a630f23b9 | ||
|
|
6d12ed685b | ||
|
|
fe12901f41 | ||
|
|
47a978df6c | ||
|
|
90022d9e6c | ||
|
|
b4ae89832a | ||
|
|
841eb39f05 | ||
|
|
3a61e15abf | ||
|
|
ccc7314ea0 | ||
|
|
5f0e16897d | ||
|
|
5aaf075f94 | ||
|
|
52098ca2f8 | ||
|
|
2e738f8b40 | ||
|
|
755cc74170 | ||
|
|
78f2f2027f | ||
|
|
5f703eda70 | ||
|
|
6746459c8d | ||
|
|
e56f300d3e | ||
|
|
ac9cd93c8e | ||
|
|
3156423209 | ||
|
|
a928952dc4 | ||
|
|
9ed431d7ad | ||
|
|
4d949362be | ||
|
|
b460260bae | ||
|
|
0b58b3a4d1 | ||
|
|
85e7b4b571 | ||
|
|
72708ca590 | ||
|
|
79da225b9f | ||
|
|
49b4ae1a6e | ||
|
|
d6a9f7c71a | ||
|
|
442dee0280 | ||
|
|
6213344090 | ||
|
|
3edf89e884 | ||
|
|
c2d2644bbe | ||
|
|
256edd622f | ||
|
|
fd647c5953 | ||
|
|
db72d05680 | ||
|
|
7f4fa0dea2 | ||
|
|
2515575f25 | ||
|
|
607dcaf6e5 | ||
|
|
fc5ab113db | ||
|
|
d0689c9e3b | ||
|
|
c2e2a98b82 | ||
|
|
b1cdf61e4b | ||
|
|
d71d99fa9a | ||
|
|
7e8c65fb33 | ||
|
|
f4e31eab4f | ||
|
|
4ed0b75c9d | ||
|
|
5de8c77ffb | ||
|
|
53e571bd6e | ||
|
|
73af64ef2b | ||
|
|
44cff242d7 | ||
|
|
72840433cb | ||
|
|
d56b10955e | ||
|
|
8c022e5cb6 | ||
|
|
f9b7ffb5b7 | ||
|
|
3e4f150262 | ||
|
|
fa8e719de3 | ||
|
|
34f94bf9b5 | ||
|
|
20b6fd55aa | ||
|
|
2d71d2954e | ||
|
|
e9071b123c | ||
|
|
1f4e5430b7 | ||
|
|
0153640422 | ||
|
|
a2e0edc491 | ||
|
|
2eb1112c3b | ||
|
|
d2ab7b87ad | ||
|
|
8c39d0c017 | ||
|
|
f1fd92f1cf | ||
|
|
58a2f0662d | ||
|
|
7b5a871c5a | ||
|
|
f36cfa7898 | ||
|
|
c54e3020e9 | ||
|
|
288657107e | ||
|
|
679e72759a | ||
|
|
0ec97e9ba1 | ||
|
|
11f580bb95 | ||
|
|
01049cb34d | ||
|
|
189698bed9 | ||
|
|
7f8da3abe2 | ||
|
|
94322f3a77 | ||
|
|
51b7d19cb0 | ||
|
|
bb4de15ff2 | ||
|
|
c93ffa5a79 | ||
|
|
5ef3c4f59c | ||
|
|
fd6fd0f1bd | ||
|
|
add4d1abac | ||
|
|
b7929d06d3 | ||
|
|
9c5e5ff9a7 | ||
|
|
9a8dcbdff1 | ||
|
|
e1f1fe7c57 | ||
|
|
6f94af7a4a | ||
|
|
fd18c70bec | ||
|
|
9c59d302c9 | ||
|
|
6273e3bcc8 | ||
|
|
00f6c28cfc | ||
|
|
d27eb0049f | ||
|
|
d1e174405a | ||
|
|
15afa940f0 | ||
|
|
46d10fdc81 | ||
|
|
ed25d30ff0 | ||
|
|
9f04fbc136 | ||
|
|
67e635dbbb | ||
|
|
deeb72770e | ||
|
|
f48a9e66cf | ||
|
|
d8303eb1a6 | ||
|
|
b039e97fa7 | ||
|
|
6e41423b7a | ||
|
|
189d1c7010 | ||
|
|
2d1876b6b7 | ||
|
|
89a80eeaad | ||
|
|
14769f6f36 | ||
|
|
b2d31c5b7e | ||
|
|
aeffc8fc51 | ||
|
|
4286b35775 | ||
|
|
5c53694f9d | ||
|
|
23080370b2 | ||
|
|
d981b34d0b | ||
|
|
8ad35aeea2 | ||
|
|
02092aec08 | ||
|
|
efd9c0ccee | ||
|
|
79ab296f1f | ||
|
|
22ea5a7e27 | ||
|
|
1464ec81f4 | ||
|
|
34adbd32d7 | ||
|
|
c8dad9dfbb | ||
|
|
3cdcccde57 | ||
|
|
7ca43e3cd9 | ||
|
|
cf1119e2cb | ||
|
|
69f0ee23be | ||
|
|
2e66e5c794 | ||
|
|
244f90c4c7 | ||
|
|
13169be774 | ||
|
|
8971e3c02b | ||
|
|
a947b02c87 | ||
|
|
0b0882e63d | ||
|
|
8dc55442b1 | ||
|
|
d025a3004d | ||
|
|
0b687d8f8e | ||
|
|
71a13c3bab | ||
|
|
3cbf73b0d7 | ||
|
|
fce58722c8 | ||
|
|
9d2fe0c454 | ||
|
|
f6c4e62846 | ||
|
|
67fd75b91e | ||
|
|
82170c1d09 | ||
|
|
e28451f8e7 | ||
|
|
b84a65fd8e | ||
|
|
a3a77962b3 | ||
|
|
a22454e6e4 | ||
|
|
9e3ef8087e | ||
|
|
eeb1939235 | ||
|
|
4dec71c75e | ||
|
|
af03fb63f8 | ||
|
|
7520957b93 | ||
|
|
48c2a55995 | ||
|
|
2780ae05d3 | ||
|
|
2cb197a717 | ||
|
|
0326000b42 | ||
|
|
d4b0a335c4 | ||
|
|
13360430d9 | ||
|
|
8e8ff1fe39 | ||
|
|
a294a7c57f | ||
|
|
0b97aeee8d | ||
|
|
4c7da4940e | ||
|
|
dfaa79b9c7 | ||
|
|
94fe61e076 | ||
|
|
abaf3f77aa | ||
|
|
912b1b2ba7 | ||
|
|
a49a1c7649 | ||
|
|
48092d0422 | ||
|
|
f984b20344 | ||
|
|
2972e818c7 | ||
|
|
9c8df06c6b |
@ -1,2 +0,0 @@
|
||||
https://github.com/mojodna/heroku-buildpack-cairo.git
|
||||
https://github.com/heroku/heroku-buildpack-nodejs.git
|
||||
6
.dockerignore
Normal file
@ -0,0 +1,6 @@
|
||||
.*
|
||||
*.md
|
||||
Dockerfile
|
||||
LICENSE
|
||||
images/
|
||||
node_modules/
|
||||
@ -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
@ -1,9 +1,3 @@
|
||||
images/*/*.png
|
||||
*.log
|
||||
node_modules/
|
||||
.DS_Store
|
||||
*.rdb
|
||||
coverage/
|
||||
modules/config.js
|
||||
undefined*.png
|
||||
*.sublime-*
|
||||
|
||||
17
.travis.yml
@ -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
@ -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
|
||||
4
LICENSE
@ -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
|
||||
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
98
README.md
@ -1,65 +1,85 @@
|
||||
# Crafatar [](https://travis-ci.org/crafatar/crafatar/) [](https://coveralls.io/r/crafatar/crafatar) [](https://codeclimate.com/github/crafatar/crafatar)
|
||||
[](https://david-dm.org/crafatar/crafatar) [](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
|
||||
[](https://travis-ci.org/crafatar/crafatar/) [](https://coveralls.io/r/crafatar/crafatar) [](https://codeclimate.com/github/crafatar/crafatar) [](https://david-dm.org/crafatar/crafatar) [](https://david-dm.org/crafatar/crafatar#info=devDependencies) [](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 [](https://github.com/crafatar/crafatar/labels/help%20wanted) show where we could especially need your help!
|
||||
|
||||
# Examples
|
||||
|
||||
| | | | |
|
||||
| :---: | :---: | :---: | :---: |
|
||||
|  |  |  |  |  |
|
||||
|  |  |  |  |  |
|
||||
|  |  |  |  |  |
|
||||
|  |  |  |  |  |
|
||||
|
||||
    
|
||||
## Usage / Documentation
|
||||
|
||||
Please [visit the website](https://crafatar.com) for details.
|
||||
|
||||
## Contact
|
||||
|
||||
* You can follow us on [@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
|
||||
[](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:
|
||||
```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
|
||||
```
|
||||
|
||||
```docker
|
||||
-v /var/lib/crafatar/images:/app/images
|
||||
-v /var/log/crafatar:/app/logs
|
||||
```
|
||||
0. Deploy with ENV config:
|
||||
## Manual
|
||||
|
||||
```bash
|
||||
PORT=5000
|
||||
BIND=0.0.0.0
|
||||
```
|
||||
- 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`
|
||||
|
||||
#### 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)
|
||||
Crafatar is now available at http://0.0.0.0:3000.
|
||||
|
||||
## Configration / Environment variables
|
||||
|
||||
## Tests
|
||||
```shell
|
||||
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
|
||||
```
|
||||
19
app.json
@ -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"
|
||||
]
|
||||
}
|
||||
21
bin/www.js
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
|
After Width: | Height: | Size: 1.1 KiB |
BIN
lib/public/favicon.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 150 B After Width: | Height: | Size: 150 B |
|
Before Width: | Height: | Size: 997 B After Width: | Height: | Size: 997 B |
|
Before Width: | Height: | Size: 222 B After Width: | Height: | Size: 222 B |
|
Before Width: | Height: | Size: 835 B After Width: | Height: | Size: 835 B |
77
lib/public/javascript/crafatar.js
Normal 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 can’t 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. <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
|
After Width: | Height: | Size: 8.3 KiB |
5
lib/public/stylesheets/bootstrap.min.css
vendored
Normal 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,185 +165,88 @@ 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);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
260
lib/renders.js
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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 <a href="https://crafatar.com">Crafatar</a> 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>
|
||||
165
modules/cache.js
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
126
modules/skins.js
@ -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
50
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 428 B |
|
Before Width: | Height: | Size: 830 B |
|
Before Width: | Height: | Size: 371 B |
@ -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);
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
@ -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);
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
@ -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
@ -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);
|
||||
}
|
||||
@ -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
|
||||
68
test/bulk.sh
@ -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
|
||||
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"
|
||||
}
|
||||
|
||||
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
|
||||
curl -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" "http://$host/avatars/$uuid?size=$size$helm"
|
||||
done
|
||||
}
|
||||
|
||||
bulk() {
|
||||
trap return INT # return from this function on Ctrl+C
|
||||
get_ids | while read id; do
|
||||
if [ "$async" = "false" ]; then
|
||||
curl -H "Host: $hostname" -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" -- "$host/avatars/$id?overlay"
|
||||
else
|
||||
curl -H "Host: $hostname" -sSL -o /dev/null -w "%{url_effective} %{http_code} %{time_total}s\\n" -- "$host/avatars/$id?overlay" &
|
||||
sleep "$interval"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
while [ $# != 0 ]; do
|
||||
case "$1" in
|
||||
-s)
|
||||
async="false";;
|
||||
-r)
|
||||
random="true";;
|
||||
-i)
|
||||
interval="$2"
|
||||
shift;;
|
||||
*)
|
||||
[ -n "$host" ] && usage
|
||||
host="$1";;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[ -z "$host" ] && usage
|
||||
|
||||
time bulk
|
||||
858
test/test.js
@ -4424,4 +4424,4 @@ ffdd082bf54e415b943a8713f2885913
|
||||
ffe0be5f0cab4b3785f67974c23660bb
|
||||
ffe3d4c861354928b932794d85a30567
|
||||
ffe72a222ac9463d81d3ee5eafb7f68e
|
||||
fff854a189644f12b92764fdb4573f8b
|
||||
fff854a189644f12b92764fdb4573f8b
|
||||
|
||||
383
views/index.jade
@ -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&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")
|
||||
@ -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
@ -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();
|
||||