Giter VIP home page Giter VIP logo

chrly's Introduction

Chrly

Written in Go Build Status Coverage Keep a Changelog Software License

Chrly is a lightweight implementation of Minecraft skins system server with ability to proxy requests to Mojang's skins system. It's packaged and distributed as a Docker image and can be downloaded from Dockerhub. App is written in Go, can withstand heavy loads and is production ready.

Installation

You can easily install Chrly using docker-compose. The configuration below (save it as docker-compose.yml) can be used to start a Chrly server. It relies on CHRLY_SECRET and CHRLY_SIGNING_KEY environment variables that you must set before running docker-compose up -d. Other possible variables are described below.

version: '2'
services:
  app:
    image: elyby/chrly
    hostname: chrly0
    restart: always
    links:
      - redis
    volumes:
      - ./data/capes:/data/capes
    ports:
      - "80:80"
    environment:
      CHRLY_SECRET: replace_this_value_in_production
      CHRLY_SIGNING_KEY: base64:LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT3dJQkFBSkJBTmJVcFZDWmtNS3BmdllaMDhXM2x1bWRBYVl4TEJubVVEbHpIQlFIM0RwWWVmNVdDTzMyClREVTZmZUlKNThBMGxBeXdndFo0d3dpMmRHSE96LzFoQXZjQ0F3RUFBUUpBSXRheFNIVGU2UEtieUVVLzlweGoKT05kaFlSWXdWTExvNTZnbk1ZaGt5b0VxYWFNc2ZvdjhoaG9lcGtZWkJNdlpGQjJiRE9zUTJTYUorRTJlaUJPNApBUUloQVBzc1MwK0JSOXcwYk9kbWpHcW1kRTlOck41VUpRY09XMTNzMjkrNlF6VUJBaUVBMnZXT2VwQTVBcGl1CnBFQTNwd29HZGtWQ3JOU25uS2pEUXpEWEJucGQzL2NDSUVGTmQ5c1k0cVVHNEZXZFhONlJubVhMN1NqMHVaZkgKRE13enU4ckVNNXNCQWlFQWh2ZG9ETnFMbWJNZHEzYytGc1BTT2VMMWQyMVpwL0pLOGtiUHRGbUhOZjhDSVFEVgo2RlNaRHd2V2Z1eGFNN0JzeWNRT05rakRCVFBOdStscWN0SkJHbkJ2M0E9PQotLS0tLUVORCBSU0EgUFJJVkFURSBLRVktLS0tLQo=

  redis:
    image: redis:4.0-32bit
    restart: always
    volumes:
      - ./data/redis:/data

Tip: to generate a value for the CHRLY_SIGNING_KEY use the command below and then join it with a base64: prefix.

openssl genrsa 4096 | base64 -w0

Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to the host machine to do not lose data on container recreations.

Config

Application's configuration is based on the environment variables. You can adjust config by modifying environment key inside your docker-compose.yml file. After value will have been changed, container should be stopped and recreated. If environment variables have been changed, Docker will automatically recreate the container, so you only need to up it again:

docker-compose up -d app

Variables to adjust:

ENV Description Example
STORAGE_REDIS_HOST By default, Chrly tries to connect to the redis host (by service name in docker-compose configuration). localhost
STORAGE_REDIS_PORT Specifies the Redis connection port. 6379
STORAGE_REDIS_POOL By default, Chrly creates pool with 10 connection, but you may want to increase it 20
STATSD_ADDR StatsD can be used to collect metrics localhost:8125
SENTRY_DSN Sentry can be used to collect app errors https://public:[email protected]/1
QUEUE_STRATEGY Sets the strategy for the queue in the batch provider of Mojang UUIDs. Allowed values are periodic and full-bus (see #24). periodic
QUEUE_LOOP_DELAY Parameter is sets the delay before each iteration of the Mojang's textures queue (Go's duration) 3s200ms
QUEUE_BATCH_SIZE Sets the count of usernames, which will be sent to the Mojang's API to exchange them to their UUIDs. The current limit is 10, but it may change in the future, so you may want to adjust it. 10
MOJANG_TEXTURES_ENABLED Allows to completely disable Mojang textures provider for unknown usernames. Enabled by default. true
MOJANG_TEXTURES_UUIDS_PROVIDER_DRIVER Specifies the preferred provider of the Mojang's UUIDs. Takes remote value. In any other case, the local queue will be used. remote
MOJANG_TEXTURES_UUIDS_PROVIDER_URL When the UUIDs driver set to remote, sets the remote URL. The trailing slash won't cause any problems. http://remote-provider.com/api/worker/mojang-uuid
MOJANG_API_BASE_URL Allows you to spoof the Mojang's API server address. https://api.mojang.com
MOJANG_SESSION_SERVER_BASE_URL Allows you to spoof the Mojang's Session server address. https://sessionserver.mojang.com
TEXTURES_EXTRA_PARAM_NAME Sets the name of the extra property in the signed textures response. your-name
TEXTURES_EXTRA_PARAM_VALUE Sets the value of the extra property in the signed textures response. your awesome joke!

If something goes wrong, you can always access logs by executing docker-compose logs -f app.

Endpoints

Each endpoint that accepts username as a part of an url takes it case-insensitive. The .png postfix can be omitted.

GET /skins/{username}.png

This endpoint responds to requested username with a skin texture. If user's skin was set as texture's link, then it'll respond with the 301 redirect to that url. If the skin entry isn't found, it'll request textures information from Mojang's API and if it has a skin, than it'll return a 301 redirect to it.

GET /cloaks/{username}.png

It responds to requested username with a cape texture. If the cape entry isn't found, it'll request textures information from Mojang's API and if it has a cape, than it'll return a 301 redirect to it.

GET /textures/{username}

This endpoint forms response payloads as if it was the textures' property, but without base64 encoding. For example:

{
    "SKIN": {
        "url": "http://example.com/skin.png",
        "metadata": {
            "model": "slim"
        }
    },
    "CAPE": {
        "url": "http://example.com/cape.png"
    }
}

If both the skin and the cape entries aren't found, it'll request textures information from Mojang's API and if it has a textures property, than it'll return decoded contents.

That request is handy in case when your server implements authentication for a game server (e.g. join/hasJoined operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request to the Chrly server and put the result in your hasJoined response.

GET /profile/{username}

This endpoint behaves exactly like the Mojang's UUID -> Profile + Skin/Cape endpoint, but using a username instead of the UUID. Just like in the Mojang's API, you can append ?unsigned=false part to URL to sign the textures property. If the textures for the requested username aren't found, it'll request them through the Mojang's API, but the Mojang's signature will be discarded and the textures will be re-signed using the signature key for your Chrly instance.

Response example:

{
    "id": "0f657aa8bfbe415db7005750090d3af3",
    "name": "username",
    "properties": [
        {
            "name": "textures",
            "signature": "textures signature value",
            "value": "base64 encoded value"
        },
        {
            "name": "chrly",
            "signature": "custom property signature value",
            "value": "how do you tame a horse in Minecraft?"
        }
    ]
}

The base64 value string for the textures property decoded:

{
    "timestamp": 1614387238630,
    "profileId": "0f657aa8bfbe415db7005750090d3af3",
    "profileName": "username",
    "textures": {
        "SKIN": {
            "url": "http://example.com/skin.png"
        },
        "CAPE": {
            "url": "http://example.com/cape.png"
        }
    }
}

If username can't be found locally and can't be obtained from the Mojang's API, empty response with 204 status code will be sent.

Note that this endpoint will try to use the UUID for the stored profile in the database. This is an edge case, related to the situation where the user is available in the database but has no textures, which caused them to be retrieved from the Mojang's API.

GET /signature-verification-key.der

This endpoint returns a public key that can be used to verify textures signatures. The key is provided in DER format, so it can be used directly in the Authlib, without modifying the signature checking algorithm.

GET /signature-verification-key.pem

The same endpoint as the previous one, except that it returns the key in PEM format.

GET /textures/signed/{username}

Actually, this is the Ely.by's feature called Server Skins System, but if you have your own source of Mojang's signatures, then you can pass it with textures and it'll be displayed in response of this endpoint. Received response should be directly sent to the client without any modification via game server API.

Response example:

{
    "id": "0f657aa8bfbe415db7005750090d3af3",
    "name": "username",
    "properties": [
        {
            "name": "textures",
            "signature": "signature value",
            "value": "base64 encoded value"
        },
        {
            "name": "chrly",
            "value": "how do you tame a horse in Minecraft?"
        }
    ]
}

If there is no requested username or mojangSignature field isn't set, 204 status code will be sent.

You can adjust URL to /textures/signed/{username}?proxy=true to obtain textures information for provided username from Mojang's API. The textures will contain unmodified json with addition property with name "chrly" as shown in the example above.

GET /skins?name={username}

Equivalent of the GET /skins/{username}.png, but constructed especially for old Minecraft versions, where username placeholder wasn't used.

GET /cloaks?name={username}

Equivalent of the GET /cloaks/{username}.png, but constructed especially for old Minecraft versions, where username placeholder wasn't used.

Records manipulating API

Each request to the internal API should be performed with the Bearer authorization header. Example curl request:

curl -X POST -i http://chrly.domain.com/api/skins \
  -H "Authorization: Bearer Ym9zY236Ym9zY28="

You can obtain token by executing docker-compose run --rm app token.

POST /api/skins

Endpoint allows you to create or update skin record for a username.

The request body must be encoded as application/x-www-form-urlencoded.

Request params:

Field Type Description
identityId int Unique record identifier.
username string Username. Case insensitive.
uuid uuid UUID of the user.
skinId int Skin identifier.
is1_8 bool Does the skin have the new format (64x64).
isSlim bool Does skin have slim arms (Alex model).
mojangTextures string Mojang textures field. It must be a base64 encoded json string. Not required.
mojangSignature string Signature for Mojang textures, which is required when mojangTextures passed.
url string Actual url of the skin.

Important: all parameters are always read at least as their default values. So, if you only want to update the username and not pass the skin data it will reset all skin information. If you want to keep the data, you should always pass the full set of parameters.

If successful you'll receive 201 status code. In the case of failure there will be 400 status code and errors list as json:

{
    "errors": {
        "identityId": [
            "The identityId field must be numeric"
        ]
    }
}

DELETE /api/skins/id:{identityId}

Performs record removal by identity id. Request body is not required. On success you will receive 204 status code. On failure it'll be 404 with the json body:

{
    "error": "Cannot find record for requested user id"
}

DELETE /api/skins/{username}

Same endpoint as above but it removes record by identity's username. Have the same behavior, but in case of failure response will be:

{
    "error": "Cannot find record for requested username"
}

Worker mode

The worker mode can be used in cooperation with the remote server mode to exchange Mojang usernames to UUIDs. This mode by itself doesn't solve the problem of extremely strict limits on the number of requests to the Mojang's API. But with a proxying load balancer (e.g. HAProxy, Nginx, etc.) it's easy to build a cluster of workers, which will multiply the bandwidth of the exchanging usernames to its UUIDs.

The instructions for setting up a proxy load balancer are outside the context of this documentation, but you get the idea ;)

GET /api/worker/mojang-uuid/{username}

Performs batch usernames exchange to UUIDs and returns the result in the same format as it returns from the Mojang's API:

{
    "id": "3e3ee6c35afa48abb61e8cd8c42fc0d9",
    "name": "ErickSkrauch"
}

Note: the results aren't cached.

Health check

GET /healthcheck

This endpoint can be used to programmatically check the status of the server. If all internal checks are successful, the server will return 200 status code with the following body:

{
    "status": "OK"
}

If any of the checks fails, the server will return 503 status code with the following body:

{
    "status": "Service Unavailable",
    "errors": {
        "mojang-batch-uuids-provider-queue-length": "the maximum number of tasks in the queue has been exceeded"
    }
}

Development

First of all you should install the latest stable version of Go and set GOPATH environment variable.

Then you must fork this repository. Now follow these steps:

# Get the source code
git clone https://github.com/elyby/chrly.git
# Switch to the project folder
cd chrly
# Install dependencies
go mod download
# Add your fork link as a remote
git remote add fork [email protected]:your-username/chrly.git
# Create a new branch for your task
git checkout -b iss-123

You only need to execute go run main.go to run the project, but without Redis database and a secret key it won't work for very long. You have to export CHRLY_SECRET environment variable globally or pass it via env:

env CHRLY_SECRET=some_local_secret go run main.go serve

Redis can be installed manually, but if you have Docker installed, you can run predefined docker-compose service. Simply execute the next commands:

cp docker-compose.dev.yml docker-compose.yml
docker-compose up -d

If your Redis instance isn't located at the localhost, you can change host by editing environment variable STORAGE_REDIS_HOST.

After all of that go run main.go serve should successfully start the application. To run tests execute go test ./....

chrly's People

Contributors

erickskrauch avatar kolfoxy avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar

chrly's Issues

Server-side cropping for pre-1.8 textures

Hi, and first thank you for this convenient service.

I have a proposal: versions before 1.8 use 64x32 skins and expect the API endpoint to return 64x32 texture, failing to properly render it in case a 64x64 skin is returned.

To address that, I suggest cropping images before serving them to the client. This is as simple as just removing bottom 64x32 part of the image, additionally it can be possible to fix slim texture by simply mirroring one pixel wide strip on the arms so it doesn't cause black areas on the skin.

If you approve the idea, I'm ready to work on the necessary changes and submit a PR.

Change StatsD prefix to "chrly"

Now it usesely.skinsystem.{hostname}.app., but since this is a separate independent project, there should be no Ely.by mentioning.

Potential import collision: import path should be "gopkg.in/h2non/gock.v1", not "github.com/h2non/gock".

Background

The h2non/gock has already renamed it’s import path from "github.com/h2non/gock" to "gopkg.in/h2non/gock.v1".
As README of h2non/gock v1.0.14 said, downstream repos should use "gopkg.in/h2non/gock.v1" to get or import h2non/gock.

Installation
> go get -u gopkg.in/h2non/gock.v1

Examples
See examples directory for more featured use cases.
Simple mocking via tests
package test
import (
  "github.com/nbio/st"
  "gopkg.in/h2non/gock.v1"
  "io/ioutil"
  "net/http"
  "testing"
)
…

But elyby/chrly still used the old path:
https://github.com/elyby/chrly/blob/master/Gopkg.lock#L92

[[projects]]
  digest = "1:5eeb4bfc6db411dbb34a6d9e5d49a9956b160d59fd004ee8f03fe53c9605c082"
  name = "github.com/h2non/gock"
  packages = ["."]
  pruneopts = ""
  revision = "ba88c4862a27596539531ce469478a91bc5a0511"
  version = "v1.0.14"

When you use the old path "github.com/h2non/gock" to import the h2non/gock, it will be very easy to reintroduce h2non/gock through the import statements "import gopkg.in/h2non/gock.v1" in the go source file of h2non/gock.
https://github.com/h2non/gock/blob/v1.0.14/_examples/custom_matcher/matcher.go#L5

package main
import (
	"fmt"
	"gopkg.in/h2non/gock.v1"
	"net/http"
)
…

The "gopkg.in/h2non/gock.v1" and "github.com/h2non/gock" are the same repos. This will work in isolation, bring about potential risks and problems.

Solution

Replace all the old import paths, change "github.com/h2non/gock" to "gopkg.in/h2non/gock.v1".
Where did you import it: https://github.com/elyby/chrly/search?q=github.com%2Fh2non%2Fgock&unscoped_q=github.com%2Fh2non%2Fgock

Allow to store profile information without textures

Right now it's not possible to remove skin without removing a whole user profile. But we should respond with a profile with empty textures when the profile at least exists. We can implement it there, but in the case when the profile has been requested with signed textures, it will be not possible to sign them in place.

So the solution will be to allow the upload of user information without skin and correctly handle it when forming responses.

Skin system for proxy server isn't functioning at all.

Screenshot 2024-05-28 185230
Other players who aren't ely.by users cannot see your skin, I have installed the server plugin just so it would allow everyone to see each other but it seems to not function at all.
I have tried using it on the backend servers only (Paper)
and the proxy server (Velocity with Snap! BungeeCord to Velocity plugin translator)
I have also gave the plugin an auth key.

Restore Mojang skins proxy

Since Mojang disabled skins.minecraft.net API, Chrly doesn't proxy skins if skin not found in data storage.


We investigated current rate limits for Mojang API. From documentation we know that all public APIs are rate limited and currently set at 600 requests per 10 minutes. And it's true for the biggest part of endpoints, but not for UUID -> Profile + Skin/Cape endpoint. For this point there such note as "This has a much stricter rate limit: You can request the same profile once per minute, however you can send as many unique requests as you like". And it's confirmed: we easily performed 19k requests from the one IP for about a minute (Mojang, if it affects you, we are really sorry. You know, it's all for science!).


So here is our new solution.

Chrly must organize some processing queue. In this queue Chrly will send request to Playernames -> UUIDs endpoint to exchange usernames to actual uuids. These requests shouldn't be triggered more than once per second and contain more than 100 nicknames. After uuids received, we can exchange them to textures via UUID -> Profile + Skin/Cape endpoint. This request can be performed without any queue: just as fast as possible.

Data obviously should be cached, but there appear some questions:

  • Should we cache username -> textures and always respond it or should we cache username -> uuid and request textures every time to immediately handle textures changes? It's important, 'cause if we will have rps over 100 (at daytime (UTC+3:00) Ely.by have a way more rps), than we will be unable to check all textures and some users will don't have skins.

  • Another question is should we use primary storage (Redis) or cache it in the Chrly memory with fixed size and some sort of strategy of repression? Currently, Redis eats about 1.5Gb of memory on our production server and is not going to reduce appetites.

  • Third question is how long we should cache it? Should it be hour or a few days?


If someone has any ideas about this stuff, I'd like to hear your opinion.

Avatar by UUID

Is there any way to get the avatar (head 2d image) of a player's skin by their username or uuid on png from the ely.by skinsystem database?

Like for example this link gives me this image:
Notch

Corrupted value in the hash:mojang-username-to-uuid

Sentry Issue: CHRLY-H

*errors.errorString: runtime error: index out of range [1] with length 1
  File "/home/travis/.gimme/versions/go1.14.linux.amd64/src/runtime/panic.go", line 88, in goPanicIndex
  File "/home/travis/gopath/src/github.com/elyby/chrly/db/redis/redis.go", line 207, in findMojangUuidByUsername
  File "/home/travis/gopath/src/github.com/elyby/chrly/db/redis/redis.go", line 195, in GetUuid
  File "/home/travis/gopath/src/github.com/elyby/chrly/mojangtextures/storage.go", line 40, in GetUuid
  File "/home/travis/gopath/src/github.com/elyby/chrly/mojangtextures/mojang_textures.go", line 171, in getUuidFromCache
...
(10 additional frame(s) were not displayed)

runtime error: index out of range [1] with length 1

image

Update dependencies, fix dep warnings

~/g/s/g/e/chrly> dep status
dep: WARNING: branch, version, revision, or source should be provided for "github.com/mediocregopher/radix.v2"
dep: WARNING: branch, version, revision, or source should be provided for "github.com/mono83/slf"
dep: WARNING: branch, version, revision, or source should be provided for "github.com/spf13/viper"
dep: WARNING: branch, version, revision, or source should be provided for "github.com/getsentry/raven-go"

Add a new strategy for batch uuids queue which will perform request as soon as batch size limit will be filled

This strategy will allow for a faster return of results in case of load bursts. The idea is as follows: the strategy will send a request as soon as there are n elements in the queue or until t time has passed since the first element appeared for processing.

Explaining for humans: people from the CIS should easily understand this logic by drawing an analogy with the drivers of the route: until a full car is filled, it will not go 😅

Implement worker mode for the textures queue

image

Due to the decrease of the limit on the number of nicknames for which you can get uuid from 100 to 10, as well as the decrease in requests rate limit from 600 to ~240 per 10 minutes (gained experimentally), we can't now process all incoming traffic from one server.

During the initial development we had some doubts that we would be able to process the whole load from 1 server, so we thought over the options to solve this problem. There are 2 ideas:

  1. Connect additional IPs to the server.
  2. Use as a queue some remote executors.

The first option can work in case of placement on a dedicated server with an additional network card, but it's not suitable for some minimal hardware, which we focus on.

The second option is much more viable, because it allows us to share the load on many cheap servers, each of which will work from its own IP address.

The easiest implementation involves launching a web server that will send all incoming requests to the queue and return them as they are returned by the Mojang API. The master node will receive a config parameter that defines the list of IP addresses of the workers to which requests will be sent according to a round-robin algorithm.

But even in the simplest implementation there is a question of the need to ensure the work of the cluster because some of the workers may go down and will have to be temporarily removed from the pool of workers. This task can be solved on our side (which requires additional development) or using some middleware (for example, HAProxy). There is no final solution yet.

Using the skin from SkinsRestorer, if there's no skin on Ely.by

Currently Ely.by SkinSystem doesn't work correctly with Mojang skins.
When skin is not found, it returns URL for the old version of Mojang API, such as "skins.minecraft.net/MinecraftSkins/%player%.png".

Most of offline-mode servers uses plugins for restoring Mojang skins: SkinsRestorer, Custom Skins Manager etc. They make request to Mojang API and get original skin URL, using network and CPU resources of dedicated server.

Now Ely.by authlib has the following algorithm (will describe partially simplifed):

  • If profile recievied from server is empty:
    Library requests Ely.by skin from chrly. If there's no skin with such name in Ely.by database, client use broken Mojang link.
    In this case it's normal behavior, 'cause we can't now get skin from Mojang, if server didn't do that itself (#11).
  • If recievied profile isn't empty (Profile name is different from player name)
    Library uses textures, provided by a server (skin restoring plugin). It allows to use something like "/skin set ".
  • If recievied profile isn't empty (Profile name is equals to player name)
    In this situation, library requests skin from Ely.by. And if it's not found, client uses broken Mojang URL, received from chrly.

Since Minecraft launcher "TL" uses Ely.by skin system by default, all the players have broken Mojang skins on offline-mode servers even with installed skin restoring plugin.

Here we can see logs of Minecraft client:
https://hastebin.brikster.ru/mocowewato.shell

My player with nickname Brikster has Mojang skin and it was restored by the server. Client got correct Mojang link with my skin texture, but finally chose broken Mojang link, that was returned by Ely.by's chrly.

Possible solution (until #11 will be completely solved):
In the last case (when profile, received by client, isn't empty, and player name is equals to name of profile), we can request Ely.by skin from chrly. Then, if chrly replied, that skin is not found, we shouldn't use broken link to a Mojang skin. Instead of that we can use textures, that was provided by the server.

Username to UUID hash entry should be invalidated if UUID no more exists

Sentry Issue: CHRLY-D

Stacktrace (most recent call first):

  File "/home/travis/.gimme/versions/go1.14.linux.amd64/src/reflect/value.go", line 460, in call
  File "/home/travis/.gimme/versions/go1.14.linux.amd64/src/reflect/value.go", line 321, in Call
  File "/home/travis/gopath/src/github.com/elyby/chrly/vendor/github.com/asaskevich/EventBus/event_bus.go", line 158, in doPublish
  File "/home/travis/gopath/src/github.com/elyby/chrly/vendor/github.com/asaskevich/EventBus/event_bus.go", line 144, in Publish
  File "/home/travis/gopath/src/github.com/elyby/chrly/dispatcher/dispatcher.go", line 27, in Emit
  File "/home/travis/gopath/src/github.com/elyby/chrly/mojangtextures/mojang_textures.go", line 196, in getTextures
  File "/home/travis/gopath/src/github.com/elyby/chrly/mojangtextures/mojang_textures.go", line 157, in getResult
  File "/home/travis/gopath/src/github.com/elyby/chrly/mojangtextures/mojang_textures.go", line 132, in getResultAndBroadcast

textures: Unexpected Mojang response error: 200: Empty Response

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.