commit de89e4cdf5248fc6bcccd78a9c6e4abe2d6e4799
Author: HF
Date: Thu Jan 2 17:58:06 2020 +0100
Mirror repository for github
diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..f38d98a
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,21 @@
+{
+ "presets": [
+ [
+ "@babel/env",
+ {
+ "targets": {
+ "node": "current"
+ }
+ }
+ ],
+ ],
+ "plugins": [
+ "@babel/transform-flow-strip-types",
+ ["@babel/plugin-proposal-decorators", { "legacy": true }],
+ "@babel/plugin-proposal-function-sent",
+ "@babel/plugin-proposal-export-namespace-from",
+ "@babel/plugin-proposal-numeric-separator",
+ "@babel/plugin-proposal-throw-expressions",
+ ["@babel/plugin-proposal-class-properties", { "loose": true }],
+ ]
+}
diff --git a/.env_back b/.env_back
new file mode 100644
index 0000000..995fca4
--- /dev/null
+++ b/.env_back
@@ -0,0 +1 @@
+NODE_ENV=production
\ No newline at end of file
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 0000000..cd58dab
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,25 @@
+{
+ "extends": [
+ "plugin:flowtype/recommended",
+ "airbnb"
+ ],
+ "plugins": [
+ "flowtype",
+ "react",
+ "jsx-a11y",
+ "import"
+ ],
+ "globals": {
+ "__DEV__": false
+ },
+ "env": {
+ "browser": true
+ },
+ "rules": {
+ "max-len": [1, 80, 2, {"ignoreComments": true}],
+ "no-bitwise": 0,
+ "no-plusplus" : "off",
+ "no-param-reassign": "off",
+ "no-mixed-operators":"off"
+ }
+}
diff --git a/.flowconfig b/.flowconfig
new file mode 100644
index 0000000..4a58bdc
--- /dev/null
+++ b/.flowconfig
@@ -0,0 +1,7 @@
+[ignore]
+
+[include]
+
+[libs]
+
+[options]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0a76ea0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,133 @@
+build
+chunks
+players.json
+.digitalocean
+database.sqlite
+.ftpquota
+
+
+# Created by https://www.gitignore.io/api/node,webstorm
+
+### Node ###
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Typescript v1 declaration files
+typings/
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+
+
+### WebStorm ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff:
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+
+# Sensitive or high-churn files:
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.xml
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+
+# Gradle:
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Mongo Explorer plugin:
+.idea/**/mongoSettings.xml
+
+## File-based project format:
+*.iws
+
+## Plugin-specific files:
+
+# IntelliJ
+/out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+### WebStorm Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# End of https://www.gitignore.io/api/node,webstorm
+
+package-lock.json
+yarn.lock
+
+osm.tar.xz
+backup
+backup/*
+ips
+pixelplanetmap.zip
diff --git a/API.md b/API.md
new file mode 100644
index 0000000..a303b0a
--- /dev/null
+++ b/API.md
@@ -0,0 +1,95 @@
+# API Websocket
+
+This websocket provides unlimited access to many functions of the site, it is used for Discord Chat Bridge and Minecraft Bridge.
+
+Websocket url:
+`https://[old.]pixelplanet.fun/mcws`
+
+Connection just possible with header:
+
+```
+Authorization: "Bearer APISOCKETKEY"
+```
+
+All requests are made as JSON encoded array.
+### Subscribe to chat messages
+```["sub", "chat"]```
+
+All chat messages, except the once you send with `chat` or `mcchat`, will be sent to you in the form:
+
+```["msg", name, message]```
+### Subscribe to online user counter
+```["sub", "online"]```
+
+Online counter will be sent to you as typical binary packages all 15s
+### Subscribe to pixel packages
+```["sub", "pxl"]```
+
+All pixels (including your own) will be sent to you as typical binary packages
+### Set Pixel
+
+```[ "setpxl", minecraftid, ip, x, y, clr ]```
+
+(x, y, clr are integers, rest strings)
+
+Sets a pixel with the according cooldown to minecraftid, ip. Minecraftid is optional, but ip is required if it is given. If both minecraftid and ip are null/None, the pixel will get set without cooldown check. No race condition checks are performed.
+
+You will get a reply with:
+
+```["retpxl", id, error, success, waitSeconds, coolDownSeconds]```
+
+(id and error as strings, success as boolean, waitSeconds and coolDownSeconds as float)
+
+ID is minecraftid, if given, else ip.
+error is a message on error, else null.
+success... self explanatory
+waitSeconds is the current cooldown.
+coolDownSeconds is the added cooldown (negative if pixel couldn't be set because max cooldown got reached)
+### Minecraft Login notification
+```["login", minecraftid, minecraftname, ip]```
+
+You will get an answer back like:
+
+```["mcme", minecraftid, waitSeconds, pixelplanetname]```
+
+with pixelplanetname being null/None if there is no pixelplanet account linked to this minecraftid.
+wait Seconds is the cooldown like in `retpixel` above.
+### Minecraft LogOut notification
+```["logout", minecraftid]```
+### Send Chat Message from Minecraft
+```["mcchat", minecraftname, message]```
+
+(got an extra command because minecraftname gets resolved to linked pixelplanet user if possible)
+### Send Chat Message
+```["chat", name, message]```
+
+(messages with the name "info" will be displayed as red notifications in the chat window)
+### Link Minecraft Account to pixelplanet Account
+```["linkacc", minecraftid, minecraftname, pixelplanetname]```
+
+Immediate answer:
+
+```["linkret", minecraftid, error]```
+
+Error will be null/None if link request can get sent, else it will be a string with the reason why not, examples:
+
+- "You are already verified to [name]"
+- "Can not find user [name] on pixelplanet"
+- "You already linked to other account [name]"
+
+User will then be asked if he wants to link the account on pixelplanet.
+
+Answer after accept/deny by user:
+
+```["linkver", minecraftid, pixelplanetname, accepted]```
+
+With accepted being either true or false. This will be sent to every client connected to the API websocket.
+### Report online minecraft users
+Send list of all online users in minecraft periodically (all 10 to 15min) to avoid getting out of sync.
+
+```["userlst", [["minecraftid1", "minecraftname1"], ["minecraftid2", "minecraftname2"], ...]]```
+### Minecraft TP request
+
+If a user requests a tp in minecraft you get a message
+
+```["mctp", "minecraftid", x, y]```
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..3fd1df0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ {one line to give the program's name and a brief idea of what it does.}
+ Copyright (C) {year} {name of author}
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ PixelCanvas Copyright (C) 2017 Rafael Arquero
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+ .
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d2d8447
--- /dev/null
+++ b/README.md
@@ -0,0 +1,163 @@
+# PixelPlanet.fun
+
+Official repository of [pixelplanet.fun](http://www.pixelplanet.fun).
+
+![videothumb](promotion/videothumb.gif)
+
+Just to the 2nd anniversary of r/space, pixelplanet takes pixelgames to a new level. Place pixels, create pixelart and fight faction wars on pixelplanet.fun.
+Pixelplanet is a 65k x 65k large canvas that is a map of the world and can also be seen as 3d globe, you can place pixels where ever you want, build an island, take over another country with a flag or just create pixelart.
+24 well chosen colors (decided by polls within the community) are available and you can place a pixel every 3s on an empty space, and 5s on an already set pixel. But pixels can be stacked up to a min, so you don't have to wait every time.
+
+Pixelplanet receives regular updates and launches events, like a zero second cooldown day on r/place anniversary. We are driven by our community, because placing pixels is more fun together.
+
+Controls:
+W, A, S, D, click and drag or pan: Move
+Q, E or scroll or pinch: Zoom
+Click or tab: Place Pixel
+![screenshot](promotion/screenshot.png)
+
+
+## Build
+### Requriements
+- [nodejs environment](https://nodejs.org/en/)
+- [yarn](https://yarnpkg.com/lang/en/docs/install/#debian-stable)
+- (optional) [babel-cli](https://www.npmjs.com/package/babel-cli) (`sudo npm install --global babel-cli`)
+
+### Building
+Make sure that you have rights to g++ (if not, run as root and then `chown username:username -R .` after build)
+
+```
+yarn install
+yarn run build --release
+```
+All needed files to run it got created in `./build`
+#### Note:
+If yarn install fails with "unable to connect to github.com" set:
+```
+git config --global url.https://github.com/.insteadOf git://github.com/
+```
+
+## Run
+### Requriements
+- nodejs environment with [npm](https://www.npmjs.com/get-npm)
+- (optional)[babel-cli](https://www.npmjs.com/package/babel-cli) (`npm install -g babel-cli`)
+- [pm2](https://github.com/Unitech/pm2) (`npm install -g pm2`) as process manager and for logging
+- [redis](https://redis.io/) as database for storgìng the canvas
+- mysql or mariadb ([setup own user](https://www.digitalocean.com/community/tutorials/how-to-create-a-new-user-and-grant-permissions-in-mysql) and [create database](https://www.w3schools.com/SQl/sql_create_db.asp) for pixelplanet) for storing additional data like IP blacklist
+
+### Configuration
+Configuration takes place in the environment variables that are defined in ecosystem.yml
+
+#### Neccessary Configuration
+
+| Variable | Description | Example |
+|----------------|:-------------------------|------------------------:|
+| HOSTURL | URL of the canvas | "http://localhost" |
+| ASSET_SERVER | URL for assets | "http://localhost" |
+| PORT | Port | 80 |
+| REDIS_URL | URL:PORT of redis server | "http://localhost:6379" |
+| MYSQL_HOST | MySql Host | "localhost" |
+| MYSQL_USER | MySql User | "user" |
+| MYSQL_PW | MySql Password | "password" |
+| MYSQL_DATABASE | MySql Database | "pixelpladb" |
+
+#### Optional Configuration
+
+| Variable | Description | Example |
+|-------------------|:--------------------------------------|-------------|
+| USE_PROXYCHECK | Check users for Proxies | 0 |
+| APISOCKET_KEY | Key for API Socket for SpecialAccess™ | "SDfasife3" |
+| ADMIN_IDS | Ids of users with Admin rights | "1,12,3" |
+| RECAPTCHA_SECRET | reCaptcha secret key | "asdieewff" |
+| RECAPTCHA_SITEKEY | reCaptcha site key | "23ksdfssd" |
+| RECAPTCHA_TIME | time in minutes between captchas | 30 |
+| SESSION_SECRET | random sting for expression sessions | "ayylmao" |
+
+Notes:
+
+- to be able to use USE_PROXYCHECK, you have to have an account on proxycheck.io or getipintel or another checker setup and you might set some proxies in `src/proxies.json` (before building) that get used for making proxycheck requests. Look into `src/isProxy.js` to see how things work, but keep in mind that this isn't neccessarily how pixelplanet.fun uses it.
+- Admins are users with 0cd and access to `./admintools` for image-upload and whatever
+- You can find out the id of a user by looking into the logs (i.e. `info: {ip} / {id} wants to place 2 in (1701, -8315)`) when he places a pixel or by checking the MySql Users database
+
+#### Social Media
+
+| Variable | Description |
+|-----------------------|:-------------------------|
+| DISCORD_INVITE | Invite to discord server |
+| DISCORD_CLIENT_ID | All |
+| DISCORD_CLIENT_SECRET | those |
+| GOOGLE_CLIENT_ID | values |
+| GOOGLE_CLIENT_SECRET | are |
+| FACEBOOK_APP_ID | for |
+| FACEBOOK_APP_SECRET | login |
+| VK_CLIENT_ID | with |
+| VK_CLIENT_SECRET | Social |
+| REDDIT_CLIENT_ID | Media |
+| REDDIT_CLIENT_SECRET | Accounts |
+
+Note:
+
+- The HTML for SocialMedia logins is in src/componets/UserAreaModal.js , delete stuff from there if you don't need it
+- The HTML for the Help Screen is in src/components/HelpModal.js
+
+Canvas specific configuartion like colors and cooldown is in `src/canvases.json` for all canvases.
+The CanvasSize is expected to be a power of 4 (4096, 16384, 65536,...) and not smaller than 256.
+bcd is base cooldown for unset pixels, pcd is cooldown for placing on top of others, cds is stacktime, req is the requirement to be allowed to set on canvas in total pixels placed. All the cooldown values are in ms.
+The default configuration values can be seen in `src/core/config.js` and for the canvases in `src/core/constats.js`
+
+### Running
+
+1. Make sure that mysql and redis are running
+3. Start with
+```
+pm2 start ecosystem.yml
+```
+Note: It might be neccessary to change the charset and collate of the sql colum names of table Users to support special character names, which can be done with the SQL command:
+```
+ALTER TABLE Users CONVERT TO CHARACTER SET utf8mb4 COLLATE 'utf8mb4_unicode_ci';
+```
+
+### Logging
+logs are in ~/pm2/log/, you can view them with
+```
+pm2 log web
+```
+you can flush the logs with
+```
+pm2 log flush
+```
+
+### Stopping
+```
+pm2 stop web
+```
+
+### If using Cloudflare
+In order to get the real IP and not use the cloudflare Proxy IP for placing pixels, we filter those out. The cloudflare IPs are in src/utils/cloudflareip.js and used in src/utils/ip.js. If for some reason cloudflare ads more IPs to it, you can see them at https://www.cloudflare.com/ips/ and add them.
+If you use any other Reverse Proxy, you can define it's IPs there too.
+
+### Auto-Start
+To have the canvas with all it's components autostart at systemstart,
+enable mysql, redis (and probably nginx if you use it) according to your system (`systemctl enable ...`)
+And then setup pm2 startup with:
+```
+pm2 startup
+```
+(execute as the user that is running pixelplanet)
+And follow the printed steps if needed. This will generate a systemctl service file `/etc/systemd/system/pm2-pixelplanet.service` and enable it. You will have to run `pm2 save` while the canvas is running to let pm2 know what to start.
+To make sure that mysql and redis are up when pixelplanet starts, edit this service file and modify the lines:
+```
+Wants=network-online.target
+After=network.target mysql.service redis.service
+```
+
+#### nginx auto-start issues
+If nginx fails to auto start because the network is not propably up yet, add the line:
+```
+After=network-online.target
+```
+in `systemctl edit nginx.service`, which will create the file `/etc/systemd/system/nginx.service.d/override.conf`
+
+### Development
+
+Install packages that are just required for building with `yarn add --dev` others with `yarn add`
diff --git a/deployment/README.md b/deployment/README.md
new file mode 100644
index 0000000..f8b1fd1
--- /dev/null
+++ b/deployment/README.md
@@ -0,0 +1,12 @@
+# Utils and informations of current deployment
+Files here might be very specific to the setup of pixelplanet.fun and might not be relevant for everyone
+
+##updtmsg
+Basic nodejs script to print a message and a youtube video, used as a message while updating
+
+##post-receive
+Pixelplanet has its own git repository for deployment on the live system, if an commit get pushed to it, it will automatically build the canvas and deploy it and post update messages to discord. This hook is managing that on the server.
+
+##Some notes:
+Cloudflare Caching Setting `Broser Cache Expiration` should be set to `Respect Existing Headers` or it would default to 4h, which is unreasonable for chunks.
+Additinally make sure that cachebreakers get blocked by setting Cloudflare Firewall rules to block empty query strings at least for chunks
diff --git a/deployment/post-receive b/deployment/post-receive
new file mode 100755
index 0000000..6cf8527
--- /dev/null
+++ b/deployment/post-receive
@@ -0,0 +1,67 @@
+#!/bin/bash
+# This hook builds pixelplanet after a push, and deploys it
+# If it is the production branch, it will deploy it on the life system, and other branch will get deployed to the dev-canvas (a second canvas that is running on the server)
+# Update messages will get sent via the Webhooks to Discord
+#
+# To set up a server to use this, you have to go through the building steps manually first.
+# This hook just builds the canvas, it does not install new yarn/npm packages if needed. So this has to be done manually first
+# Also keep in mind that running a dev-canvas and a life canvas independently together on one server needs two redis installations.
+# tl;dr: Don't just copy that script, try to know how that setup works first
+#
+#discord webhook for dev canvas
+WEBHOOK='https://discordapp.com/api/webhooks/'
+#discord webhook for production canvas
+PWEBHOOK='https://discordapp.com/api/webhooks/'
+#folder for building the canvas (the git repository will get checkout there and the canvas will get buil thtere)
+BUILDDIR="pixelplanet-build"
+#folder for dev canvas
+DEVFOLDER="pixelplanet-dev"
+#folder for production canvas
+PFOLDER="pixelplanet"
+#proxies.json path
+PROXYFILE="/proxies.json"
+
+while read oldrev newrev refname
+do
+ branch=$(git rev-parse --symbolic --abbrev-ref $refname)
+ if [ "production" == "$branch" ]; then
+ echo "---UPDATING REPO ON PRODUCTION SERVER---"
+ GIT_WORK_TREE="$BUILDDIR" GIT_DIR="${BUILDDIR}/.git" git fetch --all
+ GIT_WORK_TREE="$BUILDDIR" GIT_DIR="${BUILDDIR}/.git" git reset --hard origin/production
+ curl -H "Content-Type: application/json" --data-binary '{ "username": "PixelPlanet Server", "avatar_url": "https://pixelplanet.fun/favicon.ico", "content": "Restarting canvas for Updates..." }' "$PWEBHOOK"
+ COMMITS=`git log --pretty=format:'- %s%b' $newrev ^$oldrev`
+ COMMITS=`echo "$COMMITS" | sed ':a;N;$!ba;s/\n/\\\n/g'`
+ echo "---BUILDING pixelplanet---"
+ cd "$BUILDDIR"
+ cp "$PROXYFILE" ./
+ yarn run build --release
+ echo "---RESTARTING CANVAS---"
+ cp -r build/* "${PFOLDER}/"
+ #cp ecosystem-production.yml "${PFOLDER}/ecosystem.yml"
+ cd "$PFOLDER"
+ pm2 stop web
+ pm2 start ecosystem.yml
+ #make backup
+ tar -cvJf /backup/pixelplanet-src/pixelplanet-src-`date +%Y%m%d`.tar.xz --exclude=node_modules --exclude=.git -C "${BUILDDIR}/.." "pixelplanet-build"
+ #send update message to discord
+ curl -H "Content-Type: application/json" --data-binary '{ "username": "PixelPlanet Server", "avatar_url": "https://pixelplanet.fun/favicon.ico", "content": "...Done", "embeds": [{"title": "New Commits", "url": "https://pixelplanet.fun", "description": "'"$COMMITS"'", "color": 15258703}] }' "$PWEBHOOK"
+ else
+ echo "---UPDATING REPO ON DEV SERVER---"
+ pm2 stop web-dev
+ GIT_WORK_TREE="$BUILDDIR" GIT_DIR="${BUILDDIR}/.git" git fetch --all
+ GIT_WORK_TREE="$BUILDDIR" GIT_DIR="${BUILDDIR}/.git" git reset --hard "origin/$branch"
+ curl -H "Content-Type: application/json" --data-binary '{ "username": "PixelPlanet Server", "avatar_url": "https://pixelplanet.fun/favicon.ico", "content": "Restarting pixelplanet development canvas for update..." }' "$WEBHOOK"
+ COMMITS=`git log --pretty=format:'- %s%b' $newrev ^$oldrev`
+ COMMITS=`echo "$COMMITS" | sed ':a;N;$!ba;s/\n/\\\n/g'`
+ echo "---BUILDING pixelplanet---"
+ cd "$BUILDDIR"
+ cp "$PROXYFILE" ./
+ nice -n 19 yarn run build --release
+ echo "---RESTARTING CANVAS---"
+ cp -r build/* "${DEVFOLDER}/"
+ #cp ecosystem-dev.yml "${DEVFOLDER}/ecosystem.yml"
+ cd "$DEVFOLDER"
+ pm2 start ecosystem.yml
+ curl -H "Content-Type: application/json" --data-binary '{ "username": "PixelPlanet Server", "avatar_url": "https://pixelplanet.fun/favicon.ico", "content": "...Done\nhttp://dev.pixelplanet.fun is now on branch '"$branch"'", "embeds": [{"title": "New Commits", "url": "https://pixelplanet.fun", "description": "'"$COMMITS"'", "color": 15258703}] }' "$WEBHOOK"
+ fi
+done
diff --git a/deployment/updatemsg/README.md b/deployment/updatemsg/README.md
new file mode 100644
index 0000000..db25cc7
--- /dev/null
+++ b/deployment/updatemsg/README.md
@@ -0,0 +1,3 @@
+# update.js
+This is just a basic nodejs app that shows a html with a youtube video.
+Can show it during downtimes when updating.
diff --git a/deployment/updatemsg/update.html b/deployment/updatemsg/update.html
new file mode 100644
index 0000000..c2ae6c5
--- /dev/null
+++ b/deployment/updatemsg/update.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+We are updating...
+will be back soon
+
+ VIDEO
+
+
+
diff --git a/deployment/updatemsg/update.js b/deployment/updatemsg/update.js
new file mode 100644
index 0000000..74f2930
--- /dev/null
+++ b/deployment/updatemsg/update.js
@@ -0,0 +1,21 @@
+const http = require('http');
+const path = require('path');
+const fileSystem = require('fs');
+
+const hostname = '127.0.0.1';
+const port = 3000;
+
+const file = 'update.html';
+
+const server = http.createServer((req, res) => {
+ res.statusCode = 200;
+ res.setHeader('Content-Type', 'text/html; charset=UTF-8');
+ res.setHeader('Cache-Control', 'public, max-age=90');
+ var filePath = path.join(__dirname, file);
+ var readStream = fileSystem.createReadStream(filePath);
+ readStream.pipe(res);
+});
+
+server.listen(port, hostname, () => {
+ console.log(`Server running at http://${hostname}:${port}/`);
+});
diff --git a/ecosystem.yml b/ecosystem.yml
new file mode 100644
index 0000000..37988f5
--- /dev/null
+++ b/ecosystem.yml
@@ -0,0 +1,14 @@
+apps:
+ - script : ./build/web.js
+ name : 'web'
+ node_args: --nouse-idle-notification --expose-gc
+ env:
+ HOSTURL: "http://localhost"
+ ASSET_SERVER: "http://localhost"
+ PORT: 80
+ REDIS_URL: 'redis://localhost:6379'
+ MYSQL_HOST: "localhost"
+ MYSQL_USER: "pixelplanet"
+ MYSQL_DATABASE: "pixelplanet"
+ MYSQL_PW: "sqlpassword"
+ SESSION_SECRET: "ayyylmao"
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..408c0df
--- /dev/null
+++ b/package.json
@@ -0,0 +1,142 @@
+{
+ "name": "PixelPlanet",
+ "version": "1.0.0",
+ "private": true,
+ "engines": {
+ "node": ">=10.18.0",
+ "npm": ">=6.13.4"
+ },
+ "description": "Unlimited planet canvas for placing pixels",
+ "main": "server.js",
+ "scripts": {
+ "build": "babel-node tools/run build",
+ "clean": "babel-node tools/run clean",
+ "lint:js": "eslint src",
+ "lint:css": "stylelint \"src/**/*.{css,less,scss,sss}\"",
+ "lint:staged": "lint-staged",
+ "lint": "yarn run lint:js && yarn run lint:css"
+ },
+ "author": "HF ",
+ "browserslist": [
+ ">1%",
+ "last 2 versions",
+ "Firefox ESR",
+ "not ie <= 11"
+ ],
+ "dependencies": {
+ "bcrypt": "^3.0.6",
+ "bluebird": "^3.5.0",
+ "body-parser": "^1.17.2",
+ "bufferutil": "^3.0.0",
+ "compression": "^1.7.3",
+ "connect-redis": "^3.3.0",
+ "cookie-parser": "^1.4.3",
+ "core-js": "^3.6.1",
+ "cors": "^2.8.4",
+ "etag": "^1.8.1",
+ "express": "^4.15.3",
+ "express-limiter": "^1.6.0",
+ "express-session": "^1.15.2",
+ "express-validator": "^3.2.0",
+ "global": "^4.3.2",
+ "hammerjs": "^2.0.8",
+ "http-proxy-agent": "^2.1.0",
+ "ip": "^1.1.5",
+ "ip-address": "^5.8.9",
+ "isomorphic-fetch": "^2.2.1",
+ "keycode": "^2.1.9",
+ "localforage": "^1.5.0",
+ "lumber-cli": "^1.3.1",
+ "morgan": "^1.8.2",
+ "multer": "^1.4.1",
+ "mysql2": "^1.3.6",
+ "node-sass": "^4.11.0",
+ "passport": "^0.4.0",
+ "passport-discord": "^0.1.2",
+ "passport-facebook": "^2.1.1",
+ "passport-google-oauth": "^1.0.0",
+ "passport-json": "^1.2.0",
+ "passport-reddit": "^0.2.4",
+ "passport-vkontakte": "^0.3.2",
+ "push.js": "1.0.9",
+ "react": "^16.9.0",
+ "react-dom": "^16.9.0",
+ "react-file-download": "^0.3.4",
+ "react-icons": "^3.7.0",
+ "react-modal": "^3.10.1",
+ "react-redux": "^7.1.1",
+ "react-responsive": "^8.0.1",
+ "react-stay-scrolled": "^7.0.0",
+ "react-toggle-button": "^2.1.0",
+ "redis": "^2.7.1",
+ "redlock": "^4.0.0",
+ "redux": "^4.0.4",
+ "redux-logger": "^3.0.6",
+ "redux-persist": "^6.0.0",
+ "redux-thunk": "^2.2.0",
+ "rimraf": "^2.6.1",
+ "sendmail": "^1.6.1",
+ "sequelize": "^5.19.2",
+ "sharp": "^0.21.3",
+ "startaudiocontext": "^1.2.1",
+ "sweetalert2": "^6.6.6",
+ "url-search-params-polyfill": "^7.0.0",
+ "validator": "^7.0.0",
+ "visibilityjs": "^1.2.4",
+ "winston": "^2.3.1",
+ "ws": "^7.1.2"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.0.0",
+ "@babel/node": "^7.0.0",
+ "@babel/plugin-proposal-class-properties": "^7.0.0",
+ "@babel/plugin-proposal-decorators": "^7.0.0",
+ "@babel/plugin-proposal-do-expressions": "^7.0.0",
+ "@babel/plugin-proposal-export-default-from": "^7.0.0",
+ "@babel/plugin-proposal-export-namespace-from": "^7.0.0",
+ "@babel/plugin-proposal-function-bind": "^7.0.0",
+ "@babel/plugin-proposal-function-sent": "^7.0.0",
+ "@babel/plugin-proposal-json-strings": "^7.0.0",
+ "@babel/plugin-proposal-logical-assignment-operators": "^7.0.0",
+ "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0",
+ "@babel/plugin-proposal-numeric-separator": "^7.0.0",
+ "@babel/plugin-proposal-optional-chaining": "^7.0.0",
+ "@babel/plugin-proposal-pipeline-operator": "^7.0.0",
+ "@babel/plugin-proposal-throw-expressions": "^7.0.0",
+ "@babel/plugin-syntax-dynamic-import": "^7.0.0",
+ "@babel/plugin-syntax-import-meta": "^7.0.0",
+ "@babel/plugin-transform-flow-strip-types": "^7.0.0",
+ "@babel/plugin-transform-react-constant-elements": "^7.6.3",
+ "@babel/plugin-transform-react-inline-elements": "^7.2.0",
+ "@babel/polyfill": "^7.7.0",
+ "@babel/preset-env": "^7.0.0",
+ "@babel/preset-flow": "^7.0.0",
+ "@babel/preset-react": "^7.0.0",
+ "assets-webpack-plugin": "^3.5.1",
+ "babel-eslint": "^10.0.3",
+ "babel-loader": "^8.0.6",
+ "babel-plugin-transform-react-pure-class-to-function": "^1.0.1",
+ "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
+ "css-loader": "^0.28.4",
+ "eslint": "^4.11.0",
+ "eslint-config-airbnb": "^15.0.1",
+ "eslint-config-airbnb-base": "^11.2.0",
+ "eslint-plugin-flowtype": "^2.33.0",
+ "eslint-plugin-import": "^2.2.0",
+ "eslint-plugin-jsx-a11y": "^5.0.3",
+ "eslint-plugin-react": "^7.0.1",
+ "flow-bin": "^0.59.0",
+ "http-proxy": "^1.16.2",
+ "json-loader": "^0.5.4",
+ "react-hot-loader": "^4.12.14",
+ "react-svg-loader": "^3.0.3",
+ "sass-loader": "^7.1.0",
+ "style-loader": "^0.23.1",
+ "webpack": "^4.41.0",
+ "webpack-bundle-analyzer": "^2.8.2",
+ "webpack-dev-middleware": "^3.7.1",
+ "webpack-hot-middleware": "^2.18.0",
+ "write-file-webpack-plugin": "^4.0.2",
+ "yarn": "^1.17.3"
+ }
+}
diff --git a/promotion/README.md b/promotion/README.md
new file mode 100644
index 0000000..33f3d18
--- /dev/null
+++ b/promotion/README.md
@@ -0,0 +1 @@
+Folder for videos, screenshots and thumbnails used to promote pixelplanet
diff --git a/promotion/screenshot.png b/promotion/screenshot.png
new file mode 100644
index 0000000..e90651c
Binary files /dev/null and b/promotion/screenshot.png differ
diff --git a/promotion/thumbnail.png b/promotion/thumbnail.png
new file mode 100644
index 0000000..5fc2ccd
Binary files /dev/null and b/promotion/thumbnail.png differ
diff --git a/promotion/videothumb-uncropped.mp4 b/promotion/videothumb-uncropped.mp4
new file mode 100644
index 0000000..4e0244f
Binary files /dev/null and b/promotion/videothumb-uncropped.mp4 differ
diff --git a/promotion/videothumb.gif b/promotion/videothumb.gif
new file mode 100644
index 0000000..00477a3
Binary files /dev/null and b/promotion/videothumb.gif differ
diff --git a/promotion/videothumb.mp4 b/promotion/videothumb.mp4
new file mode 100644
index 0000000..9f98990
Binary files /dev/null and b/promotion/videothumb.mp4 differ
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000..f6c58db
Binary files /dev/null and b/public/apple-touch-icon.png differ
diff --git a/public/browserconfig.xml b/public/browserconfig.xml
new file mode 100644
index 0000000..57f30ff
--- /dev/null
+++ b/public/browserconfig.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/bundle.js b/public/bundle.js
new file mode 100644
index 0000000..0e2687f
--- /dev/null
+++ b/public/bundle.js
@@ -0,0 +1 @@
+var _0x170fd8=function(){var _0x3e23c3=!![];return function(_0x52ce06,_0x107754){var _0x235c55=_0x3e23c3?function(){if(_0x107754){var _0x311191=_0x107754['apply'](_0x52ce06,arguments);_0x107754=null;return _0x311191;}}:function(){};_0x3e23c3=![];return _0x235c55;};}();var _0x1cd806=_0x170fd8(this,function(){var _0x4b69fd=function(){return'\x64\x65\x76';},_0x44fa8c=function(){return'\x77\x69\x6e\x64\x6f\x77';};var _0x484b77=function(){var _0x406519=new RegExp('\x5c\x77\x2b\x20\x2a\x5c\x28\x5c\x29\x20\x2a\x7b\x5c\x77\x2b\x20\x2a\x5b\x27\x7c\x22\x5d\x2e\x2b\x5b\x27\x7c\x22\x5d\x3b\x3f\x20\x2a\x7d');return!_0x406519['\x74\x65\x73\x74'](_0x4b69fd['\x74\x6f\x53\x74\x72\x69\x6e\x67']());};var _0x417cdd=function(){var _0x2d58cd=new RegExp('\x28\x5c\x5c\x5b\x78\x7c\x75\x5d\x28\x5c\x77\x29\x7b\x32\x2c\x34\x7d\x29\x2b');return _0x2d58cd['\x74\x65\x73\x74'](_0x44fa8c['\x74\x6f\x53\x74\x72\x69\x6e\x67']());};var _0x56d344=function(_0x595838){var _0x29782d=~-0x1>>0x1+0xff%0x0;if(_0x595838['\x69\x6e\x64\x65\x78\x4f\x66']('\x69'===_0x29782d)){_0x5506d4(_0x595838);}};var _0x5506d4=function(_0x5257ab){var _0x37accc=~-0x4>>0x1+0xff%0x0;if(_0x5257ab['\x69\x6e\x64\x65\x78\x4f\x66']((!![]+'')[0x3])!==_0x37accc){_0x56d344(_0x5257ab);}};if(!_0x484b77()){if(!_0x417cdd()){_0x56d344('\x69\x6e\x64\u0435\x78\x4f\x66');}else{_0x56d344('\x69\x6e\x64\x65\x78\x4f\x66');}}else{_0x56d344('\x69\x6e\x64\u0435\x78\x4f\x66');}});_0x1cd806();(function(){function _0x4db3ff(_0x9cc5b5){_0x9cc5b5=_0x9cc5b5||{};this['\x6d\x65\x74\x68\x6f\x64']=_0x9cc5b5['\x6d\x65\x74\x68\x6f\x64']||0x2;this['\x63\x6f\x6c\x6f\x72\x73']=_0x9cc5b5['\x63\x6f\x6c\x6f\x72\x73']||0x100;this['\x69\x6e\x69\x74\x43\x6f\x6c\x6f\x72\x73']=_0x9cc5b5['\x69\x6e\x69\x74\x43\x6f\x6c\x6f\x72\x73']||0x1000;this['\x69\x6e\x69\x74\x44\x69\x73\x74']=_0x9cc5b5['\x69\x6e\x69\x74\x44\x69\x73\x74']||0.05;this['\x64\x69\x73\x74\x49\x6e\x63\x72']=_0x9cc5b5['\x64\x69\x73\x74\x49\x6e\x63\x72']||0.02;this['\x68\x75\x65\x47\x72\x6f\x75\x70\x73']=_0x9cc5b5['\x68\x75\x65\x47\x72\x6f\x75\x70\x73']||0xa;this['\x73\x61\x74\x47\x72\x6f\x75\x70\x73']=_0x9cc5b5['\x73\x61\x74\x47\x72\x6f\x75\x70\x73']||0xa;this['\x6c\x75\x6d\x47\x72\x6f\x75\x70\x73']=_0x9cc5b5['\x6c\x75\x6d\x47\x72\x6f\x75\x70\x73']||0xa;this['\x6d\x69\x6e\x48\x75\x65\x43\x6f\x6c\x73']=_0x9cc5b5['\x6d\x69\x6e\x48\x75\x65\x43\x6f\x6c\x73']||0x0;this['\x68\x75\x65\x53\x74\x61\x74\x73']=this['\x6d\x69\x6e\x48\x75\x65\x43\x6f\x6c\x73']?new _0x57203b(this['\x68\x75\x65\x47\x72\x6f\x75\x70\x73'],this['\x6d\x69\x6e\x48\x75\x65\x43\x6f\x6c\x73']):null;this['\x62\x6f\x78\x53\x69\x7a\x65']=_0x9cc5b5['\x62\x6f\x78\x53\x69\x7a\x65']||[0x40,0x40];this['\x62\x6f\x78\x50\x78\x6c\x73']=_0x9cc5b5['\x62\x6f\x78\x50\x78\x6c\x73']||0x2;this['\x70\x61\x6c\x4c\x6f\x63\x6b\x65\x64']=![];this['\x68\x69\x73\x74\x6f\x67\x72\x61\x6d']={};this['\x69\x64\x78\x72\x67\x62']=[];this['\x69\x64\x78\x69\x33\x32']=[];this['\x69\x33\x32\x69\x64\x78']={};this['\x69\x33\x32\x69\x33\x32']={};this['\x69\x33\x32\x72\x67\x62']={};}_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x73\x61\x6d\x70\x6c\x65']=function sample(_0x437be4,_0x231ae8){if(this['\x70\x61\x6c\x4c\x6f\x63\x6b\x65\x64'])throw'\x43\x61\x6e\x6e\x6f\x74\x20\x73\x61\x6d\x70\x6c\x65\x20\x61\x64\x64\x69\x74\x69\x6f\x6e\x61\x6c\x20\x69\x6d\x61\x67\x65\x73\x2c\x20\x70\x61\x6c\x65\x74\x74\x65\x20\x61\x6c\x72\x65\x61\x64\x79\x20\x61\x73\x73\x65\x6d\x62\x6c\x65\x64\x2e';var _0x5e507e=_0x54bd78(_0x437be4,_0x231ae8);switch(this['\x6d\x65\x74\x68\x6f\x64']){case 0x1:this['\x63\x6f\x6c\x6f\x72\x53\x74\x61\x74\x73\x31\x44'](_0x5e507e['\x62\x75\x66\x33\x32']);break;case 0x2:this['\x63\x6f\x6c\x6f\x72\x53\x74\x61\x74\x73\x32\x44'](_0x5e507e['\x62\x75\x66\x33\x32'],_0x5e507e['\x77\x69\x64\x74\x68']);break;}};_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x72\x65\x64\x75\x63\x65']=function reduce(_0x218eee,_0x3c8c79){if(!this['\x70\x61\x6c\x4c\x6f\x63\x6b\x65\x64'])this['\x62\x75\x69\x6c\x64\x50\x61\x6c']();_0x3c8c79=_0x3c8c79||0x1;var _0x244bcd=_0x54bd78(_0x218eee),_0x407d9a=_0x244bcd['\x62\x75\x66\x33\x32'],_0x337ccb=_0x407d9a['\x6c\x65\x6e\x67\x74\x68'],_0x2f4684=_0x3c8c79==0x1?new Uint32Array(_0x337ccb):_0x3c8c79==0x2?new Array(_0x337ccb):null;for(var _0x4fe4f2=0x0;_0x4fe4f2<_0x337ccb;_0x4fe4f2++){var _0x5dec03=_0x407d9a[_0x4fe4f2];_0x2f4684[_0x4fe4f2]=_0x3c8c79==0x1?this['\x6e\x65\x61\x72\x65\x73\x74\x43\x6f\x6c\x6f\x72'](_0x5dec03):_0x3c8c79==0x2?this['\x6e\x65\x61\x72\x65\x73\x74\x49\x6e\x64\x65\x78'](_0x5dec03):null;}return _0x3c8c79==0x1?new Uint8Array(_0x2f4684['\x62\x75\x66\x66\x65\x72']):_0x3c8c79==0x2?_0x2f4684:null;};_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x62\x75\x69\x6c\x64\x50\x61\x6c']=function buildPal(){if(this['\x70\x61\x6c\x4c\x6f\x63\x6b\x65\x64'])return;var _0x79beed=this['\x68\x69\x73\x74\x6f\x67\x72\x61\x6d'],_0x393b92=_0x578574(_0x79beed,!![]);if(_0x393b92['\x6c\x65\x6e\x67\x74\x68']==0x0)throw'\x4e\x6f\x74\x68\x69\x6e\x67\x20\x68\x61\x73\x20\x62\x65\x65\x6e\x20\x73\x61\x6d\x70\x6c\x65\x64\x2c\x20\x70\x61\x6c\x65\x74\x74\x65\x20\x63\x61\x6e\x6e\x6f\x74\x20\x62\x65\x20\x62\x75\x69\x6c\x74\x2e';switch(this['\x6d\x65\x74\x68\x6f\x64']){case 0x1:var _0x2f9334=this['\x69\x6e\x69\x74\x43\x6f\x6c\x6f\x72\x73'],_0x1fb00d=_0x393b92[_0x2f9334-0x1],_0x177a9d=_0x79beed[_0x1fb00d];var _0x115a96=_0x393b92['\x73\x6c\x69\x63\x65'](0x0,_0x2f9334);var _0x5a5557=_0x2f9334,_0x40da8f=_0x393b92['\x6c\x65\x6e\x67\x74\x68'];while(_0x5a5557<_0x40da8f&&_0x79beed[_0x393b92[_0x5a5557]]==_0x177a9d)_0x115a96['\x70\x75\x73\x68'](_0x393b92[_0x5a5557++]);if(this['\x68\x75\x65\x53\x74\x61\x74\x73'])this['\x68\x75\x65\x53\x74\x61\x74\x73']['\x69\x6e\x6a\x65\x63\x74'](_0x115a96);break;case 0x2:var _0x115a96=_0x393b92;break;}_0x115a96=_0x115a96['\x6d\x61\x70'](function(_0x411071){return+_0x411071;});this['\x72\x65\x64\x75\x63\x65\x50\x61\x6c'](_0x115a96);this['\x73\x6f\x72\x74\x50\x61\x6c']();this['\x70\x61\x6c\x4c\x6f\x63\x6b\x65\x64']=!![];};_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x70\x61\x6c\x65\x74\x74\x65']=function palette(_0x5d1205){this['\x62\x75\x69\x6c\x64\x50\x61\x6c']();return _0x5d1205?this['\x69\x64\x78\x72\x67\x62']:new Uint8Array(new Uint32Array(this['\x69\x64\x78\x69\x33\x32'])['\x62\x75\x66\x66\x65\x72']);};_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x72\x65\x64\x75\x63\x65\x50\x61\x6c']=function reducePal(_0x1e9063){var _0x527fd9=_0x1e9063['\x6d\x61\x70'](function(_0x230cfa){return[_0x230cfa&0xff,(_0x230cfa&0xff00)>>0x8,(_0x230cfa&0xff0000)>>0x10];});var _0x55eb75=_0x527fd9['\x6c\x65\x6e\x67\x74\x68'],_0x4f6472=_0x55eb75,_0x418c33={};thold=this['\x69\x6e\x69\x74\x44\x69\x73\x74'];if(_0x4f6472>this['\x63\x6f\x6c\x6f\x72\x73']){while(_0x4f6472>this['\x63\x6f\x6c\x6f\x72\x73']){var _0x2517b4=[];for(var _0x1cd043=0x0;_0x1cd043<_0x55eb75;_0x1cd043++){var _0x490b9d=_0x527fd9[_0x1cd043],_0x7ac611=_0x1e9063[_0x1cd043];if(!_0x490b9d)continue;for(var _0x424529=_0x1cd043+0x1;_0x424529<_0x55eb75;_0x424529++){var _0x35f5d3=_0x527fd9[_0x424529],_0x2309a0=_0x1e9063[_0x424529];if(!_0x35f5d3)continue;var _0x21b271=_0x5b551f(_0x490b9d,_0x35f5d3)*0x64;if(_0x21b271>0x18==0x0)continue;if(this['\x68\x75\x65\x53\x74\x61\x74\x73'])this['\x68\x75\x65\x53\x74\x61\x74\x73']['\x63\x68\x65\x63\x6b'](_0x1b7f79);if(_0x1b7f79 in _0x5eb595)_0x5eb595[_0x1b7f79]++;else _0x5eb595[_0x1b7f79]=0x1;}};_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x63\x6f\x6c\x6f\x72\x53\x74\x61\x74\x73\x32\x44']=function colorStats2D(_0x833914,_0x552a3b){var _0x2d16e3=this['\x62\x6f\x78\x53\x69\x7a\x65'][0x0],_0x400952=this['\x62\x6f\x78\x53\x69\x7a\x65'][0x1],_0x641d95=_0x2d16e3*_0x400952,_0x4887ce=_0x405a7b(_0x552a3b,_0x833914['\x6c\x65\x6e\x67\x74\x68']/_0x552a3b,_0x2d16e3,_0x400952),_0x371bc7=this['\x68\x69\x73\x74\x6f\x67\x72\x61\x6d'],_0x44c6ce=this;_0x4887ce['\x66\x6f\x72\x45\x61\x63\x68'](function(_0x5c4a84){var _0x29082f=Math['\x6d\x61\x78'](Math['\x72\x6f\x75\x6e\x64'](_0x5c4a84['\x77']*_0x5c4a84['\x68']/_0x641d95)*_0x44c6ce['\x62\x6f\x78\x50\x78\x6c\x73'],0x2),_0x3f3f1c={},_0x41a5ec;_0x7afce0(_0x5c4a84,_0x552a3b,function(_0x32332b){_0x41a5ec=_0x833914[_0x32332b];if((_0x41a5ec&0xff000000)>>0x18==0x0)return;if(_0x44c6ce['\x68\x75\x65\x53\x74\x61\x74\x73'])_0x44c6ce['\x68\x75\x65\x53\x74\x61\x74\x73']['\x63\x68\x65\x63\x6b'](_0x41a5ec);if(_0x41a5ec in _0x371bc7)_0x371bc7[_0x41a5ec]++;else if(_0x41a5ec in _0x3f3f1c){if(++_0x3f3f1c[_0x41a5ec]>=_0x29082f)_0x371bc7[_0x41a5ec]=_0x3f3f1c[_0x41a5ec];}else _0x3f3f1c[_0x41a5ec]=0x1;});});if(this['\x68\x75\x65\x53\x74\x61\x74\x73'])this['\x68\x75\x65\x53\x74\x61\x74\x73']['\x69\x6e\x6a\x65\x63\x74'](_0x371bc7);};_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x73\x6f\x72\x74\x50\x61\x6c']=function sortPal(){var _0x1a875b=this;this['\x69\x64\x78\x69\x33\x32']['\x73\x6f\x72\x74'](function(_0x207b21,_0x24f641){var _0x3ba669=_0x1a875b['\x69\x33\x32\x69\x64\x78'][_0x207b21],_0x392967=_0x1a875b['\x69\x33\x32\x69\x64\x78'][_0x24f641],_0x56b6c9=_0x1a875b['\x69\x64\x78\x72\x67\x62'][_0x3ba669],_0x4d4318=_0x1a875b['\x69\x64\x78\x72\x67\x62'][_0x392967];var _0x1e7476=_0x3ffe90(_0x56b6c9[0x0],_0x56b6c9[0x1],_0x56b6c9[0x2]),_0x36f595=_0x3ffe90(_0x4d4318[0x0],_0x4d4318[0x1],_0x4d4318[0x2]);var _0x33f104=_0x56b6c9[0x0]==_0x56b6c9[0x1]&&_0x56b6c9[0x1]==_0x56b6c9[0x2]?-0x1:_0x2e8671(_0x1e7476['\x68'],_0x1a875b['\x68\x75\x65\x47\x72\x6f\x75\x70\x73']);var _0x1279fc=_0x4d4318[0x0]==_0x4d4318[0x1]&&_0x4d4318[0x1]==_0x4d4318[0x2]?-0x1:_0x2e8671(_0x36f595['\x68'],_0x1a875b['\x68\x75\x65\x47\x72\x6f\x75\x70\x73']);var _0x2947f4=_0x1279fc-_0x33f104;if(_0x2947f4)return-_0x2947f4;var _0x298a02=_0x4266c4(+_0x36f595['\x6c']['\x74\x6f\x46\x69\x78\x65\x64'](0x2))-_0x4266c4(+_0x1e7476['\x6c']['\x74\x6f\x46\x69\x78\x65\x64'](0x2));if(_0x298a02)return-_0x298a02;var _0x1fce61=_0x326408(+_0x36f595['\x73']['\x74\x6f\x46\x69\x78\x65\x64'](0x2))-_0x326408(+_0x1e7476['\x73']['\x74\x6f\x46\x69\x78\x65\x64'](0x2));if(_0x1fce61)return-_0x1fce61;});this['\x69\x64\x78\x69\x33\x32']['\x66\x6f\x72\x45\x61\x63\x68'](function(_0x474a95,_0x44c5c3){_0x1a875b['\x69\x64\x78\x72\x67\x62'][_0x44c5c3]=_0x1a875b['\x69\x33\x32\x72\x67\x62'][_0x474a95];_0x1a875b['\x69\x33\x32\x69\x64\x78'][_0x474a95]=_0x44c5c3;});};_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x6e\x65\x61\x72\x65\x73\x74\x43\x6f\x6c\x6f\x72']=function nearestColor(_0x17d140){var _0x5d6498=this['\x6e\x65\x61\x72\x65\x73\x74\x49\x6e\x64\x65\x78'](_0x17d140);return _0x5d6498===null?0x0:this['\x69\x64\x78\x69\x33\x32'][_0x5d6498];};_0x4db3ff['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x6e\x65\x61\x72\x65\x73\x74\x49\x6e\x64\x65\x78']=function nearestIndex(_0xb4dddc){if((_0xb4dddc&0xff000000)>>0x18==0x0)return null;var _0x4f471b=this['\x69\x33\x32\x69\x33\x32'][_0xb4dddc];if(_0x4f471b)return this['\x69\x33\x32\x69\x64\x78'][_0x4f471b];var _0x42580f=0x3e8,_0x540576,_0x33e966=[_0xb4dddc&0xff,(_0xb4dddc&0xff00)>>0x8,(_0xb4dddc&0xff0000)>>0x10],_0x4e6991=this['\x69\x64\x78\x72\x67\x62']['\x6c\x65\x6e\x67\x74\x68'];for(var _0x13251e=0x0;_0x13251e<_0x4e6991;_0x13251e++){var _0x9e245f=_0x5b551f(_0x33e966,this['\x69\x64\x78\x72\x67\x62'][_0x13251e]);if(_0x9e245f<_0x42580f){_0x42580f=_0x9e245f;_0x540576=_0x13251e;}}return _0x540576;};function _0x57203b(_0x288981,_0x61071a){this['\x6e\x75\x6d\x47\x72\x6f\x75\x70\x73']=_0x288981;this['\x6d\x69\x6e\x43\x6f\x6c\x73']=_0x61071a;this['\x73\x74\x61\x74\x73']={};for(var _0x4696e0=-0x1;_0x4696e0<_0x288981;_0x4696e0++)this['\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x33\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x34\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x36\x5c\x78\x33\x31\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x34\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x33'][_0x4696e0]={};this['\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x33\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x34\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x36\x5c\x78\x33\x31\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x34\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x33'][_0x4696e0]['\x6e\x75\x6d']=0x0;this['\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x33\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x34\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x36\x5c\x78\x33\x31\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x34\x5c\x78\x35\x63\x5c\x78\x37\x38\x5c\x78\x33\x37\x5c\x78\x33\x33'][_0x4696e0]['\x63\x6f\x6c\x73']=[];this['\x67\x72\x6f\x75\x70\x73\x46\x75\x6c\x6c']=0x0;}_0x57203b['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x63\x68\x65\x63\x6b']=function checkHue(_0x3adcba){if(this['\x67\x72\x6f\x75\x70\x73\x46\x75\x6c\x6c']==this['\x6e\x75\x6d\x47\x72\x6f\x75\x70\x73']+0x1)this['\x63\x68\x65\x63\x6b']=function(){return;};var _0x5021cc=_0x3adcba&0xff,_0xcee686=(_0x3adcba&0xff00)>>0x8,_0x3e07b1=(_0x3adcba&0xff0000)>>0x10,_0x5ab897=_0x5021cc==_0xcee686&&_0xcee686==_0x3e07b1?-0x1:_0x2e8671(_0x3ffe90(_0x5021cc,_0xcee686,_0x3e07b1)['\x68'],this['\x6e\x75\x6d\x47\x72\x6f\x75\x70\x73']),_0x3547cf=this['\x73\x74\x61\x74\x73'][_0x5ab897],_0x50965d=this['\x6d\x69\x6e\x43\x6f\x6c\x73'];_0x3547cf['\x6e\x75\x6d']++;if(_0x3547cf['\x6e\x75\x6d']>_0x50965d)return;if(_0x3547cf['\x6e\x75\x6d']==_0x50965d)this['\x67\x72\x6f\x75\x70\x73\x46\x75\x6c\x6c']++;if(_0x3547cf['\x6e\x75\x6d']<=_0x50965d)this['\x73\x74\x61\x74\x73'][_0x5ab897]['\x63\x6f\x6c\x73']['\x70\x75\x73\x68'](_0x3adcba);};_0x57203b['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x69\x6e\x6a\x65\x63\x74']=function injectHues(_0x28a858){for(var _0xedeeec=-0x1;_0xedeeec0.5?_0x53f1b6/(0x2-_0x11cf9e-_0x10cef3):_0x53f1b6/(_0x11cf9e+_0x10cef3);switch(_0x11cf9e){case _0x3c09c4:_0x2e53f4=(_0x10dccc-_0x4393c0)/_0x53f1b6+(_0x10dccc<_0x4393c0?0x6:0x0);break;case _0x10dccc:_0x2e53f4=(_0x4393c0-_0x3c09c4)/_0x53f1b6+0x2;break;case _0x4393c0:_0x2e53f4=(_0x3c09c4-_0x10dccc)/_0x53f1b6+0x4;break;}_0x2e53f4/=0x6;}return{'\x68':_0x2e53f4,'\x73':_0x22745e,'\x6c':_0x252a05(_0x3c09c4,_0x10dccc,_0x4393c0)};}function _0x2e8671(_0x56a8c6,_0x2cf08b){var _0x1a317b=0x1/_0x2cf08b,_0x55be05=_0x1a317b/0x2;if(_0x56a8c6>=0x1-_0x55be05||_0x56a8c6<=_0x55be05)return 0x0;for(var _0x3181e4=0x1;_0x3181e4<_0x2cf08b;_0x3181e4++){var _0x352b80=_0x3181e4*_0x1a317b;if(_0x56a8c6>=_0x352b80-_0x55be05&&_0x56a8c6<=_0x352b80+_0x55be05)return _0x3181e4;}}function _0x326408(_0x10d287){return _0x10d287;}function _0x4266c4(_0x4700e9){return _0x4700e9;}function _0x4e9995(_0x2bf5c7){return Object['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x74\x6f\x53\x74\x72\x69\x6e\x67']['\x63\x61\x6c\x6c'](_0x2bf5c7)['\x73\x6c\x69\x63\x65'](0x8,-0x1);}var _0x23a0e7=_0x1e2e31()?Array['\x70\x72\x6f\x74\x6f\x74\x79\x70\x65']['\x73\x6f\x72\x74']:_0x2aa31f;function _0x2aa31f(_0x338e77){var _0x1adbaa=_0x4e9995(this[0x0]);if(_0x1adbaa=='\x4e\x75\x6d\x62\x65\x72'||_0x1adbaa=='\x53\x74\x72\x69\x6e\x67'){var _0x405e1e={},_0x12d561=this['\x6c\x65\x6e\x67\x74\x68'],_0x3b908a;for(var _0x13cfc8=0x0;_0x13cfc8<_0x12d561;_0x13cfc8++){_0x3b908a=this[_0x13cfc8];if(_0x405e1e[_0x3b908a]||_0x405e1e[_0x3b908a]===0x0)continue;_0x405e1e[_0x3b908a]=_0x13cfc8;}return this['\x73\x6f\x72\x74'](function(_0xa9d1f5,_0x13eaf6){return _0x338e77(_0xa9d1f5,_0x13eaf6)||_0x405e1e[_0xa9d1f5]-_0x405e1e[_0x13eaf6];});}else{var _0x405e1e=this['\x6d\x61\x70'](function(_0x406225){return _0x406225;});return this['\x73\x6f\x72\x74'](function(_0x1e9342,_0x506bae){return _0x338e77(_0x1e9342,_0x506bae)||_0x405e1e['\x69\x6e\x64\x65\x78\x4f\x66'](_0x1e9342)-_0x405e1e['\x69\x6e\x64\x65\x78\x4f\x66'](_0x506bae);});}}function _0x1e2e31(){var _0x551a35='\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a';return'\x78\x79\x7a\x76\x77\x74\x75\x72\x73\x6f\x70\x71\x6d\x6e\x6b\x6c\x68\x69\x6a\x66\x67\x64\x65\x61\x62\x63'==_0x551a35['\x73\x70\x6c\x69\x74']('')['\x73\x6f\x72\x74'](function(_0x176d7a,_0x18f158){return~~(_0x551a35['\x69\x6e\x64\x65\x78\x4f\x66'](_0x18f158)/2.3)-~~(_0x551a35['\x69\x6e\x64\x65\x78\x4f\x66'](_0x176d7a)/2.3);})['\x6a\x6f\x69\x6e']('');}function _0x54bd78(_0x2b5918,_0x43c1cf){var _0x1961b8,_0x52c2e2,_0x2d90c1,_0x42133d,_0x1dd272,_0x4687ea;switch(_0x4e9995(_0x2b5918)){case'\x48\x54\x4d\x4c\x49\x6d\x61\x67\x65\x45\x6c\x65\x6d\x65\x6e\x74':_0x1961b8=document['\x63\x72\x65\x61\x74\x65\x45\x6c\x65\x6d\x65\x6e\x74']('\x63\x61\x6e\x76\x61\x73');_0x1961b8['\x77\x69\x64\x74\x68']=_0x2b5918['\x6e\x61\x74\x75\x72\x61\x6c\x57\x69\x64\x74\x68'];_0x1961b8['\x68\x65\x69\x67\x68\x74']=_0x2b5918['\x6e\x61\x74\x75\x72\x61\x6c\x48\x65\x69\x67\x68\x74'];_0x52c2e2=_0x1961b8['\x67\x65\x74\x43\x6f\x6e\x74\x65\x78\x74']('\x32\x64');_0x52c2e2['\x64\x72\x61\x77\x49\x6d\x61\x67\x65'](_0x2b5918,0x0,0x0);case'\x48\x54\x4d\x4c\x43\x61\x6e\x76\x61\x73\x45\x6c\x65\x6d\x65\x6e\x74':_0x1961b8=_0x1961b8||_0x2b5918;_0x52c2e2=_0x52c2e2||_0x1961b8['\x67\x65\x74\x43\x6f\x6e\x74\x65\x78\x74']('\x32\x64');case'\x43\x61\x6e\x76\x61\x73\x52\x65\x6e\x64\x65\x72\x69\x6e\x67\x43\x6f\x6e\x74\x65\x78\x74\x32\x44':_0x52c2e2=_0x52c2e2||_0x2b5918;_0x1961b8=_0x1961b8||_0x52c2e2['\x63\x61\x6e\x76\x61\x73'];_0x2d90c1=_0x52c2e2['\x67\x65\x74\x49\x6d\x61\x67\x65\x44\x61\x74\x61'](0x0,0x0,_0x1961b8['\x77\x69\x64\x74\x68'],_0x1961b8['\x68\x65\x69\x67\x68\x74']);case'\x49\x6d\x61\x67\x65\x44\x61\x74\x61':_0x2d90c1=_0x2d90c1||_0x2b5918;_0x42133d=_0x2d90c1['\x64\x61\x74\x61'];_0x43c1cf=_0x2d90c1['\x77\x69\x64\x74\x68'];case'\x41\x72\x72\x61\x79':_0x42133d=_0x42133d||new Uint8Array(_0x2b5918);case'\x55\x69\x6e\x74\x38\x41\x72\x72\x61\x79':case'\x55\x69\x6e\x74\x38\x43\x6c\x61\x6d\x70\x65\x64\x41\x72\x72\x61\x79':_0x42133d=_0x42133d||_0x2b5918;_0x1dd272=new Uint32Array(_0x42133d['\x62\x75\x66\x66\x65\x72']);case'\x55\x69\x6e\x74\x33\x32\x41\x72\x72\x61\x79':_0x1dd272=_0x1dd272||_0x2b5918;_0x42133d=_0x42133d||new Uint8Array(_0x1dd272['\x62\x75\x66\x66\x65\x72']);_0x43c1cf=_0x43c1cf||_0x1dd272['\x6c\x65\x6e\x67\x74\x68'];_0x4687ea=_0x1dd272['\x6c\x65\x6e\x67\x74\x68']/_0x43c1cf;}return{'\x63\x61\x6e':_0x1961b8,'\x63\x74\x78':_0x52c2e2,'\x69\x6d\x67\x64':_0x2d90c1,'\x62\x75\x66\x38':_0x42133d,'\x62\x75\x66\x33\x32':_0x1dd272,'\x77\x69\x64\x74\x68':_0x43c1cf,'\x68\x65\x69\x67\x68\x74':_0x4687ea};}function _0x405a7b(_0x533f08,_0x1f6e4d,_0x347e61,_0x565984){var _0x1ff064=~~(_0x533f08/_0x347e61),_0x54f466=_0x533f08%_0x347e61,_0x447bce=~~(_0x1f6e4d/_0x565984),_0x5285f2=_0x1f6e4d%_0x565984,_0x544229=_0x533f08-_0x54f466,_0x3ce3a5=_0x1f6e4d-_0x5285f2;var _0x156971=[];for(var _0x2f93d9=0x0;_0x2f93d9<_0x1f6e4d;_0x2f93d9+=_0x565984)for(var _0x47c024=0x0;_0x47c024<_0x533f08;_0x47c024+=_0x347e61)_0x156971['\x70\x75\x73\x68']({'\x78':_0x47c024,'\x79':_0x2f93d9,'\x77':_0x47c024==_0x544229?_0x54f466:_0x347e61,'\x68':_0x2f93d9==_0x3ce3a5?_0x5285f2:_0x565984});return _0x156971;}function _0x7afce0(_0x5f16c7,_0x51c213,_0x2770a6){var _0x2a0003=_0x5f16c7,_0x31b9f8=_0x2a0003['\x79']*_0x51c213+_0x2a0003['\x78'],_0x3222b6=(_0x2a0003['\x79']+_0x2a0003['\x68']-0x1)*_0x51c213+(_0x2a0003['\x78']+_0x2a0003['\x77']-0x1),_0xcfcc49=0x0,_0x345337=_0x51c213-_0x2a0003['\x77']+0x1,_0x34c4cb=_0x31b9f8;do{_0x2770a6['\x63\x61\x6c\x6c'](this,_0x34c4cb);_0x34c4cb+=++_0xcfcc49%_0x2a0003['\x77']==0x0?_0x345337:0x1;}while(_0x34c4cb<=_0x3222b6);}function _0x578574(_0x5c678d,_0x1a4a9b){var _0x42c95f=[];for(var _0x9425f6 in _0x5c678d)_0x42c95f['\x70\x75\x73\x68'](_0x9425f6);return _0x23a0e7['\x63\x61\x6c\x6c'](_0x42c95f,function(_0x525135,_0x962967){return _0x1a4a9b?_0x5c678d[_0x962967]-_0x5c678d[_0x525135]:_0x5c678d[_0x525135]-_0x5c678d[_0x962967];});}this['\x52\x67\x62\x51\x75\x61\x6e\x74']=_0x4db3ff;}['\x63\x61\x6c\x6c'](this));
\ No newline at end of file
diff --git a/public/convert.html b/public/convert.html
new file mode 100644
index 0000000..261e40a
--- /dev/null
+++ b/public/convert.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ PixelPlace.fun - Image Converter
+
+
+
+
+
+
+
+
+ Select your image:
+ Conversion Strategy:
+ Default
+ FloydSteinberg
+ Stucki Atkinson
+ Jarvis
+ Burkes
+ Sierra
+ TwoSierra
+ SierraLite
+ FalseFloyd
+
+
+ Github Reference!
+
+ Converted image
+
+
diff --git a/public/convert2.html b/public/convert2.html
new file mode 100644
index 0000000..87da279
--- /dev/null
+++ b/public/convert2.html
@@ -0,0 +1,41 @@
+
+
+
+
+
+ PixelPlace.fun - Image Converter
+
+
+
+
+
+
+
+
+ Select your image:
+ Conversion Strategy:
+ Default
+ FloydSteinberg
+ Stucki Atkinson
+ Jarvis
+ Burkes
+ Sierra
+ TwoSierra
+ SierraLite
+ FalseFloyd
+
+
+ Github Reference!
+
+ Converted image
+
+
diff --git a/public/discordlogo.svg b/public/discordlogo.svg
new file mode 100644
index 0000000..65c9fcc
--- /dev/null
+++ b/public/discordlogo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/facebooklogo.svg b/public/facebooklogo.svg
new file mode 100644
index 0000000..e19a18f
--- /dev/null
+++ b/public/facebooklogo.svg
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..e1e57db
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/go/Detector.js b/public/go/Detector.js
new file mode 100644
index 0000000..315f8ed
--- /dev/null
+++ b/public/go/Detector.js
@@ -0,0 +1,40 @@
+/**
+ * @author alteredq / http://alteredqualia.com/
+ * @author mr.doob / http://mrdoob.com/
+ */
+
+var Detector = {
+
+ canvas: !! window.CanvasRenderingContext2D,
+ webgl: ( function () { try { var canvas = document.createElement( 'canvas' ); return !! window.WebGLRenderingContext && ( canvas.getContext( 'webgl' ) || canvas.getContext( 'experimental-webgl' ) ); } catch( e ) { return false; } } )(),
+ workers: !! window.Worker,
+ fileapi: window.File && window.FileReader && window.FileList && window.Blob,
+
+ getWebGLErrorMessage: function () {
+
+ var element = document.createElement( 'div' );
+ element.className = 'webgl-error';
+
+ if ( !this.webgl ) {
+
+ element.innerHTML = window.WebGLRenderingContext ? [
+ 'Your graphics card does not seem to support WebGL . ',
+ 'Find out how to get it here .'
+ ].join( '\n' ) : [
+ 'Your browser does not seem to support WebGL . ',
+ 'Find out how to get it here .'
+ ].join( '\n' );
+
+ }
+
+ return element;
+
+ },
+
+ addGetWebGLMessage: function (parent ) {
+
+ parent.appendChild( Detector.getWebGLErrorMessage() );
+
+ }
+
+};
\ No newline at end of file
diff --git a/public/go/TrackballControls.js b/public/go/TrackballControls.js
new file mode 100644
index 0000000..c1fa60d
--- /dev/null
+++ b/public/go/TrackballControls.js
@@ -0,0 +1,537 @@
+/**
+ * @author Eberhard Graether / http://egraether.com/
+ */
+
+THREE.TrackballControls = function ( object, domElement ) {
+
+ var _this = this;
+ var STATE = { NONE: -1, ROTATE: 0, ZOOM: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_ZOOM: 4, TOUCH_PAN: 5 };
+
+ this.object = object;
+ this.domElement = ( domElement !== undefined ) ? domElement : document;
+
+ // API
+
+ this.enabled = true;
+
+ this.screen = { width: 0, height: 0, offsetLeft: 0, offsetTop: 0 };
+ this.radius = ( this.screen.width + this.screen.height ) / 4;
+
+ this.rotateSpeed = 1.0;
+ this.zoomSpeed = 1.2;
+ this.panSpeed = 0.3;
+
+ this.noRotate = false;
+ this.noZoom = false;
+ this.noPan = false;
+
+ this.staticMoving = false;
+ this.dynamicDampingFactor = 0.2;
+
+ this.minDistance = 1.6;
+ this.maxDistance = 118.32;
+
+ this.keys = [ 65 /*A*/, 83 /*S*/, 68 /*D*/ ];
+
+ // internals
+
+ this.target = new THREE.Vector3();
+
+ var lastPosition = new THREE.Vector3();
+
+ var _state = STATE.NONE,
+ _prevState = STATE.NONE,
+
+ _eye = new THREE.Vector3(),
+
+ _rotateStart = new THREE.Vector3(),
+ _rotateEnd = new THREE.Vector3(),
+
+ _zoomStart = new THREE.Vector2(),
+ _zoomEnd = new THREE.Vector2(),
+
+ _touchZoomDistanceStart = 0,
+ _touchZoomDistanceEnd = 0,
+
+ _panStart = new THREE.Vector2(),
+ _panEnd = new THREE.Vector2();
+
+ // for reset
+
+ this.target0 = this.target.clone();
+ this.position0 = this.object.position.clone();
+ this.up0 = this.object.up.clone();
+
+ // events
+
+ var changeEvent = { type: 'change' };
+
+
+ // methods
+
+ this.handleResize = function () {
+
+ this.screen.width = window.innerWidth;
+ this.screen.height = window.innerHeight;
+
+ this.screen.offsetLeft = 0;
+ this.screen.offsetTop = 0;
+
+ this.radius = ( this.screen.width + this.screen.height ) / 4;
+
+ };
+
+ this.handleEvent = function ( event ) {
+
+ if ( typeof this[ event.type ] == 'function' ) {
+
+ this[ event.type ]( event );
+
+ }
+
+ };
+
+ this.getMouseOnScreen = function ( clientX, clientY ) {
+
+ return new THREE.Vector2(
+ ( clientX - _this.screen.offsetLeft ) / _this.radius * 0.5,
+ ( clientY - _this.screen.offsetTop ) / _this.radius * 0.5
+ );
+
+ };
+
+ this.getMouseProjectionOnBall = function ( clientX, clientY ) {
+
+ var mouseOnBall = new THREE.Vector3(
+ ( clientX - _this.screen.width * 0.5 - _this.screen.offsetLeft ) / _this.radius,
+ ( _this.screen.height * 0.5 + _this.screen.offsetTop - clientY ) / _this.radius,
+ 0.0
+ );
+
+ var length = mouseOnBall.length();
+
+ if ( length > 1.0 ) {
+
+ mouseOnBall.normalize();
+
+ } else {
+
+ mouseOnBall.z = Math.sqrt( 1.0 - length * length );
+
+ }
+
+ _eye.copy( _this.object.position ).sub( _this.target );
+
+ var projection = _this.object.up.clone().setLength( mouseOnBall.y );
+ projection.add( _this.object.up.clone().cross( _eye ).setLength( mouseOnBall.x ) );
+ projection.add( _eye.setLength( mouseOnBall.z ) );
+
+ return projection;
+
+ };
+
+ this.rotateCamera = function () {
+
+ var angle = Math.acos( _rotateStart.dot( _rotateEnd ) / _rotateStart.length() / _rotateEnd.length() );
+
+ if ( angle ) {
+
+ var axis = ( new THREE.Vector3() ).crossVectors( _rotateStart, _rotateEnd ).normalize();
+ quaternion = new THREE.Quaternion();
+
+ angle *= _this.rotateSpeed;
+
+ quaternion.setFromAxisAngle( axis, -angle );
+
+ _eye.applyQuaternion( quaternion );
+ _this.object.up.applyQuaternion( quaternion );
+
+ _rotateEnd.applyQuaternion( quaternion );
+
+ if ( _this.staticMoving ) {
+
+ _rotateStart.copy( _rotateEnd );
+
+ } else {
+
+ quaternion.setFromAxisAngle( axis, angle * ( _this.dynamicDampingFactor - 1.0 ) );
+ _rotateStart.applyQuaternion( quaternion );
+
+ }
+
+ }
+
+ };
+
+ this.zoomCamera = function () {
+
+ if ( _state === STATE.TOUCH_ZOOM ) {
+
+ var factor = _touchZoomDistanceStart / _touchZoomDistanceEnd;
+ _touchZoomDistanceStart = _touchZoomDistanceEnd;
+ _eye.multiplyScalar( factor );
+
+ } else {
+
+ var factor = 1.0 + ( _zoomEnd.y - _zoomStart.y ) * _this.zoomSpeed;
+
+ if ( factor !== 1.0 && factor > 0.0 ) {
+
+ _eye.multiplyScalar( factor );
+
+ if ( _this.staticMoving ) {
+
+ _zoomStart.copy( _zoomEnd );
+
+ } else {
+
+ _zoomStart.y += ( _zoomEnd.y - _zoomStart.y ) * this.dynamicDampingFactor;
+
+ }
+
+ }
+
+ }
+
+ };
+
+ this.panCamera = function () {
+
+ var mouseChange = _panEnd.clone().sub( _panStart );
+
+ if ( mouseChange.lengthSq() ) {
+
+ mouseChange.multiplyScalar( _eye.length() * _this.panSpeed );
+
+ var pan = _eye.clone().cross( _this.object.up ).setLength( mouseChange.x );
+ pan.add( _this.object.up.clone().setLength( mouseChange.y ) );
+
+ _this.object.position.add( pan );
+ _this.target.add( pan );
+
+ if ( _this.staticMoving ) {
+
+ _panStart = _panEnd;
+
+ } else {
+
+ _panStart.add( mouseChange.subVectors( _panEnd, _panStart ).multiplyScalar( _this.dynamicDampingFactor ) );
+
+ }
+
+ }
+
+ };
+
+ this.checkDistances = function () {
+
+ if ( !_this.noZoom || !_this.noPan ) {
+
+ if ( _this.object.position.lengthSq() > _this.maxDistance * _this.maxDistance ) {
+
+ _this.object.position.setLength( _this.maxDistance );
+
+ }
+
+ if ( _eye.lengthSq() < _this.minDistance * _this.minDistance ) {
+
+ _this.object.position.addVectors( _this.target, _eye.setLength( _this.minDistance ) );
+
+ }
+
+ }
+
+ };
+
+ this.update = function () {
+
+ _eye.subVectors( _this.object.position, _this.target );
+
+ if ( !_this.noRotate ) {
+
+ _this.rotateCamera();
+
+ }
+
+ if ( !_this.noZoom ) {
+
+ _this.zoomCamera();
+
+ }
+
+ if ( !_this.noPan ) {
+
+ _this.panCamera();
+
+ }
+
+ _this.object.position.addVectors( _this.target, _eye );
+
+ _this.checkDistances();
+
+ _this.object.lookAt( _this.target );
+
+ if ( lastPosition.distanceToSquared( _this.object.position ) > 0 ) {
+
+ _this.dispatchEvent( changeEvent );
+
+ lastPosition.copy( _this.object.position );
+
+ }
+
+ };
+
+ this.reset = function () {
+
+ _state = STATE.NONE;
+ _prevState = STATE.NONE;
+
+ _this.target.copy( _this.target0 );
+ _this.object.position.copy( _this.position0 );
+ _this.object.up.copy( _this.up0 );
+
+ _eye.subVectors( _this.object.position, _this.target );
+
+ _this.object.lookAt( _this.target );
+
+ _this.dispatchEvent( changeEvent );
+
+ lastPosition.copy( _this.object.position );
+
+ };
+
+ // listeners
+
+ function keydown( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ window.removeEventListener( 'keydown', keydown );
+
+ _prevState = _state;
+
+ if ( _state !== STATE.NONE ) {
+
+ return;
+
+ } else if ( event.keyCode === _this.keys[ STATE.ROTATE ] && !_this.noRotate ) {
+
+ _state = STATE.ROTATE;
+
+ } else if ( event.keyCode === _this.keys[ STATE.ZOOM ] && !_this.noZoom ) {
+
+ _state = STATE.ZOOM;
+
+ } else if ( event.keyCode === _this.keys[ STATE.PAN ] && !_this.noPan ) {
+
+ _state = STATE.PAN;
+
+ }
+
+ }
+
+ function keyup( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ _state = _prevState;
+
+ window.addEventListener( 'keydown', keydown, false );
+
+ }
+
+ function mousedown( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if ( _state === STATE.NONE ) {
+
+ _state = event.button;
+
+ }
+
+ if ( _state === STATE.ROTATE && !_this.noRotate ) {
+
+ _rotateStart = _rotateEnd = _this.getMouseProjectionOnBall( event.clientX, event.clientY );
+
+ } else if ( _state === STATE.ZOOM && !_this.noZoom ) {
+
+ _zoomStart = _zoomEnd = _this.getMouseOnScreen( event.clientX, event.clientY );
+
+ } else if ( _state === STATE.PAN && !_this.noPan ) {
+
+ _panStart = _panEnd = _this.getMouseOnScreen( event.clientX, event.clientY );
+
+ }
+
+ document.addEventListener( 'mousemove', mousemove, false );
+ document.addEventListener( 'mouseup', mouseup, false );
+
+ }
+
+ function mousemove( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if ( _state === STATE.ROTATE && !_this.noRotate ) {
+
+ _rotateEnd = _this.getMouseProjectionOnBall( event.clientX, event.clientY );
+
+ } else if ( _state === STATE.ZOOM && !_this.noZoom ) {
+
+ _zoomEnd = _this.getMouseOnScreen( event.clientX, event.clientY );
+
+ } else if ( _state === STATE.PAN && !_this.noPan ) {
+
+ _panEnd = _this.getMouseOnScreen( event.clientX, event.clientY );
+
+ }
+
+ }
+
+ function mouseup( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ _state = STATE.NONE;
+
+ document.removeEventListener( 'mousemove', mousemove );
+ document.removeEventListener( 'mouseup', mouseup );
+
+ }
+
+ function mousewheel( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ var delta = 0;
+
+ if ( event.wheelDelta ) { // WebKit / Opera / Explorer 9
+
+ delta = event.wheelDelta / 40;
+
+ } else if ( event.detail ) { // Firefox
+
+ delta = - event.detail / 3;
+
+ }
+
+ _zoomStart.y += delta * 0.01;
+
+ }
+
+ function touchstart( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ switch ( event.touches.length ) {
+
+ case 1:
+ _state = STATE.TOUCH_ROTATE;
+ _rotateStart = _rotateEnd = _this.getMouseProjectionOnBall( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ case 2:
+ _state = STATE.TOUCH_ZOOM;
+ var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
+ var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
+ _touchZoomDistanceEnd = _touchZoomDistanceStart = Math.sqrt( dx * dx + dy * dy );
+ break;
+
+ case 3:
+ _state = STATE.TOUCH_PAN;
+ _panStart = _panEnd = _this.getMouseOnScreen( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ default:
+ _state = STATE.NONE;
+
+ }
+
+ }
+
+ function touchmove( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ switch ( event.touches.length ) {
+
+ case 1:
+ _rotateEnd = _this.getMouseProjectionOnBall( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ case 2:
+ var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
+ var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
+ _touchZoomDistanceEnd = Math.sqrt( dx * dx + dy * dy )
+ break;
+
+ case 3:
+ _panEnd = _this.getMouseOnScreen( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ default:
+ _state = STATE.NONE;
+
+ }
+
+ }
+
+ function touchend( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ switch ( event.touches.length ) {
+
+ case 1:
+ _rotateStart = _rotateEnd = _this.getMouseProjectionOnBall( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ case 2:
+ _touchZoomDistanceStart = _touchZoomDistanceEnd = 0;
+ break;
+
+ case 3:
+ _panStart = _panEnd = _this.getMouseOnScreen( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ }
+
+ _state = STATE.NONE;
+
+ }
+
+ this.domElement.addEventListener( 'contextmenu', function ( event ) { event.preventDefault(); }, false );
+
+ this.domElement.addEventListener( 'mousedown', mousedown, false );
+
+ this.domElement.addEventListener( 'mousewheel', mousewheel, false );
+ this.domElement.addEventListener( 'DOMMouseScroll', mousewheel, false ); // firefox
+
+ this.domElement.addEventListener( 'touchstart', touchstart, false );
+ this.domElement.addEventListener( 'touchend', touchend, false );
+ this.domElement.addEventListener( 'touchmove', touchmove, false );
+
+ window.addEventListener( 'keydown', keydown, false );
+ window.addEventListener( 'keyup', keyup, false );
+
+ this.handleResize();
+
+};
+
+THREE.TrackballControls.prototype = Object.create( THREE.EventDispatcher.prototype );
diff --git a/public/go/clouds.png b/public/go/clouds.png
new file mode 100644
index 0000000..528ca99
Binary files /dev/null and b/public/go/clouds.png differ
diff --git a/public/go/globe.glb b/public/go/globe.glb
new file mode 100644
index 0000000..d86553c
Binary files /dev/null and b/public/go/globe.glb differ
diff --git a/public/go/index.html b/public/go/index.html
new file mode 100644
index 0000000..e22852f
--- /dev/null
+++ b/public/go/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+ PixelPlanet.fun
+
+
+
+
+ (0, 0)
+ Double click on globe to go back.
+
+
+
+
+
+
+
diff --git a/public/go/normal.jpg b/public/go/normal.jpg
new file mode 100644
index 0000000..f049bc5
Binary files /dev/null and b/public/go/normal.jpg differ
diff --git a/public/go/normalm.jpg b/public/go/normalm.jpg
new file mode 100644
index 0000000..70e14c4
Binary files /dev/null and b/public/go/normalm.jpg differ
diff --git a/public/go/space.js b/public/go/space.js
new file mode 100644
index 0000000..2ebde19
--- /dev/null
+++ b/public/go/space.js
@@ -0,0 +1,178 @@
+(function () {
+ function checkMaterial(object) {
+ if (object.material) {
+ const materialName = object.material.name;
+ if (materialName == "canvas") {
+ console.log("Found material");
+ object.material = canvasTexture;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ function parseHashCoords() {
+ try {
+ const hash = window.location.hash;
+ const array = hash.substring(1).split(',');
+ const ident = array.shift();
+ const [id, size, x, y] = array.map((z) => parseInt(z));
+ if (!ident || isNaN(x) || isNaN(y) || isNaN(id) || isNaN(size)) {
+ throw "NaN";
+ }
+ return [ident, id, size, x, y];
+ } catch (error) {
+ return ['d', 0, 65536, 0, 0];
+ };
+ }
+
+ function rotateToCoords(canvasSize, object, coords) {
+ console.log("Rotate to", coords);
+ const [x, y] = coords;
+ const rotOffsetX = 0;
+ const rotOffsetY = 3 * Math.PI / 2;
+ const rotX = -y * Math.PI / canvasSize;
+ const rotY = -x * 2 * Math.PI / canvasSize;
+ object.rotation.x += rotOffsetX + rotX;
+ object.rotation.y += rotOffsetY + rotY;
+ }
+
+ var webglEl = document.getElementById('webgl');
+
+ if (!Detector.webgl) {
+ Detector.addGetWebGLMessage(webglEl);
+ return;
+ }
+
+ const [canvasIdent, canvasId, canvasSize, x, y] = parseHashCoords();
+
+ const canvasTexture = new THREE.MeshPhongMaterial({
+ map: new THREE.TextureLoader().load(`../tiles/${canvasId}/texture.png`),
+ bumpMap: new THREE.TextureLoader().load((canvasId == 0) ? 'normal.jpg' : 'normalm.jpg'),
+ bumpScale: 0.02,
+ specularMap: new THREE.TextureLoader().load((canvasId == 0) ? 'specular.jpg' : 'specularm.jpg'),
+ specular: new THREE.Color('grey')
+ });
+
+ var width = window.innerWidth,
+ height = window.innerHeight;
+
+ var scene = new THREE.Scene();
+
+ var camera = new THREE.PerspectiveCamera(45, width / height, 0.01, 1000);
+ camera.position.z = 4.0;
+
+ var renderer = new THREE.WebGLRenderer();
+ renderer.setSize(width, height);
+
+ scene.add(new THREE.AmbientLight(0x333333));
+
+ var light = new THREE.DirectionalLight(0xffffff, 0.7);
+ light.position.set(10,6,10);
+ scene.add(light);
+
+ var object = null;
+ var loader = new THREE.GLTFLoader();
+ loader.load('globe.glb', function (glb) {
+ scene.add(glb.scene);
+ const children = glb.scene.children;
+ for (let cnt = 0; cnt < children.length; cnt++) {
+ //children[cnt].scale.x *= -1;
+ //children[cnt].scale.y *= -1;
+ if (checkMaterial(children[cnt]))
+ object = children[cnt];
+ const grandchildren = children[cnt].children;
+ for (let xnt = 0; xnt < grandchildren.length; xnt++) {
+ if (checkMaterial(grandchildren[xnt]))
+ object = children[cnt];
+ //children[cnt].material.side = THREE.DoubleSide;
+ }
+ }
+ rotateToCoords(canvasSize, object, [x, y]);
+ }, function (xhr) {console.log((xhr.loaded / xhr.total * 100) + '% loaded');
+ }, function (error) {console.log('An error happened', error);
+ });
+
+
+ // Earth params
+ var radius = 0.5,
+ segments = 32,
+ rotation = 6;
+
+
+ var stars = createStars(90, 64);
+ scene.add(stars);
+
+ var controls = new THREE.TrackballControls(camera);
+
+ webglEl.appendChild(renderer.domElement);
+
+ render();
+
+ function render() {
+ controls.update();
+ if (object) object.rotation.y += 0.0005;
+ requestAnimationFrame(render);
+ renderer.render(scene, camera);
+ }
+
+ function createStars(radius, segments) {
+ return new THREE.Mesh(
+ new THREE.SphereGeometry(radius, segments, segments),
+ new THREE.MeshBasicMaterial({
+ map: THREE.ImageUtils.loadTexture('starfield.png'),
+ side: THREE.BackSide
+ })
+ );
+ }
+
+ setInterval(onDocumentMouseMove, 1000);
+
+ var raycaster = new THREE.Raycaster();
+ var mouse = new THREE.Vector2();
+ var lastView = [0, 0];
+ const coorbox = document.getElementById("coorbox");
+ function onDocumentMouseMove(event) {
+ if (event) {
+ mouse.x = ( event.clientX / window.innerWidth ) * 2 - 1;
+ mouse.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
+ } else {
+ mouse.x = 0.0;
+ mouse.y = 0.0;
+ }
+
+ raycaster.setFromCamera( mouse, camera );
+ var intersects = raycaster.intersectObject( object );
+
+ const elem = document.getElementsByTagName("BODY")[0];
+ if(intersects.length > 0) {
+ const { x, y } = intersects[0].uv;
+ const xabs = Math.floor((x - 0.5) * canvasSize);
+ const yabs = Math.floor((0.5 - y) * canvasSize);
+ //console.log(`On ${xabs} / ${yabs} cam: ${camera.position.z}`);
+ coorbox.innerHTML = `(${xabs}, ${yabs})`;
+ elem.style.cursor = 'move';
+ } else {
+ elem.style.cursor = 'default';
+ }
+ }
+
+ function onDocumentDblClick(event) {
+ mouse.x = ( event.clientX / window.innerWidth ) * 2 - 1;
+ mouse.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
+
+ raycaster.setFromCamera( mouse, camera );
+ var intersects = raycaster.intersectObject( object );
+
+ if(intersects.length > 0) {
+ const { x, y } = intersects[0].uv;
+ const xabs = Math.round((x - 0.5) * canvasSize);
+ const yabs = Math.round((0.5 - y) * canvasSize);
+ window.location.href = `../#${canvasIdent},${xabs},${yabs},0`;
+ }
+ }
+
+ document.addEventListener('mousemove', onDocumentMouseMove, false);
+ document.addEventListener('dblclick', onDocumentDblClick, false);
+
+}());
diff --git a/public/go/specular.jpg b/public/go/specular.jpg
new file mode 100644
index 0000000..db881ef
Binary files /dev/null and b/public/go/specular.jpg differ
diff --git a/public/go/specularm.jpg b/public/go/specularm.jpg
new file mode 100644
index 0000000..cc779dc
Binary files /dev/null and b/public/go/specularm.jpg differ
diff --git a/public/go/starfield.png b/public/go/starfield.png
new file mode 100644
index 0000000..67d808a
Binary files /dev/null and b/public/go/starfield.png differ
diff --git a/public/go/texture.png b/public/go/texture.png
new file mode 100644
index 0000000..e604fff
Binary files /dev/null and b/public/go/texture.png differ
diff --git a/public/googlelogo.svg b/public/googlelogo.svg
new file mode 100644
index 0000000..b518c52
--- /dev/null
+++ b/public/googlelogo.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/loading.png b/public/loading.png
new file mode 100644
index 0000000..d9b8332
Binary files /dev/null and b/public/loading.png differ
diff --git a/public/palette0.gpl b/public/palette0.gpl
new file mode 100644
index 0000000..b5f2d6a
--- /dev/null
+++ b/public/palette0.gpl
@@ -0,0 +1,34 @@
+GIMP Palette
+Name: Pixelplanet
+Columns: 0
+#
+255 255 255 White
+228 228 228 Light Gray
+136 136 136 Dark Gray
+ 78 78 78 Darker Gray
+ 0 0 0 Black
+244 179 174 Skin
+255 167 209 Light Pink
+255 84 178 Pink
+255 101 101 Peach
+229 0 0 Red
+154 0 0 Dark Red
+254 164 96 Light Brown
+229 149 0 Orange
+160 106 66 Unbenannt
+245 223 176 Unbenannt
+229 217 0 Unbenannt
+148 224 68 Unbenannt
+ 2 190 1 Unbenannt
+ 0 101 19 Unbenannt
+202 227 255 Unbenannt
+ 0 211 221 Unbenannt
+ 0 131 199 Unbenannt
+ 0 0 234 Unbenannt
+ 25 25 115 Unbenannt
+207 110 228 Unbenannt
+130 0 128 Unbenannt
+196 196 196 Silver
+96 64 40 Dark Brown
+255 248 137 Khaki
+104 131 56 Olive
diff --git a/public/palette1.gpl b/public/palette1.gpl
new file mode 100644
index 0000000..bdbe8b6
--- /dev/null
+++ b/public/palette1.gpl
@@ -0,0 +1,34 @@
+GIMP Palette
+Name: Greenstar30
+#Description: Created by starhouse for their game CrescentWhole. Based off MortMort's SoftMilk32 palette. Used on pixelplanet with 2 colors removed
+#Colors: 32
+49 46 47 #312e2f
+99 92 90 #635c5a
+129 119 107 #81776b
+198 181 165 #c6b5a5
+255 237 212 #ffedd4
+150 86 122 #96567a
+202 112 145 #ca7091
+96 67 79 #60434f
+136 79 94 #884f5e
+175 101 103 #af6567
+195 124 107 #c37c6b
+221 153 126 #dd997e
+233 181 140 #e9b58c
+198 139 91 #c68b5b
+140 89 74 #8c594a
+94 68 63 #5e443f
+225 173 86 #e1ad56
+248 207 142 #f8cf8e
+239 220 118 #efdc76
+206 190 85 #cebe55
+157 159 55 #9d9f37
+114 121 43 #72792b
+81 94 46 #515e2e
+69 100 79 #45644f
+80 134 87 #508657
+187 209 138 #bbd18a
+91 84 108 #5b546c
+106 113 137 #6a7189
+122 148 156 #7a949c
+174 215 185 #aed7b9
diff --git a/public/redditlogo.svg b/public/redditlogo.svg
new file mode 100644
index 0000000..d250320
--- /dev/null
+++ b/public/redditlogo.svg
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/tile-wide.png b/public/tile-wide.png
new file mode 100644
index 0000000..828e75c
Binary files /dev/null and b/public/tile-wide.png differ
diff --git a/public/tile.png b/public/tile.png
new file mode 100644
index 0000000..06c4c83
Binary files /dev/null and b/public/tile.png differ
diff --git a/public/turtle/Detector.js b/public/turtle/Detector.js
new file mode 100644
index 0000000..315f8ed
--- /dev/null
+++ b/public/turtle/Detector.js
@@ -0,0 +1,40 @@
+/**
+ * @author alteredq / http://alteredqualia.com/
+ * @author mr.doob / http://mrdoob.com/
+ */
+
+var Detector = {
+
+ canvas: !! window.CanvasRenderingContext2D,
+ webgl: ( function () { try { var canvas = document.createElement( 'canvas' ); return !! window.WebGLRenderingContext && ( canvas.getContext( 'webgl' ) || canvas.getContext( 'experimental-webgl' ) ); } catch( e ) { return false; } } )(),
+ workers: !! window.Worker,
+ fileapi: window.File && window.FileReader && window.FileList && window.Blob,
+
+ getWebGLErrorMessage: function () {
+
+ var element = document.createElement( 'div' );
+ element.className = 'webgl-error';
+
+ if ( !this.webgl ) {
+
+ element.innerHTML = window.WebGLRenderingContext ? [
+ 'Your graphics card does not seem to support WebGL . ',
+ 'Find out how to get it here .'
+ ].join( '\n' ) : [
+ 'Your browser does not seem to support WebGL . ',
+ 'Find out how to get it here .'
+ ].join( '\n' );
+
+ }
+
+ return element;
+
+ },
+
+ addGetWebGLMessage: function (parent ) {
+
+ parent.appendChild( Detector.getWebGLErrorMessage() );
+
+ }
+
+};
\ No newline at end of file
diff --git a/public/turtle/TrackballControls.js b/public/turtle/TrackballControls.js
new file mode 100644
index 0000000..103b4a5
--- /dev/null
+++ b/public/turtle/TrackballControls.js
@@ -0,0 +1,537 @@
+/**
+ * @author Eberhard Graether / http://egraether.com/
+ */
+
+THREE.TrackballControls = function ( object, domElement ) {
+
+ var _this = this;
+ var STATE = { NONE: -1, ROTATE: 0, ZOOM: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_ZOOM: 4, TOUCH_PAN: 5 };
+
+ this.object = object;
+ this.domElement = ( domElement !== undefined ) ? domElement : document;
+
+ // API
+
+ this.enabled = true;
+
+ this.screen = { width: 0, height: 0, offsetLeft: 0, offsetTop: 0 };
+ this.radius = ( this.screen.width + this.screen.height ) / 4;
+
+ this.rotateSpeed = 1.0;
+ this.zoomSpeed = 1.2;
+ this.panSpeed = 0.3;
+
+ this.noRotate = false;
+ this.noZoom = false;
+ this.noPan = false;
+
+ this.staticMoving = false;
+ this.dynamicDampingFactor = 0.2;
+
+ this.minDistance = 0;
+ this.maxDistance = Infinity;
+
+ this.keys = [ 65 /*A*/, 83 /*S*/, 68 /*D*/ ];
+
+ // internals
+
+ this.target = new THREE.Vector3();
+
+ var lastPosition = new THREE.Vector3();
+
+ var _state = STATE.NONE,
+ _prevState = STATE.NONE,
+
+ _eye = new THREE.Vector3(),
+
+ _rotateStart = new THREE.Vector3(),
+ _rotateEnd = new THREE.Vector3(),
+
+ _zoomStart = new THREE.Vector2(),
+ _zoomEnd = new THREE.Vector2(),
+
+ _touchZoomDistanceStart = 0,
+ _touchZoomDistanceEnd = 0,
+
+ _panStart = new THREE.Vector2(),
+ _panEnd = new THREE.Vector2();
+
+ // for reset
+
+ this.target0 = this.target.clone();
+ this.position0 = this.object.position.clone();
+ this.up0 = this.object.up.clone();
+
+ // events
+
+ var changeEvent = { type: 'change' };
+
+
+ // methods
+
+ this.handleResize = function () {
+
+ this.screen.width = window.innerWidth;
+ this.screen.height = window.innerHeight;
+
+ this.screen.offsetLeft = 0;
+ this.screen.offsetTop = 0;
+
+ this.radius = ( this.screen.width + this.screen.height ) / 4;
+
+ };
+
+ this.handleEvent = function ( event ) {
+
+ if ( typeof this[ event.type ] == 'function' ) {
+
+ this[ event.type ]( event );
+
+ }
+
+ };
+
+ this.getMouseOnScreen = function ( clientX, clientY ) {
+
+ return new THREE.Vector2(
+ ( clientX - _this.screen.offsetLeft ) / _this.radius * 0.5,
+ ( clientY - _this.screen.offsetTop ) / _this.radius * 0.5
+ );
+
+ };
+
+ this.getMouseProjectionOnBall = function ( clientX, clientY ) {
+
+ var mouseOnBall = new THREE.Vector3(
+ ( clientX - _this.screen.width * 0.5 - _this.screen.offsetLeft ) / _this.radius,
+ ( _this.screen.height * 0.5 + _this.screen.offsetTop - clientY ) / _this.radius,
+ 0.0
+ );
+
+ var length = mouseOnBall.length();
+
+ if ( length > 1.0 ) {
+
+ mouseOnBall.normalize();
+
+ } else {
+
+ mouseOnBall.z = Math.sqrt( 1.0 - length * length );
+
+ }
+
+ _eye.copy( _this.object.position ).sub( _this.target );
+
+ var projection = _this.object.up.clone().setLength( mouseOnBall.y );
+ projection.add( _this.object.up.clone().cross( _eye ).setLength( mouseOnBall.x ) );
+ projection.add( _eye.setLength( mouseOnBall.z ) );
+
+ return projection;
+
+ };
+
+ this.rotateCamera = function () {
+
+ var angle = Math.acos( _rotateStart.dot( _rotateEnd ) / _rotateStart.length() / _rotateEnd.length() );
+
+ if ( angle ) {
+
+ var axis = ( new THREE.Vector3() ).crossVectors( _rotateStart, _rotateEnd ).normalize();
+ quaternion = new THREE.Quaternion();
+
+ angle *= _this.rotateSpeed;
+
+ quaternion.setFromAxisAngle( axis, -angle );
+
+ _eye.applyQuaternion( quaternion );
+ _this.object.up.applyQuaternion( quaternion );
+
+ _rotateEnd.applyQuaternion( quaternion );
+
+ if ( _this.staticMoving ) {
+
+ _rotateStart.copy( _rotateEnd );
+
+ } else {
+
+ quaternion.setFromAxisAngle( axis, angle * ( _this.dynamicDampingFactor - 1.0 ) );
+ _rotateStart.applyQuaternion( quaternion );
+
+ }
+
+ }
+
+ };
+
+ this.zoomCamera = function () {
+
+ if ( _state === STATE.TOUCH_ZOOM ) {
+
+ var factor = _touchZoomDistanceStart / _touchZoomDistanceEnd;
+ _touchZoomDistanceStart = _touchZoomDistanceEnd;
+ _eye.multiplyScalar( factor );
+
+ } else {
+
+ var factor = 1.0 + ( _zoomEnd.y - _zoomStart.y ) * _this.zoomSpeed;
+
+ if ( factor !== 1.0 && factor > 0.0 ) {
+
+ _eye.multiplyScalar( factor );
+
+ if ( _this.staticMoving ) {
+
+ _zoomStart.copy( _zoomEnd );
+
+ } else {
+
+ _zoomStart.y += ( _zoomEnd.y - _zoomStart.y ) * this.dynamicDampingFactor;
+
+ }
+
+ }
+
+ }
+
+ };
+
+ this.panCamera = function () {
+
+ var mouseChange = _panEnd.clone().sub( _panStart );
+
+ if ( mouseChange.lengthSq() ) {
+
+ mouseChange.multiplyScalar( _eye.length() * _this.panSpeed );
+
+ var pan = _eye.clone().cross( _this.object.up ).setLength( mouseChange.x );
+ pan.add( _this.object.up.clone().setLength( mouseChange.y ) );
+
+ _this.object.position.add( pan );
+ _this.target.add( pan );
+
+ if ( _this.staticMoving ) {
+
+ _panStart = _panEnd;
+
+ } else {
+
+ _panStart.add( mouseChange.subVectors( _panEnd, _panStart ).multiplyScalar( _this.dynamicDampingFactor ) );
+
+ }
+
+ }
+
+ };
+
+ this.checkDistances = function () {
+
+ if ( !_this.noZoom || !_this.noPan ) {
+
+ if ( _this.object.position.lengthSq() > _this.maxDistance * _this.maxDistance ) {
+
+ _this.object.position.setLength( _this.maxDistance );
+
+ }
+
+ if ( _eye.lengthSq() < _this.minDistance * _this.minDistance ) {
+
+ _this.object.position.addVectors( _this.target, _eye.setLength( _this.minDistance ) );
+
+ }
+
+ }
+
+ };
+
+ this.update = function () {
+
+ _eye.subVectors( _this.object.position, _this.target );
+
+ if ( !_this.noRotate ) {
+
+ _this.rotateCamera();
+
+ }
+
+ if ( !_this.noZoom ) {
+
+ _this.zoomCamera();
+
+ }
+
+ if ( !_this.noPan ) {
+
+ _this.panCamera();
+
+ }
+
+ _this.object.position.addVectors( _this.target, _eye );
+
+ _this.checkDistances();
+
+ _this.object.lookAt( _this.target );
+
+ if ( lastPosition.distanceToSquared( _this.object.position ) > 0 ) {
+
+ _this.dispatchEvent( changeEvent );
+
+ lastPosition.copy( _this.object.position );
+
+ }
+
+ };
+
+ this.reset = function () {
+
+ _state = STATE.NONE;
+ _prevState = STATE.NONE;
+
+ _this.target.copy( _this.target0 );
+ _this.object.position.copy( _this.position0 );
+ _this.object.up.copy( _this.up0 );
+
+ _eye.subVectors( _this.object.position, _this.target );
+
+ _this.object.lookAt( _this.target );
+
+ _this.dispatchEvent( changeEvent );
+
+ lastPosition.copy( _this.object.position );
+
+ };
+
+ // listeners
+
+ function keydown( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ window.removeEventListener( 'keydown', keydown );
+
+ _prevState = _state;
+
+ if ( _state !== STATE.NONE ) {
+
+ return;
+
+ } else if ( event.keyCode === _this.keys[ STATE.ROTATE ] && !_this.noRotate ) {
+
+ _state = STATE.ROTATE;
+
+ } else if ( event.keyCode === _this.keys[ STATE.ZOOM ] && !_this.noZoom ) {
+
+ _state = STATE.ZOOM;
+
+ } else if ( event.keyCode === _this.keys[ STATE.PAN ] && !_this.noPan ) {
+
+ _state = STATE.PAN;
+
+ }
+
+ }
+
+ function keyup( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ _state = _prevState;
+
+ window.addEventListener( 'keydown', keydown, false );
+
+ }
+
+ function mousedown( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if ( _state === STATE.NONE ) {
+
+ _state = event.button;
+
+ }
+
+ if ( _state === STATE.ROTATE && !_this.noRotate ) {
+
+ _rotateStart = _rotateEnd = _this.getMouseProjectionOnBall( event.clientX, event.clientY );
+
+ } else if ( _state === STATE.ZOOM && !_this.noZoom ) {
+
+ _zoomStart = _zoomEnd = _this.getMouseOnScreen( event.clientX, event.clientY );
+
+ } else if ( _state === STATE.PAN && !_this.noPan ) {
+
+ _panStart = _panEnd = _this.getMouseOnScreen( event.clientX, event.clientY );
+
+ }
+
+ document.addEventListener( 'mousemove', mousemove, false );
+ document.addEventListener( 'mouseup', mouseup, false );
+
+ }
+
+ function mousemove( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if ( _state === STATE.ROTATE && !_this.noRotate ) {
+
+ _rotateEnd = _this.getMouseProjectionOnBall( event.clientX, event.clientY );
+
+ } else if ( _state === STATE.ZOOM && !_this.noZoom ) {
+
+ _zoomEnd = _this.getMouseOnScreen( event.clientX, event.clientY );
+
+ } else if ( _state === STATE.PAN && !_this.noPan ) {
+
+ _panEnd = _this.getMouseOnScreen( event.clientX, event.clientY );
+
+ }
+
+ }
+
+ function mouseup( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ _state = STATE.NONE;
+
+ document.removeEventListener( 'mousemove', mousemove );
+ document.removeEventListener( 'mouseup', mouseup );
+
+ }
+
+ function mousewheel( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ var delta = 0;
+
+ if ( event.wheelDelta ) { // WebKit / Opera / Explorer 9
+
+ delta = event.wheelDelta / 40;
+
+ } else if ( event.detail ) { // Firefox
+
+ delta = - event.detail / 3;
+
+ }
+
+ _zoomStart.y += delta * 0.01;
+
+ }
+
+ function touchstart( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ switch ( event.touches.length ) {
+
+ case 1:
+ _state = STATE.TOUCH_ROTATE;
+ _rotateStart = _rotateEnd = _this.getMouseProjectionOnBall( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ case 2:
+ _state = STATE.TOUCH_ZOOM;
+ var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
+ var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
+ _touchZoomDistanceEnd = _touchZoomDistanceStart = Math.sqrt( dx * dx + dy * dy );
+ break;
+
+ case 3:
+ _state = STATE.TOUCH_PAN;
+ _panStart = _panEnd = _this.getMouseOnScreen( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ default:
+ _state = STATE.NONE;
+
+ }
+
+ }
+
+ function touchmove( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ switch ( event.touches.length ) {
+
+ case 1:
+ _rotateEnd = _this.getMouseProjectionOnBall( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ case 2:
+ var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX;
+ var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY;
+ _touchZoomDistanceEnd = Math.sqrt( dx * dx + dy * dy )
+ break;
+
+ case 3:
+ _panEnd = _this.getMouseOnScreen( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ default:
+ _state = STATE.NONE;
+
+ }
+
+ }
+
+ function touchend( event ) {
+
+ if ( _this.enabled === false ) return;
+
+ switch ( event.touches.length ) {
+
+ case 1:
+ _rotateStart = _rotateEnd = _this.getMouseProjectionOnBall( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ case 2:
+ _touchZoomDistanceStart = _touchZoomDistanceEnd = 0;
+ break;
+
+ case 3:
+ _panStart = _panEnd = _this.getMouseOnScreen( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY );
+ break;
+
+ }
+
+ _state = STATE.NONE;
+
+ }
+
+ this.domElement.addEventListener( 'contextmenu', function ( event ) { event.preventDefault(); }, false );
+
+ this.domElement.addEventListener( 'mousedown', mousedown, false );
+
+ this.domElement.addEventListener( 'mousewheel', mousewheel, false );
+ this.domElement.addEventListener( 'DOMMouseScroll', mousewheel, false ); // firefox
+
+ this.domElement.addEventListener( 'touchstart', touchstart, false );
+ this.domElement.addEventListener( 'touchend', touchend, false );
+ this.domElement.addEventListener( 'touchmove', touchmove, false );
+
+ window.addEventListener( 'keydown', keydown, false );
+ window.addEventListener( 'keyup', keyup, false );
+
+ this.handleResize();
+
+};
+
+THREE.TrackballControls.prototype = Object.create( THREE.EventDispatcher.prototype );
\ No newline at end of file
diff --git a/public/turtle/index.html b/public/turtle/index.html
new file mode 100644
index 0000000..f82b5f9
--- /dev/null
+++ b/public/turtle/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ WebGL PixelPlace TechDemo Globe
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/turtle/space.js b/public/turtle/space.js
new file mode 100644
index 0000000..bfa2aad
--- /dev/null
+++ b/public/turtle/space.js
@@ -0,0 +1,137 @@
+// Created by Bjorn Sandvik - thematicmapping.org
+(function () {
+
+ var webglEl = document.getElementById('webgl');
+
+ if (!Detector.webgl) {
+ Detector.addGetWebGLMessage(webglEl);
+ return;
+ }
+
+ var width = window.innerWidth,
+ height = window.innerHeight;
+
+ // Earth params
+ var radius = 0.5,
+ segments = 32,
+ rotation = 6;
+
+ var scene = new THREE.Scene();
+
+ var camera = new THREE.PerspectiveCamera(45, width / height, 0.01, 1000);
+ camera.position.z = 20.0;
+
+ var renderer = new THREE.WebGLRenderer();
+ renderer.setSize(width, height);
+
+ scene.add(new THREE.AmbientLight(0x333333));
+
+ var light = new THREE.DirectionalLight(0xffffff, 1);
+ light.position.set(5,3,5);
+ scene.add(light);
+
+ var loader = new THREE.GLTFLoader();
+ loader.load('turtle.glb', function (glb) {
+ window.test2 = glb.scene;
+ scene.add(glb.scene);
+ window.test = scene;
+ const children = glb.scene.children;
+ for (let cnt = 0; cnt < children.length; cnt++) {
+ //children[cnt].scale.x *= -1;
+ //children[cnt].scale.y *= -1;
+ const child = children[cnt].children;
+ if (children[cnt].material) {
+ const material = children[cnt].material.name;
+ console.log(material);
+ if (material == "canvas") {
+ console.log("Found material");
+ children[cnt].material = new THREE.MeshPhongMaterial({
+ map: new THREE.TextureLoader().load('../tiles/texture.png?a=b'),
+ bumpMap: new THREE.TextureLoader().load('../images/elev_bump_4k2.jpg'),
+ bumpScale: 0.04,
+ });
+ //children[cnt].material.side = THREE.DoubleSide;
+ }
+ }
+ for (let xnt = 0; xnt < child.length; xnt++) {
+ const material = child[xnt].material.name;
+ console.log(material);
+ if (material == "canvas") {
+ console.log("Found material");
+ child[xnt].material = new THREE.MeshPhongMaterial({
+ map: new THREE.TextureLoader().load('../tiles/texture.png?a=b'),
+ bumpMap: new THREE.TextureLoader().load('../images/elev_bump_4k2.jpg'),
+ bumpScale: 0.04,
+ });
+ //children[cnt].material.side = THREE.DoubleSide;
+ }
+ }
+ }
+ //glb.animations;
+ //glb.scene;
+ //glb.scenes;
+ //glb.cameras;
+ //glb.asset;
+ }, function (xhr) {console.log((xhr.loaded / xhr.total * 100) + '% loaded');
+ }, function (error) {console.log('An error happened', error);
+ });
+
+ //var sphere = createSphere(radius, segments);
+ //sphere.rotation.y = rotation;
+ //scene.add(sphere)
+
+ //var clouds = createClouds(radius, segments);
+ //clouds.rotation.y = rotation * 10;
+ //scene.add(clouds)
+
+ var stars = createStars(90, 64);
+ scene.add(stars);
+
+ var controls = new THREE.TrackballControls(camera);
+
+ webglEl.appendChild(renderer.domElement);
+
+ render();
+
+ function render() {
+ controls.update();
+ //sphere.rotation.y += 0.0005;
+ //clouds.rotation.y += 0.005;
+ requestAnimationFrame(render);
+ renderer.render(scene, camera);
+ }
+
+ function createSphere(radius, segments) {
+ return new THREE.Mesh(
+ new THREE.SphereGeometry(radius, segments, segments),
+ new THREE.MeshPhongMaterial({
+ map: THREE.ImageUtils.loadTexture('../tiles/texture.png'),
+ bumpMap: THREE.ImageUtils.loadTexture('../images/elev_bump_4k.jpg'),
+ bumpScale: 0.004,
+ //specularMap: THREE.ImageUtils.loadTexture('images/water_4k.png'),
+ //specular: new THREE.Color('grey')
+ })
+ );
+ }
+
+ function createClouds(radius, segments) {
+ return new THREE.Mesh(
+ new THREE.SphereGeometry(radius + 0.005, segments, segments),
+ new THREE.MeshPhongMaterial({
+ map: THREE.ImageUtils.loadTexture('../images/fair_clouds_dark_4k.png'),
+ transparent: true
+ })
+ );
+ }
+
+ function createStars(radius, segments) {
+ return new THREE.Mesh(
+ new THREE.SphereGeometry(radius, segments, segments),
+ new THREE.MeshBasicMaterial({
+ map: THREE.ImageUtils.loadTexture('../images/galaxy_starfield.png'),
+ side: THREE.BackSide
+ })
+ );
+ }
+
+}());
diff --git a/public/turtle/turtle.glb b/public/turtle/turtle.glb
new file mode 100644
index 0000000..0c9f2b0
Binary files /dev/null and b/public/turtle/turtle.glb differ
diff --git a/public/vklogo.svg b/public/vklogo.svg
new file mode 100644
index 0000000..e679e79
--- /dev/null
+++ b/public/vklogo.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/public/vpPlace.png b/public/vpPlace.png
new file mode 100644
index 0000000..60f172d
Binary files /dev/null and b/public/vpPlace.png differ
diff --git a/src/actions/index.js b/src/actions/index.js
new file mode 100644
index 0000000..87d50d1
--- /dev/null
+++ b/src/actions/index.js
@@ -0,0 +1,674 @@
+/* @flow */
+
+import swal from 'sweetalert2';
+import 'sweetalert2/src/sweetalert2.scss';
+
+import type {
+ Action,
+ ThunkAction,
+ PromiseAction,
+} from './types';
+import type { Cell } from '../core/Cell';
+import type { ColorIndex } from '../core/Palette';
+
+import ProtocolClient from '../socket/ProtocolClient';
+import loadImage from '../ui/loadImage';
+import {
+ getColorIndexOfPixel,
+} from '../core/utils';
+
+
+export function toggleChatBox(): Action {
+ return {
+ type: 'TOGGLE_CHAT_BOX',
+ };
+}
+
+export function toggleGrid(): Action {
+ return {
+ type: 'TOGGLE_GRID',
+ };
+}
+
+export function togglePixelNotify(): Action {
+ return {
+ type: 'TOGGLE_PIXEL_NOTIFY',
+ };
+}
+
+export function toggleAutoZoomIn(): Action {
+ return {
+ type: 'TOGGLE_AUTO_ZOOM_IN',
+ };
+}
+
+export function toggleMute(): Action {
+ return {
+ type: 'TOGGLE_MUTE',
+ };
+}
+
+export function toggleCompactPalette(): Action {
+ return {
+ type: 'TOGGLE_COMPACT_PALETTE',
+ };
+}
+
+export function toggleChatNotify(): Action {
+ return {
+ type: 'TOGGLE_CHAT_NOTIFY',
+ };
+}
+
+export function togglePotatoMode(): Action {
+ return {
+ type: 'TOGGLE_POTATO_MODE',
+ };
+}
+
+export function toggleOpenPalette(): Action {
+ return {
+ type: 'TOGGLE_OPEN_PALETTE',
+ };
+}
+
+export function toggleOpenMenu(): Action {
+ return {
+ type: 'TOGGLE_OPEN_MENU',
+ };
+}
+
+export function setPlaceAllowed(placeAllowed: boolean): Action {
+ return {
+ type: 'SET_PLACE_ALLOWED',
+ placeAllowed,
+ };
+}
+
+export function setNotification(notification: string): Action {
+ return {
+ type: 'SET_NOTIFICATION',
+ notification,
+ };
+}
+
+export function unsetNotification(): Action {
+ return {
+ type: 'UNSET_NOTIFICATION',
+ };
+}
+
+export function setHover(hover: Cell): Action {
+ return {
+ type: 'SET_HOVER',
+ hover,
+ };
+}
+
+export function unsetHover(): Action {
+ return {
+ type: 'UNSET_HOVER',
+ };
+}
+
+export function setWait(wait: ?number): Action {
+ return {
+ type: 'SET_WAIT',
+ wait,
+ };
+}
+
+export function selectColor(color: ColorIndex): Action {
+ return {
+ type: 'SELECT_COLOR',
+ color,
+ };
+}
+
+export function selectCanvas(canvasId: number): Action {
+ return {
+ type: 'SELECT_CANVAS',
+ canvasId,
+ };
+}
+
+export function placePixel(coordinates: Cell, color: ColorIndex): Action {
+ return {
+ type: 'PLACE_PIXEL',
+ coordinates,
+ color,
+ };
+}
+
+export function pixelWait(coordinates: Cell, color: ColorIndex): Action {
+ return {
+ type: 'PIXEL_WAIT',
+ coordinates,
+ color,
+ };
+}
+
+export function pixelFailure(): Action {
+ return {
+ type: 'PIXEL_FAILURE',
+ };
+}
+
+export function receiveOnline(online: number): Action {
+ return {
+ type: 'RECEIVE_ONLINE',
+ online,
+ };
+}
+
+export function receiveChatMessage(name: string, text: string): Action {
+ return {
+ type: 'RECEIVE_CHAT_MESSAGE',
+ name,
+ text,
+ };
+}
+
+export function receiveChatHistory(data: Array): Action {
+ return {
+ type: 'RECEIVE_CHAT_HISTORY',
+ data,
+ };
+}
+
+let lastNotify = null;
+export function notify(notification: string) {
+ return async (dispatch) => {
+ dispatch(setNotification(notification));
+ if (lastNotify) {
+ clearTimeout(lastNotify);
+ lastNotify = null;
+ }
+ lastNotify = setTimeout(() => {
+ dispatch(unsetNotification());
+ }, 1500);
+ };
+}
+
+export function requestPlacePixel(
+ canvasId: number,
+ coordinates: Cell,
+ color: ColorIndex,
+ token: ?string = null,
+): ThunkAction {
+ const [x, y] = coordinates;
+
+ return async (dispatch) => {
+ const body = JSON.stringify({
+ cn: canvasId,
+ x,
+ y,
+ clr: color,
+ token,
+ a: x + y + 8,
+ });
+
+ dispatch(setPlaceAllowed(false));
+ try {
+ const response = await fetch('/api/pixel', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body,
+ // https://github.com/github/fetch/issues/349
+ credentials: 'include',
+ });
+ const {
+ success,
+ waitSeconds,
+ coolDownSeconds,
+ errors,
+ errorTitle,
+ } = await response.json();
+
+ if (waitSeconds) {
+ dispatch(setWait(waitSeconds * 1000));
+ }
+ if (coolDownSeconds) {
+ dispatch(notify(Math.round(coolDownSeconds)));
+ }
+ if (response.ok) {
+ if (success) {
+ dispatch(placePixel(coordinates, color));
+ } else {
+ dispatch(pixelWait(coordinates, color));
+ }
+ return;
+ }
+
+ if (response.status === 422) {
+ window.pixel = { canvasId, coordinates, color };
+ grecaptcha.execute();
+ return;
+ }
+
+ dispatch(pixelFailure());
+ swal({
+ title: (errorTitle || `Error ${response.status}`),
+ text: errors[0].msg,
+ type: 'error',
+ confirmButtonText: 'OK',
+ });
+ } catch (e) {
+ throw e;
+ } finally {
+ dispatch(setPlaceAllowed(true));
+ }
+ };
+}
+
+export function tryPlacePixel(
+ coordinates: Cell,
+ color: ?ColorIndex = null,
+): ThunkAction {
+ return (dispatch, getState) => {
+ const state = getState();
+ const { canvasId } = state.canvas;
+ const selectedColor = (color === undefined || color === null)
+ ? state.gui.selectedColor
+ : color;
+
+ if (getColorIndexOfPixel(getState(), coordinates) !== selectedColor) {
+ dispatch(requestPlacePixel(canvasId, coordinates, selectedColor));
+ }
+ };
+}
+
+export function setViewCoordinates(view: Cell): Action {
+ return {
+ type: 'SET_VIEW_COORDINATES',
+ view,
+ };
+}
+
+export function move([dx, dy]: Cell): ThunkAction {
+ return (dispatch, getState) => {
+ const { view } = getState().canvas;
+
+ const [x, y] = view;
+ dispatch(setViewCoordinates([x + dx, y + dy]));
+ };
+}
+
+export function moveDirection([vx, vy]: Cell): ThunkAction {
+ // TODO check direction is unitary vector
+ return (dispatch, getState) => {
+ const { viewscale } = getState().canvas;
+
+ const speed = 100.0 / viewscale;
+ dispatch(move([speed * vx, speed * vy]));
+ };
+}
+
+export function moveNorth(): ThunkAction {
+ return (dispatch) => {
+ dispatch(moveDirection([0, -1]));
+ };
+}
+
+export function moveWest(): ThunkAction {
+ return (dispatch) => {
+ dispatch(moveDirection([-1, 0]));
+ };
+}
+
+export function moveSouth(): ThunkAction {
+ return (dispatch) => {
+ dispatch(moveDirection([0, 1]));
+ };
+}
+
+export function moveEast(): ThunkAction {
+ return (dispatch) => {
+ dispatch(moveDirection([1, 0]));
+ };
+}
+
+
+export function setScale(scale: number, zoompoint: Cell): Action {
+ return {
+ type: 'SET_SCALE',
+ scale,
+ zoompoint,
+ };
+}
+
+export function zoomIn(zoompoint): ThunkAction {
+ return (dispatch, getState) => {
+ const { scale } = getState().canvas;
+ const zoomscale = scale >= 1.0 ? scale * 1.1 : scale * 1.04;
+ dispatch(setScale(zoomscale, zoompoint));
+ };
+}
+
+export function zoomOut(zoompoint): ThunkAction {
+ return (dispatch, getState) => {
+ const { scale } = getState().canvas;
+ const zoomscale = scale >= 1.0 ? scale / 1.1 : scale / 1.04;
+ dispatch(setScale(zoomscale, zoompoint));
+ };
+}
+
+function requestBigChunk(center: Cell): Action {
+ return {
+ type: 'REQUEST_BIG_CHUNK',
+ center,
+ };
+}
+
+function receiveBigChunk(
+ center: Cell,
+ arrayBuffer: ArrayBuffer,
+): Action {
+ return {
+ type: 'RECEIVE_BIG_CHUNK',
+ center,
+ arrayBuffer,
+ };
+}
+
+function receiveImageTile(
+ center: Cell,
+ tile: Image,
+): Action {
+ return {
+ type: 'RECEIVE_IMAGE_TILE',
+ center,
+ tile,
+ };
+}
+
+
+function receiveBigChunkFailure(center: Cell, error: Error): Action {
+ return {
+ type: 'RECEIVE_BIG_CHUNK_FAILURE',
+ center,
+ error,
+ };
+}
+
+export function fetchTile(canvasId, center: Cell): PromiseAction {
+ const [cz, cx, cy] = center;
+
+ return async (dispatch) => {
+ dispatch(requestBigChunk(center));
+ try {
+ const url = `/tiles/${canvasId}/${cz}/${cx}/${cy}.png`;
+ const img = await loadImage(url);
+ dispatch(receiveImageTile(center, img));
+ } catch (error) {
+ dispatch(receiveBigChunkFailure(center, error));
+ }
+ };
+}
+
+export function fetchChunk(canvasId, center: Cell): PromiseAction {
+ const [, cx, cy] = center;
+
+ return async (dispatch) => {
+ dispatch(requestBigChunk(center));
+ try {
+ ProtocolClient.registerChunk([cx, cy]);
+ const url = `/chunks/${canvasId}/${cx}/${cy}.bmp`;
+ const response = await fetch(url);
+ if (response.ok) {
+ const arrayBuffer = await response.arrayBuffer();
+ dispatch(receiveBigChunk(center, arrayBuffer));
+ } else {
+ const error = new Error('Network response was not ok.');
+ dispatch(receiveBigChunkFailure(center, error));
+ }
+ } catch (error) {
+ console.log(`Error at requesting chunk ${cx}/${cy}`);
+ dispatch(receiveBigChunkFailure(center, error));
+ }
+ };
+}
+
+
+export function receiveCoolDown(
+ waitSeconds: number,
+): Action {
+ return {
+ type: 'RECEIVE_COOLDOWN',
+ waitSeconds,
+ };
+}
+
+
+export function receivePixelUpdate(
+ i: number,
+ j: number,
+ offset: number,
+ color: ColorIndex,
+): Action {
+ return {
+ type: 'RECEIVE_PIXEL_UPDATE',
+ i,
+ j,
+ offset,
+ color,
+ };
+}
+
+export function receiveMe(
+ me: Object,
+): Action {
+ const {
+ name,
+ messages,
+ mailreg,
+ totalPixels,
+ dailyTotalPixels,
+ ranking,
+ dailyRanking,
+ minecraftname,
+ canvases,
+ } = me;
+ ProtocolClient.setName(name);
+ return {
+ type: 'RECEIVE_ME',
+ name: (name) || null,
+ messages: (messages) || [],
+ mailreg: (mailreg) || false,
+ totalPixels,
+ dailyTotalPixels,
+ ranking,
+ dailyRanking,
+ minecraftname,
+ canvases,
+ };
+}
+
+export function receiveStats(
+ rankings: Object,
+): Action {
+ const { ranking: totalRanking, dailyRanking: totalDailyRanking } = rankings;
+ return {
+ type: 'RECEIVE_STATS',
+ totalRanking,
+ totalDailyRanking,
+ };
+}
+
+export function setName(
+ name: string,
+): Action {
+ ProtocolClient.setName(name);
+ return {
+ type: 'SET_NAME',
+ name,
+ };
+}
+
+export function setMinecraftName(
+ minecraftname: string,
+): Action {
+ return {
+ type: 'SET_MINECRAFT_NAME',
+ minecraftname,
+ };
+}
+
+export function setMailreg(
+ mailreg: boolean,
+): Action {
+ return {
+ type: 'SET_MAILREG',
+ mailreg,
+ };
+}
+
+export function remFromMessages(
+ message: string,
+): Action {
+ return {
+ type: 'REM_FROM_MESSAGES',
+ message,
+ };
+}
+
+export function fetchStats(): PromiseAction {
+ return async (dispatch) => {
+ const response = await fetch('api/ranking', { credentials: 'include' });
+ if (response.ok) {
+ const rankings = await response.json();
+
+ dispatch(receiveStats(rankings));
+ }
+ };
+}
+
+export function fetchMe(): PromiseAction {
+ return async (dispatch, getState) => {
+ const response = await fetch('/api/me', {
+ credentials: 'include',
+ });
+
+ if (response.ok) {
+ const me = await response.json();
+ await dispatch(receiveMe(me));
+ const state = getState();
+ ProtocolClient.setCanvas(state.canvas.canvasId);
+ }
+ };
+}
+
+function setCoolDown(coolDown): Action {
+ return {
+ type: 'COOLDOWN_SET',
+ coolDown,
+ };
+}
+
+function endCoolDown(): Action {
+ return {
+ type: 'COOLDOWN_END',
+ };
+}
+
+function getPendingActions(state): Array {
+ const actions = [];
+
+ const { wait } = state.user;
+ if (wait === null || wait === undefined) return actions;
+
+ const coolDown = wait - Date.now();
+
+ if (coolDown > 0) actions.push(setCoolDown(coolDown));
+ else actions.push(endCoolDown());
+
+ return actions;
+}
+
+export function initTimer(): ThunkAction {
+ return (dispatch, getState) => {
+ function tick() {
+ const state = getState();
+ const actions = getPendingActions(state);
+ dispatch(actions);
+ }
+
+ // something shorter than 1000 ms
+ setInterval(tick, 333);
+ };
+}
+
+export function showModal(modalType: string, modalProps: Object = {}): Action {
+ return {
+ type: 'SHOW_MODAL',
+ modalType,
+ modalProps,
+ };
+}
+
+export function showSettingsModal(): Action {
+ return showModal('SETTINGS');
+}
+
+export function showUserAreaModal(): Action {
+ return showModal('USERAREA');
+}
+
+export function showMinecraftModal(): Action {
+ return showModal('MINECRAFT');
+}
+
+export function showRegisterModal(): Action {
+ return showModal('REGISTER');
+}
+
+export function showForgotPasswordModal(): Action {
+ return showModal('FORGOT_PASSWORD');
+}
+
+export function showHelpModal(): Action {
+ return showModal('HELP');
+}
+
+export function showChatModal(): Action {
+ if (window.innerWidth > 604) { return toggleChatBox(); }
+ return showModal('CHAT');
+}
+
+export function hideModal(): Action {
+ return {
+ type: 'HIDE_MODAL',
+ };
+}
+
+export function reloadUrl(): Action {
+ return {
+ type: 'RELOAD_URL',
+ };
+}
+
+export function onViewFinishChange(): Action {
+ return {
+ type: 'ON_VIEW_FINISH_CHANGE',
+ };
+}
+
+export function urlChange(): PromiseAction {
+ return async (dispatch, getState) => {
+ await dispatch(reloadUrl());
+ const state = getState();
+ ProtocolClient.setCanvas(state.canvas.canvasId);
+ };
+}
+
+export function switchCanvas(canvasId: number): PromiseAction {
+ return async (dispatch, getState) => {
+ await dispatch(selectCanvas(canvasId));
+ const state = getState();
+ ProtocolClient.setCanvas(state.canvas.canvasId);
+ dispatch(onViewFinishChange());
+ };
+}
+
diff --git a/src/actions/types.js b/src/actions/types.js
new file mode 100644
index 0000000..36ba568
--- /dev/null
+++ b/src/actions/types.js
@@ -0,0 +1,62 @@
+/* @flow */
+
+import type { Cell } from '../core/Cell';
+import type { ColorIndex } from '../core/Palette';
+import type { State } from '../reducers';
+
+
+export type Action =
+ { type: 'LOGGED_OUT' }
+ // my actions
+ | { type: 'TOGGLE_GRID' }
+ | { type: 'TOGGLE_PIXEL_NOTIFY' }
+ | { type: 'TOGGLE_AUTO_ZOOM_IN' }
+ | { type: 'TOGGLE_MUTE' }
+ | { type: 'TOGGLE_OPEN_PALETTE' }
+ | { type: 'TOGGLE_COMPACT_PALETTE' }
+ | { type: 'TOGGLE_CHAT_NOTIFY' }
+ | { type: 'TOGGLE_POTATO_MODE' }
+ | { type: 'TOGGLE_OPEN_MENU' }
+ | { type: 'SET_NOTIFICATION', notification: string }
+ | { type: 'UNSET_NOTIFICATION' }
+ | { type: 'SET_PLACE_ALLOWED', placeAllowed: boolean }
+ | { type: 'SET_HOVER', hover: Cell }
+ | { type: 'UNSET_HOVER' }
+ | { type: 'SET_WAIT', wait: ?number }
+ | { type: 'COOLDOWN_END' }
+ | { type: 'COOLDOWN_SET', coolDown: number }
+ | { type: 'SELECT_COLOR', color: ColorIndex }
+ | { type: 'SELECT_CANVAS', canvasId: number }
+ | { type: 'PLACE_PIXEL', coordinates: Cell, color: ColorIndex, wait: string }
+ | { type: 'PIXEL_WAIT', coordinates: Cell, color: ColorIndex, wait: string }
+ | { type: 'PIXEL_FAILURE' }
+ | { type: 'SET_VIEW_COORDINATES', view: Cell }
+ | { type: 'SET_SCALE', scale: number, zoompoint: Cell }
+ | { type: 'REQUEST_BIG_CHUNK', center: Cell }
+ | { type: 'RECEIVE_BIG_CHUNK', center: Cell, arrayBuffer: ArrayBuffer }
+ | { type: 'RECEIVE_IMAGE_TILE', center: Cell, tile: Image }
+ | { type: 'RECEIVE_BIG_CHUNK_FAILURE', center: Cell, error: Error }
+ | { type: 'RECEIVE_COOLDOWN', waitSeconds: number }
+ | { type: 'RECEIVE_PIXEL_UPDATE', i: number, j: number, offset: number, color: ColorIndex }
+ | { type: 'RECEIVE_ONLINE', online: number }
+ | { type: 'RECEIVE_CHAT_MESSAGE', name: string, text: string }
+ | { type: 'RECEIVE_CHAT_HISTORY', data: Array }
+ | { type: 'RECEIVE_ME', name: string, waitSeconds: number, messages: Array,
+ mailreg: boolean, totalPixels: number, dailyTotalPixels: number,
+ ranking: number, dailyRanking: number, minecraftname: string, canvases: Object}
+ | { type: 'RECEIVE_STATS', totalRanking: Object, totalDailyRanking: Object }
+ | { type: 'SET_NAME', name: string }
+ | { type: 'SET_MINECRAFT_NAME', minecraftname: string }
+ | { type: 'SET_MAILREG', mailreg: boolean }
+ | { type: 'REM_FROM_MESSAGES', message: string }
+ | { type: 'SHOW_MODAL', modalType: string, modalProps: obj }
+ | { type: 'HIDE_MODAL' }
+ | { type: 'RELOAD_URL' }
+ | { type: 'ON_VIEW_FINISH_CHANGE' }
+ ;
+
+
+export type PromiseAction = Promise;
+export type Dispatch = (action: Action | ThunkAction | PromiseAction | Array) => any;
+export type GetState = () => State;
+export type ThunkAction = (dispatch: Dispatch, getState: GetState) => any;
diff --git a/src/canvases.json b/src/canvases.json
new file mode 100644
index 0000000..bdbe1f1
--- /dev/null
+++ b/src/canvases.json
@@ -0,0 +1,88 @@
+{
+ "0": {
+ "ident":"d",
+ "colors": [
+ [ 202, 227, 255 ],
+ [ 255, 255, 255 ],
+ [ 255, 255, 255 ],
+ [ 228, 228, 228 ],
+ [ 196, 196, 196 ],
+ [ 136, 136, 136 ],
+ [ 78, 78, 78 ],
+ [ 0, 0, 0 ],
+ [ 244, 179, 174 ],
+ [ 255, 167, 209 ],
+ [ 255, 84, 178 ],
+ [ 255, 101, 101 ],
+ [ 229, 0, 0 ],
+ [ 154, 0, 0 ],
+ [ 254, 164, 96 ],
+ [ 229, 149, 0 ],
+ [ 160, 106, 66 ],
+ [ 96, 64, 40 ],
+ [ 245, 223, 176 ],
+ [ 255, 248, 137 ],
+ [ 229, 217, 0 ],
+ [ 148, 224, 68 ],
+ [ 2, 190, 1 ],
+ [ 104, 131, 56 ],
+ [ 0, 101, 19 ],
+ [ 202, 227, 255 ],
+ [ 0, 211, 221 ],
+ [ 0, 131, 199 ],
+ [ 0, 0, 234 ],
+ [ 25, 25, 115 ],
+ [ 207, 110, 228 ],
+ [ 130, 0, 128 ]
+ ],
+ "alpha": 0,
+ "size": 65536,
+ "bcd": 4000,
+ "pcd" : 7000,
+ "cds": 60000,
+ "req": 0
+ },
+ "1": {
+ "ident": "m",
+ "colors" : [
+ [ 49, 46, 47 ],
+ [ 99, 92, 90 ],
+ [ 49, 46, 47 ],
+ [ 99, 92, 90 ],
+ [ 129, 119, 107 ],
+ [ 198, 181, 165 ],
+ [ 255, 237, 212 ],
+ [ 150, 86, 122 ],
+ [ 202, 112, 145 ],
+ [ 96, 67, 79 ],
+ [ 136, 79, 94 ],
+ [ 175, 101, 103 ],
+ [ 195, 124, 107 ],
+ [ 221, 153, 126 ],
+ [ 233, 181, 140 ],
+ [ 198, 139, 91 ],
+ [ 140, 89, 74 ],
+ [ 94, 68, 63 ],
+ [ 225, 173, 86 ],
+ [ 248, 207, 142 ],
+ [ 239, 220, 118 ],
+ [ 206, 190, 85 ],
+ [ 157, 159, 55 ],
+ [ 114, 121, 43 ],
+ [ 81, 94, 46 ],
+ [ 69, 100, 79 ],
+ [ 80, 134, 87 ],
+ [ 187, 209, 138 ],
+ [ 91, 84, 108 ],
+ [ 106, 113, 137 ],
+ [ 122, 148, 156 ],
+ [ 174, 215, 185 ]
+ ],
+ "alpha": 0,
+ "size" : 1024,
+ "bcd": 15000,
+ "pcd": 15000,
+ "cds": 900000,
+ "req": 8000
+ }
+}
diff --git a/src/client.js b/src/client.js
new file mode 100644
index 0000000..029cfa0
--- /dev/null
+++ b/src/client.js
@@ -0,0 +1,269 @@
+/* @flow */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { Provider } from 'react-redux';
+import fetch from 'isomorphic-fetch'; // TODO put in the beggining with webpack!
+import Hammer from 'hammerjs';
+
+import './components/font.css';
+
+import {
+ screenToWorld,
+ getColorIndexOfPixel,
+} from './core/utils';
+
+import type { State } from './reducers';
+import initAds, { requestAds } from './ui/ads';
+import {
+ tryPlacePixel,
+ setHover,
+ unsetHover,
+ setViewCoordinates,
+ setScale,
+ zoomIn,
+ zoomOut,
+ receivePixelUpdate,
+ receiveCoolDown,
+ fetchMe,
+ fetchStats,
+ initTimer,
+ urlChange,
+ onViewFinishChange,
+ receiveOnline,
+ receiveChatMessage,
+ receiveChatHistory,
+ selectColor,
+} from './actions';
+import store from './ui/store';
+
+import onKeyPress from './ui/keypress';
+
+import App from './components/App';
+
+import Renderer from './ui/Renderer';
+import ProtocolClient from './socket/ProtocolClient';
+
+window.addEventListener('keydown', onKeyPress, false);
+
+
+function initViewport() {
+ const canvas = document.getElementById('gameWindow');
+
+ const viewport = canvas;
+ viewport.width = window.innerWidth;
+ viewport.height = window.innerHeight;
+
+ // track hover
+ viewport.onmousemove = ({ clientX, clientY }: MouseEvent) => {
+ store.dispatch(setHover([clientX, clientY]));
+ };
+ viewport.onmouseout = () => {
+ store.dispatch(unsetHover());
+ };
+ viewport.onwheel = ({ deltaY }: WheelEvent) => {
+ const state = store.getState();
+ const { hover } = state.gui;
+ let zoompoint = null;
+ if (hover) {
+ zoompoint = screenToWorld(state, viewport, hover);
+ }
+ if (deltaY < 0) {
+ store.dispatch(zoomIn(zoompoint));
+ }
+ if (deltaY > 0) {
+ store.dispatch(zoomOut(zoompoint));
+ }
+ store.dispatch(onViewFinishChange());
+ };
+ viewport.onauxclick = ({ which, clientX, clientY }: MouseEvent) => {
+ // middle mouse button
+ if (which !== 2) {
+ return;
+ }
+ const state = store.getState();
+ if (state.canvas.scale < 3) {
+ return;
+ }
+ const coords = screenToWorld(state, viewport, [clientX, clientY]);
+ const clrIndex = getColorIndexOfPixel(state, coords);
+ if (clrIndex === null) {
+ return;
+ }
+ store.dispatch(selectColor(clrIndex));
+ };
+
+ // fingers controls on touch
+ const hammertime = new Hammer(viewport);
+ hammertime.get('pan').set({ direction: Hammer.DIRECTION_ALL });
+ hammertime.get('swipe').set({ direction: Hammer.DIRECTION_ALL });
+ // Zoom-in Zoom-out in touch devices
+ hammertime.get('pinch').set({ enable: true });
+
+ hammertime.on('tap', ({ center }) => {
+ const state = store.getState();
+ const { autoZoomIn } = state.gui;
+ const { placeAllowed } = state.user;
+
+ const { scale } = state.canvas;
+ const { x, y } = center;
+ const cell = screenToWorld(state, viewport, [x, y]);
+
+ if (autoZoomIn && scale < 8) {
+ store.dispatch(setViewCoordinates(cell));
+ store.dispatch(setScale(12));
+ return;
+ }
+
+ // don't allow placing of pixel just on low zoomlevels
+ if (scale < 3) return;
+
+ if (!placeAllowed) return;
+
+ // dirty trick: to fetch only before multiple 3 AND on user action
+ // if (pixelsPlaced % 3 === 0) requestAds();
+
+ // TODO assert only one finger
+ store.dispatch(tryPlacePixel(cell));
+ });
+
+ const initialState: State = store.getState();
+ [window.lastPosX, window.lastPosY] = initialState.canvas.view;
+ let lastScale = initialState.canvas.scale;
+ hammertime.on(
+ 'panstart pinchstart pan pinch panend pinchend',
+ ({ type, deltaX, deltaY, scale },
+ ) => {
+ viewport.style.cursor = 'move'; // like google maps
+ const { scale: viewportScale } = store.getState().canvas;
+
+ // pinch start
+ if (type === 'pinchstart') {
+ store.dispatch(unsetHover());
+ lastScale = viewportScale;
+ }
+
+ // panstart
+ if (type === 'panstart') {
+ store.dispatch(unsetHover());
+ const { view: initView } = store.getState().canvas;
+ [window.lastPosX, window.lastPosY] = initView;
+ }
+
+ // pinch
+ if (type === 'pinch') {
+ store.dispatch(setScale(lastScale * scale));
+ }
+
+ // pan
+ store.dispatch(setViewCoordinates([
+ window.lastPosX - (deltaX / viewportScale),
+ window.lastPosY - (deltaY / viewportScale),
+ ]));
+
+ // pinch end
+ if (type === 'pinchend') {
+ lastScale = viewportScale;
+ }
+
+ // panend
+ if (type === 'panend') {
+ store.dispatch(onViewFinishChange());
+ const { view } = store.getState().canvas;
+ [window.lastPosX, window.lastPosY] = view;
+ viewport.style.cursor = 'auto';
+ }
+ },
+ );
+
+ return viewport;
+}
+
+
+document.addEventListener('DOMContentLoaded', () => {
+ ReactDOM.render(
+
+
+ ,
+ document.getElementById('app'),
+ );
+
+ const viewport = initViewport();
+ const renderer = new Renderer();
+ renderer.setViewport(viewport);
+
+ ProtocolClient.on('pixelUpdate', ({ i, j, offset, color }) => {
+ store.dispatch(receivePixelUpdate(i, j, offset, color));
+ // render updated pixel
+ renderer.renderPixel(i, j, offset, color);
+ });
+ ProtocolClient.on('cooldownPacket', (waitSeconds) => {
+ console.log(`Received CoolDown ${waitSeconds}`);
+ store.dispatch(receiveCoolDown(waitSeconds));
+ });
+ ProtocolClient.on('onlineCounter', ({ online }) => {
+ store.dispatch(receiveOnline(online));
+ });
+ ProtocolClient.on('chatMessage', (name, text) => {
+ store.dispatch(receiveChatMessage(name, text));
+ });
+ ProtocolClient.on('chatHistory', (data) => {
+ store.dispatch(receiveChatHistory(data));
+ });
+ ProtocolClient.on('changedMe', () => {
+ store.dispatch(fetchMe());
+ });
+
+ window.addEventListener('resize', () => {
+ viewport.width = window.innerWidth;
+ viewport.height = window.innerHeight;
+ renderer.forceNextRender = true;
+ });
+ window.addEventListener('hashchange', () => {
+ store.dispatch(urlChange());
+ });
+
+ store.subscribe(() => {
+ // const state: State = store.getState();
+ // this gets executed when store changes
+ });
+
+ store.dispatch(initTimer());
+
+ function animationLoop() {
+ renderer.render(viewport);
+ window.requestAnimationFrame(animationLoop);
+ }
+ animationLoop();
+
+ store.dispatch(fetchMe());
+ ProtocolClient.connect();
+
+ store.dispatch(fetchStats());
+ setInterval(() => { store.dispatch(fetchStats()); }, 300000);
+
+ // garbage collection
+ function runGC() {
+ const state: State = store.getState();
+ const { chunks } = state.canvas;
+
+ const curTime = Date.now();
+ let cnt = 0;
+ chunks.forEach((value, key) => {
+ if (curTime > value.timestamp + 300000) {
+ cnt++;
+ const [z, i, j] = value.cell;
+ if (!renderer.isChunkInView(z, i, j)) {
+ console.log(value.cell);
+ if (value.isBasechunk) {
+ console.log(`deregister chunk ${i},${j}`);
+ ProtocolClient.deRegisterChunk([i, j]);
+ }
+ chunks.delete(key);
+ }
+ }
+ });
+ console.log('Garbage collection cleaned', cnt, 'chunks');
+ }
+ setInterval(runGC, 300000);
+});
diff --git a/src/components/Admin.js b/src/components/Admin.js
new file mode 100644
index 0000000..a6fb6ec
--- /dev/null
+++ b/src/components/Admin.js
@@ -0,0 +1,37 @@
+/*
+ * react html for adminpage
+ */
+
+import React from 'react';
+
+const Admin = () => (
+
+);
+
+export default Admin;
diff --git a/src/components/App.js b/src/components/App.js
new file mode 100644
index 0000000..d0bda9d
--- /dev/null
+++ b/src/components/App.js
@@ -0,0 +1,54 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { IconContext } from 'react-icons';
+
+import CoolDownBox from './CoolDownBox';
+import NotifyBox from './NotifyBox.js';
+import CoordinatesBox from './CoordinatesBox';
+import GlobeButton from './GlobeButton';
+import CanvasSwitchButton from './CanvasSwitchButton';
+import OnlineBox from './OnlineBox';
+import PalselButton from './PalselButton.js';
+import ChatButton from './ChatButton';
+import Palette from './Palette';
+import ChatBox from './ChatBox';
+import Menu from './Menu';
+import ReCaptcha from './ReCaptcha';
+import ExpandMenuButton from './ExpandMenuButton';
+import ModalRoot from './ModalRoot';
+
+import baseCss from './base.tcss';
+
+const position = 'absolute';
+const left = '1em';
+const right = left;
+
+const App = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export default App;
diff --git a/src/components/CanvasSwitchButton.js b/src/components/CanvasSwitchButton.js
new file mode 100644
index 0000000..1ee7584
--- /dev/null
+++ b/src/components/CanvasSwitchButton.js
@@ -0,0 +1,42 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { FaGlobe, FaGlobeAfrica } from 'react-icons/fa';
+
+import { switchCanvas } from '../actions';
+
+import type { State } from '../reducers';
+
+
+function globe(canvasId, canvasIdent, canvasSize, view) {
+ const [x, y] = view.map(Math.round);
+ window.location.href = `globe/#${canvasIdent},${canvasId},${canvasSize},${x},${y}`;
+}
+
+
+const CanvasSwitchButton = ({ canvasId, switchCanvas }) => (
+ switchCanvas(canvasId)}>
+ {(canvasId == 0) ? : }
+
+);
+
+function mapStateToProps(state: State) {
+ const { canvasId } = state.canvas;
+ return { canvasId };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ switchCanvas(canvasId) {
+ const newCanvasId = (canvasId == 0) ? 1 : 0;
+ dispatch(switchCanvas(newCanvasId));
+ },
+ };
+}
+
+export default connect(mapStateToProps,
+ mapDispatchToProps)(CanvasSwitchButton);
diff --git a/src/components/ChangeMail.js b/src/components/ChangeMail.js
new file mode 100644
index 0000000..fb3a539
--- /dev/null
+++ b/src/components/ChangeMail.js
@@ -0,0 +1,115 @@
+/*
+ * Change Mail Form
+ * @flow
+ */
+
+import React from 'react';
+import { validateName, validateEMail, validatePassword, parseAPIresponse } from '../utils/validation';
+
+function validate(email, password) {
+ const errors = [];
+
+ const passerror = validatePassword(password);
+ if (passerror) errors.push(passerror);
+ const mailerror = validateEMail(email);
+ if (mailerror) errors.push(mailerror);
+
+ return errors;
+}
+
+async function submit_mailchange(email, password) {
+ const body = JSON.stringify({
+ email,
+ password,
+ });
+ const response = await fetch('./api/auth/change_mail', {
+ method: 'POST',
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ body,
+ credentials: 'include',
+ });
+
+ return parseAPIresponse(response);
+}
+
+class ChangeMail extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ password: '',
+ email: '',
+ submitting: false,
+ success: false,
+
+ errors: [],
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ async handleSubmit(e) {
+ e.preventDefault();
+
+ const { email, password, submitting } = this.state;
+ if (submitting) return;
+
+ const errors = validate(email, password);
+
+ this.setState({ errors });
+ if (errors.length > 0) return;
+ this.setState({ submitting: true });
+
+ const { errors: resperrors } = await submit_mailchange(email, password);
+ if (resperrors) {
+ this.setState({
+ errors: resperrors,
+ submitting: false,
+ });
+ return;
+ }
+ this.setState({
+ success: true,
+ });
+ }
+
+ render() {
+ if (this.state.success) {
+ return (
+
+
Changed Mail successfully. We sent you a verification mail, please verify your new mail adress.
+
Close
+
+ );
+ }
+ const { errors } = this.state;
+ return (
+
+ );
+ }
+}
+
+export default ChangeMail;
diff --git a/src/components/ChangeName.js b/src/components/ChangeName.js
new file mode 100644
index 0000000..5839f82
--- /dev/null
+++ b/src/components/ChangeName.js
@@ -0,0 +1,95 @@
+/*
+ * Change Name Form
+ * @flow
+ */
+
+import React from 'react';
+import { validateName, parseAPIresponse } from '../utils/validation';
+
+
+function validate(name) {
+ const errors = [];
+
+ const nameerror = validateName(name);
+ if (nameerror) errors.push(nameerror);
+
+ return errors;
+}
+
+async function submit_namechange(name) {
+ const body = JSON.stringify({
+ name,
+ });
+ const response = await fetch('./api/auth/change_name', {
+ method: 'POST',
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ body,
+ credentials: 'include',
+ });
+
+ return parseAPIresponse(response);
+}
+
+class ChangeName extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ name: '',
+ submitting: false,
+
+ errors: [],
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ async handleSubmit(e) {
+ e.preventDefault();
+
+ const { name, submitting } = this.state;
+ if (submitting) return;
+
+ const errors = validate(name);
+
+ this.setState({ errors });
+ if (errors.length > 0) return;
+ this.setState({ submitting: true });
+
+ const { errors: resperrors } = await submit_namechange(name);
+ if (resperrors) {
+ this.setState({
+ errors: resperrors,
+ submitting: false,
+ });
+ return;
+ }
+ this.props.set_name(name);
+ this.props.done();
+ }
+
+ render() {
+ const { errors } = this.state;
+ return (
+
+ );
+ }
+}
+
+export default ChangeName;
diff --git a/src/components/ChangePassword.js b/src/components/ChangePassword.js
new file mode 100644
index 0000000..b3fa717
--- /dev/null
+++ b/src/components/ChangePassword.js
@@ -0,0 +1,132 @@
+/*
+ * Change Password Form
+ * @flow
+ */
+
+import React from 'react';
+import { validatePassword, parseAPIresponse } from '../utils/validation';
+
+function validate(mailreg, password, new_password, confirm_password) {
+ const errors = [];
+
+ if (mailreg) {
+ const oldpasserror = validatePassword(password);
+ if (oldpasserror) errors.push(oldpasserror);
+ }
+ if (new_password != confirm_password) {
+ errors.push('Passwords do not match.');
+ return errors;
+ }
+ const passerror = validatePassword(new_password);
+ if (passerror) errors.push(passerror);
+
+ return errors;
+}
+
+async function submit_passwordchange(new_password, password) {
+ const body = JSON.stringify({
+ password,
+ new_password,
+ });
+ const response = await fetch('./api/auth/change_passwd', {
+ method: 'POST',
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ body,
+ credentials: 'include',
+ });
+
+ return parseAPIresponse(response);
+}
+
+
+class ChangePassword extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ password: '',
+ new_password: '',
+ confirm_password: '',
+ success: false,
+ submitting: false,
+
+ errors: [],
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ async handleSubmit(e) {
+ e.preventDefault();
+
+ const { password, new_password, confirm_password, submitting } = this.state;
+ if (submitting) return;
+
+ const errors = validate(this.props.mailreg, password, new_password, confirm_password);
+
+ this.setState({ errors });
+ if (errors.length > 0) return;
+ this.setState({ submitting: true });
+
+ const { errors: resperrors } = await submit_passwordchange(new_password, password);
+ if (resperrors) {
+ this.setState({
+ errors: resperrors,
+ submitting: false,
+ });
+ return;
+ }
+ this.setState({
+ success: true,
+ });
+ }
+
+ render() {
+ if (this.state.success) {
+ return (
+
+
Changed Password successfully.
+
Close
+
+ );
+ }
+ const { errors } = this.state;
+ return (
+
+ );
+ }
+}
+
+export default ChangePassword;
diff --git a/src/components/Chat.js b/src/components/Chat.js
new file mode 100644
index 0000000..52be78f
--- /dev/null
+++ b/src/components/Chat.js
@@ -0,0 +1,57 @@
+/**
+ *
+ * @flow
+ */
+
+import React, { useRef, useLayoutEffect } from 'react';
+import useStayScrolled from 'react-stay-scrolled';
+import { connect } from 'react-redux';
+
+import type { State } from '../reducers';
+import ChatInput from './ChatInput';
+import { colorFromText, splitCoordsInString } from '../core/utils';
+
+
+const Chat = ({ chatMessages }) => {
+ const listRef = useRef();
+ const { stayScrolled } = useStayScrolled(listRef, {
+ initialScroll: Infinity,
+ });
+
+ useLayoutEffect(() => {
+ stayScrolled();
+ }, [chatMessages.length]);
+
+ return (
+
+
+ {
+ chatMessages.map(message => (
+
+ {(message[0] == 'info') ?
+ {message[1]} :
+
+
{`${message[0]}: `}
+ {
+ splitCoordsInString(message[1]).map((text, i) => {
+ if (i % 2 == 0) { return (
{text} ); }
+ return (
{text} );
+ })
+ }
+
+ }
+
+ ))
+ }
+
+
+
+ );
+};
+
+function mapStateToProps(state: State) {
+ const { chatMessages } = state.user;
+ return { chatMessages };
+}
+
+export default connect(mapStateToProps)(Chat);
diff --git a/src/components/ChatBox.js b/src/components/ChatBox.js
new file mode 100644
index 0000000..fb56c39
--- /dev/null
+++ b/src/components/ChatBox.js
@@ -0,0 +1,29 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import type { State } from '../reducers';
+
+import Chat from './Chat';
+
+
+const ChatBox = ({ chatOpen }) => (
+
+ {(chatOpen) ?
+
+
+
: null}
+
+);
+
+// TODO optimize
+function mapStateToProps(state: State) {
+ const { chatOpen } = state.modal;
+ return { chatOpen };
+}
+
+export default connect(mapStateToProps)(ChatBox);
diff --git a/src/components/ChatButton.js b/src/components/ChatButton.js
new file mode 100644
index 0000000..5a50ade
--- /dev/null
+++ b/src/components/ChatButton.js
@@ -0,0 +1,27 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { MdForum } from 'react-icons/md';
+
+import { showChatModal } from '../actions';
+
+
+const ChatButton = ({ name, open }) => (
+
+
+
: null
+);
+
+function mapDispatchToProps(dispatch) {
+ return {
+ open() {
+ dispatch(showChatModal());
+ },
+ };
+}
+
+export default connect(null, mapDispatchToProps)(ChatButton);
diff --git a/src/components/ChatInput.js b/src/components/ChatInput.js
new file mode 100644
index 0000000..5118f33
--- /dev/null
+++ b/src/components/ChatInput.js
@@ -0,0 +1,71 @@
+/*
+ * Chat input field
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import type { State } from '../reducers';
+import ProtocolClient from '../socket/ProtocolClient';
+
+import { showUserAreaModal } from '../actions';
+
+class ChatInput extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ message: '',
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ handleSubmit(e) {
+ e.preventDefault();
+
+ const { message } = this.state;
+ if (!message) return;
+ // send message via websocket
+ ProtocolClient.sendMessage(message);
+ this.setState({
+ message: '',
+ });
+ }
+
+ render() {
+ if (this.props.name) {
+ return (
+
+
+
+ );
+ }
+ return (
+ You must be logged in to chat
+ );
+ }
+}
+
+function mapStateToProps(state: State) {
+ const { name } = state.user;
+ return { name };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ open() {
+ dispatch(showUserAreaModal());
+ },
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(ChatInput);
diff --git a/src/components/ChatModal.js b/src/components/ChatModal.js
new file mode 100644
index 0000000..dde7abd
--- /dev/null
+++ b/src/components/ChatModal.js
@@ -0,0 +1,44 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import Modal from './Modal';
+import Chat from './Chat';
+
+import type { State } from '../reducers';
+
+
+const textStyle = {
+ color: 'hsla(218, 5%, 47%, .6)',
+ fontSize: 14,
+ fontWeight: 500,
+ position: 'relative',
+ textAlign: 'inherit',
+ float: 'none',
+ margin: 0,
+ padding: 0,
+ lineHeight: 'normal',
+};
+
+
+const ChatModal = ({ center }) => (
+
+
+
Chat with other people here
+
+
+
+
+
+);
+
+function mapStateToProps(state: State) {
+ const { center } = state.user;
+ return { center };
+}
+
+export default connect(mapStateToProps)(ChatModal);
diff --git a/src/components/CoolDownBox.js b/src/components/CoolDownBox.js
new file mode 100644
index 0000000..a4f5fad
--- /dev/null
+++ b/src/components/CoolDownBox.js
@@ -0,0 +1,31 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import {
+ durationToString,
+} from '../core/utils';
+import type { State } from '../reducers';
+
+
+const CoolDownBox = ({ coolDown }) => (
+
+ {coolDown && durationToString(coolDown, true)}
+
+);
+
+function mapStateToProps(state: State) {
+ const { coolDown } = state.user;
+ return { coolDown };
+}
+
+export default connect(mapStateToProps)(CoolDownBox);
diff --git a/src/components/CoordinatesBox.js b/src/components/CoordinatesBox.js
new file mode 100644
index 0000000..1b1ddd6
--- /dev/null
+++ b/src/components/CoordinatesBox.js
@@ -0,0 +1,31 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { screenToWorld } from '../core/utils';
+import type { State } from '../reducers';
+
+
+function renderCoordinates([x, y]: Cell): string {
+ return `(${x}, ${y})`;
+}
+
+// TODO vaya chapuza, arreglalo un poco...
+// TODO create viewport state
+const CoordinatesBox = ({ state, view, hover }) => (
+ {renderCoordinates(hover
+ ? screenToWorld(state, document.getElementById('gameWindow'), hover)
+ : view.map(Math.round))}
+);
+
+function mapStateToProps(state: State) {
+ const { view } = state.canvas;
+ const { hover } = state.gui;
+ return { view, state, hover };
+}
+
+export default connect(mapStateToProps)(CoordinatesBox);
diff --git a/src/components/Creeper.svg b/src/components/Creeper.svg
new file mode 100644
index 0000000..38d24f0
--- /dev/null
+++ b/src/components/Creeper.svg
@@ -0,0 +1,56 @@
+
+
+
+image/svg+xml
+
+
+
+
+
diff --git a/src/components/DailyRankings.js b/src/components/DailyRankings.js
new file mode 100644
index 0000000..09d80ac
--- /dev/null
+++ b/src/components/DailyRankings.js
@@ -0,0 +1,40 @@
+/*
+ * Rankings Tabs
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import type { State } from '../reducers';
+
+const DailyRankings = ({ totalDailyRanking }) => (
+
+
+
+ #
+ user
+ Pixels
+ # Total
+ Total Pixels
+
+ {
+ totalDailyRanking.map(rank => (
+
+ {rank.dailyRanking}
+ {rank.name}
+ {rank.dailyTotalPixels}
+ {rank.ranking}
+ {rank.totalPixels}
+ ))
+ }
+
+
+);
+
+function mapStateToProps(state: State) {
+ const { totalDailyRanking } = state.user;
+ return { totalDailyRanking };
+}
+
+export default connect(mapStateToProps)(DailyRankings);
diff --git a/src/components/DeleteAccount.js b/src/components/DeleteAccount.js
new file mode 100644
index 0000000..0c27aaa
--- /dev/null
+++ b/src/components/DeleteAccount.js
@@ -0,0 +1,93 @@
+/*
+ * Change Password Form
+ * @flow
+ */
+
+import React from 'react';
+import { validatePassword, parseAPIresponse } from '../utils/validation';
+
+function validate(password) {
+ const errors = [];
+
+ const passworderror = validatePassword(password);
+ if (passworderror) errors.push(passworderror);
+
+ return errors;
+}
+
+async function submit_delete_account(password) {
+ const body = JSON.stringify({
+ password,
+ });
+ const response = await fetch('./api/auth/delete_account', {
+ method: 'POST',
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ body,
+ credentials: 'include',
+ });
+
+ return parseAPIresponse(response);
+}
+
+class DeleteAccount extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ password: '',
+ submitting: false,
+
+ errors: [],
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ async handleSubmit(e) {
+ e.preventDefault();
+
+ const { password, submitting } = this.state;
+ if (submitting) return;
+
+ const errors = validate(password);
+
+ this.setState({ errors });
+ if (errors.length > 0) return;
+ this.setState({ submitting: true });
+
+ const { errors: resperrors } = await submit_delete_account(password);
+ if (resperrors) {
+ this.setState({
+ errors: resperrors,
+ submitting: false,
+ });
+ return;
+ }
+ this.props.set_name(null);
+ }
+
+ render() {
+ const { errors } = this.state;
+ return (
+
+ );
+ }
+}
+
+export default DeleteAccount;
diff --git a/src/components/DownloadButton.js b/src/components/DownloadButton.js
new file mode 100644
index 0000000..6f6c664
--- /dev/null
+++ b/src/components/DownloadButton.js
@@ -0,0 +1,43 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { MdFileDownload } from 'react-icons/md';
+import fileDownload from 'react-file-download';
+
+import type { State } from '../reducers';
+
+
+/**
+ * https://jsfiddle.net/AbdiasSoftware/7PRNN/
+ */
+function download(view) {
+ // TODO id shouldnt be hardcoded
+ const $viewport = document.getElementById('gameWindow');
+ if (!$viewport) return;
+
+ // TODO change name
+
+ const [x, y] = view.map(Math.round);
+ const filename = `pixelplanet-${x}-${y}.png`;
+
+ $viewport.toBlob(blob => fileDownload(blob, filename));
+}
+
+
+const DownloadButton = ({ view }) => (
+ download(view)}>
+
+
+);
+
+// TODO optimize
+function mapStateToProps(state: State) {
+ const { view } = state.canvas;
+ return { view };
+}
+
+export default connect(mapStateToProps)(DownloadButton);
diff --git a/src/components/ExpandMenuButton.js b/src/components/ExpandMenuButton.js
new file mode 100644
index 0000000..4096c58
--- /dev/null
+++ b/src/components/ExpandMenuButton.js
@@ -0,0 +1,30 @@
+/*
+ * espand menu / show other menu buttons
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { MdExpandMore, MdExpandLess } from 'react-icons/md';
+
+import { toggleOpenMenu } from '../actions';
+
+const ExpandMenuButton = ({ menuOpen, expand }) => (
+
+);
+
+function mapStateToProps(state: State) {
+ const { menuOpen } = state.gui;
+ return { menuOpen };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ expand() {
+ dispatch(toggleOpenMenu());
+ },
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(ExpandMenuButton);
diff --git a/src/components/ForgotPasswordModal.js b/src/components/ForgotPasswordModal.js
new file mode 100644
index 0000000..5301d9e
--- /dev/null
+++ b/src/components/ForgotPasswordModal.js
@@ -0,0 +1,50 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import Modal from './Modal';
+
+import type { State } from '../reducers';
+
+import { showUserAreaModal } from '../actions';
+import NewPasswordForm from './NewPasswordForm';
+
+const textStyle = {
+ color: 'hsla(218, 5%, 47%, .6)',
+ fontSize: 14,
+ fontWeight: 500,
+ position: 'relative',
+ textAlign: 'inherit',
+ float: 'none',
+ margin: 0,
+ padding: 0,
+ lineHeight: 'normal',
+};
+
+const ForgotPasswordModal = ({ login }) => (
+
+ Enter your mail adress and we will send you a new password:
+
+
+
Also join our Discord: pixelplanet.fun/discord
+
+
+);
+
+function mapDispatchToProps(dispatch) {
+ return {
+ login() {
+ dispatch(showUserAreaModal());
+ },
+ };
+}
+
+function mapStateToProps(state: State) {
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(ForgotPasswordModal);
diff --git a/src/components/GlobeButton.js b/src/components/GlobeButton.js
new file mode 100644
index 0000000..1ed6ea9
--- /dev/null
+++ b/src/components/GlobeButton.js
@@ -0,0 +1,34 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { Md3DRotation } from 'react-icons/md';
+
+import type { State } from '../reducers';
+
+
+/**
+ * https://jsfiddle.net/AbdiasSoftware/7PRNN/
+ */
+function globe(canvasId, canvasIdent, canvasSize, view) {
+ const [x, y] = view.map(Math.round);
+ window.location.href = `go/#${canvasIdent},${canvasId},${canvasSize},${x},${y}`;
+}
+
+
+const GlobeButton = ({ canvasId, canvasIdent, canvasSize, view }) => (
+ globe(canvasId, canvasIdent, canvasSize, view)}>
+
+
+);
+
+// TODO optimize
+function mapStateToProps(state: State) {
+ const { canvasId, canvasIdent, canvasSize, view } = state.canvas;
+ return { canvasId, canvasIdent, canvasSize, view };
+}
+
+export default connect(mapStateToProps)(GlobeButton);
diff --git a/src/components/GridButton.js b/src/components/GridButton.js
new file mode 100644
index 0000000..7fa084b
--- /dev/null
+++ b/src/components/GridButton.js
@@ -0,0 +1,32 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { FaTh } from 'react-icons/fa';
+
+import { toggleGrid } from '../actions';
+
+
+const GridButton = ({ onToggleGrid }) => (
+
+
+
+);
+
+// TODO simplify...
+function mapStateToProps(state: State) {
+ return {};
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ open() {
+ dispatch(toggleGrid());
+ },
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(GridButton);
diff --git a/src/components/HelpButton.js b/src/components/HelpButton.js
new file mode 100644
index 0000000..a517845
--- /dev/null
+++ b/src/components/HelpButton.js
@@ -0,0 +1,28 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { FaQuestion } from 'react-icons/fa';
+
+import { showHelpModal } from '../actions';
+
+
+const HelpButton = ({ open }) => (
+
+
+
+);
+
+
+function mapDispatchToProps(dispatch) {
+ return {
+ open() {
+ dispatch(showHelpModal());
+ },
+ };
+}
+
+export default connect(null, mapDispatchToProps)(HelpButton);
diff --git a/src/components/HelpModal.js b/src/components/HelpModal.js
new file mode 100644
index 0000000..866da59
--- /dev/null
+++ b/src/components/HelpModal.js
@@ -0,0 +1,97 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+// import FaFacebook from 'react-icons/lib/fa/facebook';
+// import FaTwitter from 'react-icons/lib/fa/twitter';
+// import FaRedditAlien from 'react-icons/lib/fa/reddit-alien';
+
+import Modal from './Modal';
+import { social, MIN_COOLDOWN, BLANK_COOLDOWN } from '../core/constants';
+
+import type { State } from '../reducers';
+
+
+const linkStyle = {
+ textDecoration: 'none',
+ color: '#428bca',
+};
+
+const titleStyle = {
+ color: '#4f545c',
+ marginLeft: 0,
+ marginRight: 10,
+ overflow: 'hidden',
+ wordWrap: 'break-word',
+ lineHeight: '24px',
+ fontSize: 16,
+ fontWeight: 500,
+ // marginTop: 0,
+ marginBottom: 0,
+};
+
+const textStyle = {
+ color: 'hsla(218, 5%, 47%, .6)',
+ fontSize: 14,
+ fontWeight: 500,
+ position: 'relative',
+ textAlign: 'inherit',
+ float: 'none',
+ margin: 0,
+ padding: 0,
+ lineHeight: 'normal',
+};
+
+
+const HelpModal = ({ center }) => (
+
+
+
Place color pixels on a large canvas with other players online!
+ Cooldown is {(BLANK_COOLDOWN / 1000) | 0} seconds for fresh pixels and {(MIN_COOLDOWN / 1000) | 0}s for overwriting existing pixels!
+ The current canvas size is from -32768 to +32768 in x and y.
+ Higher zoomlevels take some time to update, the 3D globe gets updated at least once per day.
+ Have fun!
+ New Discord: pixelplanet.fun/discord
+ Reddit: r/PixelPlanetFun
+ Image Converter: pixelplanet.fun/convert
+ Image Converter for 2nd Planet: pixelplanet.fun/convert2
+ Map Data
+ The bare map data that we use, together with converted OpenStreetMap tiles for orientation,
+ can be downloaded from mega.nz here: pixelplanetmap.zip (422MB)
+ GIMP Palette
+ The Palettes for GIMP can be found here and here . Credit for the Palette of the second planet goes to starhouse .
+ Detected as Proxy?
+ If you got detected as proxy, but you are none, please send us an e-mail with your IP to pixelplanetdev@gmail.com. Don't post your IP anywhere else. We are sorry for the inconvenience.
+ Controls
+ Click a color in palette to select
+ Press G to toggle grid
+ Press C to toggle showing of pixel activity
+ Press q or e to zoom
+ Press W ,A ,S , D to move
+ Press ↑ ,← ,↓ , → to move
+ Drag mouse to move
+ Scroll mouse wheel to zoom
+ Click middle mouse button to current hovering color
+ Pinch to zoom (on touch devices)
+ Pan to move (on touch devices)
+ Click or tab to place a pixel
+ Partners: crazygames.com
+
+ This site is protected by reCAPTCHA and the Google
+ Privacy Policy and
+ Terms of Service apply.
+
+
+
+
+);
+
+function mapStateToProps(state: State) {
+ const { center } = state.user;
+ return { center };
+}
+
+export default connect(mapStateToProps)(HelpModal);
diff --git a/src/components/Html.js b/src/components/Html.js
new file mode 100644
index 0000000..b6f4092
--- /dev/null
+++ b/src/components/Html.js
@@ -0,0 +1,84 @@
+/* @flow */
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-present Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+import React from 'react';
+import { analytics, RECAPTCHA_SITEKEY } from '../core/config';
+
+class Html extends React.Component {
+ static defaultProps = {
+ styles: [],
+ scripts: [],
+ };
+
+ props: {
+ title: string,
+ description: string,
+ styles: Array<{
+ id: string,
+ cssText: string,
+ }>,
+ scripts: Array,
+ body: string,
+ code: string,
+ };
+
+ render() {
+ const { title, description, styles, scripts, body, code } = this.props;
+ return (
+
+
+
+
+ {title}
+
+
+
+
+ {styles.map(style =>
+ (),
+ )}
+ {RECAPTCHA_SITEKEY && }
+ {RECAPTCHA_SITEKEY && }
+
+
+
+ {body}
+
+
+ {scripts.map(script => )}
+ {analytics.google.trackingId &&
+
+ }
+ {analytics.google.trackingId &&
+
+ }
+
+
+ );
+ }
+}
+
+export default Html;
diff --git a/src/components/LogInButton.js b/src/components/LogInButton.js
new file mode 100644
index 0000000..a31c094
--- /dev/null
+++ b/src/components/LogInButton.js
@@ -0,0 +1,29 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { MdPerson } from 'react-icons/md';
+
+import { showUserAreaModal } from '../actions';
+
+import type { State } from '../reducers';
+
+
+const LogInButton = ({ open }) => (
+
+
+
+);
+
+function mapDispatchToProps(dispatch) {
+ return {
+ open() {
+ dispatch(showUserAreaModal());
+ },
+ };
+}
+
+export default connect(null, mapDispatchToProps)(LogInButton);
diff --git a/src/components/LogInForm.js b/src/components/LogInForm.js
new file mode 100644
index 0000000..038ec18
--- /dev/null
+++ b/src/components/LogInForm.js
@@ -0,0 +1,107 @@
+/*
+ * LogIn Form
+ * @flow
+ */
+import React from 'react';
+import { validateEMail, validateName, validatePassword, parseAPIresponse } from '../utils/validation';
+
+
+function validate(nameoremail, password) {
+ const errors = [];
+ const mailerror = (nameoremail.indexOf('@') !== -1) ?
+ validateEMail(nameoremail) :
+ validateName(nameoremail);
+ if (mailerror) errors.push(mailerror);
+ const passworderror = validatePassword(password);
+ if (passworderror) errors.push(passworderror);
+
+ return errors;
+}
+
+async function submit_login(nameoremail, password, component) {
+ const body = JSON.stringify({
+ nameoremail,
+ password,
+ });
+ const response = await fetch('./api/auth/local', {
+ method: 'POST',
+ body,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+
+ return parseAPIresponse(response);
+}
+
+const inputStyles = {
+ display: 'block',
+ width: '100%',
+};
+
+class LogInForm extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ nameoremail: '',
+ password: '',
+ submitting: false,
+
+ errors: [],
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ async handleSubmit(e) {
+ e.preventDefault();
+
+ const { nameoremail, password, submitting } = this.state;
+ if (submitting) return;
+
+ const errors = validate(nameoremail, password);
+
+ this.setState({ errors });
+ if (errors.length > 0) return;
+
+ this.setState({ submitting: true });
+ const { errors: resperrors, me } = await submit_login(nameoremail, password);
+ if (resperrors) {
+ this.setState({
+ errors: resperrors,
+ submitting: false,
+ });
+ return;
+ }
+ this.props.me(me);
+ }
+
+ render() {
+ const { errors } = this.state;
+ return (
+
+ );
+ }
+}
+
+export default LogInForm;
diff --git a/src/components/MdToggleButton.js b/src/components/MdToggleButton.js
new file mode 100644
index 0000000..9a03cd0
--- /dev/null
+++ b/src/components/MdToggleButton.js
@@ -0,0 +1,20 @@
+/**
+ */
+
+import React from 'react';
+import ToggleButton from 'react-toggle-button';
+import { MdCheck, MdClose } from 'react-icons/md';
+
+
+const MdToggleButton = ({ value, onToggle, ...props }) => (
+ }
+ activeLabel={ }
+ thumbAnimateRange={[-10, 36]}
+ value={value}
+ onToggle={onToggle}
+ {...props}
+ />
+);
+
+export default MdToggleButton;
diff --git a/src/components/MdToggleButtonHover.js b/src/components/MdToggleButtonHover.js
new file mode 100644
index 0000000..5984871
--- /dev/null
+++ b/src/components/MdToggleButtonHover.js
@@ -0,0 +1,32 @@
+/**
+ */
+
+import React from 'react';
+import MdToggleButton from './MdToggleButton';
+
+
+const MdToggleButtonHover = ({ value, onToggle }) => (
+ ({
+ boxShadow: `0 0 ${2 + (4 * n)}px rgba(0,0,0,.16),0 ${2 + (3 * n)}px ${4 + (8 * n)}px rgba(0,0,0,.32)`,
+ })}
+ />
+);
+
+export default MdToggleButtonHover;
diff --git a/src/components/Menu.js b/src/components/Menu.js
new file mode 100644
index 0000000..2c2d13c
--- /dev/null
+++ b/src/components/Menu.js
@@ -0,0 +1,33 @@
+/*
+ * Menu with Buttons on the top left
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import HelpButton from './HelpButton';
+import SettingsButton from './SettingsButton';
+import LogInButton from './LogInButton';
+import DownloadButton from './DownloadButton';
+import MinecraftTPButton from './MinecraftTPButton.js';
+import MinecraftButton from './MinecraftButton';
+
+const Menu = ({ menuOpen, minecraftname, messages, canvasId }) => (
+
+ {(menuOpen) ? : null}
+ {(menuOpen) ? : null}
+ {(menuOpen) ? : null}
+ {(menuOpen) ? : null}
+ {(menuOpen) ? : null}
+ {(minecraftname && !messages.includes('not_mc_verified') && canvasId == 0) ? : null}
+
+);
+
+function mapStateToProps(state: State) {
+ const { menuOpen } = state.gui;
+ const { minecraftname, messages } = state.user;
+ const { canvasId } = state.canvas;
+ return { menuOpen, minecraftname, messages, canvasId };
+}
+
+export default connect(mapStateToProps)(Menu);
diff --git a/src/components/MinecraftButton.js b/src/components/MinecraftButton.js
new file mode 100644
index 0000000..cfcabe2
--- /dev/null
+++ b/src/components/MinecraftButton.js
@@ -0,0 +1,29 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import Creeper from './Creeper.svg';
+
+import { showMinecraftModal } from '../actions';
+
+import type { State } from '../reducers';
+
+
+const MinecraftButton = ({ open }) => (
+
+
+
+);
+
+function mapDispatchToProps(dispatch) {
+ return {
+ open() {
+ dispatch(showMinecraftModal());
+ },
+ };
+}
+
+export default connect(null, mapDispatchToProps)(MinecraftButton);
diff --git a/src/components/MinecraftModal.js b/src/components/MinecraftModal.js
new file mode 100644
index 0000000..cdb7751
--- /dev/null
+++ b/src/components/MinecraftModal.js
@@ -0,0 +1,27 @@
+/*
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import Modal from './Modal';
+
+
+const MinecraftModal = () => (
+
+
+
You can also place pixels from our Minecraft Server at
+
+ Please Note that the Minecraft Server is down from time to time
+
+
+);
+
+function mapStateToProps(state: State) {
+ const { center } = state.user;
+ return { center };
+}
+
+export default connect(mapStateToProps)(MinecraftModal);
diff --git a/src/components/MinecraftTPButton.js b/src/components/MinecraftTPButton.js
new file mode 100644
index 0000000..dd7b171
--- /dev/null
+++ b/src/components/MinecraftTPButton.js
@@ -0,0 +1,40 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { MdNearMe } from 'react-icons/md';
+
+import type { State } from '../reducers';
+
+async function submit_minecraft_tp(view) {
+ const [x, y] = view.map(Math.round);
+ const body = JSON.stringify({
+ x,
+ y,
+ });
+ const response = await fetch('./api/mctp', {
+ method: 'POST',
+ headers: {
+ 'Content-type': 'application/json',
+ },
+ body,
+ credentials: 'include',
+ });
+}
+
+
+const MinecraftTPButton = ({ view }) => (
+ submit_minecraft_tp(view)}>
+
+
+);
+
+function mapStateToProps(state: State) {
+ const { view } = state.canvas;
+ return { view };
+}
+
+export default connect(mapStateToProps)(MinecraftTPButton);
diff --git a/src/components/Modal.js b/src/components/Modal.js
new file mode 100644
index 0000000..b207011
--- /dev/null
+++ b/src/components/Modal.js
@@ -0,0 +1,67 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import Modal from 'react-modal';
+import { connect } from 'react-redux';
+import { MdClose } from 'react-icons/md';
+
+import {
+ hideModal,
+} from '../actions';
+
+import type { State } from '../reducers';
+
+
+const closeStyles = {
+ position: 'fixed',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ flex: '0 0 36px',
+ borderWidth: 2,
+ borderStyle: 'solid',
+ borderRadius: '50%',
+ width: 36,
+ height: 36,
+ cursor: 'pointer',
+ backgroundColor: '#f6f6f7',
+ borderColor: '#dcddde',
+ top: 30,
+ right: 40,
+};
+
+
+// TODO appear with animation
+function MyModal({ close, title, children }) {
+ return (
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function mapStateToProps(state: State) {
+ return {};
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ close() {
+ dispatch(hideModal());
+ },
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(MyModal);
diff --git a/src/components/ModalRoot.js b/src/components/ModalRoot.js
new file mode 100644
index 0000000..9e18097
--- /dev/null
+++ b/src/components/ModalRoot.js
@@ -0,0 +1,42 @@
+/**
+ *
+ * https://stackoverflow.com/questions/35623656/how-can-i-display-a-modal-dialog-in-redux-that-performs-asynchronous-actions/35641680#35641680
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import HelpModal from './HelpModal';
+import SettingsModal from './SettingsModal';
+import UserAreaModal from './UserAreaModal';
+import RegisterModal from './RegisterModal';
+import ChatModal from './ChatModal';
+import ForgotPasswordModal from './ForgotPasswordModal';
+import MinecraftModal from './MinecraftModal';
+
+
+const MODAL_COMPONENTS = {
+ HELP: HelpModal,
+ SETTINGS: SettingsModal,
+ USERAREA: UserAreaModal,
+ REGISTER: RegisterModal,
+ FORGOT_PASSWORD: ForgotPasswordModal,
+ CHAT: ChatModal,
+ MINECRAFT: MinecraftModal,
+ /* other modals */
+};
+
+const ModalRoot = ({ modalType, modalProps }) => {
+ if (!modalType) {
+ return null;
+ }
+
+ const SpecificModal = MODAL_COMPONENTS[modalType];
+ return ;
+};
+
+export default connect(
+ state => state.modal,
+)(ModalRoot);
diff --git a/src/components/NewPasswordForm.js b/src/components/NewPasswordForm.js
new file mode 100644
index 0000000..6f9459d
--- /dev/null
+++ b/src/components/NewPasswordForm.js
@@ -0,0 +1,104 @@
+/*
+ * Form for requesting password-reset mail
+ * @flow
+ */
+import React from 'react';
+import { validateEMail, parseAPIresponse } from '../utils/validation';
+
+function validate(email) {
+ const errors = [];
+ const mailerror = validateEMail(email);
+ if (mailerror) errors.push(mailerror);
+ return errors;
+}
+
+async function submit_newpass(email, component) {
+ const body = JSON.stringify({
+ email,
+ });
+ const response = await fetch('./api/auth/restore_password', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body,
+ });
+
+ return parseAPIresponse(response);
+}
+
+const inputStyles = {
+ display: 'block',
+ width: '100%',
+};
+
+class NewPasswordForm extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ email: '',
+ submitting: false,
+ success: false,
+
+ errors: [],
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ async handleSubmit(e) {
+ e.preventDefault();
+
+ const { email, submitting } = this.state;
+ if (submitting) return;
+
+ const errors = validate(email);
+
+ this.setState({ errors });
+ if (errors.length > 0) return;
+
+ this.setState({ submitting: true });
+ const { errors: resperrors } = await submit_newpass(email);
+ if (resperrors) {
+ this.setState({
+ errors: resperrors,
+ submitting: false,
+ });
+ return;
+ }
+ this.setState({
+ success: true,
+ });
+ }
+
+ render() {
+ const { errors } = this.state;
+ if (this.state.success) {
+ return (
+
+
Sent you a mail with instructions to reset your password.
+
Back
+
+ );
+ }
+ return (
+
+ );
+ }
+}
+
+export default NewPasswordForm;
diff --git a/src/components/NotifyBox.js b/src/components/NotifyBox.js
new file mode 100644
index 0000000..133e095
--- /dev/null
+++ b/src/components/NotifyBox.js
@@ -0,0 +1,28 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import type { State } from '../reducers';
+
+let style = {};
+function getStyle(notification) {
+ if (notification) style = { backgroundColor: (notification >= 0) ? '#a9ffb0cc' : '#ffa9a9cc' };
+ return style;
+}
+
+const NotifyBox = ({ notification }) => (
+
+ {notification}
+
+);
+
+function mapStateToProps(state: State) {
+ const { notification } = state.gui;
+ return { notification };
+}
+
+export default connect(mapStateToProps)(NotifyBox);
diff --git a/src/components/OnlineBox.js b/src/components/OnlineBox.js
new file mode 100644
index 0000000..46e0cce
--- /dev/null
+++ b/src/components/OnlineBox.js
@@ -0,0 +1,31 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { numberToString } from '../core/utils';
+import { FaUser, FaPaintBrush } from 'react-icons/fa';
+
+
+import type { State } from '../reducers';
+
+
+const OnlineBox = ({ online, totalPixels, name }) => (
+
+ {(online || name) ?
+
+ {(online) && {online} }
+ {(name != null) && {numberToString(totalPixels)} }
+
: null
+ }
+
+);
+
+function mapStateToProps(state: State) {
+ const { online, totalPixels, name } = state.user;
+ return { online, totalPixels, name };
+}
+
+export default connect(mapStateToProps)(OnlineBox);
diff --git a/src/components/Palette.js b/src/components/Palette.js
new file mode 100644
index 0000000..479ba1c
--- /dev/null
+++ b/src/components/Palette.js
@@ -0,0 +1,41 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { selectColor } from '../actions';
+
+import type { State } from '../reducers';
+
+
+const Palette = ({ colors, selectedColor, paletteOpen, compactPalette, select }) => (
+
+ {colors.slice(2).map((color, index) => ( select(index + 2)}
+ />),
+ )}
+
+);
+
+function mapStateToProps(state: State) {
+ const { selectedColor, paletteOpen, compactPalette } = state.gui;
+ const { palette } = state.canvas;
+ return { colors: palette.colors, selectedColor, paletteOpen, compactPalette };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ select(color) {
+ dispatch(selectColor(color));
+ },
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(Palette);
diff --git a/src/components/PalselButton.js b/src/components/PalselButton.js
new file mode 100644
index 0000000..dd7ffb6
--- /dev/null
+++ b/src/components/PalselButton.js
@@ -0,0 +1,34 @@
+/**
+ *
+ * Button to open/close palette
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { MdPalette } from 'react-icons/md';
+
+import { toggleOpenPalette } from '../actions';
+
+const PalselButton = ({ palette, onToggle, selectedColor, paletteOpen }) => (
+
+
+
+);
+
+// TODO simplify...
+function mapStateToProps(state: State) {
+ const { selectedColor, paletteOpen } = state.gui;
+ const { palette } = state.canvas;
+ return { palette, selectedColor, paletteOpen };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ onToggle() {
+ dispatch(toggleOpenPalette());
+ },
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(PalselButton);
diff --git a/src/components/PasswordReset.js b/src/components/PasswordReset.js
new file mode 100644
index 0000000..57c94aa
--- /dev/null
+++ b/src/components/PasswordReset.js
@@ -0,0 +1,36 @@
+/*
+ * Make basic reset_password forms
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom/server';
+import Html from '../components/Html';
+
+const PasswordReset = ({ name, code }) => (
+
+);
+
+const PasswordResetError = ({ message }) => (
+
+
Reset Password
+
{message}
+
Click here to go back to pixelplanet
+
+);
+
+export function getPasswordResetHtml(name, code, message = null) {
+ const data = {
+ title: 'PixelPlanet.fun Password Reset',
+ description: 'reset your password here',
+ body: (message) ? : ,
+ };
+ const index = `${ReactDOM.renderToStaticMarkup( )}`;
+ return index;
+}
diff --git a/src/components/Rankings.js b/src/components/Rankings.js
new file mode 100644
index 0000000..b60d7c6
--- /dev/null
+++ b/src/components/Rankings.js
@@ -0,0 +1,56 @@
+/*
+ * Rankings Tabs
+ * @flow
+ */
+
+import React from 'react';
+
+import TotalRankings from './TotalRankings';
+import DailyRankings from './DailyRankings';
+
+const textStyle = {
+ color: 'hsla(218, 5%, 47%, .6)',
+ fontSize: 14,
+ fontWeight: 500,
+ position: 'relative',
+ textAlign: 'inherit',
+ float: 'none',
+ margin: 0,
+ padding: 0,
+ lineHeight: 'normal',
+};
+
+class Rankings extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ order_daily: false,
+ };
+ }
+
+ render() {
+ return (
+
+
+ { this.setState({ order_daily: false }); }}
+ >Total |
+ { this.setState({ order_daily: true }); }}
+ >Daily
+
+ {(this.state.order_daily) ?
:
}
+
Ranking updates every 5 min. Daily rankings get reset at midnight UTC.
+
+ );
+ }
+}
+
+function mapStateToProps(state: State) {
+ const { totalRanking } = state.user;
+ return { totalRanking };
+}
+
+export default Rankings;
diff --git a/src/components/ReCaptcha.js b/src/components/ReCaptcha.js
new file mode 100644
index 0000000..f575554
--- /dev/null
+++ b/src/components/ReCaptcha.js
@@ -0,0 +1,35 @@
+/**
+ *
+ * @flow
+ * Implement ReCaptcha
+ * (the recaptcha sitekey gets received in the Html inline script sent by components/Html)
+ */
+
+import React from 'react';
+
+import type { State } from '../reducers';
+import store from '../ui/store';
+import { requestPlacePixel } from '../actions';
+
+
+function onCaptcha(token: string) {
+ console.log('token', token);
+
+ const { canvasId, coordinates, color } = window.pixel;
+
+ store.dispatch(requestPlacePixel(canvasId, coordinates, color, token));
+ grecaptcha.reset();
+}
+// https://stackoverflow.com/questions/41717304/recaptcha-google-data-callback-with-angularjs
+window.onCaptcha = onCaptcha;
+
+const ReCaptcha = () => (
+
+);
+
+export default ReCaptcha;
diff --git a/src/components/RedirectionPage.js b/src/components/RedirectionPage.js
new file mode 100644
index 0000000..cfc4129
--- /dev/null
+++ b/src/components/RedirectionPage.js
@@ -0,0 +1,28 @@
+/*
+ * Make basic redirection page
+ * @flow
+ */
+
+import React from 'react';
+import ReactDOM from 'react-dom/server';
+import Html from '../components/Html';
+
+const RedirectionPage = ({ text }) => (
+
+
{text}
+
You will be automatically redirected after 5s
+
Or Click here to go back to pixelplanet
+
+);
+
+export function getHtml(description, text) {
+ const data = {
+ title: 'PixelPlanet.fun Accounts',
+ description,
+ body: ,
+ code: 'window.setTimeout(function(){window.location.href="https://pixelplanet.fun";},4000)',
+ };
+ const index = `${ReactDOM.renderToStaticMarkup( )}`;
+ return index;
+}
+
diff --git a/src/components/RegisterModal.js b/src/components/RegisterModal.js
new file mode 100644
index 0000000..90b71ec
--- /dev/null
+++ b/src/components/RegisterModal.js
@@ -0,0 +1,57 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import Modal from './Modal';
+
+import type { State } from '../reducers';
+
+import { showUserAreaModal, receiveMe } from '../actions';
+
+// import { send_registration } from '../ui/register';
+import SignUpForm from './SignUpForm';
+
+
+const textStyle = {
+ color: 'hsla(218, 5%, 47%, .6)',
+ fontSize: 14,
+ fontWeight: 500,
+ position: 'relative',
+ textAlign: 'inherit',
+ float: 'none',
+ margin: 0,
+ padding: 0,
+ lineHeight: 'normal',
+};
+
+const RegisterModal = ({ login, doMe }) => (
+
+ Register new account here
+
+
+ Cancel
+
Also join our Discord: pixelplanet.fun/discord
+
+
+);
+
+function mapDispatchToProps(dispatch) {
+ return {
+ login() {
+ dispatch(showUserAreaModal());
+ },
+ doMe(me) {
+ dispatch(receiveMe(me));
+ },
+ };
+}
+
+function mapStateToProps(state: State) {
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(RegisterModal);
diff --git a/src/components/SettingsButton.js b/src/components/SettingsButton.js
new file mode 100644
index 0000000..df70ab4
--- /dev/null
+++ b/src/components/SettingsButton.js
@@ -0,0 +1,29 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+import { FaCog } from 'react-icons/fa';
+
+import { showSettingsModal } from '../actions';
+
+import type { State } from '../reducers';
+
+
+const SettingsButton = ({ open }) => (
+
+
+
+);
+
+function mapDispatchToProps(dispatch) {
+ return {
+ open() {
+ dispatch(showSettingsModal());
+ },
+ };
+}
+
+export default connect(null, mapDispatchToProps)(SettingsButton);
diff --git a/src/components/SettingsModal.js b/src/components/SettingsModal.js
new file mode 100644
index 0000000..0126d4a
--- /dev/null
+++ b/src/components/SettingsModal.js
@@ -0,0 +1,190 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import Modal from './Modal';
+import MdToggleButtonHover from './MdToggleButtonHover';
+import {
+ toggleGrid,
+ togglePixelNotify,
+ toggleMute,
+ toggleAutoZoomIn,
+ toggleCompactPalette,
+ toggleChatNotify,
+ togglePotatoMode,
+} from '../actions';
+
+import type { State } from '../reducers';
+
+
+const flexy = {
+ display: 'flex',
+ alignItems: 'stretch',
+ justifyContent: 'flex-start',
+ flexWrap: 'nowrap',
+ boxSizing: 'border-box',
+ flex: '1 1 auto',
+};
+
+const itemStyles = {
+ ...flexy,
+ flexDirection: 'column',
+ marginBottom: 20,
+};
+
+const titleStyles = {
+ flex: '1 1 auto',
+ marginLeft: 0,
+ marginRight: 10,
+ color: '#4f545c',
+ overflow: 'hidden',
+ wordWrap: 'break-word',
+ lineHeight: '24px',
+ fontSize: 16,
+ fontWeight: 500,
+ marginTop: 0,
+ marginBottom: 0,
+};
+
+const rowStyles = {
+ ...flexy,
+ flexDirection: 'row',
+};
+
+const descriptionStyle = {
+ boxSizing: 'border-box',
+ flex: '1 1 auto',
+ color: 'hsla(218, 5%, 47%, .6)',
+ fontSize: 14,
+ lineHeight: '20px',
+ fontWeight: 500,
+ marginTop: 4,
+};
+
+const dividerStyles = {
+ boxSizing: 'border-box',
+ marginTop: 20,
+ height: 1,
+ width: '100%',
+ backgroundColor: 'hsla(216, 4%, 74%, .3)',
+};
+
+
+const SettingsItem = ({ title, description, keyBind, value, onToggle }) => (
+
+
+
{title} {keyBind && {keyBind} }
+
+
+ {description &&
{description}
}
+
+
+);
+
+function SettingsModal({
+ isMuted,
+ isGridShown,
+ isPixelNotifyShown,
+ isPotato,
+ onMute,
+ autoZoomIn,
+ compactPalette,
+ onToggleGrid,
+ onTogglePixelNotify,
+ onToggleAutoZoomIn,
+ onToggleCompactPalette,
+ onToggleChatNotify,
+ onTogglePotatoMode,
+ chatNotify,
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function mapStateToProps(state: State) {
+ const { mute, chatNotify } = state.audio;
+ const { showGrid, showPixelNotify, autoZoomIn, compactPalette, isPotato } = state.gui;
+ const isMuted = mute;
+ const isGridShown = showGrid;
+ const isPixelNotifyShown = showPixelNotify;
+ return { isMuted, isGridShown, isPixelNotifyShown, autoZoomIn, compactPalette, chatNotify, isPotato };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ onMute() {
+ dispatch(toggleMute());
+ },
+ onToggleGrid() {
+ dispatch(toggleGrid());
+ },
+ onTogglePixelNotify() {
+ dispatch(togglePixelNotify());
+ },
+ onToggleAutoZoomIn() {
+ dispatch(toggleAutoZoomIn());
+ },
+ onToggleCompactPalette() {
+ dispatch(toggleCompactPalette());
+ },
+ onToggleChatNotify() {
+ dispatch(toggleChatNotify());
+ },
+ onTogglePotatoMode() {
+ dispatch(togglePotatoMode());
+ },
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(SettingsModal);
diff --git a/src/components/SignUpForm.js b/src/components/SignUpForm.js
new file mode 100644
index 0000000..056bd02
--- /dev/null
+++ b/src/components/SignUpForm.js
@@ -0,0 +1,130 @@
+/*
+ * SignUp Form to register new user by mail
+ * @flow
+ */
+
+import React from 'react';
+import { validateEMail, validateName, validatePassword, parseAPIresponse } from '../utils/validation';
+
+function validate(name, email, password, confirm_password) {
+ const errors = [];
+ const mailerror = validateEMail(email);
+ if (mailerror) errors.push(mailerror);
+ const nameerror = validateName(name);
+ if (nameerror) errors.push(nameerror);
+ const passworderror = validatePassword(password);
+ if (passworderror) errors.push(passworderror);
+
+ if (password !== confirm_password) {
+ errors.push('Passwords do not match');
+ }
+ return errors;
+}
+
+
+async function submit_registration(name, email, password, component) {
+ const body = JSON.stringify({
+ name,
+ email,
+ password,
+ });
+ const response = await fetch('./api/auth/register', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body,
+ credentials: 'include',
+ });
+
+ return parseAPIresponse(response);
+}
+
+const inputStyles = {
+ display: 'block',
+ width: '100%',
+};
+
+class SignUpForm extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ name: '',
+ email: '',
+ password: '',
+ confirm_password: '',
+ submitting: false,
+
+ errors: [],
+ };
+
+ this.handleSubmit = this.handleSubmit.bind(this);
+ }
+
+ async handleSubmit(e) {
+ e.preventDefault();
+
+ const { name, email, password, confirm_password, submitting } = this.state;
+ if (submitting) return;
+
+ const errors = validate(name, email, password, confirm_password);
+
+ this.setState({ errors });
+ if (errors.length > 0) return;
+
+ this.setState({ submitting: true });
+ const { errors: resperrors, me } = await submit_registration(name, email, password);
+ if (resperrors) {
+ this.setState({
+ errors: resperrors,
+ submitting: false,
+ });
+ return;
+ }
+ this.props.me(me);
+ this.props.userarea();
+ }
+
+ render() {
+ const { errors } = this.state;
+ return (
+
+ );
+ }
+}
+
+export default SignUpForm;
diff --git a/src/components/Tab.js b/src/components/Tab.js
new file mode 100644
index 0000000..d98c884
--- /dev/null
+++ b/src/components/Tab.js
@@ -0,0 +1,42 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+
+class Tab extends Component {
+ static propTypes = {
+ activeTab: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ onClick: PropTypes.func.isRequired,
+ };
+
+ onClick = () => {
+ const { label, onClick } = this.props;
+ onClick(label);
+ }
+
+ render() {
+ const {
+ onClick,
+ props: {
+ activeTab,
+ label,
+ },
+ } = this;
+
+ let className = 'tab-list-item';
+
+ if (activeTab === label) {
+ className += ' tab-list-active';
+ }
+
+ return (
+
+ {label}
+
+ );
+ }
+}
+
+export default Tab;
diff --git a/src/components/Tabs.js b/src/components/Tabs.js
new file mode 100644
index 0000000..7c5586f
--- /dev/null
+++ b/src/components/Tabs.js
@@ -0,0 +1,61 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+
+import Tab from './Tab';
+
+class Tabs extends Component {
+ static propTypes = {
+ children: PropTypes.instanceOf(Array).isRequired,
+ }
+
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ activeTab: this.props.children[0].props.label,
+ };
+ }
+
+ onClickTabItem = (tab) => {
+ this.setState({ activeTab: tab });
+ }
+
+ render() {
+ const {
+ onClickTabItem,
+ props: {
+ children,
+ },
+ state: {
+ activeTab,
+ },
+ } = this;
+
+ return (
+
+
+ {children.map((child) => {
+ const { label } = child.props;
+
+ return (
+
+ );
+ })}
+
+
+ {children.map((child) => {
+ if (child.props.label !== activeTab) return undefined;
+ return child.props.children;
+ })}
+
+
+ );
+ }
+}
+
+export default Tabs;
diff --git a/src/components/TotalRankings.js b/src/components/TotalRankings.js
new file mode 100644
index 0000000..8609809
--- /dev/null
+++ b/src/components/TotalRankings.js
@@ -0,0 +1,40 @@
+/*
+ * Rankings Tabs
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import type { State } from '../reducers';
+
+const TotalRankings = ({ totalRanking }) => (
+
+
+
+ #
+ user
+ Pixels
+ # Today
+ Pixels Today
+
+ {
+ totalRanking.map(rank => (
+
+ {rank.ranking}
+ {rank.name}
+ {rank.totalPixels}
+ {rank.dailyRanking}
+ {rank.dailyTotalPixels}
+ ))
+ }
+
+
+);
+
+function mapStateToProps(state: State) {
+ const { totalRanking } = state.user;
+ return { totalRanking };
+}
+
+export default connect(mapStateToProps)(TotalRankings);
diff --git a/src/components/UserArea.js b/src/components/UserArea.js
new file mode 100644
index 0000000..929c8fb
--- /dev/null
+++ b/src/components/UserArea.js
@@ -0,0 +1,149 @@
+/*
+ * Menu to change user credentials
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import type { State } from '../reducers';
+
+import UserMessages from './UserMessages';
+import ChangePassword from './ChangePassword';
+import ChangeName from './ChangeName';
+import ChangeMail from './ChangeMail';
+import DeleteAccount from './DeleteAccount';
+
+import { numberToString } from '../core/utils';
+
+const textStyle = {
+ color: 'hsla(218, 5%, 47%, .6)',
+ fontSize: 14,
+ fontWeight: 500,
+ position: 'relative',
+ textAlign: 'inherit',
+ float: 'none',
+ margin: 0,
+ padding: 0,
+ lineHeight: 'normal',
+};
+
+const Stat = ({ text, value, rank }) => (
+
+ {(rank) ? `${text}: #` : `${text}: `}
+ {numberToString(value)}
+
+);
+
+class UserArea extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ // that should be an ENUM tbh
+ change_name_extended: false,
+ change_mail_extended: false,
+ change_passwd_extended: false,
+ delete_account_extended: false,
+ };
+ }
+
+ render() {
+ return (
+
+
+
+
+
+
+
+
Your name is: {this.props.name}
(
+ Log out |
+ this.setState({
+ change_name_extended: true,
+ change_mail_extended: false,
+ change_passwd_extended: false,
+ delete_account_extended: false,
+ })}
+ > Change Username |
+ {(this.props.mailreg) &&
+
+ this.setState({
+ change_name_extended: false,
+ change_mail_extended: true,
+ change_passwd_extended: false,
+ delete_account_extended: false,
+ })}
+ > Change Mail |
+ }
+ this.setState({
+ change_name_extended: false,
+ change_mail_extended: false,
+ change_passwd_extended: true,
+ delete_account_extended: false,
+ })}
+ > Change Password |
+ this.setState({
+ change_name_extended: false,
+ change_mail_extended: false,
+ change_passwd_extended: false,
+ delete_account_extended: true,
+ })}
+ > Delete Account )
+
+ {(this.state.change_passwd_extended) &&
+ { this.props.set_mailreg(true); this.setState({ change_passwd_extended: false }); }}
+ cancel={() => { this.setState({ change_passwd_extended: false }); }}
+ />}
+ {(this.state.change_name_extended) &&
+ { this.setState({ change_name_extended: false }); }}
+ />}
+ {(this.state.change_mail_extended) &&
+ { this.setState({ change_mail_extended: false }); }}
+ />}
+ {(this.state.delete_account_extended) &&
+ { this.setState({ delete_account_extended: false }); }}
+ />}
+
+ );
+ }
+}
+
+
+function mapStateToProps(state: State) {
+ const {
+ name,
+ mailreg,
+ totalPixels,
+ dailyTotalPixels,
+ ranking,
+ dailyRanking,
+ } = state.user;
+ const stats = {
+ totalPixels,
+ dailyTotalPixels,
+ ranking,
+ dailyRanking,
+ };
+
+ return { name, mailreg, stats };
+}
+
+export default connect(mapStateToProps)(UserArea);
diff --git a/src/components/UserAreaModal.js b/src/components/UserAreaModal.js
new file mode 100644
index 0000000..62cd163
--- /dev/null
+++ b/src/components/UserAreaModal.js
@@ -0,0 +1,120 @@
+/**
+ *
+ * @flow
+ */
+
+import React from 'react';
+import { connect } from 'react-redux';
+
+import Modal from './Modal';
+
+import type { State } from '../reducers';
+
+
+import { showRegisterModal, showForgotPasswordModal, setName, setMailreg, receiveMe } from '../actions';
+import LogInForm from './LogInForm';
+import Tabs from './Tabs';
+import UserArea from './UserArea';
+import Rankings from './Rankings';
+
+const logoStyle = {
+ marginRight: 5,
+};
+
+const titleStyle = {
+ color: '#4f545c',
+ marginLeft: 0,
+ marginRight: 10,
+ overflow: 'hidden',
+ wordWrap: 'break-word',
+ lineHeight: '24px',
+ fontSize: 16,
+ fontWeight: 500,
+ // marginTop: 0,
+ marginBottom: 0,
+};
+
+const textStyle = {
+ color: 'hsla(218, 5%, 47%, .6)',
+ fontSize: 14,
+ fontWeight: 500,
+ position: 'relative',
+ textAlign: 'inherit',
+ float: 'none',
+ margin: 0,
+ padding: 0,
+ lineHeight: 'normal',
+};
+
+const LogInArea = ({ register, forgot_password, me }) => (
+
+
Login to access more features and stats.
+ Login with Mail:
+
+ I forgot my Password.
+ or login with:
+
+
+
+
+
+ or register here:
+ Register
+
+);
+
+const UserAreaModal = ({ name, register, forgot_password, doMe, logout, setName, setMailreg }) => (
+
+
+ {(name === null) ?
+ :
+
+
+
+
+
+
+
+ }
+
Also join our Discord: pixelplanet.fun/discord
+
+
+);
+
+function mapDispatchToProps(dispatch) {
+ return {
+ register() {
+ dispatch(showRegisterModal());
+ },
+ forgot_password() {
+ dispatch(showForgotPasswordModal());
+ },
+ doMe(me) {
+ dispatch(receiveMe(me));
+ },
+ setName(name) {
+ dispatch(setName(name));
+ },
+ setMailreg(mailreg) {
+ dispatch(setMailreg(mailreg));
+ },
+ async logout() {
+ const response = await fetch('./api/auth/logout', { credentials: 'include' });
+ if (response.ok) {
+ const resp = await response.json();
+ dispatch(receiveMe(resp.me));
+ }
+ },
+ };
+}
+
+function mapStateToProps(state: State) {
+ const { name } = state.user;
+ return { name };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(UserAreaModal);
diff --git a/src/components/UserMessages.js b/src/components/UserMessages.js
new file mode 100644
index 0000000..db78c4f
--- /dev/null
+++ b/src/components/UserMessages.js
@@ -0,0 +1,122 @@
+/*
+ * Messages on top of UserArea
+ * @flow
+ */
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { parseAPIresponse } from '../utils/validation';
+import { setMinecraftName, remFromMessages } from '../actions';
+
+
+class UserMessages extends React.Component {
+ constructor() {
+ super();
+ this.state = {
+ resent_verify: false,
+ sent_link: false,
+ verify_answer: null,
+ link_answer: null,
+ };
+
+ this.submit_resend_verify = this.submit_resend_verify.bind(this);
+ this.submit_mc_link = this.submit_mc_link.bind(this);
+ }
+
+ async submit_resend_verify() {
+ if (this.state.resent_verify) return;
+ this.setState({
+ resent_verify: true,
+ });
+
+ const response = await fetch('./api/auth/resend_verify', {
+ credentials: 'include',
+ });
+
+ const { errors } = await parseAPIresponse(response);
+ const verify_answer = (errors) ? errors[0] : 'A new verification mail is getting sent to you.';
+ this.setState({
+ verify_answer,
+ });
+ }
+
+ async submit_mc_link(accepted) {
+ if (this.state.sent_link) return;
+ this.setState({
+ sent_link: true,
+ });
+ const body = JSON.stringify({ accepted });
+ const rep = await fetch('./api/auth/mclink', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body,
+ credentials: 'include',
+ });
+
+ const { errors } = parseAPIresponse(rep);
+ if (errors) {
+ this.setState({
+ link_answer: errors[0],
+ });
+ return;
+ }
+ if (!accepted) {
+ this.props.setMCName(null);
+ }
+ this.props.rem_from_messages('not_mc_verified');
+ this.setState({
+ link_answer: (accepted) ? 'You successfully linked your mc account.' : 'You denied.',
+ });
+ }
+
+ render() {
+ if (!this.props.messages) return null;
+ // state variable is not allowed to be changed, make copy
+ const messages = [...this.props.messages];
+
+ return (
+
+ {(messages.includes('not_verified') && messages.splice(messages.indexOf('not_verified'), 1)) ?
+
+ Please verify your mail address or your account could get deleted after a few days.
+ {(this.state.verify_answer) ?
+ {this.state.verify_answer} :
+ Click here to request a new verification mail.
+ }
+
: null
+ }
+ {(messages.includes('not_mc_verified') && messages.splice(messages.indexOf('not_mc_verified'), 1)) ?
+
You requested to link your mc account {this.props.minecraftname}.
+ {(this.state.link_answer) ?
+ {this.state.link_answer} :
+
+ { this.submit_mc_link(true); }}>Accept or { this.submit_mc_link(false); }}>Deny .
+
+ }
+
: null
+ }
+ {messages.map(message => (
+
{message}
+ ))}
+
+ );
+ }
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ setMCName(minecraftname) {
+ dispatch(setMinecraftName(minecraftname));
+ },
+ rem_from_messages(message) {
+ dispatch(remFromMessages(message));
+ },
+ };
+}
+
+function mapStateToProps(state: State) {
+ const { messages, minecraftname } = state.user;
+ return { messages, minecraftname };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(UserMessages);
diff --git a/src/components/base.tcss b/src/components/base.tcss
new file mode 100644
index 0000000..3910fdd
--- /dev/null
+++ b/src/components/base.tcss
@@ -0,0 +1,393 @@
+body {
+ margin: 0;
+ font-family: 'Montserrat', sans-serif;
+ font-size: 16px;
+ border: none;
+ user-select: none;
+ background: white;
+}
+
+html, body {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ overflow: hidden;
+}
+
+/**
+ * https://github.com/benweet/stackedit/issues/212
+ */
+kbd {
+ padding: 0.1em 0.6em;
+ border: 1px solid #ccc;
+ font-size: 11px;
+ font-family: Arial,Helvetica,sans-serif;
+ background-color: #f7f7f7;
+ color: #333;
+ -moz-box-shadow: 0 1px 0px rgba(0, 0, 0, 0.2),0 0 0 2px #ffffff inset;
+ -webkit-box-shadow: 0 1px 0px rgba(0, 0, 0, 0.2),0 0 0 2px #ffffff inset;
+ box-shadow: 0 1px 0px rgba(0, 0, 0, 0.2),0 0 0 2px #ffffff inset;
+ -moz-border-radius: 3px;
+ -webkit-border-radius: 3px;
+ display: inline-block;
+ margin: 0 0.1em;
+ text-shadow: 0 1px 0 #fff;
+ line-height: 1.4;
+ white-space: nowrap;
+}
+
+:global(.modallink) {
+ text-decoration: none;
+ color: #428bca;
+ cursor: pointer;
+}
+:global(.modallink:hover){
+ font-weight: bold;
+ color: #226baa;
+}
+
+:global(.inarea) {
+ border-style: solid;
+ border-color: #d5d5d5;
+ padding: 4px;
+ margin-top: 4px;
+ border-width: 1px;
+}
+
+:global(.tab-list) {
+ border-bottom: 1px solid #ccc;
+ padding-left: 0;
+}
+
+:global(.tab-list-item) {
+ display: inline-block;
+ list-style: none;
+ margin-bottom: -1px;
+ padding: 0.5rem 0.75rem;
+}
+
+:global(.tab-list-active) {
+ background-color: white;
+ border: solid #ccc;
+ border-width: 1px 1px 0 1px;
+}
+
+:global(table) {
+ font-family: arial, sans-serif;
+ border-collapse: collapse;
+ width: 100%;
+}
+
+:global(td, th) {
+ border: 1px solid #dddddd;
+ text-align: left;
+ padding: 8px;
+}
+
+:global(tr:nth-child(even)) {
+ background-color: #dddddd;
+}
+
+:global(.chatbox) {
+ position: absolute;
+ background-color: rgba(226, 226, 226, 0.92);
+ width: 350px;
+ height: 200px;
+ bottom: 16px;
+ right: 98px;
+ border: solid black;
+ border-width: thin;
+}
+
+:global(.actionbuttons), :global(.coorbox), :global(.onlinebox), :global(.cooldownbox), :global(.palettebox) {
+ position: absolute;
+ background-color: rgba(226, 226, 226, 0.80);
+ color: black;
+ text-align: center;
+ vertical-align: middle;
+ line-height: 36px;
+ height: 36px;
+ width: auto;
+ padding: 0 16px;
+ border: solid black;
+ border-width: thin;
+}
+:global(.coorbox) {
+ left: 16px;
+ bottom: 16px;
+}
+:global(.onlinebox) {
+ left: 16px;
+ bottom: 57px;
+ white-space: nowrap;
+}
+
+:global(#menubutton) {
+ left: 16px;
+ top: 16px;
+}
+
+:global(#helpbutton) {
+ left: 16px;
+ top: 221px;
+}
+:global(#minecraftbutton) {
+ left: 16px;
+ top: 180px;
+}
+:global(#settingsbutton) {
+ left: 16px;
+ top: 57px;
+}
+:global(#loginbutton) {
+ left: 16px;
+ top: 98px;
+}
+:global(#downloadbutton) {
+ left: 16px;
+ top: 139px;
+}
+:global(#globebutton) {
+ left: 57px;
+ top: 16px;
+}
+:global(#canvasbutton) {
+ left: 98px;
+ top: 16px;
+}
+:global(#minecrafttpbutton) {
+ top: 16px;
+ right: 16px;
+}
+:global(#palselbutton) {
+ bottom: 16px;
+ right: 16px;
+}
+:global(#chatbutton) {
+ bottom: 16px;
+ right: 57px;
+}
+
+:global(.Modal) {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ right: auto;
+ bottom: auto;
+ border: 1px solid rgb(204, 204, 204);
+ background: rgb(255, 255, 255) none repeat scroll 0% 0%;
+ overflow-y: auto;
+ border-radius: 4px;
+ outline: currentcolor none medium;
+ padding: 20px 40px;
+ transform: translate(-50%, -50%);
+ height: 80%;
+ max-height: 700px;
+ transition: all 0.5s ease 0s;
+}
+@media (max-width: 604px) {
+ :global(.Modal) {
+ position: fixed;
+ top: 0px;
+ left: 0px;
+ right: 0px;
+ bottom: 0px;
+ height: 100%;
+ transform: none;
+ max-width: none;
+ max-height: none;
+ }
+}
+:global(.Overlay) {
+ position: fixed;
+ top: 0px;
+ left: 0px;
+ right: 0px;
+ bottom: 0px;
+ background-color: rgba(255, 255, 255, 0.75);
+ z-index: 2;
+}
+
+:global(.chatbox div .chatarea ) {
+ height: 174px;
+}
+:global(.chatarea) {
+ padding: 3px 3px 0px 3px;
+ margin: 0px;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ height: 95%;
+}
+:global(.Modal div chatarea) {
+ max-height: 600px;
+}
+:global(.chatinput) {
+ height: 22px;
+ white-space: nowrap;
+}
+:global(.chatname) {
+ color: #4B0000;
+ font-size: 13px;
+}
+:global(.chatmsg) {
+ color: #030303;
+ font-size: 13px;
+ margin: 0;
+}
+
+:global(.usermessages) {
+ font-size: 14px;
+ font-weight: 500;
+ line-height: normal;
+}
+:global(.stattext) {
+ font-size: 18px;
+}
+:global(.statvalue) {
+ font-size: 18px;
+ font-weight: bold;
+ color: #2d0045;
+}
+
+:global(.pressed) {
+ box-shadow:0 0 3px 2px rgba(0,0,0,.6);
+}
+
+:global(.notifyboxvis), :global(.notifyboxhid) {
+ position: absolute;
+ top: 57px;
+ left: 0px;
+ right: 0px;
+ height: 30px;
+ width: 20px;
+ color: black;
+ font-size: 14px;
+ line-height: 30px;
+ text-align: center;
+ vertical-align: middle;
+ border: solid black;
+ border-width: thin;
+ padding: 0 24px;
+ margin-left: auto;
+ margin-right: auto;
+ z-index: 2;
+}
+
+:global(.notifyboxvis) {
+ visibility: visible;
+ opacity: 1;
+ transition: visibility 0s, opacity 0.5s linear;
+}
+:global(.notifyboxhid) {
+ visibility: hidden;
+ opacity: 0;
+ transition: visibility 0.5s, opacity 0.5s linear;
+}
+
+:global(.cooldownbox) {
+ top: 16px;
+ width: 48px;
+ left: 0px;
+ right: 0px;
+ margin-left: auto;
+ margin-right: auto;
+ z-index: 2;
+}
+
+:global(.actionbuttons) {
+ vertical-align: text-bottom;
+ cursor: pointer;
+ width: 36px;
+ padding: 0;
+}
+
+:global(.palettebox) {
+ text-align: center;
+ line-height: 0;
+ z-index: 1;
+ bottom: 59px;
+ padding: 3px;
+ position: fixed;
+ right: 16px;
+ margin-left: auto;
+ margin-right: auto;
+ display: flex;
+ flex-wrap: wrap;
+}
+
+:global(.widpalette) {
+ height: 720px;
+ width: 24px;
+ flex-direction: column;
+}
+:global(.widpalette) > span {
+ width: 24px;
+ height: 24px;
+ margin: 0px;
+}
+
+@media (max-height: 801px) {
+ :global(.widpalette) {
+ height: 360px;
+ width: 48px;
+ }
+}
+
+:global(.selected), :global(.unselected) {
+ display: block;
+ cursor: pointer;
+ padding: 0;
+}
+
+:global(.compalette) {
+ flex-direction: row;
+ width: 140px;
+ height: 168px;
+}
+:global(.compalette) > span {
+ width: 28px;
+ height: 28px;
+ margin: 0px;
+}
+
+@media (max-width: 300px), (max-height: 432px) {
+ :global(.widpalette), :global(.compalette) {
+ flex-direction: row;
+ width: 120px;
+ height: 144px;
+ }
+ :global(.widpalette) > span, :global(.compalette) > span {
+ width: 24px;
+ height: 24px;
+ margin: 0px;
+ }
+}
+
+:global(#colors) :global(.selected),
+:global(#colors) span:hover {
+ z-index: 2 !important;
+ outline: rgb(234, 234, 234) solid 1px;
+ box-shadow: rgba(0, 0, 0, 0.80) 0px 0px 5px 2px;
+ -ms-transform: scale(1.10,1.10); /* IE 9 */
+ -webkit-transform: scale(1.10,1.10); /* Safari */
+ transform: scale(1.10,1.10); /* Standard syntax */
+}
+
+:global(#outstreamContainer) {
+ position: fixed;
+ display: none;
+ width: 100%;
+ height: 100%;
+ top: 0px;
+ left: 0px;
+ right: 0px;
+ bottom: 0px;
+ background-color: black;
+ z-index: 9000;
+}
+
+:global(.grecaptcha-badge) {
+ visibility: hidden;
+}
diff --git a/src/components/font.css b/src/components/font.css
new file mode 100644
index 0000000..860c8af
--- /dev/null
+++ b/src/components/font.css
@@ -0,0 +1,40 @@
+/* cyrillic-ext */
+@font-face {
+ font-family: 'Montserrat';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Montserrat Regular'), local('Montserrat-Regular'), url(https://fonts.gstatic.com/s/montserrat/v12/JTUSjIg1_i6t8kCHKm459WRhyzbi.woff2) format('woff2');
+ unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
+}
+/* cyrillic */
+@font-face {
+ font-family: 'Montserrat';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Montserrat Regular'), local('Montserrat-Regular'), url(https://fonts.gstatic.com/s/montserrat/v12/JTUSjIg1_i6t8kCHKm459W1hyzbi.woff2) format('woff2');
+ unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
+}
+/* vietnamese */
+@font-face {
+ font-family: 'Montserrat';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Montserrat Regular'), local('Montserrat-Regular'), url(https://fonts.gstatic.com/s/montserrat/v12/JTUSjIg1_i6t8kCHKm459WZhyzbi.woff2) format('woff2');
+ unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB;
+}
+/* latin-ext */
+@font-face {
+ font-family: 'Montserrat';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Montserrat Regular'), local('Montserrat-Regular'), url(https://fonts.gstatic.com/s/montserrat/v12/JTUSjIg1_i6t8kCHKm459Wdhyzbi.woff2) format('woff2');
+ unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+ font-family: 'Montserrat';
+ font-style: normal;
+ font-weight: 400;
+ src: local('Montserrat Regular'), local('Montserrat-Regular'), url(https://fonts.gstatic.com/s/montserrat/v12/JTUSjIg1_i6t8kCHKm459Wlhyw.woff2) format('woff2');
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/src/core/Cell.js b/src/core/Cell.js
new file mode 100644
index 0000000..085ba10
--- /dev/null
+++ b/src/core/Cell.js
@@ -0,0 +1,5 @@
+/* @flow */
+
+export type Index = number; // TODO integer >= 0
+export type Cell = [number, number, number];
+
diff --git a/src/core/Image.js b/src/core/Image.js
new file mode 100644
index 0000000..4ee97f9
--- /dev/null
+++ b/src/core/Image.js
@@ -0,0 +1,155 @@
+/* @flow
+ *
+ * functions to deal with images
+ *
+ */
+
+import RedisCanvas from '../data/models/RedisCanvas';
+import logger from './logger';
+import { getChunkOfPixel } from './utils';
+import { TILE_SIZE } from './constants';
+import canvases from '../canvases.json';
+import Palette from './Palette';
+
+
+/*
+ * Load iamge from ABGR buffer onto canvas
+ * (be aware that tis function does no validation of arguments)
+ * @param canvadIs numerical ID of canvas
+ * @param x X coordinate on canvas
+ * @param y Y coordinate on canvas
+ * @param data buffer of image in ABGR format
+ * @param width Width of image
+ * @param height height of image
+ */
+export async function imageABGR2Canvas(
+ canvasId: number,
+ x: number,
+ y: number,
+ data: Buffer,
+ width: number,
+ height: number,
+ wipe?: boolean,
+ protect?: boolean,
+) {
+ logger.info(
+ `Loading image with dim ${width}/${height} to ${x}/${y}/${canvasId}`,
+ );
+ const canvas = canvases[canvasId];
+ const palette = new Palette(canvas.colors, canvas.alpha);
+ const canvasMinXY = -(canvas.size / 2);
+ const imageData = new Uint32Array(data.buffer);
+
+ const [ucx, ucy] = getChunkOfPixel([x, y], canvas.size);
+ const [lcx, lcy] = getChunkOfPixel([(x + width), (y + height)], canvas.size);
+
+ logger.info(`Loading to chunks from ${ucx} / ${ucy} to ${lcx} / ${lcy} ...`);
+ let chunk;
+ for (let cx = ucx; cx <= lcx; cx += 1) {
+ for (let cy = ucy; cy <= lcy; cy += 1) {
+ chunk = await RedisCanvas.getChunk(cx, cy);
+ chunk = (chunk) ? new Uint8Array(chunk) : new Uint8Array(TILE_SIZE * TILE_SIZE);
+ // offset of chunk in image
+ const cOffX = cx * TILE_SIZE + canvasMinXY - x;
+ const cOffY = cy * TILE_SIZE + canvasMinXY - y;
+ let cOff = 0;
+ let pxlCnt = 0;
+ for (let py = 0; py < TILE_SIZE; py += 1) {
+ for (let px = 0; px < TILE_SIZE; px += 1) {
+ const clrX = cOffX + px;
+ const clrY = cOffY + py;
+ if (clrX >= 0 && clrY >= 0 && clrX < width && clrY < height) {
+ const clr = imageData[clrX + clrY * width];
+ const clrIndex = (wipe) ? palette.abgr.indexOf(clr) : palette.abgr.indexOf(clr, 2);
+ if (~clrIndex) {
+ const pixel = (protect) ? (clrIndex | 0x20) : clrIndex;
+ chunk[cOff] = pixel;
+ pxlCnt += 1;
+ }
+ }
+ cOff += 1;
+ }
+ }
+ if (pxlCnt) {
+ const ret = await RedisCanvas.setChunk(cx, cy, chunk, canvasId);
+ if (ret) {
+ logger.info(`Loaded ${pxlCnt} pixels into chunk ${cx}, ${cy}.`);
+ }
+ }
+ chunk = null;
+ }
+ }
+ logger.info('Image loading done.');
+}
+
+
+/*
+ * Load iamgemask from ABGR buffer and execute function for each black pixel
+ * (be aware that tis function does no validation of arguments)
+ * @param canvadIs numerical ID of canvas
+ * @param x X coordinate on canvas
+ * @param y Y coordinate on canvas
+ * @param data buffer of image in ABGR format
+ * @param width Width of image
+ * @param height height of image
+ * @param filter function that defines what happens to the pixel that matches,
+ * it will be called with the pixelcolor as argument, its return value gets set
+ */
+export async function imagemask2Canvas(
+ canvasId: number,
+ x: number,
+ y: number,
+ data: Buffer,
+ width: number,
+ height: number,
+ filter,
+) {
+ logger.info(
+ `Loading mask with size ${width} / ${height} to ${x} / ${y} to the canvas`,
+ );
+ const canvas = canvases[canvasId];
+ const palette = new Palette(canvas.colors, canvas.alpha);
+ const canvasMinXY = -(canvas.size / 2);
+
+ const imageData = new Uint8Array(data.buffer);
+
+ const [ucx, ucy] = getChunkOfPixel([x, y], canvas.size);
+ const [lcx, lcy] = getChunkOfPixel([(x + width), (y + height)], canvas.size);
+
+ logger.info(`Loading to chunks from ${ucx} / ${ucy} to ${lcx} / ${lcy} ...`);
+ let chunk;
+ for (let cx = ucx; cx <= lcx; cx += 1) {
+ for (let cy = ucy; cy <= lcy; cy += 1) {
+ chunk = await RedisCanvas.getChunk(cx, cy);
+ chunk = (chunk) ? new Uint8Array(chunk) : new Uint8Array(TILE_SIZE * TILE_SIZE);
+ // offset of chunk in image
+ const cOffX = cx * TILE_SIZE + canvasMinXY - x;
+ const cOffY = cy * TILE_SIZE + canvasMinXY - y;
+ let cOff = 0;
+ let pxlCnt = 0;
+ for (let py = 0; py < TILE_SIZE; py += 1) {
+ for (let px = 0; px < TILE_SIZE; px += 1) {
+ const clrX = cOffX + px;
+ const clrY = cOffY + py;
+ if (clrX >= 0 && clrY >= 0 && clrX < width && clrY < height) {
+ let offset = (clrX + clrY * width) * 3;
+ if (!imageData[offset++] && !imageData[offset++] && !imageData[offset]) {
+ chunk[cOff] = filter(palette.abgr[chunk[cOff]]);
+ pxlCnt += 1;
+ }
+ }
+ cOff += 1;
+ }
+ }
+ if (pxlCnt) {
+ const ret = await RedisCanvas.setChunk(cx, cy, chunk);
+ if (ret) {
+ logger.info(`Loaded ${pxlCnt} pixels into chunk ${cx}, ${cy}.`);
+ }
+ }
+ chunk = null;
+ }
+ }
+ logger.info('Imagemask loading done.');
+}
+
diff --git a/src/core/Palette.js b/src/core/Palette.js
new file mode 100644
index 0000000..aa9be97
--- /dev/null
+++ b/src/core/Palette.js
@@ -0,0 +1,179 @@
+/* @flow */
+
+export type ColorIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
+ 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
+ 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
+ 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31;
+export type Color = string;
+
+
+class Palette {
+ length: number;
+ rgb: Uint8Array;
+ colors: Array;
+ abgr: Uint32Array;
+ alpha: number = 0;
+
+ constructor(colors: Array, alpha: number = 0) {
+ this.alpha = alpha;
+ this.length = colors.length;
+ this.rgb = new Uint8Array(this.length * 3);
+ this.colors = new Array(this.length);
+ this.abgr = new Uint32Array(this.length);
+
+ let cnt = 0;
+ for (let index = 0; index < colors.length; index++) {
+ const r = colors[index][0];
+ const g = colors[index][1];
+ const b = colors[index][2];
+ this.rgb[cnt++] = r;
+ this.rgb[cnt++] = g;
+ this.rgb[cnt++] = b;
+ this.colors[index] = `rgb(${r}, ${g}, ${b})`;
+ this.abgr[index] = (0xFF000000) | (b << 16) | (g << 8) | (r);
+ }
+ }
+
+ /*
+ * Check if a color is light (closer to white) or dark (closer to black)
+ * @param color Index of color in palette
+ * @return dark True if color is dark
+ */
+ isDark(color: number) {
+ color *= 3;
+ const r = this.rgb[color++];
+ const g = this.rgb[color++];
+ const b = this.rgb[color];
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
+ return (luminance < 128);
+ }
+
+ /*
+ * Get last matching color index of RGB color
+ * @param r r
+ * @param g g
+ * @param b b
+ * @return index of color
+ */
+ getIndexOfColor(r: number, g: number, b: number): ColorIndex {
+ const rgb = this.rgb;
+ let i = rgb.length;
+ while (i >= 0) {
+ if (rgb[--i] === b &&
+ rgb[--i] === g &&
+ rgb[--i] === r
+ ) {
+ return (i / 3);
+ }
+ }
+ return null;
+ }
+
+ /*
+ * Take a buffer of indexed pixels and output it as ABGR Array
+ * @param chunkBuffer Buffer of indexed pixels
+ * @return ABRG Buffer
+ */
+ buffer2ABGR(chunkBuffer: Buffer): Uint32Array {
+ const length = chunkBuffer.length;
+ const colors = new Uint32Array(length);
+ let value: number;
+ const buffer = chunkBuffer;
+
+ let pos = 0;
+ for (let i = 0; i < length; i++) {
+ value = (buffer[i] & 0x1F);
+ colors[pos++] = this.abgr[value];
+ }
+ return colors;
+ }
+
+ /*
+ * Take a buffer of indexed pixels and output it as RGB Array
+ * @param chunkBuffer Buffer of indexed pixels
+ * @return RGB Buffer
+ */
+ buffer2RGB(chunkBuffer: Buffer): Uint8Array {
+ const length = chunkBuffer.length;
+ const colors = new Uint8Array(length * 3);
+ let color: number;
+ let value: number;
+ const buffer = chunkBuffer;
+
+ let c = 0;
+ for (let i = 0; i < length; i++) {
+ value = buffer[i];
+
+ color = (value & 0x1F) * 3;
+ colors[c++] = this.rgb[color++];
+ colors[c++] = this.rgb[color++];
+ colors[c++] = this.rgb[color];
+ }
+ return colors;
+ }
+
+ /*
+ * Create a RGB Buffer of a specific size with just one color
+ * @param color Color Index of color to use
+ * @param length Length of needed Buffer
+ * @return RGB Buffer of wanted size with just one color
+ */
+ oneColorBuffer(color: ColorIndex, length: number) {
+ const buffer = new Uint8Array(length * 3);
+ const r = this.rgb[color * 3];
+ const g = this.rgb[color * 3 + 1];
+ const b = this.rgb[color * 3 + 2];
+ let pos = 0;
+ for (let i = 0; i < length; i++) {
+ buffer[pos++] = r;
+ buffer[pos++] = g;
+ buffer[pos++] = b;
+ }
+
+ return buffer;
+ }
+}
+
+export const COLORS_RGB: Uint8Array = new Uint8Array([
+ 202, 227, 255, // first color is unset pixel in ocean
+ 255, 255, 255, // second color is unset pixel on land
+ 255, 255, 255, // white
+ 228, 228, 228, // light gray
+ 196, 196, 196, // silver
+ 136, 136, 136, // dark gray
+ 78, 78, 78, // darker gray
+ 0, 0, 0, // black
+ 244, 179, 174, // skin
+ 255, 167, 209, // light pink
+ 255, 84, 178, // pink
+ 255, 101, 101, // peach
+ 229, 0, 0, // red
+ 154, 0, 0, // dark red
+ 254, 164, 96, // light brown
+ 229, 149, 0, // orange
+ 160, 106, 66, // brown
+ 96, 64, 40, // dark brown
+ 245, 223, 176, // sand
+ 255, 248, 137, // khaki
+ 229, 217, 0, // yellow
+ 148, 224, 68, // light green
+ 2, 190, 1, // green
+ 104, 131, 56, // olive
+ 0, 101, 19, // dark green
+ 202, 227, 255, // sky blue
+ 0, 211, 221, // light blue
+ 0, 131, 199, // dark blue
+ 0, 0, 234, // blue
+ 25, 25, 115, // darker blue
+ 207, 110, 228, // light violette
+ 130, 0, 128, // violette
+],
+);
+
+export const COLORS_AMOUNT = COLORS_RGB.length / 3;
+export const COLORS: Array = new Array(COLORS_AMOUNT);
+export const COLORS_ABGR: Uint32Array = new Uint32Array(COLORS_AMOUNT);
+export const TRANSPARENT: ColorIndex = 0;
+
+
+export default Palette;
diff --git a/src/core/Player.js b/src/core/Player.js
new file mode 100644
index 0000000..23630b0
--- /dev/null
+++ b/src/core/Player.js
@@ -0,0 +1,15 @@
+/* @flow */
+
+
+class Player {
+ wait: ?number; // date
+
+ constructor() {
+ this.wait = null;
+ }
+ setWait(wait) {
+ this.wait = wait;
+ }
+}
+
+export default Player;
diff --git a/src/core/Queue.js b/src/core/Queue.js
new file mode 100644
index 0000000..d8b8bb8
--- /dev/null
+++ b/src/core/Queue.js
@@ -0,0 +1,74 @@
+/* @flow */
+
+/**
+ * Created by http://code.stephenmorley.org/javascript/queues/
+ */
+
+class Queue {
+ array: Array;
+ offset: number;
+
+ constructor() {
+ this.array = [];
+ this.offset = 0;
+ }
+
+ /**
+ *
+ * @returns {number} the length of the queue.
+ */
+ getLength(): number {
+ return this.array.length - this.offset;
+ }
+
+ /**
+ * Returns true if the queue is empty, and false otherwise.
+ * @returns {boolean}
+ */
+ isEmpty(): boolean {
+ return this.array.length === 0;
+ }
+
+ /**
+ * Enqueues the specified item. The parameter is:
+ * @param item the item to enqueue
+ */
+ enqueue(item: T) {
+ this.array.push(item);
+ }
+
+ /**
+ * Dequeues an item and returns it. If the queue is empty, the value
+ * 'undefined' is returned.
+ */
+ dequeue(): ?T {
+ // if the queue is empty, return immediately
+ if (this.isEmpty()) return null;
+
+ // store the item at the front of the queue
+ const item = this.array[this.offset];
+
+ // increment the first and remove the free space if necessary
+ this.offset += 1;
+ if (this.offset * 2 >= this.array.length) {
+ this.array = this.array.slice(this.offset);
+ this.offset = 0;
+ }
+
+ // return the dequeued item
+ return item;
+ }
+
+ /**
+ * Returns the item at the front of the queue (without dequeuing it). If the
+ * queue is empty then undefined is returned.
+ * @returns {*}
+ */
+ peek(): ?T {
+ if (this.isEmpty()) return null;
+
+ return this.array[this.offset];
+ }
+}
+
+export default Queue;
diff --git a/src/core/Tile.js b/src/core/Tile.js
new file mode 100644
index 0000000..af94bc4
--- /dev/null
+++ b/src/core/Tile.js
@@ -0,0 +1,425 @@
+/*
+ * * basic functions for creating zommed tiles
+ *
+ * @flow
+ * */
+
+import sharp from 'sharp';
+import fs from 'fs';
+
+import type { Cell } from './Cell';
+import type { Palette } from './Palette';
+import logger from './logger';
+import { getMaxTiledZoom } from './utils';
+import { TILE_SIZE, TILE_ZOOM_LEVEL } from './constants';
+import RedisCanvas from '../data/models/RedisCanvas';
+
+/*
+ * Deletes a subtile from a tile (paints it in color 0), if we wouldn't do it, it would be black
+ * @param palette Palette to use
+ * @param subtilesInTile how many subtiles are in a tile (per dimension)
+ * @param cell subtile to delete [dx, dy]
+ * @param buffer Uint8Array for RGB values of tile
+ */
+function deleteSubtilefromTile(
+ palette: Palette,
+ subtilesInTile: number,
+ cell: Cell,
+ buffer: Uint8Array,
+) {
+ const [dx, dy] = cell;
+ const offset = (dx + dy * TILE_SIZE * subtilesInTile) * TILE_SIZE;
+ for (let row = 0; row < TILE_SIZE; row += 1) {
+ let channelOffset = (offset + row * TILE_SIZE * subtilesInTile) * 3;
+ const max = channelOffset + TILE_SIZE * 3;
+ const alphaIndex = palette.alpha * 3;
+ while (channelOffset < max) {
+ buffer[channelOffset++] = palette.rgb[alphaIndex];
+ buffer[channelOffset++] = palette.rgb[alphaIndex + 1];
+ buffer[channelOffset++] = palette.rgb[alphaIndex + 2];
+ }
+ }
+}
+
+/*
+ * @param subtilesInTile how many subtiles are in a tile (per dimension)
+ * @param cell subtile to delete [dx, dy]
+ * @param subtile RGB buffer of subtile
+ * @param buffer Uint8Array for RGB values of tile
+ */
+function addRGBSubtiletoTile(
+ subtilesInTile: number,
+ cell: Cell,
+ subtile: Buffer,
+ buffer: Uint8Array,
+) {
+ const [dx, dy] = cell;
+ const chunkOffset = (dx + dy * subtilesInTile * TILE_SIZE) * TILE_SIZE; // offset in pixels
+ let pos: number = 0;
+ for (let row = 0; row < TILE_SIZE; row += 1) {
+ let channelOffset = (chunkOffset + row * TILE_SIZE * subtilesInTile) * 3;
+ const max = channelOffset + TILE_SIZE * 3;
+ while (channelOffset < max) {
+ buffer[channelOffset++] = subtile[pos++];
+ buffer[channelOffset++] = subtile[pos++];
+ buffer[channelOffset++] = subtile[pos++];
+ }
+ }
+}
+
+/*
+ * @param palette Palette to use
+ * @param subtilesInTile how many subtiles are in a tile (per dimension)
+ * @param cell subtile to delete [dx, dy]
+ * @param subtile RGB buffer of subtile
+ * @param buffer RGB Buffer of tile
+ */
+function addIndexedSubtiletoTile(
+ palette: Palette,
+ subtilesInTile: number,
+ cell: Cell,
+ subtile: Buffer,
+ buffer: Uint8Array,
+) {
+ const [dx, dy] = cell;
+ const chunkOffset = (dx + dy * subtilesInTile * TILE_SIZE) * TILE_SIZE; // offset in pixels
+ let pos: number = 0;
+ let clr: number;
+ for (let row = 0; row < TILE_SIZE; row += 1) {
+ let channelOffset = (chunkOffset + row * TILE_SIZE * subtilesInTile) * 3;
+ const max = channelOffset + TILE_SIZE * 3;
+ while (channelOffset < max) {
+ clr = (subtile[pos++] & 0x1F) * 3;
+ buffer[channelOffset++] = palette.rgb[clr++];
+ buffer[channelOffset++] = palette.rgb[clr++];
+ buffer[channelOffset++] = palette.rgb[clr];
+ }
+ }
+}
+
+/*
+ * @param canvasTileFolder root folder where to save tiles
+ * @param cell tile [z, x, y]
+ * @return filename of tile
+ */
+function tileFileName(canvasTileFolder: string, cell: Cell): string {
+ const [z, x, y] = cell;
+ const filename = `${canvasTileFolder}/${z}/${x}/${y}.png`;
+ return filename;
+}
+
+/*
+ * @param canvasSize dimension of the canvas (pixels width/height)
+ * @param canvasId id of the canvas
+ * @param canvasTileFolder root folder where to save tiles
+ * @param palette Palette to use
+ * @param cell tile to create [x, y]
+ * @return true if successfully created tile, false if tile empty
+ */
+export async function createZoomTileFromChunk(
+ canvasSize: number,
+ canvasId: number,
+ canvasTileFolder: string,
+ palette: Palette,
+ cell: Cell): boolean {
+ const [x, y] = cell;
+ const maxTiledZoom = getMaxTiledZoom(canvasSize);
+ const tileRGBBuffer = new Uint8Array(
+ TILE_SIZE * TILE_SIZE * TILE_ZOOM_LEVEL * TILE_ZOOM_LEVEL * 3,
+ );
+
+ const xabs = x * TILE_ZOOM_LEVEL;
+ const yabs = y * TILE_ZOOM_LEVEL;
+ const na = [];
+ let chunk = null;
+ for (let dy = 0; dy < TILE_ZOOM_LEVEL; dy += 1) {
+ for (let dx = 0; dx < TILE_ZOOM_LEVEL; dx += 1) {
+ chunk = await RedisCanvas.getChunk(xabs + dx, yabs + dy, canvasId);
+ if (!chunk) {
+ na.push([dx, dy]);
+ continue;
+ }
+ addIndexedSubtiletoTile(
+ palette,
+ TILE_ZOOM_LEVEL,
+ [dx, dy],
+ chunk,
+ tileRGBBuffer,
+ );
+ }
+ }
+ chunk = null;
+
+ if (na.length !== TILE_ZOOM_LEVEL * TILE_ZOOM_LEVEL) {
+ na.forEach((element) => {
+ deleteSubtilefromTile(palette, TILE_ZOOM_LEVEL, element, tileRGBBuffer);
+ });
+
+ const filename = tileFileName(canvasTileFolder, [maxTiledZoom - 1, x, y]);
+ await sharp(Buffer.from(tileRGBBuffer.buffer), {
+ raw: {
+ width: TILE_SIZE * TILE_ZOOM_LEVEL,
+ height: TILE_SIZE * TILE_ZOOM_LEVEL,
+ channels: 3,
+ },
+ },
+ )
+ .resize(TILE_SIZE)
+ .png({ options: { compressionLevel: 6 } })
+ .toFile(filename);
+ logger.info(
+ `Tiling: Created Tile ${x} / ${y} with ${na.length} empty chunks`,
+ );
+ return true;
+ }
+ return false;
+}
+
+/*
+ * @param canvasTileFolder root folder where to save tiles
+ * @param palette Palette to use
+ * @param cell tile to create [z, x, y]
+ * @return trie if successfully created tile, false if tile empty
+ */
+export async function createZoomedTile(
+ canvasTileFolder: string,
+ palette: Palette,
+ cell: Cell,
+): boolean {
+ const tileRGBBuffer = new Uint8Array(
+ TILE_SIZE * TILE_SIZE * TILE_ZOOM_LEVEL * TILE_ZOOM_LEVEL * 3,
+ );
+ const [z, x, y] = cell;
+
+ const na = [];
+ for (let dy = 0; dy < TILE_ZOOM_LEVEL; dy += 1) {
+ for (let dx = 0; dx < TILE_ZOOM_LEVEL; dx += 1) {
+ const chunkfile = `${canvasTileFolder}/${z + 1}/${x * TILE_ZOOM_LEVEL + dx}/${y * TILE_ZOOM_LEVEL + dy}.png`;
+ if (!fs.existsSync(chunkfile)) {
+ na.push([dx, dy]);
+ continue;
+ }
+ const chunk = await sharp(chunkfile).removeAlpha().raw().toBuffer();
+ addRGBSubtiletoTile(TILE_ZOOM_LEVEL, [dx, dy], chunk, tileRGBBuffer);
+ }
+ }
+
+ if (na.length !== TILE_ZOOM_LEVEL * TILE_ZOOM_LEVEL) {
+ na.forEach((element) => {
+ deleteSubtilefromTile(palette, TILE_ZOOM_LEVEL, element, tileRGBBuffer);
+ });
+
+ const filename = tileFileName(canvasTileFolder, [z, x, y]);
+ await sharp(
+ Buffer.from(
+ tileRGBBuffer.buffer), {
+ raw: {
+ width: TILE_SIZE * TILE_ZOOM_LEVEL,
+ height: TILE_SIZE * TILE_ZOOM_LEVEL,
+ channels: 3,
+ },
+ },
+ ).resize(TILE_SIZE).toFile(filename);
+ logger.info(
+ `Tiling: Created tile ${filename} with ${na.length} empty subtiles.`,
+ );
+ return true;
+ }
+ return false;
+}
+
+/*
+ * create an empty image tile with just one color
+ * @param canvasTileFolder root folder where to save texture
+ * @param palette Palette to use
+ */
+export async function createEmptyTile(
+ canvasTileFolder: string,
+ palette: Palette,
+) {
+ const tileRGBBuffer = new Uint8Array(
+ TILE_SIZE * TILE_SIZE * 3,
+ );
+ let i = 0;
+ const max = TILE_SIZE * TILE_SIZE * 3;
+ const alphaIndex = palette.alpha * 3;
+ while (i < max) {
+ tileRGBBuffer[i++] = palette.rgb[alphaIndex];
+ tileRGBBuffer[i++] = palette.rgb[alphaIndex + 1];
+ tileRGBBuffer[i++] = palette.rgb[alphaIndex + 2];
+ }
+ const filename = `${canvasTileFolder}/emptytile.png`;
+ await sharp(Buffer.from(tileRGBBuffer.buffer), {
+ raw: {
+ width: TILE_SIZE,
+ height: TILE_SIZE,
+ channels: 3,
+ },
+ },
+ )
+ .png({ options: { compressionLevel: 6 } })
+ .toFile(filename);
+ logger.info(`Tiling: Created empty tile at ${filename}`);
+}
+
+/*
+ * created 4096x4096 texture of default canvas
+ * @param canvasId numberical Id of canvas
+ * @param canvasSize size of canvas
+ * @param canvasTileFolder root folder where to save texture
+ * @param palette Palette to use
+ *
+ */
+export async function createTexture(
+ canvasId: number,
+ canvasSize: numbr,
+ canvasTileFolder,
+ palette: Palette,
+) {
+ // dont create textures larger than 4096
+ const targetSize = Math.min(canvasSize, 4096);
+ const amount = targetSize / TILE_SIZE;
+ const zoom = Math.log2(amount) / 2;
+ const textureBuffer = new Uint8Array(targetSize * targetSize * 3);
+ const timeStart = Date.now();
+
+ const na = [];
+ let chunk = null;
+ if (targetSize !== canvasSize) {
+ for (let dy = 0; dy < amount; dy += 1) {
+ for (let dx = 0; dx < amount; dx += 1) {
+ const chunkfile = `${canvasTileFolder}/${zoom}/${dx}/${dy}.png`;
+ if (!fs.existsSync(chunkfile)) {
+ na.push([dx, dy]);
+ continue;
+ }
+ chunk = await sharp(chunkfile).removeAlpha().raw().toBuffer();
+ addRGBSubtiletoTile(amount, [dx, dy], chunk, textureBuffer);
+ }
+ }
+ } else {
+ for (let dy = 0; dy < amount; dy += 1) {
+ for (let dx = 0; dx < amount; dx += 1) {
+ chunk = await RedisCanvas.getChunk(dx, dy, canvasId);
+ if (!chunk) {
+ na.push([dx, dy]);
+ continue;
+ }
+ addIndexedSubtiletoTile(
+ palette,
+ amount,
+ [dx, dy],
+ chunk,
+ textureBuffer,
+ );
+ }
+ }
+ }
+ chunk = null;
+
+ na.forEach((element) => {
+ deleteSubtilefromTile(palette, amount, element, textureBuffer);
+ });
+
+ const filename = `${canvasTileFolder}/texture.png`;
+ await sharp(
+ Buffer.from(textureBuffer.buffer), {
+ raw: {
+ width: targetSize,
+ height: targetSize,
+ channels: 3,
+ },
+ },
+ ).toFile(filename);
+ logger.info(
+ `Tiling: Created texture in ${(Date.now() - timeStart) / 1000}s.`,
+ );
+}
+
+/*
+ * Create all tiles
+ * @param canvasSize dimension of the canvas (pixels width/height)
+ * @param canvasId id of the canvas
+ * @param canvasTileFolder root foler where to save tiles
+ * @param palette Palette of canvas
+ * @param force overwrite existing tiles
+ */
+export async function initializeTiles(
+ canvasSize: number,
+ canvasId: number,
+ canvasTileFolder: string,
+ palette: Palette,
+ force: boolean = false,
+) {
+ logger.info(
+ `Tiling: Initializing tiles in ${canvasTileFolder}, forceint = ${force}`,
+ );
+ const startTime = Date.now();
+ const maxTiledZoom = getMaxTiledZoom(canvasSize);
+ // empty tile
+ await createEmptyTile(canvasTileFolder, palette);
+ // base zoomlevel
+ let zoom = maxTiledZoom - 1;
+ let zoomDir = `${canvasTileFolder}/${zoom}`;
+ logger.info(`Tiling: Checking zoomlevel ${zoomDir}`);
+ if (!fs.existsSync(zoomDir)) fs.mkdirSync(zoomDir);
+ let cnt = 0;
+ let cnts = 0;
+ const maxBase = TILE_ZOOM_LEVEL ** zoom;
+ for (let cx = 0; cx < maxBase; cx += 1) {
+ const tileDir = `${canvasTileFolder}/${zoom}/${cx}`;
+ if (!fs.existsSync(tileDir)) fs.mkdirSync(tileDir);
+ for (let cy = 0; cy < maxBase; cy += 1) {
+ const filename = `${canvasTileFolder}/${zoom}/${cx}/${cy}.png`;
+ if (force || !fs.existsSync(filename)) {
+ const ret = await createZoomTileFromChunk(
+ canvasSize,
+ canvasId,
+ canvasTileFolder,
+ palette,
+ [cx, cy],
+ );
+ if (ret) cnts += 1;
+ cnt += 1;
+ }
+ }
+ }
+ logger.info(
+ `Tiling: Created ${cnts} / ${cnt} tiles for basezoom of canvas${canvasId}`,
+ );
+ // zoomlevels that are created from other zoomlevels
+ for (zoom = maxTiledZoom - 2; zoom >= 0; zoom -= 1) {
+ cnt = 0;
+ cnts = 0;
+ zoomDir = `${canvasTileFolder}/${zoom}`;
+ logger.info(`Tiling: Checking zoomlevel ${zoomDir}`);
+ if (!fs.existsSync(zoomDir)) fs.mkdirSync(zoomDir);
+ const maxZ = TILE_ZOOM_LEVEL ** zoom;
+ for (let cx = 0; cx < maxZ; cx += 1) {
+ const tileDir = `${canvasTileFolder}/${zoom}/${cx}`;
+ if (!fs.existsSync(tileDir)) fs.mkdirSync(tileDir);
+ for (let cy = 0; cy < maxZ; cy += 1) {
+ const filename = `${canvasTileFolder}/${zoom}/${cx}/${cy}.png`;
+ if (force || !fs.existsSync(filename)) {
+ const ret = await createZoomedTile(
+ canvasTileFolder,
+ palette,
+ [zoom, cx, cy],
+ );
+ if (ret) cnts += 1;
+ cnt += 1;
+ }
+ }
+ }
+ logger.info(
+ `Tiling: Created ${cnts} / ${cnt} tiles for zoom ${zoom} for canvas${canvasId}`,
+ );
+ }
+ // create snapshot texture
+ await createTexture(canvasId, canvasSize, canvasTileFolder, palette);
+ //--
+ logger.info(
+ `Tiling: Elapsed Time: ${Math.round((Date.now() - startTime) / 1000)} for canvas${canvasId}`,
+ );
+}
+
diff --git a/src/core/config.js b/src/core/config.js
new file mode 100644
index 0000000..788aded
--- /dev/null
+++ b/src/core/config.js
@@ -0,0 +1,86 @@
+/* @flow */
+// general config that is also available from client code can be found in
+// src/core/constants.js
+import path from 'path';
+
+if (process.env.BROWSER) {
+ throw new Error('Do not import `config.js` from inside the client-side code.');
+}
+
+export const HOSTURL = process.env.HOSTURL || 'https://pixelplanet.fun';
+
+export const PORT = process.env.PORT || 80;
+
+const TILE_FOLDER_REL = process.env.TILE_FOLDER || 'tiles';
+export const TILE_FOLDER = path.join(__dirname, `./${TILE_FOLDER_REL}`);
+
+export const ASSET_SERVER = process.env.ASSET_SERVER || '.';
+
+// Proxycheck
+export const USE_PROXYCHECK = parseInt(process.env.USE_PROXYCHECK) || false;
+
+export const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6380';
+// Database
+export const MYSQL_HOST = process.env.MYSQL_HOST || 'localhost';
+export const MYSQL_DATABASE = process.env.MYSQL_DATABASE || 'pixelplanet';
+export const MYSQL_USER = process.env.MYSQL_USER || 'pixelplanet';
+export const MYSQL_PW = process.env.MYSQL_PW || 'password';
+
+// Social
+export const DISCORD_INVITE = process.env.DISCORD_INVITE || 'https://discordapp.com/';
+
+// Accounts
+export const APISOCKET_KEY = process.env.APISOCKET_KEY || 'changethis';
+// Comma seperated list of user ids of Admins
+export const ADMIN_IDS = (process.env.ADMIN_IDS) ?
+ process.env.ADMIN_IDS.split(',').map((z) => parseInt(z, 10)) : [];
+
+export const analytics = {
+ // https://analytics.google.com/
+ google: {
+ trackingId: process.env.GOOGLE_TRACKING_ID, // UA-XXXXX-X
+ },
+};
+
+export const auth = {
+ // https://developers.facebook.com/
+ facebook: {
+ clientID: process.env.FACEBOOK_APP_ID || 'dummy',
+ clientSecret: process.env.FACEBOOK_APP_SECRET || 'dummy',
+ },
+ // https://discordapp.com/developers/applications/me
+ discord: {
+ clientID: process.env.DISCORD_CLIENT_ID || 'dummy',
+ clientSecret: process.env.DISCORD_CLIENT_SECRET || 'dummy',
+ },
+ // https://cloud.google.com/console/project
+ google: {
+ clientID: process.env.GOOGLE_CLIENT_ID || 'dummy',
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET || 'dummy',
+ },
+ // vk.com/dev
+ vk: {
+ clientID: process.env.VK_CLIENT_ID || 'dummy',
+ clientSecret: process.env.VK_CLIENT_SECRET || 'dummy',
+ },
+ // https://www.reddit.com/prefs/apps
+ reddit: {
+ clientID: process.env.REDDIT_CLIENT_ID || 'dummy',
+ clientSecret: process.env.REDDIT_CLIENT_SECRET || 'dummy',
+ },
+};
+
+
+export const ads = {
+ adsense: {
+ id: 'ca-pub-41116611299745444',
+ },
+};
+
+export const RECAPTCHA_SECRET = process.env.RECAPTCHA_SECRET || false;
+export const RECAPTCHA_SITEKEY = process.env.RECAPTCHA_SITEKEY || false;
+// time on which to display captcha in minutes
+export const RECAPTCHA_TIME = parseInt(process.env.RECAPTCHA_TIME) || 30;
+
+export const SESSION_SECRET = process.env.SESSION_SECRET || 'dummy';
+
diff --git a/src/core/constants.js b/src/core/constants.js
new file mode 100644
index 0000000..e3d0a63
--- /dev/null
+++ b/src/core/constants.js
@@ -0,0 +1,91 @@
+/**
+ * @flow
+ */
+
+// canvas size (width and height) MUST be 256 * 4^n to be able to stick
+// to established tiling convetions.
+// (basically by sticking to that, we keep ourself many options open for the future)
+// see OSM tiling: https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
+export const MAX_SCALE = 40; // 52 in log2
+// export const DEFAULT_SCALE = 0.25; //-20 in log2
+export const DEFAULT_SCALE = 3;
+
+// default canvas that is first assumed, before real canvas data
+// gets fetched via api/me
+export const DEFAULT_CANVAS_ID = 0;
+export const DEFAULT_CANVASES = {
+ 0: {
+ ident: 'd',
+ colors: [
+ [202, 227, 255],
+ [255, 255, 255],
+ [255, 255, 255],
+ [228, 228, 228],
+ [196, 196, 196],
+ [136, 136, 136],
+ [78, 78, 78],
+ [0, 0, 0],
+ [244, 179, 174],
+ [255, 167, 209],
+ [255, 84, 178],
+ [255, 101, 101],
+ [229, 0, 0],
+ [154, 0, 0],
+ [254, 164, 96],
+ [229, 149, 0],
+ [160, 106, 66],
+ [96, 64, 40],
+ [245, 223, 176],
+ [255, 248, 137],
+ [229, 217, 0],
+ [148, 224, 68],
+ [2, 190, 1],
+ [104, 131, 56],
+ [0, 101, 19],
+ [202, 227, 255],
+ [0, 211, 221],
+ [0, 131, 199],
+ [0, 0, 234],
+ [25, 25, 115],
+ [207, 110, 228],
+ [130, 0, 128],
+ ],
+ alpha: 0,
+ size: 65536,
+ bcd: 4000,
+ pcd: 7000,
+ cds: 60000,
+ },
+};
+
+export const TILE_LOADING_IMAGE = './loading.png';
+
+// one bigchunk has 16x16 smallchunks, one smallchunk has 64x64 pixel, so one bigchunk is 1024x1024 pixels
+export const TILE_SIZE = 256;
+// how much to scale for a new tiled zoomlevel
+export const TILE_ZOOM_LEVEL = 4;
+
+// TODO get rid of those or use it myself
+export const social = {
+ facebook: 'https://www.facebook.com/pixelplanetfun/',
+ reddit: 'https://reddit.com/r/PixelPlanetFun',
+ twitter: 'https://twitter.com/pixelplanetfun',
+ discord: 'https://pixelplanet.fun/discord',
+ telegram: 'https://telegram.me/pixelplanetfun',
+ youtube: 'https://www.youtube.com/c/PixelPlanetFun',
+};
+
+export const COOKIE_SESSION_NAME = 'pixelplanet.session';
+
+export const SECOND = 1000;
+export const MINUTE = 60 * SECOND;
+export const HOUR = 60 * MINUTE;
+export const DAY = 24 * HOUR;
+export const MONTH = 30 * DAY;
+
+/* export const BLANK_COOLDOWN = 10 * SECOND;
+export const MIN_COOLDOWN = 30 * SECOND; */
+
+export const BLANK_COOLDOWN = 3 * SECOND;
+export const MIN_COOLDOWN = 15 * SECOND;
+
diff --git a/src/core/draw.js b/src/core/draw.js
new file mode 100644
index 0000000..edce4fa
--- /dev/null
+++ b/src/core/draw.js
@@ -0,0 +1,171 @@
+/* @flow */
+
+import { using } from 'bluebird';
+
+import type { User } from '../data/models';
+import { redlock } from '../data/redis';
+import { getChunkOfPixel, getOffsetOfPixel } from './utils';
+import { broadcastPixel } from '../socket/websockets';
+import logger from './logger';
+import RedisCanvas from '../data/models/RedisCanvas';
+import { registerPixelChange } from './tileserver';
+import canvases from '../canvases.json';
+
+
+/**
+ *
+ * @param canvasId
+ * @param x
+ * @param y
+ * @param color
+ */
+export function setPixel(
+ canvasId: number,
+ x: number,
+ y: number,
+ color: ColorIndex,
+) {
+ const canvasSize = canvases[canvasId].size;
+ const [i, j] = getChunkOfPixel([x, y], canvasSize);
+ const offset = getOffsetOfPixel(x, y, canvasSize);
+ RedisCanvas.setPixelInChunk(i, j, offset, color, canvasId);
+ broadcastPixel(canvasId, i, j, offset, color);
+}
+
+/**
+ *
+ * @param user
+ * @param canvasId
+ * @param x
+ * @param y
+ * @param color
+ * @returns {Promise.}
+ */
+async function draw(
+ user: User,
+ canvasId: string,
+ x: number,
+ y: number,
+ color: ColorIndex,
+): Promise {
+ if (!({}.hasOwnProperty.call(canvases, canvasId))) {
+ return {
+ error: 'This canvas does not exist',
+ success: false,
+ };
+ }
+ const canvas = canvases[canvasId];
+
+ const canvasMaxXY = canvas.size / 2;
+ const canvasMinXY = -canvasMaxXY;
+ if (x < canvasMinXY || y < canvasMinXY ||
+ x >= canvasMaxXY || y >= canvasMaxXY) {
+ return {
+ error: 'Coordinates not withing canvas',
+ success: false,
+ };
+ }
+
+ if (canvas.req) {
+ if (user.id === null) {
+ return {
+ errorTitle: 'Not Logged In',
+ error: 'You need to be logged in to use this canvas.',
+ success: false,
+ };
+ }
+ // if the canvas has a requirement of totalPixels that the user
+ // has to have set
+ const totalPixels = await user.getTotalPixels();
+ if (totalPixels < canvas.req) {
+ return {
+ errorTitle: 'Not Yet :(',
+ error: `You need to set ${canvas.req} pixels on another canvas first, before you can use this one.`,
+ success: false,
+ };
+ }
+ }
+
+ const setColor = await RedisCanvas.getPixel(x, y, canvasId);
+
+ let coolDown = !(setColor & 0x1E) ? canvas.bcd : canvas.pcd;
+ if (user.isAdmin()) {
+ coolDown = 0.0;
+ }
+
+ const now = Date.now();
+ let wait = await user.getWait(canvasId);
+ if (!wait) wait = now;
+ wait += coolDown;
+ const waitLeft = wait - now;
+ if (waitLeft > canvas.cds) {
+ return {
+ success: false,
+ waitSeconds: (waitLeft - coolDown) / 1000,
+ coolDownSeconds: (canvas.cds - waitLeft) / 1000,
+ };
+ }
+
+ if (setColor & 0x20) {
+ logger.info(`${user.ip} tried to set on protected pixel (${x}, ${y})`);
+ return {
+ errorTitle: 'Pixel Protection',
+ error: 'This pixel is protected',
+ success: false,
+ waitSeconds: (waitLeft - coolDown) / 1000,
+ };
+ }
+
+ setPixel(canvasId, x, y, color);
+
+ user.setWait(waitLeft, canvasId);
+ user.incrementPixelcount();
+ return {
+ success: true,
+ waitSeconds: waitLeft / 1000,
+ coolDownSeconds: coolDown / 1000,
+ };
+}
+
+/**
+ * This function is a wrapper for draw. It fixes race condition exploits
+ * It permits just placing one pixel at a time per user.
+ *
+ * @param user
+ * @param canvasId
+ * @param x
+ * @param y
+ * @param color
+ * @returns {Promise.}
+ */
+function drawSafe(
+ user: User,
+ canvasId: number,
+ x: number,
+ y: number,
+ color: ColorIndex,
+): Promise {
+ if (user.isAdmin()) {
+ return draw(user, canvasId, x, y, color);
+ }
+
+ // can just check for one unique occurence,
+ // we use ip, because id for logged out users is
+ // always null
+ const userId = user.ip;
+
+ return new Promise((resolve) => {
+ using(
+ redlock.disposer(`locks:${userId}`, 5000, logger.error),
+ async () => {
+ const ret = await draw(user, canvasId, x, y, color);
+ resolve(ret);
+ },
+ ); // <-- unlock is automatically handled by bluebird
+ });
+}
+
+
+export const drawUnsafe = draw;
+
+export default drawSafe;
diff --git a/src/core/forceGC.js b/src/core/forceGC.js
new file mode 100644
index 0000000..eda605e
--- /dev/null
+++ b/src/core/forceGC.js
@@ -0,0 +1,20 @@
+/**
+ */
+
+import logger from './logger';
+
+
+/**
+ * https://blog.jayway.com/2015/04/13/600k-concurrent-websocket-connections-on-aws-using-node-js/
+ */
+function forceGC() {
+ if (global.gc) {
+ global.gc();
+ } else {
+ logger.warn('Garbage collection unavailable. ' +
+ 'Pass --expose-gc when launching node to enable forced garbage ' +
+ 'collection.');
+ }
+}
+
+export default forceGC;
diff --git a/src/core/isProxy.js b/src/core/isProxy.js
new file mode 100644
index 0000000..c78636d
--- /dev/null
+++ b/src/core/isProxy.js
@@ -0,0 +1,212 @@
+/**
+ * @flow
+ * */
+
+import fetch from '../utils/proxiedFetch.js';
+import IP from 'ip';
+
+import redis from '../data/redis';
+import { Blacklist, Whitelist } from '../data/models';
+import logger from './logger';
+
+
+/*
+ * check getipintel if IP is proxy
+ * Use proxiedFetch with random proxies and random mail for it, to not get blacklisted
+ * @param ip IP to check
+ * @return true if proxy, false if not
+ */
+async function getIPIntel(ip: string): Promise {
+ const email = `${Math.random().toString(36).substring(8)}-${Math.random().toString(36).substring(4)}@gmail.com`;
+ const url = `http://check.getipintel.net/check.php?ip=${ip}&contact=${email}&flags=m`;
+ logger.info('fetching getipintel', url);
+ const response = await fetch(url, {
+ headers: {
+ Accept: '*/*',
+ 'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
+ Referer: 'http://check.getipintel.net/',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36',
+ },
+ });
+ // TODO log response code
+ logger.debug('getipintel?', ip);
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`getipintel not ok ${response.status}/${text}`);
+ }
+ const body = await response.text();
+ logger.info('fetch getipintel is proxy?', ip, body);
+ // returns tru iff we found 1 in the response and was ok (http code = 200)
+ const value = parseFloat(body);
+ return value > 0.995;
+}
+
+/*
+ * check proxycheck.io if IP is proxy
+ * Use proxiedFetch with random proxies
+ * @param ip IP to check
+ * @return true if proxy, false if not
+ */
+async function getProxyCheck(ip: string): Promise {
+ const url = `http://proxycheck.io/v2/${ip}?risk=1&vpn=1&asn=1`;
+ logger.info('fetching proxycheck', url);
+ const response = await fetch(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36',
+ },
+ });
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`proxycheck not ok ${response.status}/${text}`);
+ }
+ const data = await response.json();
+ logger.info('proxycheck is proxy?', ip, data);
+ return data.status == 'ok' && data[ip].proxy === 'yes';
+}
+
+/*
+ * check shroomey if IP is proxy
+ * NOTE: shroomey can not check IPv6
+ * User random proxies for request, just to be sure
+ * @param ip IP to check
+ * @return true if proxy, false if not
+ */
+async function getShroomey(ip: string): Promise {
+ logger.info('fetching shroomey', ip);
+ const response = await fetch(`http://www.shroomery.org/ythan/proxycheck.php?ip=${ip}`, {
+ headers: {
+ Accept: '*/*',
+ 'Accept-Language': 'es-ES,es;q=0.8,en;q=0.6',
+ Referer: 'http://www.shroomery.org/',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36',
+ },
+ });
+ if (!response.ok) throw new Error('shroomery.org not ok');
+ const body = await response.text();
+ logger.info('fetch shroomey is proxy?', ip, body);
+ return body === 'Y';
+}
+
+/*
+ * check shroomey, and if positive there, check
+ * getipintel
+ * @param ip IP to check
+ * @return true if proxy, false if not
+ */
+async function getCombined(ip: string): Promise {
+ if (ip.indexOf(':') == -1) {
+ const shroom = await getShroomey(ip);
+ if (!shroom) return false;
+ }
+ const ipintel = await getIPIntel(ip);
+ return ipintel;
+}
+
+/*
+ * check MYSQL Blacklist table
+ * @param ip IP to check
+ * @return true if blacklisted
+ */
+async function isBlacklisted(ip: string): Promise {
+ const numIp = IP.toLong(ip);
+ const count = await Blacklist
+ .count({
+ where: {
+ numIp,
+ },
+ });
+ return count !== 0;
+}
+
+/*
+ * check MYSQL Whitelist table
+ * @param ip IP to check
+ * @return true if whitelisted
+ */
+async function isWhitelisted(ip: string): Promise {
+ const numIp = IP.toLong(ip);
+ const count = await Whitelist
+ .count({
+ where: {
+ numIp,
+ },
+ });
+ return count !== 0;
+}
+
+/*
+ * dummy function to include if you don't want any proxycheck
+ */
+async function dummy(ip: string): Promise {
+ return false;
+}
+
+/*
+ * execute proxycheck without caring about cache
+ * @param f function for checking if proxy
+ * @param ip IP to check
+ * @return true if proxy or blacklisted, false if not or whitelisted
+ */
+async function withoutCache(f, ip) {
+ if (!ip) return true;
+ return !(await isWhitelisted(ip)) && (await isBlacklisted(ip) || await f(ip));
+}
+
+/*
+ * execute proxycheck without caching results for 3 days
+ * do not check more than 3 at a time, do not check ip double
+ * @param f function for checking if proxy
+ * @param ip IP to check
+ * @return true if proxy or blacklisted, false if not or whitelisted
+ */
+let lock = 4;
+const checking = new Array();
+async function withCache(f, ip) {
+ if (!ip) return true;
+ // get from cache, if there
+ const key = `isprox:${ip}`;
+ const cache = await redis.getAsync(key);
+ if (cache) {
+ const str = cache.toString('utf8');
+ logger.debug('fetch isproxy from cache', key, cache, typeof cache, str, typeof str);
+ return str === 'y';
+ }
+ logger.debug('fetch isproxy not from cache', key);
+
+ // else make asynchronous ipcheck and assume no proxy in the meantime
+ // use lock to just check three at a time
+ // do not check ip that currently gets checked
+ if (checking.indexOf(ip) == -1 && lock > 0) {
+ lock -= 1;
+ checking.push(ip);
+ withoutCache(f, ip)
+ .then((result) => {
+ const value = result ? 'y' : 'n';
+ redis.setAsync(key, value, 'EX', 3 * 24 * 3600); // cache for three days
+ const pos = checking.indexOf(ip);
+ if (~pos) checking.splice(pos, 1);
+ lock += 1;
+ })
+ .catch((error) => {
+ logger.error('withCache', error.message || error);
+ const pos = checking.indexOf(ip);
+ if (~pos) checking.splice(pos, 1);
+ lock += 1;
+ });
+ }
+ return false;
+}
+
+export async function cheapDetector(ip: string): Promise {
+ return (await withCache(getProxyCheck, ip));
+}
+
+export async function strongDetector(ip: string): Promise {
+ return (await withCache(getShroomey, ip));
+}
+
+export async function blacklistDetector(ip: string): Promise {
+ return (await withCache(dummy, ip));
+}
+
+// export default cheapDetector;
diff --git a/src/core/logger.js b/src/core/logger.js
new file mode 100644
index 0000000..ed2f3ab
--- /dev/null
+++ b/src/core/logger.js
@@ -0,0 +1,26 @@
+/**
+ *
+ * http://tostring.it/2014/06/23/advanced-logging-with-nodejs/
+ *
+ * @flow
+ */
+
+import winston from 'winston';
+
+
+const logger = winston;
+
+export const proxyLogger = new winston.Logger({
+ transports: [
+ new winston.transports.File({
+ level: 'info',
+ filename: '/var/log/pixelplace/proxies.log',
+ json: true,
+ maxsize: 2 * 52428800, // 100MB
+ colorize: false,
+ }),
+ ],
+});
+
+
+export default logger;
diff --git a/src/core/mail.js b/src/core/mail.js
new file mode 100644
index 0000000..88a7687
--- /dev/null
+++ b/src/core/mail.js
@@ -0,0 +1,176 @@
+/*
+ * functions for mail verify
+ * @flow
+ */
+
+// must use require for arguments
+import logger from './logger';
+
+import { HOUR, MINUTE } from './constants';
+import { HOSTURL } from './config';
+import { DailyCron, HourlyCron } from '../utils/cron';
+
+import Sequelize from 'sequelize';
+import RegUser from '../data/models/RegUser';
+
+const sendmail = require('sendmail')({ silent: true });
+
+
+// TODO make code expire
+class MailProvider {
+ verify_codes: Object;
+
+ constructor() {
+ this.clear_codes = this.clear_codes.bind(this);
+
+ this.verify_codes = {};
+ HourlyCron.hook(this.clear_codes);
+ DailyCron.hook(MailProvider.clean_users);
+ }
+
+ send_verify_mail(to, name) {
+ const past_mail = this.verify_codes[to];
+ if (past_mail) {
+ const min_left = Math.floor(past_mail.timestamp / MINUTE + 15 - Date.now() / MINUTE);
+ if (min_left > 0) {
+ logger.info(`Verify mail for ${to} - already sent, ${min_left} minutes left`);
+ return `We already sent you a verification mail, you can request another one in ${min_left} minutes.`;
+ }
+ }
+ logger.info(`Sending verification mail to ${to} / ${name}`);
+ const code = this.set_code(to);
+ const verify_url = `${HOSTURL}/api/auth/verify?token=${code}`;
+ sendmail({
+ from: 'donotreply@pixelplanet.fun',
+ to,
+ replyTo: 'donotreply@pixelplanet.fun',
+ subject: `Welcome ${name} to PixelPlanet, plese verify your mail`,
+ text: `Hello,\nwelcome to our little community of pixelplacers, to use your account, you have to verify your mail. You can do that here:\n ${verify_url} \nHave fun and don't hesitate to contact us if you encouter any problems :)\nThanks`,
+ }, (err, reply) => {
+ if (err) {
+ logger.error(err & err.stack);
+ }
+ });
+ return null;
+ }
+
+ async send_passd_reset_mail(to, ip) {
+ const past_mail = this.verify_codes[to];
+ if (past_mail) {
+ if (Date.now() < past_mail.timestamp + 15 * MINUTE) {
+ logger.info(`Password reset mail for ${to} requested by ${ip} - already sent`);
+ return 'We already sent you a mail with instructions. Please wait before requesting another mail.';
+ }
+ }
+ const reguser = await RegUser.findOne({ where: { email: to } });
+ if (past_mail || !reguser) {
+ logger.info(`Password reset mail for ${to} requested by ${ip} - mail not found`);
+ return "Couldn't find this mail in our database";
+ }
+ /*
+ * not sure if this is needed yet
+ * does it matter if spaming password reset mails or verifications mails?
+ *
+ if(!reguser.verified) {
+ logger.info(`Password reset mail for ${to} requested by ${ip} - mail not verified`);
+ return "Can't reset password of unverified account.";
+ }
+ */
+
+ logger.info(`Sending Password reset mail to ${to}`);
+ const code = this.set_code(to);
+ const restore_url = `${HOSTURL}/reset_password?token=${code}`;
+ sendmail({
+ from: 'donotreply@pixelplanet.fun',
+ to,
+ replyTo: 'donotreply@pixelplanet.fun',
+ subject: 'You forgot your password for PixelPlanet? Get a new one here',
+ text: `Hello,\nYou requested to get a new password. You can change your password within the next 30min here:\n ${restore_url} \nHave fun and don't hesitate to contact us if you encouter any problems :)\nIf you did not request this mail, please just ignore it (the ip that requested this mail was ${ip}).\nThanks`,
+ }, (err, reply) => {
+ if (err) {
+ logger.error(err & err.stack);
+ }
+ });
+ return null;
+ }
+
+ set_code(email) {
+ const code = MailProvider.create_code();
+ this.verify_codes[email] = {
+ code,
+ timestamp: Date.now(),
+ };
+ return code;
+ }
+
+ async clear_codes() {
+ const cur_time = Date.now();
+ const to_delete = [];
+ for (const iteremail in this.verify_codes) {
+ if (cur_time > this.verify_codes[iteremail].timestamp + HOUR) {
+ to_delete.push(iteremail);
+ }
+ }
+ to_delete.forEach((email) => {
+ logger.info(`Mail Code for ${email} expired`);
+ delete this.verify_codes[email];
+ });
+ }
+
+ // Note: code gets deleted on check
+ check_code(code) {
+ let email = null;
+ for (const iteremail in this.verify_codes) {
+ if (this.verify_codes[iteremail].code == code) {
+ email = iteremail;
+ break;
+ }
+ }
+ if (!email) {
+ logger.info(`Mail Code ${code} not found.`);
+ return false;
+ }
+ logger.info(`Got Mail Code from ${email}.`);
+ delete this.verify_codes[email];
+ return email;
+ }
+
+ async verify(code) {
+ const email = this.check_code(code);
+ if (!email) return false;
+
+ const reguser = await RegUser.findOne({ where: { email } });
+ if (!reguser) {
+ logger.error(`${email} does not exist in database`);
+ return false;
+ }
+ await reguser.update({
+ mailVerified: true,
+ verificationReqAt: null,
+ });
+ return true;
+ }
+
+ static create_code() {
+ const part1 = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
+ const part2 = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
+ return `${part1}-${part2}`;
+ }
+
+ static clean_users() {
+ // delete users that requier verification for more than 4 days
+ RegUser.destroy({
+ where: {
+ verificationReqAt: {
+ [Sequelize.Op.lt]: Sequelize.literal('CURRENT_TIMESTAMP - INTERVAL 4 DAY'),
+ },
+ // NOTE: this means that minecraft verified accounts do not get deleted
+ verified: 0,
+ },
+ });
+ }
+}
+
+export const mailProvider = new MailProvider();
+
+export default mailProvider;
diff --git a/src/core/me.js b/src/core/me.js
new file mode 100644
index 0000000..bfcaae6
--- /dev/null
+++ b/src/core/me.js
@@ -0,0 +1,33 @@
+/**
+ *
+ * Userdata that gets sent to the client on
+ * various api endpoints.
+ *
+ * @flow
+ */
+import canvases from '../canvases.json';
+
+
+export default async function getMe(user) {
+ const userdata = user.getUserData();
+ // sanitize data
+ const { name, mailVerified, minecraftname, mcVerified } = userdata;
+ if (!name) userdata.name = null;
+ const messages = [];
+ if (name && !mailVerified) {
+ messages.push('not_verified');
+ }
+ if (minecraftname && !mcVerified) {
+ messages.push('not_mc_verified');
+ }
+ if (messages.length > 0) {
+ userdata.messages = messages;
+ }
+ delete userdata.mailVerified;
+ delete userdata.mcVerified;
+
+ userdata.canvases = canvases;
+
+ return userdata;
+}
+
diff --git a/src/core/minecraft.js b/src/core/minecraft.js
new file mode 100644
index 0000000..0fe73ca
--- /dev/null
+++ b/src/core/minecraft.js
@@ -0,0 +1,118 @@
+/*
+ *
+ * Minecraft user handling
+ *
+ * @flow
+ */
+
+import { User, RegUser } from '../data/models';
+
+
+class Minecraft {
+ online: Object;
+
+ constructor() {
+ this.online = {};
+ }
+
+ async report_login(minecraftid, minecraftname) {
+ const user = new User();
+ user.minecraftname = minecraftname;
+ const reguser = await RegUser.findOne({ where: { minecraftid } });
+ if (reguser && reguser.mcVerified) {
+ user.id = reguser.id;
+ user.regUser = reguser;
+ reguser.update({ minecraftname });
+ }
+ this.online[minecraftid] = user;
+ // this.updateRedisOnlineList();
+ return user;
+ }
+
+ /*
+ * TODO: whole online list should be handled by redis
+ updateRedisOnlineList() {
+ }
+ */
+
+ report_logout(minecraftid) {
+ delete this.online[minecraftid];
+ }
+
+ report_userlist(list) {
+ this.online = {};
+ list.forEach((user) => {
+ const [minecraftid, minecraftname] = user;
+ this.report_login(minecraftid, minecraftname);
+ });
+ }
+
+ async linkacc(minecraftid, minecraftname, name) {
+ try {
+ const finduser = await RegUser.findOne({ where: { minecraftid } });
+ if (finduser) {
+ if (finduser.name == name) {
+ if (finduser.mcVerified) {
+ return 'You are already verified';
+ }
+ return 'You already got a verification message in the pixelplanet UserArea. Please refresh the page if you do not see it.';
+ }
+ return `You already linked to other account ${finduser.name}.`;
+ }
+ const reguser = await RegUser.findOne({ where: { name } });
+ if (reguser) {
+ if (reguser.minecraftid) {
+ return `This pixelplanet account is already linked to ${reguser.minecraftname}`;
+ }
+ reguser.update({ minecraftname, minecraftid });
+ return null;
+ }
+ return `Can not find user ${name} on pixelplanet.`;
+ } catch (err) {
+ return 'An unexpected error occured :(';
+ }
+ }
+
+ minecraftid2User(minecraftid: string): User {
+ if (this.online[minecraftid]) {
+ return this.online[minecraftid];
+ }
+
+ const user = new User();
+ if (minecraftid) {
+ RegUser.findOne({ where: { minecraftid } }).then((reguser) => {
+ if (reguser && reguser.mcVerified) {
+ user.id = reguser.id;
+ user.minecraftname = reguser.minecraftname;
+ user.regUser = reguser;
+ } else {
+ user.minecraftname = minecraftid;
+ }
+ });
+ }
+ return user;
+ }
+
+ minecraftname2User(minecraftname: string): User {
+ const searchstring = minecraftname;
+ for (const [minecraftid, user] of Object.entries(this.online)) {
+ if (user.minecraftname == searchstring) { return user; }
+ }
+
+ const user = new User();
+ user.minecraftname = searchstring;
+ if (minecraftname) {
+ RegUser.findOne({ where: { minecraftname } }).then((reguser) => {
+ if (reguser && reguser.mcVerified) {
+ user.id = reguser.id;
+ user.regUser = reguser;
+ // this.online[reguser.minecraftid] = user;
+ }
+ });
+ }
+ return user;
+ }
+}
+
+
+export default Minecraft;
diff --git a/src/core/passport.js b/src/core/passport.js
new file mode 100644
index 0000000..e1fa0e7
--- /dev/null
+++ b/src/core/passport.js
@@ -0,0 +1,191 @@
+/**
+ * https://scotch.io/tutorials/easy-node-authentication-linking-all-accounts-together#toc-linking-accounts-together
+ *
+ * @flow
+ */
+
+import passport from 'passport';
+import { Strategy as JsonStrategy } from 'passport-json';
+import { Strategy as DiscordStrategy } from 'passport-discord';
+import { Strategy as RedditStrategy } from 'passport-reddit';
+import { Strategy as FacebookStrategy } from 'passport-facebook';
+import { Strategy as VkontakteStrategy } from 'passport-vkontakte';
+import { OAuth2Strategy as GoogleStrategy } from 'passport-google-oauth';
+
+import { sanitizeName } from '../utils/validation';
+
+import logger from './logger';
+import { User, RegUser } from '../data/models';
+import { auth, HOSTURL } from './config';
+import { compareToHash } from '../utils/hash';
+
+
+passport.serializeUser((user, done) => {
+ done(null, user.id);
+});
+
+passport.deserializeUser((req, id, done) => {
+ // req.noauthUser already get populated with id and ip in routes/api/index to allow
+ // some api requests (pixel) to not require this sql deserlialize
+ // but still know the id
+ const user = (req.noauthUser) ? req.noauthUser : new User(id);
+ if (id) {
+ RegUser.findOne({ where: { id } }).then((reguser) => {
+ if (reguser) {
+ user.regUser = reguser;
+ } else {
+ user.id = null;
+ }
+ done(null, user);
+ });
+ } else {
+ done(null, user);
+ }
+});
+
+/**
+ * Sign in locally
+ */
+passport.use(new JsonStrategy({
+ usernameProp: 'nameoremail',
+ passwordProp: 'password',
+}, (nameoremail, password, done) => {
+ // Decide if email or name by the occurance of @
+ // this is why we don't allow @ in usernames
+ // NOTE: could allow @ in the future by making an OR query,
+ // but i guess nobody really cares.
+ // https://sequelize.org/master/manual/querying.html
+ const query = (nameoremail.indexOf('@') !== -1) ? { email: nameoremail } : { name: nameoremail };
+ RegUser.findOne({ where: query }).then((reguser) => {
+ if (!reguser) {
+ return done(null, false, { message: 'Name or Email does not exist!' });
+ }
+ if (!compareToHash(password, reguser.password)) {
+ return done(null, false, { message: 'Incorrect password!' });
+ }
+ const user = new User(reguser.id);
+ user.regUser = reguser;
+ user.updateLogInTimestamp();
+ return done(null, user);
+ });
+}));
+
+/*
+ * OAuth SignIns, mail based
+ *
+ */
+async function oauth_login(email, name, discordid = null) {
+ name = sanitizeName(name);
+ let reguser = await RegUser.findOne({ where: { email } });
+ if (!reguser) {
+ reguser = await RegUser.findOne({ where: { name } });
+ while (reguser) {
+ // name is taken by someone else
+ name = `${name.substring(0, 15)}-${Math.random().toString(36).substring(2, 10)}`;
+ reguser = await RegUser.findOne({ where: { name } });
+ }
+ reguser = await RegUser.create({
+ email,
+ name,
+ verified: 1,
+ discordid,
+ });
+ }
+ if (!reguser.discordid && discordid) {
+ reguser.update({ discordid });
+ }
+ const user = new User(reguser.id);
+ user.regUser = reguser;
+ return user;
+}
+
+/**
+ * Sign in with Facebook.
+ */
+passport.use(new FacebookStrategy({
+ ...auth.facebook,
+ callbackURL: `${HOSTURL}/api/auth/facebook/return`,
+ profileFields: ['displayName', 'email'],
+}, async (req, accessToken, refreshToken, profile, done) => {
+ const { displayName: name, emails } = profile;
+ const email = emails[0].value;
+ const user = await oauth_login(email, name);
+ done(null, user);
+}));
+
+/**
+ * Sign in with Discord.
+ */
+passport.use(new DiscordStrategy({
+ ...auth.discord,
+ callbackURL: `${HOSTURL}/api/auth/discord/return`,
+}, async (accessToken, refreshToken, profile, done) => {
+ // TODO get discord id
+ console.log({ profile, refreshToken, accessToken });
+ const { id, email, username: name } = profile;
+ const user = await oauth_login(email, name, id);
+ done(null, user);
+}));
+
+/**
+ * Sign in with Google.
+ */
+passport.use(new GoogleStrategy({
+ ...auth.google,
+ callbackURL: `${HOSTURL}/api/auth/google/return`,
+}, async (accessToken, refreshToken, profile, done) => {
+ const { displayName: name, emails } = profile;
+ const email = emails[0].value;
+ const user = await oauth_login(email, name);
+ done(null, user);
+}));
+
+/*
+ * Sign in with Reddit
+ */
+passport.use(new RedditStrategy({
+ ...auth.reddit,
+ callbackURL: `${HOSTURL}/api/auth/reddit/return`,
+}, async (accessToken, refreshToken, profile, done) => {
+ console.log({ profile, refreshToken, accessToken });
+ const redditid = profile.id;
+ let name = sanitizeName(profile.name);
+ // reddit needs an own login strategy based on its id,
+ // because we can not access it's mail
+ let reguser = await RegUser.findOne({ where: { redditid } });
+ if (!reguser) {
+ reguser = await RegUser.findOne({ where: { name } });
+ while (reguser) {
+ // name is taken by someone else
+ name = `${name.substring(0, 15)}-${Math.random().toString(36).substring(2, 10)}`;
+ reguser = await RegUser.findOne({ where: { name } });
+ }
+ reguser = await RegUser.create({
+ name,
+ verified: 1,
+ redditid,
+ });
+ }
+ const user = new User(reguser.id);
+ user.regUser = reguser;
+ done(null, user);
+}));
+
+/**
+ * Sign in with Vkontakte
+ */
+passport.use(new VkontakteStrategy({
+ ...auth.vk,
+ callbackURL: `${HOSTURL}/api/auth/vk/return`,
+ scope: ['email'],
+ profileFields: ['displayName', 'email'],
+}, async (accessToken, refreshToken, params, profile, done) => {
+ console.log(profile);
+ const { displayName: name } = profile;
+ const { email } = params;
+ const user = await oauth_login(email, name);
+ done(null, user);
+}));
+
+
+export default passport;
diff --git a/src/core/ranking.js b/src/core/ranking.js
new file mode 100644
index 0000000..49452ec
--- /dev/null
+++ b/src/core/ranking.js
@@ -0,0 +1,58 @@
+/*
+ * timers and cron for account related actions
+ */
+
+import Sequelize from 'sequelize';
+import Model from '../data/sequelize';
+import RegUser from '../data/models/RegUser';
+import logger from './logger';
+
+import { HOUR, MINUTE } from '../core/constants';
+import { DailyCron } from '../utils/cron';
+
+class Ranks {
+ ranks: Array;
+
+ constructor() {
+ this.updateRanking = this.updateRanking.bind(this);
+ this.resetDailyRanking = this.resetDailyRanking.bind(this);
+
+ this.ranks = {};
+ this.updateRanking();
+ setInterval(this.updateRanking, 5 * MINUTE);
+ DailyCron.hook(this.resetDailyRanking);
+ }
+
+ async updateRanking() {
+ // recalculate ranking column
+ await Model.query('SET @r=0; UPDATE Users SET ranking= @r:= (@r + 1) WHERE NOT id= 18 ORDER BY totalPixels DESC;');
+ await Model.query('SET @r=0; UPDATE Users SET dailyRanking= @r:= (@r + 1) WHERE NOT id= 18 ORDER BY dailyTotalPixels DESC;');
+ // populate dictionaries
+ const ranking = await RegUser.findAll({
+ attributes: ['name', 'totalPixels', 'ranking', 'dailyRanking', 'dailyTotalPixels', [Sequelize.fn('DATEDIFF', Sequelize.literal('CURRENT_TIMESTAMP'), Sequelize.col('createdAt')), 'age']],
+ limit: 100,
+ where: { id: { [Sequelize.Op.notIn]: [18, 51] } },
+ order: ['ranking'],
+ raw: true,
+ });
+ const dailyRanking = await RegUser.findAll({
+ attributes: ['name', 'totalPixels', 'ranking', 'dailyRanking', 'dailyTotalPixels', [Sequelize.fn('DATEDIFF', Sequelize.literal('CURRENT_TIMESTAMP'), Sequelize.col('createdAt')), 'age']],
+ limit: 100,
+ where: { id: { [Sequelize.Op.notIn]: [18, 51] } },
+ order: ['dailyRanking'],
+ raw: true,
+ });
+ this.ranks.ranking = ranking;
+ this.ranks.dailyRanking = dailyRanking;
+ }
+
+ async resetDailyRanking() {
+ logger.info('Resetting Daily Ranking');
+ await RegUser.update({ dailyTotalPixels: 0 }, { where: {} });
+ await this.updateRanking();
+ }
+}
+
+
+export const rankings = new Ranks();
+export default rankings;
diff --git a/src/core/session.js b/src/core/session.js
new file mode 100644
index 0000000..2d9114e
--- /dev/null
+++ b/src/core/session.js
@@ -0,0 +1,32 @@
+/**
+ * @flow
+ */
+
+import expressSession from 'express-session';
+import connectRedis from 'connect-redis';
+
+import client from '../data/redis';
+import { HOUR, COOKIE_SESSION_NAME } from './constants';
+import { SESSION_SECRET } from './config';
+
+
+const RedisStore = connectRedis(expressSession);
+export const store = new RedisStore({ client });
+
+const session = expressSession({
+ name: COOKIE_SESSION_NAME,
+ store,
+ secret: SESSION_SECRET,
+ // The best way to know is to check with your store if it implements the touch method. If it does, then you can safely set resave: false
+ resave: false,
+ saveUninitialized: false,
+ cookie: {
+ path: '/',
+ httpOnly: true,
+ secure: false,
+ // not setting maxAge or expire makes it a non-persisting cookies
+ maxAge: 30 * 24 * HOUR,
+ },
+});
+
+export default session;
diff --git a/src/core/tileserver.js b/src/core/tileserver.js
new file mode 100644
index 0000000..fd58b33
--- /dev/null
+++ b/src/core/tileserver.js
@@ -0,0 +1,169 @@
+/* @flow
+ *
+ * creation of tiles
+ *
+ */
+
+import fs from 'fs';
+
+import type { Cell } from './Cell';
+import logger from './logger';
+import canvases from '../canvases.json';
+import Palette from './Palette';
+
+import { TILE_FOLDER } from './config';
+import { TILE_SIZE,
+ TILE_ZOOM_LEVEL } from './constants';
+import { createZoomTileFromChunk,
+ createZoomedTile,
+ createTexture,
+ initializeTiles } from './Tile';
+import { mod, getChunkOfPixel, getMaxTiledZoom } from './utils';
+
+
+// Array that holds cells of all changed base zoomlevel tiles
+const CanvasUpdaters = {};
+
+class CanvasUpdater {
+ TileLoadingQueues: Array;
+ palette: Palette;
+ id: number;
+ canvas: Object;
+ firstZoomtileWidth: number;
+ canvasTileFolder: string;
+
+ constructor(id) {
+ this.updateZoomlevelTiles = this.updateZoomlevelTiles.bind(this);
+
+ this.TileLoadingQueues = [];
+ this.id = id;
+ this.canvas = canvases[id];
+ this.canvasTileFolder = `${TILE_FOLDER}/${id}`;
+ this.palette = new Palette(this.canvas.colors, this.canvas.alpha);
+ this.firstZoomtileWidth = this.canvas.size / TILE_SIZE / TILE_ZOOM_LEVEL;
+ this.maxTiledZoom = getMaxTiledZoom(this.canvas.size);
+ this.startReloadingLoops();
+ }
+
+ /*
+ * @param zoom tilezoomlevel to update
+ */
+ async updateZoomlevelTiles(zoom: number) {
+ const queue = this.TileLoadingQueues[zoom];
+ if (typeof queue === 'undefined') return;
+
+ const tile = queue.shift();
+ if (typeof tile !== 'undefined') {
+ const width = TILE_ZOOM_LEVEL ** zoom;
+ const cx = mod(tile, width);
+ const cy = Math.floor(tile / width);
+
+ if (zoom === this.maxTiledZoom - 1) {
+ await createZoomTileFromChunk(
+ this.canvas.size,
+ this.id,
+ this.canvasTileFolder,
+ this.palette,
+ [cx, cy],
+ );
+ } else if (zoom !== this.maxTiledZoom) {
+ await createZoomedTile(
+ this.canvasTileFolder,
+ this.palette,
+ [zoom, cx, cy],
+ );
+ }
+
+ if (zoom === 0) {
+ createTexture(this.id, this.canvas.size, this.canvasTileFolder, this.palette);
+ } else {
+ const [ucx, ucy] = [cx, cy].map(z => Math.floor(z / 4));
+ const upperTile = ucx + ucy * (TILE_ZOOM_LEVEL ** (zoom - 1));
+ const upperQueue = this.TileLoadingQueues[zoom - 1];
+ if (~upperQueue.indexOf(upperTile)) return;
+ upperQueue.push(upperTile);
+ logger.info(`Tiling: Enqueued ${zoom - 1}, ${ucx}, ${ucy} for reload`);
+ }
+ }
+ }
+
+ /*
+ * register changed chunk, queue corespongind tile to reload
+ * @param chunk Chunk coordinates
+ */
+ registerChunkChange(chunk: Cell) {
+ const queue = this.TileLoadingQueues[Math.max(this.maxTiledZoom - 1, 0)];
+ if (typeof queue === 'undefined') return;
+
+ const [cx, cy] = chunk.map(z => Math.floor(z / 4));
+ const chunkOffset = cx + cy * this.firstZoomtileWidth;
+ if (~queue.indexOf(chunkOffset)) return;
+ queue.push(chunkOffset);
+ logger.info(`Tiling: Enqueued ${cx}, ${cy} / ${this.id} for basezoom reload`);
+ }
+
+ /*
+ * register changed pixel, queue corespongind tile to reload
+ * @param pixel Pixel that got changed
+ */
+ registerPixelChange(pixel: Cell) {
+ const chunk = getChunkOfPixel(pixel, this.canvas.size);
+ return this.registerChunkChange(chunk);
+ }
+
+ /*
+ * initialize queues and start loops for updating tiles
+ */
+ async startReloadingLoops() {
+ logger.info(`Tiling: Using folder ${this.canvasTileFolder}`);
+ if (!fs.existsSync(`${this.canvasTileFolder}/0`)) {
+ if (!fs.existsSync(this.canvasTileFolder)) {
+ fs.mkdirSync(this.canvasTileFolder);
+ }
+ logger.warn(
+ 'Tiling: tiledir empty, will initialize it, this can take some time',
+ );
+ await initializeTiles(
+ this.canvas.size,
+ this.id,
+ this.canvasTileFolder,
+ this.palette,
+ false,
+ );
+ }
+ for (let c = 0; c < this.maxTiledZoom; c += 1) {
+ this.TileLoadingQueues.push([]);
+ const timeout = (8 ** (this.maxTiledZoom - c - 1)) * 5 * 1000;
+ logger.info(
+ `Tiling: Set interval for zoomlevel ${c} update to ${timeout / 1000}`,
+ );
+ setInterval(this.updateZoomlevelTiles, timeout, c);
+ }
+ if (this.maxTiledZoom === 0) {
+ //in the case of canvasSize == 256
+ this.TileLoadingQueues.push([]);
+ setInterval(this.updateZoomlevelTiles, 5 * 60 * 1000, 0);
+ }
+ }
+}
+
+export function registerChunkChange(canvasId: number, chunk: Cell) {
+ return CanvasUpdaters[canvasId].registerChunkChange(chunk);
+}
+
+export function registerPixelChange(canvasId: number, pixel: Cell) {
+ return CanvasUpdaters[canvasId].registerPixelChange(pixel);
+}
+
+/*
+ * starting update loops for canvases
+ */
+export function startAllCanvasLoops() {
+ if (!fs.existsSync(`${TILE_FOLDER}`)) fs.mkdirSync(`${TILE_FOLDER}`);
+ const ids = Object.keys(canvases);
+ for (let i = 0; i < ids.length; i += 1) {
+ const updater = new CanvasUpdater(ids[i]);
+ CanvasUpdaters[ids[i]] = updater;
+ }
+}
+
diff --git a/src/core/utils.js b/src/core/utils.js
new file mode 100644
index 0000000..a0d9b03
--- /dev/null
+++ b/src/core/utils.js
@@ -0,0 +1,212 @@
+/* @flow */
+
+import type { Cell } from './Cell';
+import type { State } from '../reducers';
+
+import { TILE_SIZE, TILE_ZOOM_LEVEL } from './constants';
+
+/**
+ * http://stackoverflow.com/questions/4467539/javascript-modulo-not-behaving
+ * @param n
+ * @param m
+ * @returns {number} remainder
+ */
+export function mod(n: number, m: number): number {
+ return ((n % m) + m) % m;
+}
+
+export function distMax([x1, y1]: Cell, [x2, y2]: Cell): number {
+ return Math.max(Math.abs(x1 - x2), Math.abs(y1 - y2));
+}
+
+export function clamp(n: number, min: number, max: number): number {
+ return Math.max(min, Math.min(n, max));
+}
+
+export function getChunkOfPixel(pixel: Cell, canvasSize: number = null): Cell {
+ const target = pixel.map(x => Math.floor((x + (canvasSize / 2)) / TILE_SIZE));
+ return target;
+}
+
+export function getTileOfPixel(tileScale: number, pixel: Cell, canvasSize: number = null): Cell {
+ const target = pixel.map(x => Math.floor((x + canvasSize / 2) / TILE_SIZE * tileScale));
+ return target;
+}
+
+export function getMaxTiledZoom(canvasSize: number): number {
+ if (!canvasSize) return 0;
+ return Math.log2(canvasSize / TILE_SIZE) / TILE_ZOOM_LEVEL * 2;
+}
+
+export function getCanvasBoundaries(canvasSize: number): number {
+ const canvasMinXY = -canvasSize / 2;
+ const canvasMaxXY = canvasSize / 2 - 1;
+ return [canvasMinXY, canvasMaxXY];
+}
+
+export function getOffsetOfPixel(x: number, y: number, canvasSize: number = null): number {
+ const modOffset = mod((canvasSize / 2), TILE_SIZE);
+ const cx = mod(x + modOffset, TILE_SIZE);
+ const cy = mod(y + modOffset, TILE_SIZE);
+ return (cy * TILE_SIZE) + cx;
+}
+
+/*
+ * Searches Object for element with ident string and returns its key
+ * Used for getting canvas id from given ident-string (see canvases.json)
+ * @param obj Object
+ * @param ident ident string
+ * @return key
+ */
+export function getIdFromObject(obj: Object, ident: string): number {
+ const ids = Object.keys(obj);
+ for (let i = 0; i < ids.length; i += 1) {
+ const key = ids[i];
+ if (obj[key].ident === ident) {
+ return key;
+ }
+ }
+ return null;
+}
+
+export function getPixelFromChunkOffset(
+ i: number,
+ j: number,
+ offset: number,
+ canvasSize: number,
+): Cell {
+ const cx = mod(offset, TILE_SIZE);
+ const cy = Math.floor(offset / TILE_SIZE);
+ const devOffset = canvasSize / 2 / TILE_SIZE;
+ const x = ((i - devOffset) * TILE_SIZE) + cx;
+ const y = ((j - devOffset) * TILE_SIZE) + cy;
+ return [x, y];
+}
+
+export function getCellInsideChunk(pixel: Cell): Cell {
+ // TODO assert is positive!
+ return pixel.map(x => mod(x, TILE_SIZE));
+}
+
+export function screenToWorld(
+ state: State,
+ $viewport: HTMLCanvasElement,
+ [x, y]: Cell,
+): Cell {
+ const { view, viewscale } = state.canvas;
+ const [viewX, viewY] = view;
+ const { width, height } = $viewport;
+ return [
+ Math.floor(((x - (width / 2)) / viewscale) + viewX),
+ Math.floor(((y - (height / 2)) / viewscale) + viewY),
+ ];
+}
+
+export function worldToScreen(
+ state: State,
+ $viewport: HTMLCanvasElement,
+ [x, y]: Cell,
+): Cell {
+ const { view, viewscale } = state.canvas;
+ const [viewX, viewY] = view;
+ const { width, height } = $viewport;
+ return [
+ ((x - viewX) * viewscale) + (width / 2),
+ ((y - viewY) * viewscale) + (height / 2),
+ ];
+}
+
+/*
+ * Get Color Index of specific pixel
+ * @param state State
+ * @param viewport Viewport HTML canvas
+ * @param coordinates Coords of pixel in World coordinates
+ * @return number of color Index
+ */
+export function getColorIndexOfPixel(
+ state: State,
+ coordinates: Cell,
+): number {
+ const { chunks, canvasSize, canvasMaxTiledZoom } = state.canvas;
+ const [cx, cy] = getChunkOfPixel(coordinates, canvasSize);
+ const key = `${canvasMaxTiledZoom}:${cx}:${cy}`;
+ const chunk = chunks.get(key);
+ if (!chunk) {
+ return 0;
+ }
+ return chunk.getColorIndex(
+ getCellInsideChunk(coordinates),
+ );
+}
+
+export function durationToString(
+ ms: number,
+ smallest: boolean = false,
+): string {
+ const seconds = Math.floor(ms / 1000);
+ let timestring: string;
+ if (seconds < 60 && smallest) {
+ timestring = seconds;
+ } else {
+ timestring = `${Math.floor(seconds / 60)}:${(`0${seconds % 60}`).slice(-2)}`;
+ }
+ return timestring;
+}
+
+const postfix = ['k', 'm', 'M'];
+export function numberToString(num: number): string {
+ if (!num) {
+ return 'N/A';
+ }
+ if (num < 1000) {
+ return num;
+ }
+ let postfixNum = 0;
+ while (postfixNum < postfix.length) {
+ if (num < 10000) {
+ return `${Math.floor(num / 1000)}.${Math.floor((num % 1000) / 10)}${postfix[postfixNum]}`;
+ } else if (num < 100000) {
+ return `${Math.floor(num / 1000)}.${Math.floor((num % 1000) / 100)}${postfix[postfixNum]}`;
+ } else if (num < 1000000) {
+ return Math.floor(num / 1000) + postfix[postfixNum];
+ }
+ postfixNum += 1;
+ num = Math.round(num / 1000);
+ }
+ return '';
+}
+
+export function numberToStringFull(num: number): string {
+ if (num < 0) {
+ return `${num} :-(`;
+ } else if (num < 1000) {
+ return num;
+ } else if (num < 1000000) {
+ return `${Math.floor(num / 1000)}.${(`00${num % 1000}`).slice(-3)}`;
+ }
+
+ return `${Math.floor(num / 1000000)}.${(`00${Math.floor(num / 1000)}`).slice(-3)}.${(`00${num % 1000}`).slice(-3)}`;
+}
+
+export function colorFromText(str: string) {
+ if (!str) return '#000000';
+
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+
+ const c = (hash & 0x00FFFFFF)
+ .toString(16)
+ .toUpperCase();
+
+ return `#${'00000'.substring(0, 6 - c.length)}${c}`;
+}
+
+const linkRegExp = /(#[a-z]*,-?[0-9]*,-?[0-9]*(,-?[0-9]+)?)/gi;
+export function splitCoordsInString(text) {
+ const arr = text
+ .split(linkRegExp)
+ .filter((val, ind) => ((ind % 3) !== 2));
+ return arr;
+}
diff --git a/src/data/countrycode-coords-array.json b/src/data/countrycode-coords-array.json
new file mode 100644
index 0000000..d5845e2
--- /dev/null
+++ b/src/data/countrycode-coords-array.json
@@ -0,0 +1 @@
+{"ad":[273,-8864.04296875],"ae":[9830,-4661.30859375],"af":[11832,-6594.98046875],"ag":[-11251,-3261.77734375],"ai":[-11500,-3498.828125],"al":[3640,-8485.17578125],"am":[8192,-8237.7734375],"an":[-12516,-2327.03125],"ao":[3367,2373.61328125],"ap":[19114,-7049.4140625],"aq":[0,null],"ar":[-11651,6819.609375],"as":[-30948,2728.671875],"at":[2427,-10151.77734375],"au":[24211,5286.54296875],"aw":[-12738,-2374.6484375],"az":[8647,-8360.95703125],"ba":[3276,-9252.2265625],"bb":[-10838,-2504.04296875],"bd":[16384,-4661.30859375],"be":[728,-11158.984375],"bf":[-365,-2471.953125],"bg":[4551,-8992.40234375],"bh":[9202,-5077.44140625],"bi":[5461,659.39453125],"bj":[409,-1799.1015625],"bm":[-11788,-6444.8828125],"bn":[20874,-849.86328125],"bo":[-11833,3251.42578125],"br":[-10013,1893.30078125],"bs":[-13836,-4713.06640625],"bt":[16475,-5394.19921875],"bv":[618,12276.953125],"bw":[4369,4251.38671875],"by":[5097,-11821.484375],"bz":[-16157,-3301.11328125],"ca":[-17295,-14219.94140625],"cc":[17627,2373.61328125],"cd":[4551,0],"cf":[3822,-1322.9296875],"cg":[2730,188.3984375],"ch":[1456,-10059.6484375],"ci":[-911,-1513.3984375],"ck":[-29085,4096.11328125],"cl":[-12926,5930.41015625],"cm":[2184,-1133.49609375],"cn":[19114,-7049.4140625],"co":[-13108,-754.62890625],"cr":[-15292,-1894.3359375],"cu":[-14564,-4150.9765625],"cv":[-4370,-3055.78125],"cx":[19236,1989.5703125],"cy":[6007,-7049.4140625],"cz":[2821,-10840.15625],"de":[1638,-11209.70703125],"dj":[7827,-2182.109375],"dk":[1820,-12795.56640625],"dm":[-11166,-2941.9140625],"do":[-12865,-3648.92578125],"dz":[546,-5500.8203125],"ec":[-14109,376.796875],"ee":[4733,-13848.3203125],"eg":[5461,-5287.578125],"eh":[-2367,-4764.82421875],"er":[7099,-2860.13671875],"es":[-729,-8237.7734375],"et":[6917,-1513.3984375],"eu":[1456,-10059.6484375],"fi":[4733,-15827.5390625],"fj":[31857,3449.140625],"fk":[-10741,11434.3359375],"fm":[28808,-1307.40234375],"fo":[-1275,-14997.34375],"fr":[364,-9785.33203125],"ga":[2139,188.3984375],"gb":[-365,-12138.2421875],"gd":[-11227,-2301.15234375],"ge":[7918,-8736.71875],"gf":[-9649,-754.62890625],"gh":[-365,-1513.3984375],"gi":[-977,-7323.73046875],"gl":[-7282,-19896.73828125],"gm":[-3016,-2562.01171875],"gn":[-1821,-2085.83984375],"gp":[-11211,-3104.43359375],"gq":[1820,-377.83203125],"gr":[4004,-7993.4765625],"gs":[-6736,12298.69140625],"gt":[-16430,-2957.44140625],"gu":[26356,-2562.01171875],"gw":[-2731,-2278.37890625],"gy":[-10741,-944.0625],"hk":[20783,-4303.14453125],"hm":[13201,11851.50390625],"hn":[-15747,-2860.13671875],"hr":[2821,-9561.73828125],"ht":[-13184,-3648.92578125],"hu":[3640,-10059.6484375],"id":[21845,943.02734375],"ie":[-1457,-11821.484375],"il":[6326,-6260.625],"in":[14017,-3848.7109375],"io":[13016,1132.4609375],"iq":[8009,-6594.98046875],"ir":[9648,-6371.38671875],"is":[-3277,-16265.41015625],"it":[2336,-8949.9609375],"jm":[-14109,-3498.828125],"jo":[6553,-6149.86328125],"jp":[25122,-7280.25390625],"ke":[6917,-189.43359375],"kg":[13653,-8485.17578125],"kh":[19114,-2471.953125],"ki":[31493,-267.0703125],"km":[8055,2309.43359375],"kn":[-11424,-3317.67578125],"kp":[23119,-8237.7734375],"kr":[23210,-7515.234375],"kw":[8675,-5787.55859375],"ky":[-14655,-3748.30078125],"kz":[12379,-10338.10546875],"la":[19114,-3450.17578125],"lb":[6523,-6782.34375],"lc":[-11129,-2642.75390625],"li":[1735,-10105.1953125],"lk":[14745,-1322.9296875],"lr":[-1730,-1227.6953125],"ls":[5188,5821.71875],"lt":[4369,-12795.56640625],"lu":[1122,-10840.15625],"lv":[4551,-13137.16796875],"ly":[3094,-4868.33984375],"ma":[-911,-6371.38671875],"mc":[1347,-9182.87109375],"md":[5279,-10059.6484375],"me":[3458,-8736.71875],"mg":[8556,3847.67578125],"mh":[30583,-1703.8671875],"mk":[4004,-8695.3125],"ml":[-729,-3252.4609375],"mm":[17840,-4252.421875],"mn":[19114,-9785.33203125],"mo":[20671,-4285.546875],"mp":[26532,-2899.47265625],"mq":[-11105,-2794.921875],"mr":[-2185,-3848.7109375],"ms":[-11324,-3202.7734375],"mt":[2654,-7241.953125],"mu":[10476,3904.609375],"mv":[13289,-612.8125],"mw":[6189,2567.1875],"mx":[-18569,-4456.34765625],"my":[20480,-472.03125],"mz":[6371,3497.79296875],"na":[3094,4251.38671875],"nc":[30128,4149.94140625],"ne":[1456,-3055.78125],"nf":[30574,5721.30859375],"ng":[1456,-1894.3359375],"ni":[-15474,-2471.953125],"nl":[1046,-11666.2109375],"no":[1820,-14997.34375],"np":[15291,-5500.8203125],"nr":[30386,100.41015625],"nu":[-30924,3654.1015625],"nz":[31675,8484.140625],"om":[10376,-4049.53125],"pa":[-14564,-1703.8671875],"pe":[-13836,1893.30078125],"pf":[-25487,2859.1015625],"pg":[26760,1132.4609375],"ph":[22209,-2471.953125],"pk":[12743,-5931.4453125],"pl":[3640,-11511.97265625],"pm":[-10256,-10013.06640625],"pr":[-12106,-3498.828125],"ps":[6417,-6371.38671875],"pt":[-1457,-8115.625],"pw":[24484,-1418.1640625],"py":[-10559,4455.3125],"qa":[9329,-4972.890625],"re":[10121,4069.19921875],"ro":[4551,-9785.33203125],"rs":[3822,-9252.2265625],"ru":[18204,-14219.94140625],"rw":[5461,376.796875],"sa":[8192,-4868.33984375],"sb":[28945,1512.36328125],"sc":[10133,864.35546875],"sd":[5461,-2860.13671875],"se":[2730,-14997.34375],"sg":[18896,-257.75390625],"sh":[-1038,3041.2890625],"si":[2730,-9785.33203125],"sj":[3640,-24324.1015625],"sk":[3549,-10527.5390625],"sl":[-2094,-1608.6328125],"sm":[2260,-9191.15234375],"sn":[-2549,-2665.52734375],"so":[8920,-1894.3359375],"sr":[-10195,-754.62890625],"st":[1274,-189.43359375],"sv":[-16187,-2633.4375],"sy":[6917,-7049.4140625],"sz":[5734,5180.95703125],"tc":[-13032,-4201.69921875],"td":[3458,-2860.13671875],"tf":[12196,8991.3671875],"tg":[212,-1513.3984375],"th":[18204,-2860.13671875],"tj":[12925,-7993.4765625],"tk":[-31312,1702.83203125],"tm":[10922,-8237.7734375],"tn":[1638,-6820.64453125],"to":[-31858,3847.67578125],"tr":[6371,-7993.4765625],"tt":[-11105,-2085.83984375],"tv":[32403,1512.36328125],"tw":[22027,-4558.828125],"tz":[6371,1132.4609375],"ua":[5825,-10622.7734375],"ug":[5825,-189.43359375],"um":[30328,-3704.82421875],"us":[-17659,-7752.28515625],"uy":[-10195,6593.9453125],"uz":[11650,-8485.17578125],"va":[2266,-8711.875],"vc":[-11142,-2519.5703125],"ve":[-12015,-1513.3984375],"vg":[-11742,-3548.515625],"vi":[-11803,-3515.390625],"vn":[19296,-3055.78125],"vu":[30401,3054.74609375],"wf":[-32077,2528.88671875],"ws":[-31373,2583.75],"ye":[8738,-2860.13671875],"yt":[8222,2438.828125],"za":[4369,5714.0625],"zm":[5461,2859.1015625],"zw":[5461,3847.67578125]}
\ No newline at end of file
diff --git a/src/data/models/Blacklist.js b/src/data/models/Blacklist.js
new file mode 100644
index 0000000..5f38bb6
--- /dev/null
+++ b/src/data/models/Blacklist.js
@@ -0,0 +1,38 @@
+/**
+ *
+ * https://github.com/sequelize/sequelize/issues/1485#issuecomment-243822779
+ *
+ * @flow
+ */
+
+import DataType from 'sequelize';
+import nodeIp from 'ip';
+
+import Model from '../sequelize';
+
+
+const Blacklist = Model.define('Blacklist', {
+
+ numIp: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: false,
+ primaryKey: true,
+ },
+
+}, {
+
+ getterMethods: {
+ ip(): string {
+ return nodeIp.fromLong(this.numIp);
+ },
+ },
+
+ setterMethods: {
+ ip(value: string): number {
+ this.setDataValue('numIp', nodeIp.toLong(value));
+ },
+ },
+
+});
+
+export default Blacklist;
diff --git a/src/data/models/RedisCanvas.js b/src/data/models/RedisCanvas.js
new file mode 100644
index 0000000..1e22191
--- /dev/null
+++ b/src/data/models/RedisCanvas.js
@@ -0,0 +1,95 @@
+/* @flow */
+
+import { getChunkOfPixel, getOffsetOfPixel } from '../../core/utils';
+import { registerChunkChange } from '../../core/tileserver';
+import { TILE_SIZE } from '../../core/constants';
+import canvases from '../../canvases.json';
+import logger from '../../core/logger';
+
+import redis from '../redis';
+
+
+const UINT_SIZE = 'u8';
+
+const EMPTY_CACA = new Uint8Array(TILE_SIZE * TILE_SIZE);
+const EMPTY_CHUNK_BUFFER = Buffer.from(EMPTY_CACA.buffer);
+
+// cache existence of chunks
+const chunks: Set = new Set();
+
+
+class RedisCanvas {
+ static getChunk(i: number, j: number, canvasId: number): Promise {
+ return redis.getAsync(`ch:${canvasId}:${i}:${j}`);
+ }
+
+ static async setChunk(i: number, j: number, chunk: Uint8Array,
+ canvasId: number,
+ ) {
+ if (chunk.length !== TILE_SIZE * TILE_SIZE) {
+ logger.error(`Tried to set chunk with invalid length ${chunk.length}!`);
+ return false;
+ }
+ const key = `ch:${canvasId}:${i}:${j}`;
+ await redis.setAsync(key, Buffer.from(chunk.buffer));
+ registerChunkChange(canvasId, [i, j]);
+ return true;
+ }
+
+ static async setPixel(
+ x: number,
+ y: number,
+ color: number,
+ canvasId: number,
+ ) {
+ const canvasSize = canvases[canvasId].size;
+ const [i, j] = getChunkOfPixel([x, y], canvasSize);
+ const offset = getOffsetOfPixel(x, y, canvasSize);
+ RedisCanvas.setPixelInChunk(i, j, offset, color, canvasId);
+ }
+
+ static async setPixelInChunk(
+ i: number,
+ j: number,
+ offset: number,
+ color: number,
+ canvasId: number,
+ ) {
+ const key = `ch:${canvasId}:${i}:${j}`;
+
+ if (!chunks.has(key)) {
+ await redis.setAsync(key, EMPTY_CHUNK_BUFFER, 'NX');
+ chunks.add(key);
+ }
+
+ const args = [key, 'SET', UINT_SIZE, `#${offset}`, color];
+ await redis.sendCommandAsync('bitfield', args);
+ registerChunkChange(canvasId, [i, j]);
+ }
+
+ static async getPixel(
+ x: number,
+ y: number,
+ canvasId: number,
+ ): Promise {
+ // 1st and 2nd bit -> not used yet
+ // 3rd bit -> protected or not
+ // rest (5 bits) -> index of color
+ const canvasSize = canvases[canvasId].size;
+ const canvasAlpha = canvases[canvasId].alpha;
+ const [i, j] = getChunkOfPixel([x, y], canvasSize);
+ const offset = getOffsetOfPixel(x, y, canvasSize);
+ const args = [
+ `ch:${canvasId}:${i}:${j}`,
+ 'GET',
+ UINT_SIZE,
+ `#${offset}`,
+ ];
+ const result: ?number = await redis.sendCommandAsync('bitfield', args);
+ if (!result) return canvasAlpha;
+ const color = result[0];
+ return color || canvasAlpha;
+ }
+}
+
+export default RedisCanvas;
diff --git a/src/data/models/RegUser.js b/src/data/models/RegUser.js
new file mode 100644
index 0000000..7ba87da
--- /dev/null
+++ b/src/data/models/RegUser.js
@@ -0,0 +1,131 @@
+/**
+ * Created by HF
+ *
+ * This is the database of the data for registered Users
+ *
+ * @flow
+ */
+
+import DataType from 'sequelize';
+import Model from '../sequelize';
+import bcrypt from 'bcrypt';
+
+import { generateHash } from '../../utils/hash';
+
+
+const RegUser = Model.define('User', {
+ id: {
+ type: DataType.INTEGER.UNSIGNED,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+
+ email: {
+ type: DataType.CHAR(40),
+ allowNull: true,
+ },
+
+ name: {
+ type: DataType.CHAR(32),
+ allowNull: false,
+ },
+
+ // null if external oauth authentification
+ password: {
+ type: DataType.CHAR(60),
+ allowNull: true,
+ },
+
+ totalPixels: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: false,
+ defaultValue: 0,
+ },
+
+ dailyTotalPixels: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: false,
+ defaultValue: 0,
+ },
+
+ ranking: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: true,
+ },
+
+ dailyRanking: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: true,
+ },
+
+ // mail and Minecraft verified
+ verified: {
+ type: DataType.TINYINT,
+ allowNull: false,
+ defaultValue: false,
+ },
+
+ discordid: {
+ type: DataType.CHAR(18),
+ allowNull: true,
+ },
+
+ redditid: {
+ type: DataType.CHAR(10),
+ allowNull: true,
+ },
+
+ minecraftid: {
+ type: DataType.CHAR(36),
+ allowNull: true,
+ },
+
+ minecraftname: {
+ type: DataType.CHAR(16),
+ allowNull: true,
+ },
+
+ // when mail verification got requested,
+ // used for purging unverified accounts
+ verificationReqAt: {
+ type: DataType.DATE,
+ allowNull: true,
+ },
+
+ lastLogIn: {
+ type: DataType.DATE,
+ allowNull: true,
+ },
+}, {
+ timestamps: true,
+ updatedAt: false,
+
+ getterMethods: {
+ mailVerified(): boolean {
+ return this.verified & 0x01;
+ },
+
+ mcVerified(): boolean {
+ return this.verified & 0x02;
+ },
+ },
+
+ setterMethods: {
+ mailVerified(num: boolean) {
+ const val = (num) ? (this.verified | 0x01) : (this.verified & ~0x01);
+ this.setDataValue('verified', val);
+ },
+
+ mcVerified(num: boolean) {
+ const val = (num) ? (this.verified | 0x02) : (this.verified & ~0x02);
+ this.setDataValue('verified', val);
+ },
+
+ password(value: string) {
+ if (value) this.setDataValue('password', generateHash(value));
+ },
+ },
+
+});
+
+export default RegUser;
diff --git a/src/data/models/User.js b/src/data/models/User.js
new file mode 100644
index 0000000..ee24b45
--- /dev/null
+++ b/src/data/models/User.js
@@ -0,0 +1,129 @@
+/**
+ *
+ * user class which will be set for every single playing user,
+ * loged in or not.
+ * If user is not logged in, id = null
+ *
+ * @flow
+ * */
+
+import redis from '../redis';
+import { randomDice } from '../../utils/random';
+import { verifyCaptcha } from '../../utils/recaptcha';
+import logger from '../../core/logger';
+import Sequelize from 'sequelize';
+
+import Model from '../sequelize';
+import RegUser from './RegUser';
+
+import { ADMIN_IDS } from '../../core/config';
+
+
+class User {
+ id: string;
+ ip: string;
+ wait: ?number;
+ regUser: Object;
+
+ constructor(id: string = null, ip: string = '127.0.0.1') {
+ // id should stay null if unregistered, and user email if registered
+ this.id = id;
+ this.ip = ip;
+ this.wait = null;
+ this.regUser = null;
+ }
+
+ async setWait(coolDown: number, canvasId: number): Promise {
+ if (coolDown == 0) return false;
+ this.wait = Date.now() + coolDown;
+ // PX is milliseconds expire
+ await redis.setAsync(`cd:${canvasId}:ip:${this.ip}`, '', 'PX', coolDown);
+ if (this.id != null) {
+ await redis.setAsync(`cd:${canvasId}:id:${this.id}`, '', 'PX', coolDown);
+ }
+ return true;
+ }
+
+ async getWait(canvasId: number): Promise {
+ let ttl: number = await redis.pttlAsync(`cd:${canvasId}:ip:${this.ip}`);
+ if (this.id != null && ttl < 0) {
+ const ttlid: number = await redis.pttlAsync(`cd:${canvasId}:id:${this.id}`);
+ ttl = Math.max(ttl, ttlid);
+ }
+ logger.debug('ererer', ttl, typeof ttl);
+
+
+ const wait = ttl < 0 ? null : Date.now() + ttl;
+ this.wait = wait;
+ return wait;
+ }
+
+ async incrementPixelcount(): Promise {
+ const id = this.id;
+ if (!id) return false;
+ if (this.isAdmin()) return false;
+ try {
+ await RegUser.update({
+ totalPixels: Sequelize.literal('totalPixels + 1'),
+ dailyTotalPixels: Sequelize.literal('dailyTotalPixels + 1'),
+ }, {
+ where: { id },
+ });
+ } catch (err) {
+ return false;
+ }
+ return true;
+ }
+
+ async getTotalPixels(): Promise {
+ const id = this.id;
+ if (!id) return 0;
+ if (this.isAdmin()) return 100000;
+ if (this.regUser) {
+ return this.regUser.totalPixels;
+ }
+ try {
+ const userq = await Model.query('SELECT totalPixels FROM Users WHERE id = $1',
+ { bind: [id], type: Sequelize.QueryTypes.SELECT, raw: true, plain: true });
+ return userq.totalPixels;
+ } catch (err) {
+ return 0;
+ }
+ }
+
+ async updateLogInTimestamp(): Promise {
+ if (!this.regUser) return false;
+ try {
+ await this.regUser.update({ lastLogIn: Sequelize.literal('CURRENT_TIMESTAMP') });
+ } catch (err) {
+ return false;
+ }
+ return true;
+ }
+
+ isAdmin(): boolean {
+ return ADMIN_IDS.includes(this.id);
+ }
+
+ getUserData(): Object {
+ if (this.regUser == null) {
+ return {
+ name: null,
+ };
+ }
+ const { regUser } = this;
+ return {
+ name: regUser.name,
+ mailVerified: regUser.mailVerified,
+ mcVerified: regUser.mcVerified,
+ minecraftname: regUser.minecraftname,
+ totalPixels: regUser.totalPixels,
+ dailyTotalPixels: regUser.dailyTotalPixels,
+ ranking: regUser.ranking,
+ dailyRanking: regUser.dailyRanking,
+ mailreg: !!(regUser.password),
+ };
+ }
+}
+
+export default User;
diff --git a/src/data/models/Whitelist.js b/src/data/models/Whitelist.js
new file mode 100644
index 0000000..3ee62c3
--- /dev/null
+++ b/src/data/models/Whitelist.js
@@ -0,0 +1,39 @@
+/**
+ * Created by HF
+ *
+ * https://github.com/sequelize/sequelize/issues/1485#issuecomment-243822779
+ *
+ * @flow
+ */
+
+import DataType from 'sequelize';
+import nodeIp from 'ip';
+
+import Model from '../sequelize';
+
+
+const Whitelist = Model.define('Whitelist', {
+
+ numIp: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: false,
+ primaryKey: true,
+ },
+
+}, {
+
+ getterMethods: {
+ ip(): string {
+ return nodeIp.fromLong(this.numIp);
+ },
+ },
+
+ setterMethods: {
+ ip(value: string): number {
+ this.setDataValue('numIp', nodeIp.toLong(value));
+ },
+ },
+
+});
+
+export default Whitelist;
diff --git a/src/data/models/index.js b/src/data/models/index.js
new file mode 100644
index 0000000..5772806
--- /dev/null
+++ b/src/data/models/index.js
@@ -0,0 +1,15 @@
+/* @flow */
+
+import sequelize from '../sequelize';
+import Blacklist from './Blacklist';
+import Whitelist from './Whitelist';
+import User from './User';
+import RegUser from './RegUser';
+
+
+function sync(...args) {
+ return sequelize.sync(...args);
+}
+
+export default { sync };
+export { Whitelist, Blacklist, User, RegUser };
diff --git a/src/data/redis.js b/src/data/redis.js
new file mode 100644
index 0000000..fc44226
--- /dev/null
+++ b/src/data/redis.js
@@ -0,0 +1,36 @@
+/* @flow */
+
+import bluebird from 'bluebird';
+import redis from 'redis';
+import Redlock from 'redlock';
+
+import { REDIS_URL } from '../core/config';
+
+bluebird.promisifyAll(redis.RedisClient.prototype);
+bluebird.promisifyAll(redis.Multi.prototype);
+const client = redis.createClient(REDIS_URL, { return_buffers: true });
+
+export const redlock = new Redlock(
+ // you should have one redis for each redis node
+ // in your cluster
+ [client],
+ {
+ // the expected clock drift; for more details
+ // see http://redis.io/topics/distlock
+ driftFactor: 0.01, // time in ms
+
+ // the max number of times Redlock will attempt
+ // to lock a resource before erroring
+ retryCount: 5,
+
+ // the time in ms between attempts
+ retryDelay: 200, // time in ms
+
+ // the max time in ms randomly added to retries
+ // to improve performance under high contention
+ // see https://www.awsarchitectureblog.com/2015/03/backoff.html
+ retryJitter: 200, // time in ms
+ },
+);
+
+export default client;
diff --git a/src/data/sequelize.js b/src/data/sequelize.js
new file mode 100644
index 0000000..df9b088
--- /dev/null
+++ b/src/data/sequelize.js
@@ -0,0 +1,27 @@
+/**
+ *
+ * @flow
+ */
+
+import Sequelize from 'sequelize';
+
+import logging from '../core/logger';
+import { MYSQL_HOST, MYSQL_DATABASE, MYSQL_USER, MYSQL_PW } from '../core/config';
+
+const sequelize = new Sequelize(MYSQL_DATABASE, MYSQL_USER, MYSQL_PW, {
+ host: MYSQL_HOST,
+ dialect: 'mysql',
+ pool: {
+ min: 5,
+ max: 25,
+ idle: 10000,
+ acquire: 10000,
+ logging,
+ },
+ dialectOptions: {
+ connectTimeout: 10000,
+ multipleStatements: true,
+ },
+});
+
+export default sequelize;
diff --git a/src/proxies.json b/src/proxies.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/src/proxies.json
@@ -0,0 +1 @@
+[]
diff --git a/src/reducers/audio.js b/src/reducers/audio.js
new file mode 100644
index 0000000..e0f2254
--- /dev/null
+++ b/src/reducers/audio.js
@@ -0,0 +1,38 @@
+/* @flow */
+
+import type { Action } from '../actions/types';
+
+
+export type AudioState = {
+ mute: boolean,
+ chatNotify: boolean,
+};
+
+const initialState: AudioState = {
+ mute: false,
+ chatNotify: true,
+};
+
+
+export default function audio(
+ state: AudioState = initialState,
+ action: Action,
+): AudioState {
+ switch (action.type) {
+ case 'TOGGLE_MUTE':
+ return {
+ ...state,
+ // TODO error prone
+ mute: !state.mute,
+ };
+
+ case 'TOGGLE_CHAT_NOTIFY':
+ return {
+ ...state,
+ chatNotify: !state.chatNotify,
+ };
+
+ default:
+ return state;
+ }
+}
diff --git a/src/reducers/canvas.js b/src/reducers/canvas.js
new file mode 100644
index 0000000..ae26502
--- /dev/null
+++ b/src/reducers/canvas.js
@@ -0,0 +1,354 @@
+/* @flow */
+
+import type { Action } from '../actions/types';
+import type { Cell } from '../core/Cell';
+import Palette from '../core/Palette';
+import { getMaxTiledZoom,
+ getChunkOfPixel,
+ getCellInsideChunk,
+ clamp,
+ getIdFromObject,
+} from '../core/utils';
+
+
+import {
+ MAX_SCALE,
+ DEFAULT_SCALE,
+ DEFAULT_CANVAS_ID,
+ DEFAULT_CANVASES,
+ TILE_SIZE,
+} from '../core/constants';
+import ChunkRGB from '../ui/ChunkRGB';
+
+export type CanvasState = {
+ canvasId: number,
+ canvasIdent: string,
+ canvasSize: number,
+ canvasMaxTiledZoom: number,
+ minScale: number,
+ palette: Palette,
+ chunks: Map,
+ view: Cell,
+ scale: number,
+ viewscale: number,
+ requested: Set,
+ fetchs: number,
+ // object with all canvas informations from all canvases like colors and size
+ canvases: Object,
+};
+
+/*
+ * check if we got coords from index.html
+ */
+function getGivenCoords() {
+ if (window.coordx && window.coordy) return [window.coordx, window.coordy];
+ return [1749, -8283];
+}
+
+/*
+ * parse url hash and sets view to coordinates
+ * @param canvases Object with all canvas informations
+ * @return view, viewscale and scale for state
+ */
+function getViewFromURL(canvases: Object) {
+ const hash: string = window.location.hash;
+ try {
+ const almost = hash.substring(1)
+ .split(',');
+
+ const canvasIdent = almost[0];
+ // will be null if not in DEFAULT_CANVASES
+ const canvasId = getIdFromObject(canvases, almost[0]);
+ const colors = (canvasId !== null) ?
+ canvases[canvasId].colors : canvases[DEFAULT_CANVAS_ID].colors;
+ const canvasSize = (canvasId !== null) ? canvases[canvasId].size : 1024;
+
+ const x = parseInt(almost[1], 10);
+ const y = parseInt(almost[2], 10);
+ let urlscale = parseInt(almost[3], 10);
+ if (isNaN(x) || isNaN(y)) {
+ const thrown = 'NaN';
+ throw thrown;
+ }
+ if (!urlscale || isNaN(urlscale)) {
+ urlscale = DEFAULT_SCALE;
+ } else {
+ urlscale = 2 ** (urlscale / 10);
+ }
+ urlscale = clamp(urlscale, TILE_SIZE / canvasSize, MAX_SCALE);
+ return {
+ canvasId,
+ canvasIdent,
+ canvasSize,
+ canvasMaxTiledZoom: getMaxTiledZoom(canvasSize),
+ palette: new Palette(colors, 0),
+ view: [x, y],
+ viewscale: urlscale,
+ scale: urlscale,
+ canvases,
+ };
+ } catch (error) {
+ return {
+ canvasId: DEFAULT_CANVAS_ID,
+ canvasIdent: canvases[DEFAULT_CANVAS_ID].ident,
+ canvasSize: canvases[DEFAULT_CANVAS_ID].size,
+ canvasMaxTiledZoom: getMaxTiledZoom(canvases[DEFAULT_CANVAS_ID].size),
+ palette: new Palette(canvases[DEFAULT_CANVAS_ID].colors, 0),
+ view: getGivenCoords(),
+ viewscale: DEFAULT_SCALE,
+ scale: DEFAULT_SCALE,
+ };
+ }
+}
+
+const initialState: CanvasState = {
+ chunks: new Map(),
+ ...getViewFromURL(DEFAULT_CANVASES),
+ requested: new Set(),
+ fetchs: 0,
+};
+
+
+export default function gui(
+ state: CanvasState = initialState,
+ action: Action,
+): CanvasState {
+ switch (action.type) {
+ case 'PLACE_PIXEL': {
+ const { chunks, canvasMaxTiledZoom, palette, canvasSize } = state;
+ const { coordinates, color } = action;
+
+ const [cx, cy] = getChunkOfPixel(coordinates, canvasSize);
+ const key = ChunkRGB.getKey(canvasMaxTiledZoom, cx, cy);
+ let chunk = chunks.get(key);
+ if (!chunk) {
+ chunk = new ChunkRGB(palette, [canvasMaxTiledZoom, cx, cy]);
+ chunks.set(chunk.key, chunk);
+ }
+
+ // redis prediction
+ chunk.setColor(
+ getCellInsideChunk(coordinates),
+ color,
+ );
+ return {
+ ...state,
+ chunks,
+ };
+ }
+
+ case 'SET_SCALE': {
+ let { view, viewscale } = state;
+ const { canvasSize } = state;
+ let [hx, hy] = view;
+ let { scale } = action;
+ const { zoompoint } = action;
+ scale = clamp(scale, TILE_SIZE / canvasSize, MAX_SCALE);
+ if (zoompoint) {
+ let scalediff = viewscale;
+ // clamp to 1.0 (just do this when zoompoint is given, or it would mess with phones)
+ viewscale = (scale > 0.85 && scale < 1.20) ? 1.0 : scale;
+ // make sure that zoompoint is on the same space
+ // after zooming
+ scalediff /= viewscale;
+ const [px, py] = zoompoint;
+ hx = px + (hx - px) * scalediff;
+ hy = py + (hy - py) * scalediff;
+ } else {
+ viewscale = scale;
+ }
+ const canvasMinXY = -canvasSize / 2;
+ const canvasMaxXY = canvasSize / 2 - 1;
+ view = [hx, hy].map(z => clamp(z, canvasMinXY, canvasMaxXY));
+ return {
+ ...state,
+ view,
+ scale,
+ viewscale,
+ };
+ }
+
+ case 'SET_VIEW_COORDINATES': {
+ const { view } = action;
+ const { canvasSize } = state;
+ const canvasMinXY = -canvasSize / 2;
+ const canvasMaxXY = canvasSize / 2 - 1;
+ const newview = view.map(z => clamp(z, canvasMinXY, canvasMaxXY));
+ return {
+ ...state,
+ view: newview,
+ };
+ }
+
+ case 'RELOAD_URL': {
+ const { canvasId, chunks, canvases } = state;
+ const nextstate = getViewFromURL(canvases);
+ if (nextstate.canvasId != canvasId) {
+ chunks.clear();
+ }
+ return {
+ ...state,
+ ...nextstate,
+ };
+ }
+
+ /*
+ * set url coordinates
+ */
+ case 'ON_VIEW_FINISH_CHANGE': {
+ const { view, viewscale, canvasIdent } = state;
+ let [x, y] = view;
+ x = Math.round(x);
+ y = Math.round(y);
+ const scale = Math.round(Math.log2(viewscale) * 10);
+ const newhash = `#${canvasIdent},${x},${y},${scale}`;
+ history.replaceState(undefined, undefined, newhash);
+ return {
+ ...state,
+ };
+ }
+
+ case 'REQUEST_BIG_CHUNK': {
+ const { palette, chunks, fetchs, requested } = state;
+ const { center } = action;
+
+ const chunkRGB = new ChunkRGB(palette, center);
+ // chunkRGB.preLoad(chunks);
+ const key = chunkRGB.key;
+ chunks.set(key, chunkRGB);
+
+ requested.add(key);
+ return {
+ ...state,
+ chunks,
+ fetchs: fetchs + 1,
+ requested,
+ };
+ }
+
+ case 'RECEIVE_BIG_CHUNK': {
+ const { chunks, fetchs } = state;
+ const { center, arrayBuffer } = action;
+
+ const key = ChunkRGB.getKey(...center);
+ const chunk = chunks.get(key);
+ chunk.isBasechunk = true;
+ if (arrayBuffer.byteLength) {
+ const chunkArray = new Uint8Array(arrayBuffer);
+ chunk.fromBuffer(chunkArray);
+ } else {
+ chunk.empty();
+ }
+
+ return {
+ ...state,
+ chunks,
+ fetchs: fetchs + 1,
+ };
+ }
+
+ case 'RECEIVE_BIG_CHUNK_FAILURE': {
+ const { chunks, fetchs } = state;
+ const { center } = action;
+
+ const key = ChunkRGB.getKey(...center);
+ const chunk = chunks.get(key);
+ chunk.empty();
+
+ return {
+ ...state,
+ chunks,
+ fetchs: fetchs + 1,
+ };
+ }
+
+ case 'RECEIVE_IMAGE_TILE': {
+ const { chunks, fetchs } = state;
+ const { center, tile } = action;
+
+ const key = ChunkRGB.getKey(...center);
+ const chunk = chunks.get(key);
+ chunk.fromImage(tile);
+
+ return {
+ ...state,
+ chunks,
+ fetchs: fetchs + 1,
+ };
+ }
+
+ case 'RECEIVE_PIXEL_UPDATE': {
+ const { chunks, canvasMaxTiledZoom } = state;
+ // i, j: Coordinates of chunk
+ // offset: Offset of pixel within said chunk
+ const { i, j, offset, color } = action;
+
+ const key = ChunkRGB.getKey(canvasMaxTiledZoom, i, j);
+ const chunk = chunks.get(key);
+
+ // ignore because is not seen
+ if (!chunk) return state;
+
+ const ix = offset % TILE_SIZE;
+ const iy = Math.floor(offset / TILE_SIZE);
+ chunk.setColor([ix, iy], color);
+
+ return {
+ ...state,
+ chunks,
+ };
+ }
+
+ case 'SELECT_CANVAS': {
+ const { canvasId } = action;
+ const { canvases, chunks } = state;
+
+ chunks.clear();
+ const canvas = canvases[canvasId];
+ const canvasIdent = canvas.ident;
+ const canvasSize = canvases[canvasId].size;
+ const canvasMaxTiledZoom = getMaxTiledZoom(canvasSize);
+ const palette = new Palette(canvas.colors, 0);
+ const view = (canvasId == 0) ? getGivenCoords() : [0, 0];
+ chunks.clear();
+ return {
+ ...state,
+ canvasId,
+ canvasIdent,
+ canvasSize,
+ canvasMaxTiledZoom,
+ palette,
+ view,
+ viewscale: DEFAULT_SCALE,
+ scale: DEFAULT_SCALE,
+ };
+ }
+
+ case 'RECEIVE_ME': {
+ const { canvases } = action;
+ let { canvasIdent } = state;
+
+ let canvasId = getIdFromObject(canvases, canvasIdent);
+ if (canvasId === null) {
+ canvasId = DEFAULT_CANVAS_ID;
+ canvasIdent = canvases[DEFAULT_CANVAS_ID].ident;
+ }
+ const canvasSize = canvases[canvasId].size;
+ const canvasMaxTiledZoom = getMaxTiledZoom(canvasSize);
+ const palette = new Palette(canvases[canvasId].colors, 0);
+
+ return {
+ ...state,
+ canvasId,
+ canvasIdent,
+ canvasSize,
+ canvasMaxTiledZoom,
+ palette,
+ canvases,
+ };
+ }
+
+ default:
+ return state;
+ }
+}
diff --git a/src/reducers/gui.js b/src/reducers/gui.js
new file mode 100644
index 0000000..7bb2581
--- /dev/null
+++ b/src/reducers/gui.js
@@ -0,0 +1,141 @@
+/* @flow */
+
+import type { Action } from '../actions/types';
+import type { ColorIndex } from '../core/Palette';
+import type { Cell } from '../core/Cell';
+
+
+export type GUIState = {
+ showGrid: boolean,
+ showPixelNotify: boolean,
+ selectedColor: ColorIndex,
+ hover: ?Cell,
+ pixelsPlaced: number,
+ autoZoomIn: boolean,
+ notification: string,
+ isPotato: boolean,
+ compactPalette: boolean,
+ paletteOpen: boolean,
+ menuOpen: boolean,
+};
+
+const initialState: GUIState = {
+ showGrid: false,
+ showPixelNotify: false,
+ selectedColor: 3,
+ hover: null,
+ pixelsPlaced: 0,
+ autoZoomIn: false,
+ notification: null,
+ isPotato: false,
+ compactPalette: false,
+ paletteOpen: true,
+ menuOpen: false,
+};
+
+
+export default function gui(
+ state: GUIState = initialState,
+ action: Action,
+): GUIState {
+ switch (action.type) {
+ case 'TOGGLE_GRID': {
+ return {
+ ...state,
+ showGrid: !state.showGrid,
+ };
+ }
+
+ case 'TOGGLE_PIXEL_NOTIFY': {
+ return {
+ ...state,
+ showPixelNotify: !state.showPixelNotify,
+ };
+ }
+
+ case 'TOGGLE_AUTO_ZOOM_IN': {
+ return {
+ ...state,
+ autoZoomIn: !state.autoZoomIn,
+ };
+ }
+
+ case 'TOGGLE_POTATO_MODE': {
+ return {
+ ...state,
+ isPotato: !state.isPotato,
+ };
+ }
+
+ case 'TOGGLE_COMPACT_PALETTE': {
+ return {
+ ...state,
+ compactPalette: !state.compactPalette,
+ };
+ }
+
+ case 'TOGGLE_OPEN_PALETTE': {
+ return {
+ ...state,
+ paletteOpen: !state.paletteOpen,
+ };
+ }
+
+ case 'TOGGLE_OPEN_MENU': {
+ return {
+ ...state,
+ menuOpen: !state.menuOpen,
+ };
+ }
+
+ case 'SELECT_COLOR': {
+ const paletteOpen = (!state.compactPalette && window.innerWidth > 300);
+ return {
+ ...state,
+ paletteOpen,
+ selectedColor: action.color,
+ };
+ }
+
+ case 'SET_NOTIFICATION': {
+ return {
+ ...state,
+ notification: action.notification,
+ };
+ }
+
+ case 'UNSET_NOTIFICATION': {
+ return {
+ ...state,
+ notification: null,
+ };
+ }
+
+ case 'SET_HOVER': {
+ const { hover } = action;
+ return {
+ ...state,
+ hover,
+ };
+ }
+
+ case 'PLACE_PIXEL': {
+ let { pixelsPlaced } = state;
+ pixelsPlaced += 1;
+ return {
+ ...state,
+ pixelsPlaced,
+ };
+ }
+
+ case 'UNSET_HOVER': {
+ return {
+ ...state,
+ hover: null,
+ };
+ }
+
+ default:
+ return state;
+ }
+}
diff --git a/src/reducers/index.js b/src/reducers/index.js
new file mode 100644
index 0000000..bdda497
--- /dev/null
+++ b/src/reducers/index.js
@@ -0,0 +1,37 @@
+/* @flow */
+
+import { persistCombineReducers } from 'redux-persist';
+import localForage from 'localforage';
+import audio from './audio';
+import canvas from './canvas';
+import gui from './gui';
+import modal from './modal';
+import user from './user';
+
+import type { AudioState } from './audio';
+import type { CanvasState } from './canvas';
+import type { GUIState } from './gui';
+import type { ModalState } from './modal';
+import type { UserState } from './user';
+
+export type State = {
+ audio: AudioState,
+ canvas: CanvasState,
+ gui: GUIState,
+ modal: ModalState,
+ user: UserState,
+};
+
+const config = {
+ key: 'primary',
+ storage: localForage,
+ blacklist: ['user', 'canvas', 'modal'],
+};
+
+export default persistCombineReducers(config, {
+ audio,
+ canvas,
+ gui,
+ modal,
+ user,
+});
diff --git a/src/reducers/modal.js b/src/reducers/modal.js
new file mode 100644
index 0000000..33670f3
--- /dev/null
+++ b/src/reducers/modal.js
@@ -0,0 +1,66 @@
+/**
+ * https://stackoverflow.com/questions/35623656/how-can-i-display-a-modal-dialog-in-redux-that-performs-asynchronous-actions/35641680#35641680
+ *
+ * @flow
+ */
+
+import type { Action } from '../actions/types';
+
+export type ModalState = {
+ modalType: ?string,
+ modalProps: object,
+ chatOpen: boolean,
+};
+
+const initialState: ModalState = {
+ modalType: null,
+ modalProps: {},
+ chatOpen: false,
+};
+
+
+export default function modal(
+ state: ModalState = initialState,
+ action: Action,
+): ModalState {
+ switch (action.type) {
+ // clear hover when placing a pixel
+ // fixes a bug with iPad
+ case 'SHOW_MODAL': {
+ const { modalType, modalProps } = action;
+ const chatOpen = (modalType == 'CHAT') ? false : state.chatOpen;
+ return {
+ ...state,
+ modalType,
+ modalProps,
+ chatOpen,
+ };
+ }
+
+ case 'HIDE_MODAL':
+ return {
+ ...state,
+ modalType: null,
+ modalProps: {},
+ };
+
+ case 'TOGGLE_CHAT_BOX': {
+ return {
+ ...state,
+ chatOpen: !state.chatOpen,
+ };
+ }
+
+ case 'RECEIVE_ME': {
+ const { name } = action;
+ const chatOpen = (name) ? state.chatOpen : false;
+ return {
+ ...state,
+ chatOpen,
+ };
+ }
+
+ default:
+ return state;
+ }
+}
diff --git a/src/reducers/user.js b/src/reducers/user.js
new file mode 100644
index 0000000..b3dd5e6
--- /dev/null
+++ b/src/reducers/user.js
@@ -0,0 +1,208 @@
+/* @flow */
+
+import type { Action } from '../actions/types';
+
+
+export type UserState = {
+ name: string,
+ center: Cell,
+ wait: ?Date,
+ coolDown: ?number, // ms
+ placeAllowed: boolean,
+ online: ?number,
+ // messages are sent by api/me, like not_verified status
+ messages: Array,
+ mailreg: boolean,
+ // stats
+ totalPixels: number,
+ dailyTotalPixels: number,
+ ranking: number,
+ dailyRanking: number,
+ // global stats
+ totalRanking: Object,
+ totalDailyRanking: Object,
+ // chat
+ chatMessages: Array,
+ // minecraft
+ minecraftname: string,
+};
+
+const initialState: UserState = {
+ name: null,
+ center: [0, 0],
+ wait: null,
+ coolDown: null,
+ placeAllowed: true,
+ online: null,
+ messages: [],
+ mailreg: false,
+ totalRanking: {},
+ totalDailyRanking: {},
+ chatMessages: [['info', 'Welcome to the PixelPlanet Chat']],
+ minecraftname: null,
+};
+
+export default function user(
+ state: UserState = initialState,
+ action: Action,
+): UserState {
+ switch (action.type) {
+ case 'COOLDOWN_SET': {
+ const { coolDown } = action;
+ return {
+ ...state,
+ coolDown,
+ };
+ }
+
+ case 'COOLDOWN_END': {
+ return {
+ ...state,
+ coolDown: null,
+ wait: null,
+ };
+ }
+
+ case 'SET_PLACE_ALLOWED': {
+ const { placeAllowed } = action;
+ return {
+ ...state,
+ placeAllowed,
+ };
+ }
+
+ case 'SET_WAIT': {
+ const { wait: duration } = action;
+
+ const wait = duration ? new Date(Date.now() + duration) : null;
+
+ return {
+ ...state,
+ wait,
+ };
+ }
+
+ case 'PLACE_PIXEL': {
+ let { totalPixels, dailyTotalPixels } = state;
+ totalPixels += 1;
+ dailyTotalPixels += 1;
+ return {
+ ...state,
+ totalPixels,
+ dailyTotalPixels,
+ };
+ }
+
+ case 'RECEIVE_ONLINE': {
+ const { online } = action;
+ return {
+ ...state,
+ online,
+ };
+ }
+
+ case 'RECEIVE_CHAT_MESSAGE': {
+ const { name, text } = action;
+ let { chatMessages } = state;
+ console.log('received chat message');
+ if (chatMessages.length > 50) {
+ chatMessages = chatMessages.slice(-50);
+ }
+ return {
+ ...state,
+ chatMessages: chatMessages.concat([[name, text]]),
+ };
+ }
+
+ case 'RECEIVE_CHAT_HISTORY': {
+ const { data: chatMessages } = action;
+ return {
+ ...state,
+ chatMessages,
+ };
+ }
+
+ case 'RECEIVE_COOLDOWN': {
+ const { waitSeconds } = action;
+ const wait = waitSeconds ? new Date(Date.now() + waitSeconds * 1000) : null;
+ return {
+ ...state,
+ wait,
+ coolDown: null,
+ };
+ }
+
+ case 'RECEIVE_ME': {
+ const {
+ name,
+ mailreg,
+ totalPixels,
+ dailyTotalPixels,
+ ranking,
+ dailyRanking,
+ minecraftname,
+ } = action;
+ const messages = (action.messages) ? action.messages : [];
+ return {
+ ...state,
+ name,
+ messages,
+ mailreg,
+ totalPixels,
+ dailyTotalPixels,
+ ranking,
+ dailyRanking,
+ minecraftname,
+ };
+ }
+
+ case 'RECEIVE_STATS': {
+ const { totalRanking, totalDailyRanking } = action;
+ return {
+ ...state,
+ totalRanking,
+ totalDailyRanking,
+ };
+ }
+
+ case 'SET_NAME': {
+ const { name } = action;
+ return {
+ ...state,
+ name,
+ };
+ }
+
+ case 'SET_MINECRAFT_NAME': {
+ const { minecraftname } = action;
+ return {
+ ...state,
+ minecraftname,
+ };
+ }
+
+ case 'REM_FROM_MESSAGES': {
+ const { message } = action;
+ const messages = [...state.messages];
+ const index = messages.indexOf(message);
+ if (index > -1) {
+ messages.splice(index);
+ }
+ return {
+ ...state,
+ messages,
+ };
+ }
+
+ case 'SET_MAILREG': {
+ const { mailreg } = action;
+ return {
+ ...state,
+ mailreg,
+ };
+ }
+
+ default:
+ return state;
+ }
+}
diff --git a/src/routes/admintools.js b/src/routes/admintools.js
new file mode 100644
index 0000000..3f71db3
--- /dev/null
+++ b/src/routes/admintools.js
@@ -0,0 +1,253 @@
+/**
+ * basic admin api
+ *
+ * @flow
+ */
+
+import nodeIp from 'ip';
+import express from 'express';
+import expressLimiter from 'express-limiter';
+import type { Request, Response } from 'express';
+import bodyParser from 'body-parser';
+import sharp from 'sharp';
+import multer from 'multer';
+import React from 'react';
+import ReactDOM from 'react-dom/server';
+
+import { getIPFromRequest } from '../utils/ip';
+import { getIdFromObject } from '../core/utils';
+import redis from '../data/redis';
+import session from '../core/session';
+import passport from '../core/passport';
+import logger from '../core/logger';
+import { Blacklist, Whitelist } from '../data/models';
+
+import { MINUTE } from '../core/constants';
+import canvases from '../canvases.json';
+import { imageABGR2Canvas } from '../core/Image';
+
+import Html from '../components/Html';
+import Admin from '../components/Admin';
+
+
+const router = express.Router();
+const limiter = expressLimiter(router, redis);
+
+/*
+ * build html of admin page with react
+ */
+const data = {
+ title: 'PixelPlanet.fun AdminTools',
+ description: 'admin access on pixelplanet',
+ body: ,
+};
+const index = `${ReactDOM.renderToStaticMarkup( )}`;
+
+
+/*
+ * multer middleware for getting POST parameters
+ * into req.file (if file) and req.body for text
+ */
+router.use(bodyParser.urlencoded({ extended: true }));
+const upload = multer({
+ limits: {
+ fileSize: 5 * 1024 * 1024,
+ },
+});
+
+
+/*
+ * rate limiting to prevent bruteforce attacks
+ */
+router.use('/',
+ limiter({
+ lookup: 'headers.cf-connecting-ip',
+ total: 240,
+ expire: 5 * MINUTE,
+ skipHeaders: true,
+ }),
+);
+
+
+/*
+ * make sure User is logged in and admin
+ */
+router.use(session);
+router.use(passport.initialize());
+router.use(passport.session());
+router.use(async (req, res, next) => {
+ const ip = await getIPFromRequest(req);
+ if (!req.user) {
+ logger.info(`${ip} tried to access admintools without login`);
+ res.status(403).send('You are not logged in');
+ return;
+ }
+ if (!req.user.isAdmin()) {
+ logger.info(`${ip} / ${req.user.id} tried to access admintools but isn't Admin`);
+ res.status(403).send('You are not allowed to access this page');
+ return;
+ }
+ next();
+});
+
+
+/*
+ * Execute IP based actions (banning, whitelist, etc.)
+ * @param action what to do with the ip
+ * @param ip already sanizized ip
+ * @return true if successful
+ */
+async function executeAction(action: string, ip: string): boolean {
+ const numIp = nodeIp.toLong(ip);
+ const key = `isprox:${ip}`;
+
+ switch (action) {
+ case 'ban':
+ await Blacklist.findOrCreate({
+ where: { numIp },
+ });
+ await redis.setAsync(key, 'y', 'EX', 24 * 3600);
+ break;
+ case 'unban':
+ await Blacklist.destroy({
+ where: { numIp },
+ });
+ await redis.del(key);
+ break;
+ case 'whitelist':
+ await Whitelist.findOrCreate({
+ where: { numIp },
+ });
+ await redis.setAsync(key, 'n', 'EX', 24 * 3600);
+ break;
+ case 'unwhitelist':
+ await Whitelist.destroy({
+ where: { numIp },
+ });
+ await redis.del(key);
+ break;
+ default:
+ return false;
+ }
+ return true;
+}
+
+
+/*
+ * Check for POST parameters,
+ */
+router.post('/', upload.single('image'), async (req: Request, res: Response, next) => {
+ try {
+ if (req.file) {
+ req.checkBody('x', 'x out of limits')
+ .notEmpty()
+ .isInt();
+ req.checkBody('y', 'y out of limits')
+ .notEmpty()
+ .isInt();
+ req.checkBody('canvasident', 'canvas name not valid')
+ .notEmpty();
+ req.checkBody('imageaction', 'no imageaction given')
+ .notEmpty();
+
+ const validationResult = await req.getValidationResult();
+ if (!validationResult.isEmpty()) {
+ res.status(403).send(validationResult.array().toString());
+ return;
+ }
+ req.sanitizeBody('x').toInt();
+ req.sanitizeBody('y').toInt();
+
+ const { x, y, imageaction, canvasident } = req.body;
+ const canvasId = getIdFromObject(canvases, canvasident);
+ if (canvasId === null) {
+ res.status(403).send('This canvas does not exist');
+ return;
+ }
+
+ const canvas = canvases[canvasId];
+
+ const canvasMaxXY = canvas.size / 2;
+ const canvasMinXY = -canvasMaxXY;
+ if (x < canvasMinXY || y < canvasMinXY ||
+ x >= canvasMaxXY || y >= canvasMaxXY) {
+ res.status(403).send('Coordinates are outside of canvas');
+ return;
+ }
+
+ const protect = (imageaction === 'protect');
+ const wipe = (imageaction === 'wipe');
+
+ await sharp(req.file.buffer)
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true })
+ .then(({ err, data, info }) => {
+ if (err) throw err;
+ return imageABGR2Canvas(
+ canvasId,
+ x, y,
+ data,
+ info.width, info.height,
+ wipe, protect,
+ );
+ });
+
+ res.status(200).send('Successfully loaded image');
+ return;
+ }
+
+ if (req.body.ip) {
+ const ret = await executeAction(req.body.action, req.body.ip);
+ if (!ret) {
+ res.status(403).send('Failed');
+ } else {
+ res.status(200).send(`Succseefully did ${req.body.action} ${req.body.ip}`);
+ }
+ return;
+ }
+
+ next();
+ } catch (error) {
+ next(error);
+ }
+});
+
+
+/*
+ * Check GET parameters for action to execute
+ */
+router.get('/', async (req: Request, res: Response, next) => {
+ try {
+ const { ip, action } = req.query;
+ if (!action) {
+ next();
+ return;
+ }
+ if (!ip) {
+ res.status(400).json({ errors: 'invalid ip' });
+ return;
+ }
+
+ const ret = await executeAction(action, ip);
+
+ if (!ret) {
+ res.status(403).json({ errors: ['action not available'] });
+ }
+
+ res.json({ action: 'success' });
+ } catch (error) {
+ next(error);
+ }
+});
+
+
+router.use(async (req: Request, res: Response) => {
+ res.set({
+ 'Content-Type': 'text/html',
+ });
+ res.status(200).send(index);
+});
+
+
+export default router;
diff --git a/src/routes/api/auth/change_mail.js b/src/routes/api/auth/change_mail.js
new file mode 100644
index 0000000..cea7866
--- /dev/null
+++ b/src/routes/api/auth/change_mail.js
@@ -0,0 +1,63 @@
+/*
+ * request password change
+ */
+
+
+import type { Request, Response } from 'express';
+import Sequelize from 'sequelize';
+import mailProvider from '../../../core/mail';
+
+import { validatePassword, validateEMail } from '../../../utils/validation';
+import { compareToHash } from '../../../utils/hash';
+
+function validate(email, password) {
+ const errors = [];
+
+ const passerror = validatePassword(password);
+ if (passerror) errors.push(passerror);
+ const mailerror = validateEMail(email);
+ if (mailerror) errors.push(mailerror);
+
+ return errors;
+}
+
+export default async (req: Request, res: Response) => {
+ const { email, password } = req.body;
+ const errors = validate(email, password);
+ if (errors.length > 0) {
+ res.status(400);
+ res.json({
+ errors,
+ });
+ return;
+ }
+
+ const { user } = req;
+ if (!user) {
+ res.status(401);
+ res.json({
+ errors: ['You are not authenticated.'],
+ });
+ return;
+ }
+
+ const current_password = user.regUser.password;
+ if (!compareToHash(password, current_password)) {
+ res.status(400);
+ res.json({
+ errors: ['Incorrect password!'],
+ });
+ return;
+ }
+
+ await user.regUser.update({
+ email,
+ mailVerified: false,
+ });
+
+ mailProvider.send_verify_mail(email, user.regUser.name);
+
+ res.json({
+ success: true,
+ });
+};
diff --git a/src/routes/api/auth/change_name.js b/src/routes/api/auth/change_name.js
new file mode 100644
index 0000000..56f1f48
--- /dev/null
+++ b/src/routes/api/auth/change_name.js
@@ -0,0 +1,49 @@
+/*
+ * request password change
+ */
+
+
+import type { Request, Response } from 'express';
+
+import { RegUser } from '../../../data/models';
+import { validateName } from '../../../utils/validation';
+
+async function validate(oldname, name) {
+ if (oldname == name) return 'You already have that name.';
+
+ const nameerror = validateName(name);
+ if (nameerror) return nameerror;
+
+ const reguser = await RegUser.findOne({ where: { name } });
+ if (reguser) return 'Username already in use.';
+
+ return null;
+}
+
+export default async (req: Request, res: Response) => {
+ const { name } = req.body;
+ const { user } = req;
+
+ if (!user) {
+ res.status(401);
+ res.json({
+ errors: ['You are not authenticated.'],
+ });
+ return;
+ }
+
+ const error = await validate(user.regUser.name, name);
+ if (error) {
+ res.status(400);
+ res.json({
+ errors: [error],
+ });
+ return;
+ }
+
+ await user.regUser.update({ name });
+
+ res.json({
+ success: true,
+ });
+};
diff --git a/src/routes/api/auth/change_passwd.js b/src/routes/api/auth/change_passwd.js
new file mode 100644
index 0000000..717da90
--- /dev/null
+++ b/src/routes/api/auth/change_passwd.js
@@ -0,0 +1,54 @@
+/*
+ * request password change
+ */
+
+
+import type { Request, Response } from 'express';
+
+import { validatePassword } from '../../../utils/validation';
+import { compareToHash } from '../../../utils/hash';
+
+function validate(new_password, password) {
+ const errors = [];
+
+ const newpassworderror = validatePassword(new_password);
+ if (newpassworderror) errors.push(newpassworderror);
+
+ return errors;
+}
+
+export default async (req: Request, res: Response) => {
+ const { new_password, password } = req.body;
+ const errors = validate(new_password, password);
+ if (errors.length > 0) {
+ res.status(400);
+ res.json({
+ errors,
+ });
+ return;
+ }
+
+ const { user } = req;
+ if (!user) {
+ res.status(401);
+ res.json({
+ errors: ['You are not authenticated.'],
+ });
+ return;
+ }
+
+ const current_password = user.regUser.password;
+ if (current_password && !compareToHash(password, current_password)) {
+ res.status(400);
+ res.json({
+ errors: ['Incorrect password!'],
+ });
+ return;
+ }
+
+ await user.regUser.update({ password: new_password });
+
+ res.json({
+ success: true,
+ });
+};
diff --git a/src/routes/api/auth/delete_account.js b/src/routes/api/auth/delete_account.js
new file mode 100644
index 0000000..128c027
--- /dev/null
+++ b/src/routes/api/auth/delete_account.js
@@ -0,0 +1,57 @@
+/*
+ * request password change
+ */
+
+
+import type { Request, Response } from 'express';
+
+import { RegUser } from '../../../data/models';
+import { validatePassword } from '../../../utils/validation';
+import { compareToHash } from '../../../utils/hash';
+
+function validate(password) {
+ const errors = [];
+
+ const passworderror = validatePassword(password);
+ if (passworderror) errors.push(passworderror);
+
+ return errors;
+}
+
+export default async (req: Request, res: Response) => {
+ const { new_password, password } = req.body;
+ const errors = await validate(password);
+ if (errors.length > 0) {
+ res.status(400);
+ res.json({
+ errors,
+ });
+ return;
+ }
+
+ const { user } = req;
+ if (!user) {
+ res.status(401);
+ res.json({
+ errors: ['You are not authenticated.'],
+ });
+ return;
+ }
+ const { id } = user;
+
+ const current_password = user.regUser.password;
+ if (!current_password || !compareToHash(password, current_password)) {
+ res.status(400);
+ res.json({
+ errors: ['Incorrect password!'],
+ });
+ return;
+ }
+
+ req.logout();
+ RegUser.destroy({ where: { id } });
+
+ res.json({
+ success: true,
+ });
+};
diff --git a/src/routes/api/auth/index.js b/src/routes/api/auth/index.js
new file mode 100644
index 0000000..a503fea
--- /dev/null
+++ b/src/routes/api/auth/index.js
@@ -0,0 +1,116 @@
+/**
+ * @flow
+ */
+
+
+import express from 'express';
+import bodyParser from 'body-parser';
+
+import logger from '../../../core/logger';
+
+import register from './register';
+import verify from './verify';
+import logout from './logout';
+import resend_verify from './resend_verify';
+import change_passwd from './change_passwd';
+import delete_account from './delete_account';
+import change_name from './change_name';
+import change_mail from './change_mail';
+import restore_password from './restore_password';
+import mclink from './mclink';
+
+import { getHtml } from '../../../components/RedirectionPage';
+
+import getMe from '../../../core/me';
+
+const router = express.Router();
+
+export default (passport) => {
+ router.get('/logout', logout);
+
+ router.get('/facebook', passport.authenticate('facebook', { scope: ['email'] }));
+ router.get('/facebook/return', passport.authenticate('facebook', {
+ failureRedirect: '/api/auth/failure',
+ successRedirect: '/',
+ }));
+
+ router.get('/discord', passport.authenticate('discord', { scope: ['identify', 'email'] }));
+ router.get('/discord/return', passport.authenticate('discord', {
+ failureRedirect: '/api/auth/failure',
+ successRedirect: '/',
+ }));
+
+ router.get('/google', passport.authenticate('google', { scope: ['email', 'profile'] }));
+ router.get('/google/return', passport.authenticate('google', {
+ failureRedirect: '/api/auth/failure',
+ successRedirect: '/',
+ }));
+
+ router.get('/vk', passport.authenticate('vkontakte', { scope: ['email'] }));
+ router.get('/vk/return', passport.authenticate('vkontakte', {
+ failureRedirect: '/api/auth/failure',
+ successRedirect: '/',
+ }));
+
+ router.get('/reddit', passport.authenticate('reddit', { duration: 'temporary', state: 'foo' }));
+ router.get('/reddit/return', passport.authenticate('reddit', {
+ failureRedirect: '/api/auth/failure',
+ successRedirect: '/',
+ }));
+
+ router.get('/failure', (req: Request, res: Response) => {
+ res.set({
+ 'Content-Type': 'text/html',
+ });
+ const index = getHtml('OAuth Authentification', 'LogIn failed :(, please try again later or register a new account with Mail.');
+ res.status(200).send(index);
+ });
+
+ router.get('/verify', verify);
+
+ router.get('/logout', logout);
+
+ router.get('/resend_verify', resend_verify);
+
+ router.post('/change_passwd', change_passwd);
+
+ router.post('/change_name', change_name);
+
+ router.post('/change_mail', change_mail);
+
+ router.post('/delete_account', delete_account);
+
+ router.post('/restore_password', restore_password);
+
+ router.post('/mclink', mclink);
+
+ // while previous auth methosed work by redirect,
+ // local strategy is an json API
+ router.post('/local', async (req: Request, res: Response, next) => {
+ passport.authenticate('json', async (err, user, info) => {
+ if (!user) {
+ res.status(400);
+ res.json({
+ errors: [info.message],
+ });
+ return;
+ }
+ logger.info(`User ${user.id} logged in with mail/password.`);
+
+ req.logIn(user, async (err) => {
+ if (err) { res.json({ success: false, errors: ['Failed to establish session. Please try again later :('] }); return; }
+
+ user.ip = req.user.ip;
+ const me = await getMe(user);
+ res.json({
+ success: true,
+ me,
+ });
+ });
+ })(req, res, next);
+ });
+
+ router.post('/register', register);
+
+ return router;
+};
diff --git a/src/routes/api/auth/logout.js b/src/routes/api/auth/logout.js
new file mode 100644
index 0000000..adc8e93
--- /dev/null
+++ b/src/routes/api/auth/logout.js
@@ -0,0 +1,29 @@
+/*
+ * logout
+ */
+import type { Request, Response } from 'express';
+
+import getMe from '../../../core/me';
+
+export default async (req: Request, res: Response) => {
+ const { user } = req;
+ if (!user) {
+ res.status(401);
+ res.json({
+ errors: ['You are not even logged in.'],
+ });
+ return;
+ }
+
+ const me = await getMe(req.user);
+ req.logout();
+ res.status(200);
+ res.json({
+ success: true,
+ me: {
+ name: null,
+ waitSeconds: me.waitSeconds,
+ canvases: me.canvases,
+ },
+ });
+};
diff --git a/src/routes/api/auth/mclink.js b/src/routes/api/auth/mclink.js
new file mode 100644
index 0000000..9df3ad3
--- /dev/null
+++ b/src/routes/api/auth/mclink.js
@@ -0,0 +1,44 @@
+/*
+ * accept or deny minecraft link request
+ * @flow
+ */
+
+import type { Request, Response } from 'express';
+
+import { broadcastMinecraftLink } from '../../../socket/websockets';
+
+
+export default async (req: Request, res: Response) => {
+ const { accepted } = req.body;
+
+ const { user } = req;
+ if (!user) {
+ res.status(401);
+ res.json({
+ errors: ['You are not authenticated.'],
+ });
+ return;
+ }
+ const { name, minecraftid } = user.regUser;
+
+ if (accepted === true) {
+ user.regUser.update({
+ mcVerified: true,
+ });
+ res.json({
+ accepted: true,
+ });
+ } else if (accepted === false) {
+ user.regUser.update({
+ minecraftid: null,
+ minecraftname: null,
+ mcVerified: false,
+ });
+ res.json({
+ accepted: false,
+ });
+ } else {
+ return;
+ }
+ broadcastMinecraftLink(name, minecraftid, accepted);
+};
diff --git a/src/routes/api/auth/register.js b/src/routes/api/auth/register.js
new file mode 100644
index 0000000..dbcff25
--- /dev/null
+++ b/src/routes/api/auth/register.js
@@ -0,0 +1,80 @@
+/**
+ *
+ * @flow
+ */
+
+
+import type { Request, Response } from 'express';
+import Sequelize from 'sequelize';
+
+import { RegUser } from '../../../data/models';
+import mailProvider from '../../../core/mail';
+import getMe from '../../../core/me';
+import { validateEMail, validateName, validatePassword } from '../../../utils/validation';
+
+async function validate(email, name, password) {
+ const errors = [];
+ const emailerror = validateEMail(email);
+ if (emailerror) errors.push(emailerror);
+ const nameerror = validateName(name);
+ if (nameerror) errors.push(nameerror);
+ const passworderror = validatePassword(password);
+ if (passworderror) errors.push(passworderror);
+
+ let reguser = await RegUser.findOne({ where: { email } });
+ if (reguser) errors.push('E-Mail already in use.');
+ reguser = await RegUser.findOne({ where: { name } });
+ if (reguser) errors.push('Username already in use.');
+
+ return errors;
+}
+
+export default async (req: Request, res: Response) => {
+ const { email, name, password } = req.body;
+ const errors = await validate(email, name, password);
+ if (errors.length > 0) {
+ res.status(400);
+ res.json({
+ errors,
+ });
+ return;
+ }
+
+ const newuser = await RegUser.create({
+ email,
+ name,
+ password,
+ verificationReqAt: Sequelize.literal('CURRENT_TIMESTAMP'),
+ lastLogIn: Sequelize.literal('CURRENT_TIMESTAMP'),
+ });
+
+ if (!newuser) {
+ res.status(500);
+ res.json({
+ errors: ['Failed to create new user :('],
+ });
+ return;
+ }
+
+ const { noauthUser } = req;
+ const user = (noauthUser) || new User(id);
+ user.id = newuser.id;
+ user.regUser = newuser;
+ const me = await getMe(user);
+
+ await req.logIn(user, (err) => {
+ if (err) {
+ res.status(500);
+ res.json({
+ errors: ['Failed to establish session after register :('],
+ });
+ return;
+ }
+ mailProvider.send_verify_mail(email, name);
+ res.status(200);
+ res.json({
+ success: true,
+ me,
+ });
+ });
+};
diff --git a/src/routes/api/auth/resend_verify.js b/src/routes/api/auth/resend_verify.js
new file mode 100644
index 0000000..ee488d2
--- /dev/null
+++ b/src/routes/api/auth/resend_verify.js
@@ -0,0 +1,40 @@
+/*
+ * request resend of verification mail
+ */
+
+
+import type { Request, Response } from 'express';
+
+import mailProvider from '../../../core/mail';
+
+export default async (req: Request, res: Response) => {
+ const { user } = req;
+ if (!user) {
+ res.status(401);
+ res.json({
+ errors: ['You are not authenticated.'],
+ });
+ return;
+ }
+
+ const { name, email, mailVerified } = user.regUser;
+ if (mailVerified) {
+ res.status(400);
+ res.json({
+ errors: ['You are already verified.'],
+ });
+ return;
+ }
+
+ const error = mailProvider.send_verify_mail(email, name);
+ if (error) {
+ res.status(400);
+ res.json({
+ errors: [error],
+ });
+ return;
+ }
+ res.json({
+ success: true,
+ });
+};
diff --git a/src/routes/api/auth/restore_password.js b/src/routes/api/auth/restore_password.js
new file mode 100644
index 0000000..421ddd6
--- /dev/null
+++ b/src/routes/api/auth/restore_password.js
@@ -0,0 +1,43 @@
+/*
+ * request passowrd reset mail
+ */
+
+
+import type { Request, Response } from 'express';
+
+import mailProvider from '../../../core/mail';
+import { validateEMail } from '../../../utils/validation';
+
+async function validate(email) {
+ const errors = [];
+ const emailerror = validateEMail(email);
+ if (emailerror) errors.push(emailerror);
+
+ return errors;
+}
+
+export default async (req: Request, res: Response) => {
+ const ip = req.trueIp;
+ const { email } = req.body;
+
+ const errors = await validate(email);
+ if (errors.length > 0) {
+ res.status(400);
+ res.json({
+ errors,
+ });
+ return;
+ }
+ const error = await mailProvider.send_passd_reset_mail(email, ip);
+ if (error) {
+ res.status(400);
+ res.json({
+ errors: [error],
+ });
+ return;
+ }
+ res.status(200);
+ res.json({
+ success: true,
+ });
+};
diff --git a/src/routes/api/auth/verify.js b/src/routes/api/auth/verify.js
new file mode 100644
index 0000000..7592732
--- /dev/null
+++ b/src/routes/api/auth/verify.js
@@ -0,0 +1,21 @@
+/*
+ * verify mail adress
+ * @flow
+ */
+
+import type { Request, Response } from 'express';
+
+import { getHtml } from '../../../components/RedirectionPage';
+import mailProvider from '../../../core/mail';
+
+export default async (req: Request, res: Response) => {
+ const { token } = req.query;
+ const success = await mailProvider.verify(token);
+ if (success) {
+ const index = getHtml('Mail verification', 'You are now verified :)');
+ res.status(200).send(index);
+ } else {
+ const index = getHtml('Mail verification', 'Your mail verification code is invalid or already expired :(, please request a new one.');
+ res.status(400).send(index);
+ }
+};
diff --git a/src/routes/api/index.js b/src/routes/api/index.js
new file mode 100644
index 0000000..064292b
--- /dev/null
+++ b/src/routes/api/index.js
@@ -0,0 +1,77 @@
+/**
+ * @flow
+ */
+
+import express from 'express';
+import bodyParser from 'body-parser';
+import cors from 'cors';
+
+import session from '../../core/session';
+import passport from '../../core/passport';
+import { User } from '../../data/models';
+import { getIPFromRequest, getIPv6Subnet } from '../../utils/ip';
+
+import {
+ MINUTE,
+ SECOND,
+ DAY,
+ BLANK_COOLDOWN,
+} from '../../core/constants';
+
+import me from './me';
+import mctp from './mctp';
+import pixel from './pixel';
+import auth from './auth';
+import ranking from './ranking';
+
+
+const router = express.Router();
+
+// this route doesn't need passport
+router.get('/ranking', ranking);
+
+/*
+ * get user session
+ */
+router.use(session);
+
+/*
+ * create dummy user that has just ip and id
+ * (cut IPv6 to subnet to prevent abuse)
+ */
+router.use(async (req, res, next) => {
+ const session = req.session;
+ const id = (session.passport && session.passport.user) ? session.passport.user : null;
+ const ip = await getIPFromRequest(req);
+ const trueIp = ip || '0.0.0.1';
+ req.trueIp = trueIp;
+ const user = new User(id, getIPv6Subnet(trueIp));
+ req.noauthUser = user;
+ next();
+});
+
+router.use(bodyParser.json());
+
+/*
+ * rate limiting should occure outside,
+ * with nginx or whatever
+ */
+router.post('/pixel', pixel);
+
+/*
+ * passport authenticate
+ * and deserlialize
+ * (makes that sql request to map req.user.regUser)
+ * After this point it is assumes that user.regUser is set if user.id is too
+ */
+router.use(passport.initialize());
+router.use(passport.session());
+
+router.get('/me', me);
+
+router.post('/mctp', mctp);
+
+router.use('/auth', auth(passport));
+
+
+export default router;
diff --git a/src/routes/api/mctp.js b/src/routes/api/mctp.js
new file mode 100644
index 0000000..69116ac
--- /dev/null
+++ b/src/routes/api/mctp.js
@@ -0,0 +1,55 @@
+/**
+ *
+ * API endpoint to request tp in minecraft
+ * (might be better in websocket?)
+ *
+ * @flow
+ */
+
+
+import type { Request, Response } from 'express';
+
+import canvases from '../../canvases.json';
+import { broadcastMinecraftTP } from '../../socket/websockets';
+
+const CANVAS_MAX_XY = (canvases[0].size / 2);
+const CANVAS_MIN_XY = -CANVAS_MAX_XY;
+
+export default async (req: Request, res: Response) => {
+ const { user } = req;
+ if (!user) {
+ res.status(401);
+ res.json({
+ success: false,
+ errors: ['You are not authenticated.'],
+ });
+ return;
+ }
+
+ const x = parseInt(req.body.x, 10);
+ const y = parseInt(req.body.y, 10);
+ if (x < CANVAS_MIN_XY || y < CANVAS_MIN_XY || x >= CANVAS_MAX_XY || y >= CANVAS_MAX_XY) {
+ res.status(400);
+ res.json({
+ success: false,
+ errors: ['Coordinates out of bounds.'],
+ });
+ return;
+ }
+
+ const minecraftid = user.regUser.minecraftid;
+ if (!minecraftid) {
+ res.status(400);
+ res.json({
+ success: false,
+ errors: ['You have no minecraft account linked to you.'],
+ });
+ return;
+ }
+
+ await broadcastMinecraftTP(minecraftid, x, y);
+
+ res.json({
+ success: true,
+ });
+};
diff --git a/src/routes/api/me.js b/src/routes/api/me.js
new file mode 100644
index 0000000..164318c
--- /dev/null
+++ b/src/routes/api/me.js
@@ -0,0 +1,24 @@
+/**
+ *
+ * @flow
+ */
+
+
+import type { Request, Response } from 'express';
+
+import getMe from '../../core/me';
+
+
+export default async (req: Request, res: Response) => {
+ const user = req.user || req.noauthUser;
+ const userdata = await getMe(user);
+ user.updateLogInTimestamp();
+
+ // https://stackoverflow.com/questions/49547/how-to-control-web-page-caching-across-all-browsers
+ res.set({
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ Pragma: 'no-cache',
+ Expires: '0',
+ });
+ res.json(userdata);
+};
diff --git a/src/routes/api/pixel.js b/src/routes/api/pixel.js
new file mode 100644
index 0000000..662ab4a
--- /dev/null
+++ b/src/routes/api/pixel.js
@@ -0,0 +1,205 @@
+/**
+ *
+ * @flow
+ */
+
+import type { Request, Response } from 'express';
+import url from 'url';
+import nodeIp from 'ip';
+
+import draw from '../../core/draw';
+import { blacklistDetector, cheapDetector, strongDetector } from '../../core/isProxy';
+import { verifyCaptcha } from '../../utils/recaptcha';
+import logger from '../../core/logger';
+import { clamp } from '../../core/utils';
+import redis from '../../data/redis';
+import { USE_PROXYCHECK, RECAPTCHA_SECRET, RECAPTCHA_TIME } from '../../core/config';
+import {
+ User,
+} from '../../data/models';
+
+
+async function validate(req: Request, res: Response, next) {
+ // c canvas id
+ req.checkBody('cn', 'No canvas selected')
+ .notEmpty()
+ .isInt();
+ // x x coordinage
+ req.checkBody('x', 'x not a valid integer')
+ .notEmpty()
+ .isInt();
+ // y y coordinage
+ req.checkBody('y', 'y not a valid integer')
+ .notEmpty()
+ .isInt();
+ // clr color
+ req.checkBody('clr', 'color not valid')
+ .notEmpty()
+ .isInt({ min: 2, max: 31 });
+
+ req.sanitizeBody('cn').toInt();
+ req.sanitizeBody('x').toInt();
+ req.sanitizeBody('y').toInt();
+ req.sanitizeBody('clr').toInt();
+
+ const validationResult = await req.getValidationResult();
+ if (!validationResult.isEmpty()) {
+ res.status(400).json({ errors: validationResult.array() });
+ return;
+ }
+
+ const { noauthUser } = req;
+ let user = req.user;
+ if (!req.user) {
+ req.user = req.noauthUser;
+ user = req.user;
+ }
+ if (!user || !user.ip) {
+ res.status(400).json({ errors: ["Couldn't authenticate"] });
+ return;
+ }
+
+ next();
+}
+
+
+const TTL_CACHE = RECAPTCHA_TIME * 60; // seconds
+async function checkHuman(req: Request, res: Response, next) {
+ if (!RECAPTCHA_SECRET) {
+ next();
+ return;
+ }
+
+ const { user } = req;
+ const { ip } = user;
+ if (user.isAdmin()) {
+ next();
+ return;
+ }
+
+ try {
+ const { token } = req.body;
+ const numIp = nodeIp.toLong(ip);
+
+ const key = `human:${ip}:${ip}`;
+
+ const ttl: number = await redis.ttlAsync(key);
+ if (ttl > 0) {
+ next();
+ return;
+ }
+
+ if (!token || !await verifyCaptcha(token, ip)) {
+ logger.info(`CAPTCHA ${ip} got a captcha`);
+ res.status(422)
+ .json({ errors: [{ msg: 'Captcha occured' }] });
+ return;
+ }
+
+ // save to cache
+ await redis.setAsync(key, 'y', 'EX', TTL_CACHE);
+ } catch (error) {
+ logger.error('checkHuman', error);
+ }
+
+ next();
+}
+
+// cheap check whole canvas for proxies, if USE_PROXYCHECK is one
+// strongly check selective areas
+async function checkProxy(req: Request, res: Response, next) {
+ const { trueIp: ip } = req;
+ if (USE_PROXYCHECK && ip != '0.0.0.1') {
+ const { x, y } = req.body;
+ /*
+ //one area uses stronger detector
+ if ((x > 970 && x < 2380 && y > -11407 && y < -10597) || //nc
+ (x > 4220 && x < 6050 && y > -12955 && y < -11230) || //belarius
+ (x > 14840 && x < 15490 && y > -17380 && y < -16331) || //russian bot
+ (x > 11189 && x < 12003 && y > 3483 && y < 4170) || //random bot
+ (x > -13402 && x < -5617 && y > 1640 && y < 5300)){ //brazil
+ if (!ip || await strongDetector(ip)) {
+ res.status(403)
+ .json({ errors: [{ msg: 'You are using a proxy!' }] });
+ return;
+ }
+ } else {
+ */
+ if (!ip || await cheapDetector(ip)) {
+ res.status(403)
+ .json({ errors: [{ msg: 'You are using a proxy!' }] });
+ return;
+ }
+ /*
+ }
+ */
+ } else if (await blacklistDetector(ip)) {
+ res.status(403)
+ .json({ errors: [{ msg: 'You are using a proxy or got banned!' }] });
+ return;
+ }
+
+ next();
+}
+
+// strongly check just specific areas for proxies
+// do not proxycheck the rest
+async function checkProxySelective(req: Request, res: Response, next) {
+ const { trueIp: ip } = req;
+ if (USE_PROXYCHECK) {
+ const { x, y } = req.body;
+ if (x > 970 && x < 2380 && y > -11407 && y < -10597) { // nc
+ if (!ip || await strongDetector(ip)) {
+ res.status(403)
+ .json({ errors: [{ msg: 'You are using a proxy!' }] });
+ return;
+ }
+ }
+ } else if (await blacklistDetector(ip)) {
+ res.status(403)
+ .json({ errors: [{ msg: 'You are using a proxy or got banned!' }] });
+ return;
+ }
+
+ next();
+}
+
+// place pixel and return waiting time
+async function place(req: Request, res: Response) {
+ // https://stackoverflow.com/questions/49547/how-to-control-web-page-caching-across-all-browsers
+ // https://stackoverflow.com/a/7066740
+ res.set({
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ Pragma: 'no-cache',
+ Expires: '0',
+ });
+
+ const { cn, x, y, clr } = req.body;
+ const { user, headers, trueIp } = req;
+ const { ip } = user;
+
+ const isHashed = parseInt(req.body.a, 10) === (x + y + 8);
+
+ logger.info(`${trueIp} / ${user.id} wants to place ${clr} in (${x}, ${y})`);
+
+ const { errorTitle, error, success, waitSeconds, coolDownSeconds } = await draw(user, cn, x, y, clr);
+ logger.log('debug', success);
+
+ if (success) {
+ res.json({ success, waitSeconds, coolDownSeconds });
+ } else {
+ const errors = [];
+ if (error) {
+ res.status(403);
+ errors.push({ msg: error });
+ }
+ if (errorTitle) {
+ res.json({ success, waitSeconds, coolDownSeconds, errorTitle, errors });
+ } else {
+ res.json({ success, waitSeconds, coolDownSeconds, errors });
+ }
+ }
+}
+
+
+export default [validate, checkHuman, checkProxy, place];
diff --git a/src/routes/api/ranking.js b/src/routes/api/ranking.js
new file mode 100644
index 0000000..c110132
--- /dev/null
+++ b/src/routes/api/ranking.js
@@ -0,0 +1,13 @@
+/*
+ * send global ranking
+ * @flow
+ */
+
+import type { Request, Response } from 'express';
+
+import rankings from '../../core/ranking';
+
+
+export default async (req: Request, res: Response) => {
+ res.json(rankings.ranks);
+};
diff --git a/src/routes/chunks.js b/src/routes/chunks.js
new file mode 100644
index 0000000..0fba78e
--- /dev/null
+++ b/src/routes/chunks.js
@@ -0,0 +1,61 @@
+/**
+ *
+ * Outputs binary chunk directly from redis
+ *
+ * @flow
+ */
+
+import type { Request, Response } from 'express';
+import etag from 'etag';
+import RedisCanvas from '../data/models/RedisCanvas';
+import { TILE_SIZE } from '../core/constants';
+import logger from '../core/logger';
+
+/*
+ * Send binary chunk to the client
+ */
+export default async (req: Request, res: Response, next) => {
+ const { c: paramC, x: paramX, y: paramY } = req.params;
+ const c = parseInt(paramC, 10);
+ const x = parseInt(paramX, 10);
+ const y = parseInt(paramY, 10);
+ try {
+ // botters where using cachebreakers to update via chunk API
+ // lets not allow that for now
+ if (Object.keys(req.query).length !== 0) {
+ res.status(400).end();
+ return;
+ }
+
+ const chunk = await RedisCanvas.getChunk(x, y, c);
+
+ res.set({
+ 'Cache-Control': `public, s-maxage=${60}, max-age=${50}`, // seconds
+ 'Content-Type': 'application/octet-stream',
+ });
+
+ if (!chunk) {
+ res.status(200).end();
+ return;
+ }
+
+ // for temporary logging to see if we have invalid chunks in redis
+ if (chunk.length !== TILE_SIZE * TILE_SIZE) {
+ logger.error(`Chunk ${x},${y} has invalid length ${chunk.length}!`);
+ }
+
+ const curEtag = etag(chunk, { weak: true });
+ res.set({
+ ETag: curEtag,
+ });
+ const preEtag = req.headers['if-none-match'];
+ if (preEtag === curEtag) {
+ res.status(304).end();
+ return;
+ }
+
+ res.end(chunk, 'binary');
+ } catch (error) {
+ next(error);
+ }
+};
diff --git a/src/routes/index.js b/src/routes/index.js
new file mode 100644
index 0000000..603f733
--- /dev/null
+++ b/src/routes/index.js
@@ -0,0 +1,18 @@
+/**
+ *
+ * @flow
+ */
+
+import api from './api';
+import tiles from './tiles';
+import chunks from './chunks';
+import admintools from './admintools';
+import resetPassword from './reset_password';
+
+export {
+ api,
+ tiles,
+ chunks,
+ admintools,
+ resetPassword,
+};
diff --git a/src/routes/reset_password.js b/src/routes/reset_password.js
new file mode 100644
index 0000000..0fa8ab9
--- /dev/null
+++ b/src/routes/reset_password.js
@@ -0,0 +1,110 @@
+/**
+ * basic admin api
+ *
+ * @flow
+ */
+
+import nodeIp from 'ip';
+import express from 'express';
+import expressLimiter from 'express-limiter';
+import bodyParser from 'body-parser';
+
+import type { Request, Response } from 'express';
+
+import redis from '../data/redis';
+import logger from '../core/logger';
+import { getPasswordResetHtml } from '../components/PasswordReset';
+import { MINUTE } from '../core/constants';
+
+import mailProvider from '../core/mail';
+import { RegUser } from '../data/models';
+
+
+const router = express.Router();
+const limiter = expressLimiter(router, redis);
+
+
+/*
+ * rate limiting to prevent bruteforce attacks
+ */
+router.use('/',
+ limiter({
+ lookup: 'headers.cf-connecting-ip',
+ total: 24,
+ expire: 5 * MINUTE,
+ skipHeaders: true,
+ }),
+);
+
+
+/*
+ * decode form data to req.body
+ */
+router.use(bodyParser.urlencoded({ extended: true }));
+
+
+/*
+ * Check for POST parameters,
+ * if invalid password is given, ignore it and go to next
+ */
+router.post('/', async (req: Request, res: Response, next) => {
+ const { pass, passconf, code } = req.body;
+ if (!pass || !passconf || !code) {
+ const html = getPasswordResetHtml(null, null, 'You sent an empty password or invalid data :(');
+ res.status(400).send(html);
+ return;
+ }
+
+ const email = mailProvider.check_code(code);
+ if (!email) {
+ const html = getPasswordResetHtml(null, null, "This password-reset link isn't valid anymore :(");
+ res.status(401).send(html);
+ return;
+ }
+
+ if (pass != passconf) {
+ const html = getPasswordResetHtml(null, null, 'Your passwords do not match :(');
+ res.status(400).send(html);
+ return;
+ }
+
+ // set password
+ const reguser = await RegUser.findOne({ where: { email } });
+ if (!reguser) {
+ logger.error(`${email} from PasswordReset page does not exist in database`);
+ const html = getPasswordResetHtml(null, null, "User doesn't exist in our database :(");
+ res.status(400).send(html);
+ return;
+ }
+ await reguser.update({ password: pass });
+
+ logger.info(`Changed password of ${email} via passowrd reset form`);
+ const html = getPasswordResetHtml(null, null, 'Passowrd successfully changed.');
+ res.status(200).send(html);
+});
+
+
+/*
+ * Check GET parameters for action to execute
+ */
+router.get('/', async (req: Request, res: Response, next) => {
+ const { token } = req.query;
+ if (!token) {
+ const html = getPasswordResetHtml(null, null, 'Invalid url :( Please check your mail again.');
+ res.status(400).send(html);
+ return;
+ }
+
+ const email = mailProvider.check_code(token);
+ if (!email) {
+ const html = getPasswordResetHtml(null, null, 'This passwort reset link is wrong or already expired, please request a new one (Note: you can use those links just once)');
+ res.status(401).send(html);
+ return;
+ }
+
+ const code = mailProvider.set_code(email);
+ const html = getPasswordResetHtml(email, code);
+ res.status(200).send(html);
+});
+
+export default router;
diff --git a/src/routes/tiles.js b/src/routes/tiles.js
new file mode 100644
index 0000000..1ede194
--- /dev/null
+++ b/src/routes/tiles.js
@@ -0,0 +1,87 @@
+/**
+ *
+ * Serve zoomlevel tiles
+ *
+ * @flow
+ */
+
+import express from 'express';
+import type { Request, Response } from 'express';
+import sharp from 'sharp';
+import { TILE_SIZE, HOUR } from '../core/constants';
+import { TILE_FOLDER } from '../core/config';
+import RedisCanvas from '../data/models/RedisCanvas';
+
+
+const router = express.Router();
+
+
+/*
+ * send chunk, but as png
+ * This might be handy in the future if we decide to
+ * provide more zoomlevels to support GoogleMaps / OSM APIs
+async function basetile(req: Request, res: Response, next) {
+ const { c: paramC, x: paramX, y: paramY } = req.params;
+ const x = parseInt(paramX, 10);
+ const y = parseInt(paramY, 10);
+ try {
+ let tile = await RedisCanvas.getChunk(x, y, c);
+
+ res.set({
+ 'Cache-Control': `public, s-maxage=${5 * 60}, max-age=${3 * 60}`, // seconds
+ });
+
+ if (!tile) {
+ res.status(200).end();
+ return;
+ }
+
+ //note that we have to initialize the palette somewhere, if we decide to use it
+ const tileRGB = palette.buffer2RGB(tile);
+ tile = null;
+ const tilePng = await sharp(Buffer.from(tileRGB.buffer), { raw: { width: TILE_SIZE, height: TILE_SIZE, channels: 3 } })
+ .png({ options: { compressionLevel: 0, palette: true, quality: 16, colors: 16, dither: 0.0 } })
+ .toBuffer();
+ res.set({
+ 'Content-Type': 'image/png',
+ });
+
+ res.status(200).send(tilePng);
+ } catch (error) {
+ next(error);
+ }
+}
+
+
+// get basetiles from redis
+// note that MAX_TILED_ZOOM is not an available constant anymore, sinze different
+// canvases can have different sizes. If this should be reenabled, you have to find another
+// solution for that here.
+router.get(`/:c([a-z]+)/${MAX_TILED_ZOOM}/:x([0-9]+)/:y(-?[0-9]+).png`, basetile);
+*/
+
+
+/*
+ * get other tiles from directory
+ */
+router.use('/', express.static(TILE_FOLDER, {
+ maxAge: 2 * HOUR,
+}));
+
+
+/*
+ * catch File Not Found: Send empty tile
+ */
+router.use('/:c([0-9]+)/:z([0-9]+)/:x([0-9]+)/:y([0-9]+).png', async (req: Request, res: Response, next) => {
+ const { c: paramC } = req.params;
+ const c = parseInt(paramC, 10);
+ res.set({
+ 'Cache-Control': `public, s-maxage=${2 * 60 * 60}, max-age=${1 * 60 * 60}`, // seconds
+ 'Content-Type': 'image/png',
+ });
+ res.status(200);
+ res.sendFile(`${TILE_FOLDER}/${c}/emptytile.png`);
+});
+
+
+export default router;
diff --git a/src/socket/APISocketServer.js b/src/socket/APISocketServer.js
new file mode 100644
index 0000000..d3b5203
--- /dev/null
+++ b/src/socket/APISocketServer.js
@@ -0,0 +1,269 @@
+/*
+ *
+ * This WebSocket is used for connecting
+ * to minecraft server.
+ * The minecraft server can set pixels and report user logins
+ * and more.
+ *
+ * @flow */
+
+
+import EventEmitter from 'events';
+import WebSocket from 'ws';
+
+
+import { getIPFromRequest } from '../utils/ip';
+import Minecraft from '../core/minecraft';
+import { drawUnsafe, setPixel } from '../core/draw';
+import logger from '../core/logger';
+import redis from '../data/redis';
+import ChatHistory from './ChatHistory';
+import { broadcastChatMessage, notifyChangedMe } from './websockets';
+import { APISOCKET_KEY } from '../core/config';
+
+function heartbeat() {
+ this.isAlive = true;
+}
+
+async function verifyClient(info, done) {
+ const { req } = info;
+ const { headers } = req;
+ const ip = await getIPFromRequest(req);
+
+ if (!headers.authorization || headers.authorization != `Bearer ${APISOCKET_KEY}`) {
+ logger.warn(`API ws request from ${ip} authenticated`);
+ return done(false);
+ }
+ logger.warn(`API ws request from ${ip} successfully authenticated`);
+ return done(true);
+}
+
+
+class APISocketServer extends EventEmitter {
+ wss: WebSocket.Server;
+ mc: Minecraft;
+
+ // constructor(server: http.Server) {
+ constructor() {
+ super();
+ logger.info('Starting API websocket server');
+
+ const wss = new WebSocket.Server({
+ perMessageDeflate: false,
+ clientTracking: true,
+ maxPayload: 65536,
+ // path: "/mcws",
+ // server,
+ noServer: true,
+ verifyClient,
+ });
+ this.wss = wss;
+ this.mc = new Minecraft();
+
+ wss.on('error', logger.error);
+
+ wss.on('connection', async (ws) => {
+ ws.isAlive = true;
+ ws.subChat = false;
+ ws.subPxl = false;
+ ws.subOnline = false;
+ ws.on('pong', heartbeat);
+
+ ws.on('message', (message) => {
+ if (typeof message === 'string') {
+ this.onTextMessage(message, ws);
+ }
+ });
+ });
+
+ this.ping = this.ping.bind(this);
+ setInterval(this.ping, 45 * 1000);
+ }
+
+ broadcastChatMessage(name, msg, ws = null) {
+ const sendmsg = JSON.stringify(['msg', name, msg]);
+ this.wss.clients.forEach((client) => {
+ if (client !== ws && client.subChat && client.readyState === WebSocket.OPEN) {
+ client.send(sendmsg);
+ }
+ });
+ }
+
+ broadcastMinecraftLink(name, minecraftid, accepted) {
+ const sendmsg = JSON.stringify(['linkver', minecraftid, name, accepted]);
+ this.wss.clients.forEach((client) => {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(sendmsg);
+ }
+ });
+ }
+
+ broadcastMinecraftTP(minecraftid, x, y) {
+ const sendmsg = JSON.stringify(['mctp', minecraftid, x, y]);
+ this.wss.clients.forEach((client) => {
+ if (client.readyState === WebSocket.OPEN) {
+ client.send(sendmsg);
+ }
+ });
+ }
+
+ broadcastOnlineCounter(buffer) {
+ const frame = WebSocket.Sender.frame(buffer, {
+ readOnly: true,
+ mask: false,
+ rsv1: false,
+ opcode: 2,
+ fin: true,
+ });
+ this.wss.clients.forEach((client) => {
+ if (client.subOnline && client.readyState === WebSocket.OPEN) {
+ frame.forEach((buffer) => {
+ try {
+ client._socket.write(buffer);
+ } catch (error) {
+ logger.error('(!) Catched error on write apisocket:', error);
+ }
+ });
+ }
+ });
+ }
+
+ broadcastPixelBuffer(chunkid, buffer) {
+ const frame = WebSocket.Sender.frame(buffer, {
+ readOnly: true,
+ mask: false,
+ rsv1: false,
+ opcode: 2,
+ fin: true,
+ });
+ this.wss.clients.forEach((client) => {
+ if (client.subPxl && client.readyState === WebSocket.OPEN) {
+ frame.forEach((buffer) => {
+ try {
+ client._socket.write(buffer);
+ } catch (error) {
+ logger.error('(!) Catched error on write apisocket:', error);
+ }
+ });
+ }
+ });
+ }
+
+ async onTextMessage(message, ws) {
+ logger.info(`Got message ${message}`);
+ // try {
+ const packet = JSON.parse(message);
+ const command = packet[0];
+ packet.shift();
+ if (!command) {
+ return;
+ }
+ if (command == 'sub') {
+ const even = packet[0];
+ if (even == 'chat') {
+ ws.subChat = true;
+ }
+ if (even == 'pxl') {
+ ws.subPxl = true;
+ }
+ if (even == 'online') {
+ ws.subOnline = true;
+ }
+ return;
+ }
+ if (command == 'setpxl') {
+ const [minecraftid, ip, x, y, clr] = packet;
+ if (clr < 0 || clr > 32) return;
+ // be aware that user null has no cd
+ if (!minecraftid && !ip) {
+ setPixel(0, x, y, clr);
+ ws.send(JSON.stringify(['retpxl', null, null, true, 0, 0]));
+ return;
+ }
+ const user = this.mc.minecraftid2User(minecraftid);
+ user.ip = ip;
+ const { error, success, waitSeconds, coolDownSeconds } = await drawUnsafe(user, 0, x, y, clr);
+ ws.send(JSON.stringify([
+ 'retpxl',
+ (minecraftid) || ip,
+ (error) || null,
+ success,
+ waitSeconds,
+ (coolDownSeconds) || null,
+ ]));
+ return;
+ }
+ if (command == 'login') {
+ const [minecraftid, minecraftname, ip] = packet;
+ const user = await this.mc.report_login(minecraftid, minecraftname);
+ // get userinfo
+ user.ip = ip;
+ const wait = await user.getWait(0);
+ const waitSeconds = (wait) ? (wait - Date.now()) / 1000 : null;
+ const name = (user.id == null) ? null : user.regUser.name;
+ ws.send(JSON.stringify([
+ 'mcme',
+ minecraftid,
+ waitSeconds,
+ name,
+ ]));
+ return;
+ }
+ if (command == 'userlst') {
+ const [userlist] = packet;
+ if (!Array.isArray(userlist) || !Array.isArray(userlist[0])) {
+ logger.error('Got invalid minecraft userlist on APISocketServer');
+ return;
+ }
+ this.mc.report_userlist(userlist);
+ return;
+ }
+ if (command == 'logout') {
+ const [minecraftid] = packet;
+ this.mc.report_logout(minecraftid);
+ return;
+ }
+ if (command == 'mcchat') {
+ const [minecraftname, msg] = packet;
+ const user = this.mc.minecraftname2User(minecraftname);
+ const chatname = (user.id) ? `[MC] ${user.regUser.name}` : `[MC] ${minecraftname}`;
+ broadcastChatMessage(chatname, msg, false);
+ this.broadcastChatMessage(chatname, msg, ws);
+ return;
+ }
+ if (command == 'chat') {
+ const [name, msg] = packet;
+ broadcastChatMessage(name, msg, false);
+ this.broadcastChatMessage(name, msg, ws);
+ return;
+ }
+ if (command == 'linkacc') {
+ const [minecraftid, minecraftname, name] = packet;
+ const ret = await this.mc.linkacc(minecraftid, minecraftname, name);
+ if (!ret) {
+ notifyChangedMe(name);
+ }
+ ws.send(JSON.stringify([
+ 'linkret',
+ minecraftid,
+ ret,
+ ]));
+ }
+ // } catch(err) {
+ // logger.error(`Got undecipherable api-ws message ${message}`);
+ // }
+ }
+
+ ping() {
+ this.wss.clients.forEach((ws) => {
+ if (!ws.isAlive) {
+ return ws.terminate();
+ }
+
+ ws.isAlive = false;
+ ws.ping(() => {});
+ });
+ }
+}
+
+export default APISocketServer;
diff --git a/src/socket/ChatHistory.js b/src/socket/ChatHistory.js
new file mode 100644
index 0000000..f92dfb0
--- /dev/null
+++ b/src/socket/ChatHistory.js
@@ -0,0 +1,22 @@
+/*
+ * save the chat history
+ */
+
+class ChatHistory {
+ OP_CODE = 0xA5;
+ history: Array;
+
+ constructor() {
+ this.history = [];
+ }
+
+ addMessage(name, message) {
+ if (this.history.length > 20) {
+ this.history.shift();
+ }
+ this.history.push([name, message]);
+ }
+}
+
+const chatHistory = new ChatHistory();
+export default chatHistory;
diff --git a/src/socket/ProtocolClient.js b/src/socket/ProtocolClient.js
new file mode 100644
index 0000000..0d337ad
--- /dev/null
+++ b/src/socket/ProtocolClient.js
@@ -0,0 +1,200 @@
+/**
+ *
+ * @flow
+ */
+
+
+import EventEmitter from 'events';
+
+import CoolDownPacket from './packets/CoolDownPacket';
+import PixelUpdate from './packets/PixelUpdate';
+import OnlineCounter from './packets/OnlineCounter';
+import RegisterCanvas from './packets/RegisterCanvas';
+import RegisterChunk from './packets/RegisterChunk';
+import RegisterMultipleChunks from './packets/RegisterMultipleChunks';
+import DeRegisterChunk from './packets/DeRegisterChunk';
+import RequestChatHistory from './packets/RequestChatHistory';
+import ChangedMe from './packets/ChangedMe';
+
+
+const chunks = [];
+
+class ProtocolClient extends EventEmitter {
+ url: string;
+ ws: WebSocket;
+ name: string;
+ canvasId: number;
+ timeConnected: number;
+ isConnected: number;
+
+ constructor() {
+ super();
+ console.log('creating ProtocolClient');
+ this.isConnected = false;
+ this.ws = null;
+ this.name = null;
+ this.canvasId = null;
+ }
+
+ async connect() {
+ if (this.ws) {
+ console.log('WebSocket already open, not starting');
+ }
+ this.timeConnected = Date.now();
+ const protocol = (location.protocol === 'https:') ? 'wss:' : 'ws:';
+ const url = `${protocol}//${location.hostname}${location.port ? `:${location.port}` : ''}/ws`;
+ this.ws = new WebSocket(url);
+ this.ws.binaryType = 'arraybuffer';
+ this.ws.onopen = this.onOpen.bind(this);
+ this.ws.onmessage = this.onMessage.bind(this);
+ this.ws.onclose = this.onClose.bind(this);
+ this.ws.onerror = this.onError.bind(this);
+ }
+
+ onOpen() {
+ this.isConnected = true;
+ this.emit('open', {});
+ this.requestChatHistory();
+ console.log(`Register ${chunks.length} chunks`);
+ this.ws.send(RegisterMultipleChunks.dehydrate(chunks));
+ if (this.canvasId !== null) {
+ this.ws.send(RegisterCanvas.dehydrate(this.canvasId));
+ }
+ }
+
+ onError(err) {
+ console.error('Socket encountered error, closing socket', err);
+ this.ws.close();
+ }
+
+ setName(name) {
+ if (this.isConnected && this.name !== name) {
+ console.log('Name change requieres WebSocket restart');
+ this.reconnect();
+ }
+ }
+
+ setCanvas(canvasId) {
+ if (this.canvasId === canvasId || canvasId === null) {
+ return;
+ }
+ console.log('Notify websocket server that we changed canvas');
+ this.canvasId = canvasId;
+ if (this.isConnected) {
+ this.ws.send(RegisterCanvas.dehydrate(this.canvasId));
+ }
+ }
+
+ registerChunk(cell) {
+ const [i, j] = cell;
+ const chunkid = (i << 8) | j;
+ chunks.push(chunkid);
+ const buffer = RegisterChunk.dehydrate(chunkid);
+ if (this.isConnected) this.ws.send(buffer);
+ }
+
+ deRegisterChunk(cell) {
+ const [i, j] = cell;
+ const chunkid = (i << 8) | j;
+ const buffer = DeRegisterChunk.dehydrate(chunkid);
+ if (this.isConnected) this.ws.send(buffer);
+ const pos = chunks.indexOf(chunkid);
+ if (~pos) chunks.splice(pos, 1);
+ }
+
+ requestChatHistory() {
+ const buffer = RequestChatHistory.dehydrate();
+ if (this.isConnected) this.ws.send(buffer);
+ }
+
+ sendMessage(message) {
+ if (this.isConnected) this.ws.send(message);
+ }
+
+ onMessage({ data: message }) {
+ try {
+ if (typeof message === 'string') {
+ this.onTextMessage(message);
+ } else {
+ this.onBinaryMessage(message);
+ }
+ } catch (err) {
+ console.log(`An error occured while parsing websocket message ${message}`);
+ }
+ }
+
+ onTextMessage(message) {
+ if (!message) return;
+ const data = JSON.parse(message);
+
+ if (Array.isArray(data)) {
+ if (Array.isArray(data[0])) {
+ // Array in Array: Chat History
+ this.emit('chatHistory', data);
+ return;
+ }
+ if (data.length == 2) {
+ // Ordinary array: Chat message
+ const [name, text] = data;
+ this.emit('chatMessage', name, text);
+ }
+ } else {
+ // string = name
+ this.name = data;
+ }
+ }
+
+ onBinaryMessage(buffer) {
+ if (buffer.byteLength === 0) return;
+ const data = new DataView(buffer);
+ const opcode = data.getUint8(0);
+
+ switch (opcode) {
+ case PixelUpdate.OP_CODE:
+ this.emit('pixelUpdate', PixelUpdate.hydrate(data));
+ break;
+ case OnlineCounter.OP_CODE:
+ this.emit('onlineCounter', OnlineCounter.hydrate(data));
+ break;
+ case CoolDownPacket.OP_CODE:
+ this.emit('cooldownPacket', CoolDownPacket.hydrate(data));
+ break;
+ case ChangedMe.OP_CODE:
+ console.log('Websocket requested api/me reload');
+ this.emit('changedMe');
+ break;
+ default:
+ console.error(`Unknown op_code ${opcode} received`);
+ break;
+ }
+ }
+
+ onClose(e) {
+ this.emit('close');
+ this.ws = null;
+ this.isConnected = false;
+ // reconnect in 1s if last connect was longer than 7s ago, else 5s
+ const timeout = (this.timeConnected < Date.now() - 7000) ? 1000 : 5000;
+ console.warn('Socket is closed. ' +
+ `Reconnect will be attempted in ${timeout} ms.`, e.reason);
+
+ setTimeout(() => this.connect(), 5000);
+ }
+
+ close() {
+ this.ws.close();
+ }
+
+ reconnect() {
+ if (this.isConnected) {
+ console.log('Restarting WebSocket');
+ this.ws.onclose = null;
+ this.ws.onmessage = null;
+ this.ws.close();
+ this.ws = null;
+ this.connect();
+ }
+ }
+}
+
+export default new ProtocolClient();
diff --git a/src/socket/SocketServer.js b/src/socket/SocketServer.js
new file mode 100644
index 0000000..246cf4b
--- /dev/null
+++ b/src/socket/SocketServer.js
@@ -0,0 +1,283 @@
+/* @flow */
+
+
+import EventEmitter from 'events';
+import WebSocket from 'ws';
+
+import logger from '../core/logger';
+import Counter from '../utils/Counter';
+import RateLimiter from '../utils/RateLimiter';
+import { getIPFromRequest } from '../utils/ip';
+
+import RegisterCanvas from './packets/RegisterCanvas';
+import RegisterChunk from './packets/RegisterChunk';
+import RegisterMultipleChunks from './packets/RegisterMultipleChunks';
+import DeRegisterChunk from './packets/DeRegisterChunk';
+import DeRegisterMultipleChunks from './packets/DeRegisterMultipleChunks';
+import RequestChatHistory from './packets/RequestChatHistory';
+import CoolDownPacket from './packets/CoolDownPacket';
+import PixelUpdate from './packets/PixelUpdate';
+import ChangedMe from './packets/ChangedMe';
+
+import ChatHistory from './ChatHistory';
+import authenticateClient from './verifyClient';
+import { broadcastChatMessage } from './websockets';
+
+
+const ipCounter: Counter = new Counter();
+
+function heartbeat() {
+ this.isAlive = true;
+}
+
+async function verifyClient(info, done) {
+ const { req } = info;
+
+ // Limiting socket connections per ip
+ const ip = await getIPFromRequest(req);
+ logger.info(`Got ws request from ${ip}`);
+ if (ipCounter.get(ip) > 50) {
+ logger.info(`Client ${ip} has more than 50 connections open.`);
+ return done(false);
+ }
+
+ ipCounter.add(ip);
+ return done(true);
+}
+
+
+class SocketServer extends EventEmitter {
+ wss: WebSocket.Server;
+ CHUNK_CLIENTS: Map;
+
+ // constructor(server: http.Server) {
+ constructor() {
+ super();
+ this.CHUNK_CLIENTS = new Map();
+ logger.info('Starting websocket server');
+
+ const wss = new WebSocket.Server({
+ perMessageDeflate: false,
+ clientTracking: true,
+ maxPayload: 65536,
+ // path: "/ws",
+ // server,
+ noServer: true,
+ verifyClient,
+ });
+ this.wss = wss;
+
+ wss.on('error', logger.error);
+
+ wss.on('connection', async (ws, req) => {
+ ws.isAlive = true;
+ ws.canvasId = null;
+ ws.startDate = Date.now();
+ ws.on('pong', heartbeat);
+ const user = await authenticateClient(req);
+ ws.user = user;
+ ws.name = (user.regUser) ? user.regUser.name : null;
+ ws.rateLimiter = new RateLimiter(20, 15, true);
+ const ip = await getIPFromRequest(req);
+
+ if (ws.name) {
+ ws.send(`"${ws.name}"`);
+ }
+
+ ws.on('error', logger.error);
+ ws.on('close', () => {
+ // is close called on terminate?
+ // possible memory leak?
+ ipCounter.delete(ip);
+ this.deleteAllChunks(ws);
+ });
+ ws.on('message', (message) => {
+ if (typeof message === 'string') { this.onTextMessage(message, ws); } else { this.onBinaryMessage(message, ws); }
+ });
+ });
+
+ this.ping = this.ping.bind(this);
+ this.killOld = this.killOld.bind(this);
+
+ setInterval(this.killOld, 10 * 60 * 1000);
+ // https://github.com/websockets/ws#how-to-detect-and-close-broken-connections
+ setInterval(this.ping, 45 * 1000);
+ }
+
+
+ /**
+ * https://github.com/websockets/ws/issues/617
+ * @param data
+ */
+ broadcast(data: Buffer) {
+ const frame = WebSocket.Sender.frame(data, {
+ readOnly: true,
+ mask: false,
+ rsv1: false,
+ opcode: 2,
+ fin: true,
+ });
+ this.wss.clients.forEach((ws) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ frame.forEach((buffer) => {
+ try {
+ ws._socket.write(buffer);
+ } catch (error) {
+ logger.error('(!) Catched error on write socket:', error);
+ }
+ });
+ }
+ });
+ }
+
+ broadcastText(text: string) {
+ this.wss.clients.forEach((ws) => {
+ if (ws.readyState == WebSocket.OPEN) {
+ ws.send(text);
+ }
+ });
+ }
+
+ broadcastPixelBuffer(canvasId, chunkid, buffer) {
+ const frame = WebSocket.Sender.frame(buffer, {
+ readOnly: true,
+ mask: false,
+ rsv1: false,
+ opcode: 2,
+ fin: true,
+ });
+ if (this.CHUNK_CLIENTS.has(chunkid)) {
+ const clients = this.CHUNK_CLIENTS.get(chunkid);
+ clients.forEach((client) => {
+ if (client.readyState === WebSocket.OPEN && client.canvasId == canvasId) {
+ frame.forEach((buffer) => {
+ try {
+ client._socket.write(buffer);
+ } catch (error) {
+ logger.error('(!) Catched error on write socket:', error);
+ }
+ });
+ }
+ });
+ }
+ }
+
+ notifyChangedMe(name) {
+ this.wss.clients.forEach((ws) => {
+ if (ws.name == name) {
+ const buffer = ChangedMe.dehydrate();
+ ws.send(buffer);
+ }
+ });
+ }
+
+ getConnections(): number {
+ return this.wss.clients.size || 0;
+ }
+
+ killOld() {
+ const now = Date.now();
+ this.wss.clients.forEach((ws) => {
+ const lifetime = now - ws.startDate;
+ if (lifetime > 30 * 60 * 1000) ws.terminate();
+ });
+ }
+
+ ping() {
+ this.wss.clients.forEach((ws) => {
+ if (!ws.isAlive) return ws.terminate();
+
+ ws.isAlive = false;
+ ws.ping(() => {});
+ });
+ }
+
+ onTextMessage(message, ws) {
+ if (ws.name && message) {
+ const waitLeft = ws.rateLimiter.tick();
+ if (waitLeft) {
+ ws.send(JSON.stringify(['info', `You are sending messages too fast, you have to wait ${Math.floor(waitLeft / 1000)}s :(`]));
+ } else {
+ broadcastChatMessage(ws.name, message);
+ }
+ } else {
+ logger.info('Got empty message or message from unidentified ws');
+ }
+ }
+
+ async onBinaryMessage(buffer, ws) {
+ if (buffer.byteLength === 0) return;
+ const opcode = buffer[0];
+
+ switch (opcode) {
+ case RegisterCanvas.OP_CODE:
+ const canvasId = RegisterCanvas.hydrate(buffer);
+ if (ws.canvasId !== null && ws.canvasId !== canvasId) {
+ this.deleteAllChunks(ws);
+ }
+ ws.canvasId = canvasId;
+ const wait = await ws.user.getWait(canvasId);
+ const waitSeconds = (wait) ? Math.ceil((wait - Date.now()) / 1000) : 0;
+ ws.send(CoolDownPacket.dehydrate(waitSeconds));
+ break;
+ case RegisterChunk.OP_CODE:
+ const chunkid = RegisterChunk.hydrate(buffer);
+ this.pushChunk(chunkid, ws);
+ break;
+ case RegisterMultipleChunks.OP_CODE:
+ this.deleteAllChunks(ws);
+ const length = buffer.length;
+ let posu = 2;
+ while (posu < length) {
+ const chunkid = buffer[posu++] | buffer[posu++] << 8;
+ this.pushChunk(chunkid, ws);
+ }
+ break;
+ case DeRegisterChunk.OP_CODE:
+ const chunkidn = DeRegisterChunk.hydrate(buffer);
+ this.deleteChunk(chunkidn, ws);
+ break;
+ case DeRegisterMultipleChunks.OP_CODE:
+ const lengthl = buffer.length;
+ let posl = 2;
+ while (posl < lengthl) {
+ const chunkid = buffer[posl++] | buffer[posl++] << 8;
+ this.deleteChunk(chunkid, ws);
+ }
+ break;
+ case RequestChatHistory.OP_CODE:
+ const history = JSON.stringify(ChatHistory.history);
+ ws.send(history);
+ break;
+ default:
+ break;
+ }
+ }
+
+ pushChunk(chunkid, ws) {
+ if (!this.CHUNK_CLIENTS.has(chunkid)) {
+ this.CHUNK_CLIENTS.set(chunkid, new Array());
+ }
+ const clients = this.CHUNK_CLIENTS.get(chunkid);
+ const pos = clients.indexOf(ws);
+ if (~pos) return;
+ clients.push(ws);
+ }
+
+ deleteChunk(chunkid, ws) {
+ if (!this.CHUNK_CLIENTS.has(chunkid)) return;
+ const clients = this.CHUNK_CLIENTS.get(chunkid);
+ const pos = clients.indexOf(ws);
+ if (~pos) clients.splice(pos, 1);
+ }
+
+ deleteAllChunks(ws) {
+ this.CHUNK_CLIENTS.forEach((client) => {
+ if (!client) return;
+ const pos = client.indexOf(ws);
+ if (~pos) client.splice(pos, 1);
+ });
+ }
+}
+
+export default SocketServer;
diff --git a/src/socket/packets/ChangedMe.js b/src/socket/packets/ChangedMe.js
new file mode 100644
index 0000000..d72fa78
--- /dev/null
+++ b/src/socket/packets/ChangedMe.js
@@ -0,0 +1,13 @@
+/* @flow */
+
+const OP_CODE = 0xA6;
+
+export default {
+ OP_CODE,
+ dehydrate(): ArrayBuffer {
+ const buffer = new ArrayBuffer(1);
+ const view = new DataView(buffer);
+ view.setInt8(0, OP_CODE);
+ return buffer;
+ },
+};
diff --git a/src/socket/packets/CoolDownPacket.js b/src/socket/packets/CoolDownPacket.js
new file mode 100644
index 0000000..21c55ae
--- /dev/null
+++ b/src/socket/packets/CoolDownPacket.js
@@ -0,0 +1,21 @@
+/* @flow */
+
+
+const OP_CODE = 0xC2;
+
+export default {
+ OP_CODE,
+ hydrate(data: DataView) {
+ // SERVER (Client)
+ const waitSeconds = data.getUint16(1);
+ return waitSeconds;
+ },
+ dehydrate(waitSeconds): Buffer {
+ // CLIENT (Sender)
+ const buffer = Buffer.allocUnsafe(1 + 2);
+ buffer.writeUInt8(OP_CODE, 0);
+
+ buffer.writeUInt16BE(waitSeconds, 1);
+ return buffer;
+ },
+};
diff --git a/src/socket/packets/DeRegisterChunk.js b/src/socket/packets/DeRegisterChunk.js
new file mode 100644
index 0000000..5e8c623
--- /dev/null
+++ b/src/socket/packets/DeRegisterChunk.js
@@ -0,0 +1,21 @@
+/* @flow */
+
+
+const OP_CODE = 0xA2;
+
+export default {
+ OP_CODE,
+ hydrate(data: Buffer) {
+ // SERVER (Client)
+ const i = data[1] << 8 | data[2];
+ return i;
+ },
+ dehydrate(chunkid): Buffer {
+ // CLIENT (Sender)
+ const buffer = new ArrayBuffer(1 + 2);
+ const view = new DataView(buffer);
+ view.setInt8(0, OP_CODE);
+ view.setInt16(1, chunkid);
+ return buffer;
+ },
+};
diff --git a/src/socket/packets/DeRegisterMultipleChunks.js b/src/socket/packets/DeRegisterMultipleChunks.js
new file mode 100644
index 0000000..11a6865
--- /dev/null
+++ b/src/socket/packets/DeRegisterMultipleChunks.js
@@ -0,0 +1,20 @@
+/* @flow */
+
+
+const OP_CODE = 0xA4;
+
+export default {
+ OP_CODE,
+ dehydrate(chunks: Array): ArrayBuffer {
+ // CLIENT (Sender)
+ const buffer = new ArrayBuffer(1 + 1 + chunks.length * 2);
+ const view = new Uint16Array(buffer);
+ // this will result into a double first byte, but still better than
+ // shifting 16bit integers around later
+ view[0] = OP_CODE;
+ for (let cnt = 0; cnt < chunks.length; cnt += 1) {
+ view[cnt + 1] = chunks[cnt];
+ }
+ return buffer;
+ },
+};
diff --git a/src/socket/packets/OnlineCounter.js b/src/socket/packets/OnlineCounter.js
new file mode 100644
index 0000000..02517d7
--- /dev/null
+++ b/src/socket/packets/OnlineCounter.js
@@ -0,0 +1,27 @@
+/* @flow */
+
+type OnlineCounterPacket = {
+ online: number,
+};
+
+const OP_CODE = 0xA7;
+
+export default {
+ OP_CODE,
+ hydrate(data: DataView): OnlineCounterPacket {
+ // CLIENT
+ const online = data.getInt16(1);
+ return { online };
+ },
+ dehydrate({ online }: OnlineCounterPacket): Buffer {
+ // SERVER
+ if (!process.env.BROWSER) {
+ const buffer = Buffer.allocUnsafe(1 + 2);
+ buffer.writeUInt8(OP_CODE, 0);
+
+ buffer.writeInt16BE(online, 1);
+
+ return buffer;
+ }
+ },
+};
diff --git a/src/socket/packets/PixelUpdate.js b/src/socket/packets/PixelUpdate.js
new file mode 100644
index 0000000..dc07246
--- /dev/null
+++ b/src/socket/packets/PixelUpdate.js
@@ -0,0 +1,44 @@
+/* @flow */
+
+
+import type { ColorIndex } from '../../core/Palette';
+import {
+ getChunkOfPixel,
+ getOffsetOfPixel,
+ getPixelFromChunkOffset,
+} from '../../core/utils';
+
+
+type PixelUpdatePacket = {
+ x: number,
+ y: number,
+ color: ColorIndex,
+};
+
+const OP_CODE = 0xC1; // Chunk Update
+
+export default {
+ OP_CODE,
+ hydrate(data: DataView): PixelUpdatePacket {
+ // CLIENT
+ const i = data.getInt16(1);
+ const j = data.getInt16(3);
+ const offset = data.getUint16(5);
+ const color = data.getUint8(7);
+ return { i, j, offset, color };
+ },
+ dehydrate(i, j, offset, color): Buffer {
+ // SERVER
+ if (!process.env.BROWSER) {
+ const buffer = Buffer.allocUnsafe(1 + 2 + 2 + 2 + 1);
+ buffer.writeUInt8(OP_CODE, 0);
+
+ buffer.writeInt16BE(i, 1);
+ buffer.writeInt16BE(j, 3);
+ buffer.writeUInt16BE(offset, 5);
+ buffer.writeUInt8(color, 7);
+
+ return buffer;
+ }
+ },
+};
diff --git a/src/socket/packets/RegisterCanvas.js b/src/socket/packets/RegisterCanvas.js
new file mode 100644
index 0000000..d122dab
--- /dev/null
+++ b/src/socket/packets/RegisterCanvas.js
@@ -0,0 +1,21 @@
+/* @flow */
+
+
+const OP_CODE = 0xA0;
+
+export default {
+ OP_CODE,
+ hydrate(data: Buffer) {
+ // SERVER (Client)
+ const canvasId = data[1];
+ return canvasId;
+ },
+ dehydrate(canvasId): ArrayBuffer {
+ // CLIENT (Sender)
+ const buffer = new ArrayBuffer(1 + 1);
+ const view = new DataView(buffer);
+ view.setInt8(0, OP_CODE);
+ view.setInt8(1, canvasId);
+ return buffer;
+ },
+};
diff --git a/src/socket/packets/RegisterChunk.js b/src/socket/packets/RegisterChunk.js
new file mode 100644
index 0000000..6e68392
--- /dev/null
+++ b/src/socket/packets/RegisterChunk.js
@@ -0,0 +1,21 @@
+/* @flow */
+
+
+const OP_CODE = 0xA1;
+
+export default {
+ OP_CODE,
+ hydrate(data: Buffer) {
+ // SERVER (Client)
+ const i = data[1] << 8 | data[2];
+ return i;
+ },
+ dehydrate(chunkid): ArrayBuffer {
+ // CLIENT (Sender)
+ const buffer = new ArrayBuffer(1 + 2);
+ const view = new DataView(buffer);
+ view.setInt8(0, OP_CODE);
+ view.setInt16(1, chunkid);
+ return buffer;
+ },
+};
diff --git a/src/socket/packets/RegisterMultipleChunks.js b/src/socket/packets/RegisterMultipleChunks.js
new file mode 100644
index 0000000..e4c99c8
--- /dev/null
+++ b/src/socket/packets/RegisterMultipleChunks.js
@@ -0,0 +1,20 @@
+/* @flow */
+
+
+const OP_CODE = 0xA3;
+
+export default {
+ OP_CODE,
+ dehydrate(chunks: Array): ArrayBuffer {
+ // CLIENT (Sender)
+ const buffer = new ArrayBuffer(1 + 1 + chunks.length * 2);
+ const view = new Uint16Array(buffer);
+ // this will result into a double first byte, but still better than
+ // shifting 16bit integers around later
+ view[0] = OP_CODE;
+ for (let cnt = 0; cnt < chunks.length; cnt += 1) {
+ view[cnt + 1] = chunks[cnt];
+ }
+ return buffer;
+ },
+};
diff --git a/src/socket/packets/RequestChatHistory.js b/src/socket/packets/RequestChatHistory.js
new file mode 100644
index 0000000..0515bf9
--- /dev/null
+++ b/src/socket/packets/RequestChatHistory.js
@@ -0,0 +1,13 @@
+/* @flow */
+
+const OP_CODE = 0xA5;
+
+export default {
+ OP_CODE,
+ dehydrate(): ArrayBuffer {
+ const buffer = new ArrayBuffer(1);
+ const view = new DataView(buffer);
+ view.setInt8(0, OP_CODE);
+ return buffer;
+ },
+};
diff --git a/src/socket/verifyClient.js b/src/socket/verifyClient.js
new file mode 100644
index 0000000..0482681
--- /dev/null
+++ b/src/socket/verifyClient.js
@@ -0,0 +1,50 @@
+/*
+ * used to authenticate websocket session
+ */
+
+import express from 'express';
+
+import logger from '../core/logger';
+import session from '../core/session';
+import passport from '../core/passport';
+import { User } from '../data/models';
+import { getIPFromRequest, getIPv6Subnet } from '../utils/ip';
+
+const router = express.Router();
+
+router.use(session);
+
+/*
+ * create dummy user that has just ip and id
+ * (cut IPv6 to subnet to prevent abuse)
+ */
+router.use(async (req, res, next) => {
+ const session = req.session;
+ const ip = await getIPFromRequest(req);
+ const trueIp = ip || '0.0.0.1';
+ req.trueIp = trueIp;
+ const user = new User(null, getIPv6Subnet(trueIp));
+ req.noauthUser = user;
+ next();
+});
+
+
+router.use(passport.initialize());
+router.use(passport.session());
+
+
+export function authenticateClient(req) {
+ return new Promise(
+ ((resolve, reject) => {
+ router(req, {}, () => {
+ if (req.user) {
+ resolve(req.user);
+ } else {
+ resolve(req.noauthUser);
+ }
+ });
+ }),
+ );
+}
+
+export default authenticateClient;
diff --git a/src/socket/websockets.js b/src/socket/websockets.js
new file mode 100644
index 0000000..f44f9c9
--- /dev/null
+++ b/src/socket/websockets.js
@@ -0,0 +1,127 @@
+/* @flow
+ *
+ * Serverside communication with websockets.
+ * In general all values that get broadcasted here have to be sanitized already.
+ *
+ */
+
+import url from 'url';
+
+import logger from '../core/logger';
+import { SECOND } from '../core/constants';
+import { getChunkOfPixel } from '../core/utils';
+
+import ChatHistory from './ChatHistory';
+import SocketServer from './SocketServer';
+import APISocketServer from './APISocketServer';
+import OnlineCounter from './packets/OnlineCounter';
+import PixelUpdate from './packets/PixelUpdate';
+
+const usersocket = new SocketServer();
+const apisocket = new APISocketServer();
+
+/*
+ * broadcast message via websocket
+ * @param message Message to send
+ */
+export async function broadcast(message: Buffer) {
+ if (usersocket) usersocket.broadcast(message);
+}
+
+/*
+ * broadcast pixel message via websocket
+ * @param canvasIdent ident of canvas
+ * @param i x coordinates of chunk
+ * @param j y coordinates of chunk
+ * @param offset offset of pixel within this chunk
+ * @param color colorindex
+ */
+export async function broadcastPixel(
+ canvasId: number,
+ i: number,
+ j: number,
+ offset: number,
+ color: number,
+) {
+ const chunkid = (i << 8) | j;
+ const buffer = PixelUpdate.dehydrate(i, j, offset, color);
+ if (usersocket) usersocket.broadcastPixelBuffer(canvasId, chunkid, buffer);
+ if (apisocket && canvasId == 0) apisocket.broadcastPixelBuffer(chunkid, buffer);
+}
+
+/*
+ * broadcast chat message
+ * @param name chatname
+ * @param message Message to send
+ * @param sendapi If chat message should get boradcasted to api websocket
+ * (usefull if the api is supposed to not answer to its own messages)
+ */
+export async function broadcastChatMessage(name: string, message: string, sendapi: boolean = true) {
+ logger.info(`Received chat message ${message} from ${name}`);
+ ChatHistory.addMessage(name, message);
+ if (usersocket) usersocket.broadcastText(JSON.stringify([name, message]));
+ if (sendapi && apisocket) apisocket.broadcastChatMessage(name, message);
+}
+
+/*
+ * broadcast minecraft linking to API
+ * @param name pixelplanetname
+ * @param minecraftid minecraftid
+ * @param accepted If link request got accepted
+ */
+export async function broadcastMinecraftLink(name: string, minecraftid: string, accepted: boolean) {
+ if (apisocket) apisocket.broadcastMinecraftLink(name, minecraftid, accepted);
+}
+
+/*
+ * Notify user on websocket that he should rerequest api/message
+ * Currently just used for getting minecraft link message.
+ */
+export async function notifyChangedMe(name: string) {
+ if (usersocket) usersocket.notifyChangedMe(name);
+}
+
+/*
+ * broadcast mc tp request to API
+ * @param minecraftid minecraftid
+ * @param x x coords
+ * @param y y coords
+ */
+export async function broadcastMinecraftTP(minecraftid, x, y) {
+ if (apisocket) apisocket.broadcastMinecraftTP(minecraftid, x, y);
+}
+
+/*
+ * send websocket package of online counter every x seconds
+ */
+function startOnlineCounterBroadcast() {
+ setInterval(() => {
+ if (usersocket) {
+ const online = usersocket.getConnections();
+ const buffer = OnlineCounter.dehydrate({ online });
+ usersocket.broadcast(buffer);
+ if (apisocket) apisocket.broadcastOnlineCounter(buffer);
+ }
+ }, 15 * SECOND);
+}
+startOnlineCounterBroadcast();
+
+/*
+ * websocket upgrade / establishing connection
+ * Get hooked up to httpServer and routes to the right socket
+ */
+export function wsupgrade(request, socket, head) {
+ const pathname = url.parse(request.url).pathname;
+
+ if (pathname === '/ws') {
+ usersocket.wss.handleUpgrade(request, socket, head, (ws) => {
+ usersocket.wss.emit('connection', ws, request);
+ });
+ } else if (pathname === '/mcws') {
+ apisocket.wss.handleUpgrade(request, socket, head, (ws) => {
+ apisocket.wss.emit('connection', ws, request);
+ });
+ } else {
+ socket.destroy();
+ }
+}
diff --git a/src/store/ads.js b/src/store/ads.js
new file mode 100644
index 0000000..147ae16
--- /dev/null
+++ b/src/store/ads.js
@@ -0,0 +1,27 @@
+/**
+ *
+ * Copyright 2017 Google Inc. All Rights Reserved.
+ * You may study, modify, and use this example for any purpose.
+ * Note that this example is provided "as is", WITHOUT WARRANTY
+ * of any kind either expressed or implied.
+ *
+ * @flow
+ */
+
+import { playAd } from '../ui/ads';
+
+
+export default store => next => (action) => {
+ switch (action.type) {
+ case 'PLACE_PIXEL': {
+ // wait 1 second
+ setTimeout(playAd, 300);
+ break;
+ }
+
+ default:
+ // nothing
+ }
+
+ return next(action);
+};
diff --git a/src/store/analytics.js b/src/store/analytics.js
new file mode 100644
index 0000000..7d832fa
--- /dev/null
+++ b/src/store/analytics.js
@@ -0,0 +1,28 @@
+/**
+ * Copyright 2016 Facebook, Inc.
+ *
+ * You are hereby granted a non-exclusive, worldwide, royalty-free license to
+ * use, copy, modify, and distribute this software in source code or binary
+ * form for use in connection with the web services and APIs provided by
+ * Facebook.
+ *
+ * As with any software that integrates with the Facebook platform, your use
+ * of this software is subject to the Facebook Developer Principles and
+ * Policies [http://developers.facebook.com/policy/]. This copyright notice
+ * shall be included in all copies or substantial portions of the software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * 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
+ */
+
+import track from './track';
+
+export default store => next => (action) => {
+ track(action);
+ return next(action);
+};
diff --git a/src/store/array.js b/src/store/array.js
new file mode 100644
index 0000000..fc3ed59
--- /dev/null
+++ b/src/store/array.js
@@ -0,0 +1,26 @@
+/**
+ * Copyright 2016 Facebook, Inc.
+ *
+ * You are hereby granted a non-exclusive, worldwide, royalty-free license to
+ * use, copy, modify, and distribute this software in source code or binary
+ * form for use in connection with the web services and APIs provided by
+ * Facebook.
+ *
+ * As with any software that integrates with the Facebook platform, your use
+ * of this software is subject to the Facebook Developer Principles and
+ * Policies [http://developers.facebook.com/policy/]. This copyright notice
+ * shall be included in all copies or substantial portions of the software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * 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
+ */
+
+export default store => next => action =>
+ (Array.isArray(action)
+ ? action.map(next)
+ : next(action));
diff --git a/src/store/audio.js b/src/store/audio.js
new file mode 100644
index 0000000..0570be3
--- /dev/null
+++ b/src/store/audio.js
@@ -0,0 +1,156 @@
+/* @flow
+ *
+ * play sounds using the HTML5 AudoContext
+ *
+ * */
+
+
+const COLORS_AMOUNT = 32;
+// iPhone needs this
+const AudioContext = window.AudioContext || window.webkitAudioContext;
+const context = new AudioContext();
+
+export default store => next => (action) => {
+ const { mute, chatNotify } = store.getState().audio;
+
+ switch (action.type) {
+ case 'SELECT_COLOR': {
+ if (mute) break;
+ const oscillatorNode = context.createOscillator();
+ const gainNode = context.createGain();
+
+ oscillatorNode.type = 'sine';
+ oscillatorNode.detune.value = -600;
+
+ oscillatorNode.frequency.setValueAtTime(600, context.currentTime);
+ oscillatorNode.frequency.setValueAtTime(700, context.currentTime + 0.1);
+
+
+ gainNode.gain.setValueAtTime(0.3, context.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.2, context.currentTime + 0.1);
+
+ oscillatorNode.connect(gainNode);
+ gainNode.connect(context.destination);
+
+ oscillatorNode.start();
+ oscillatorNode.stop(context.currentTime + 0.2);
+ break;
+ }
+
+ case 'PIXEL_WAIT': {
+ if (mute) break;
+ const oscillatorNode = context.createOscillator();
+ const gainNode = context.createGain();
+
+ oscillatorNode.type = 'sine';
+ // oscillatorNode.detune.value = -600
+
+ oscillatorNode.frequency.setValueAtTime(1479.98, context.currentTime);
+ oscillatorNode.frequency.exponentialRampToValueAtTime(493.88, context.currentTime + 0.01);
+
+
+ gainNode.gain.setValueAtTime(0.5, context.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.2, context.currentTime + 0.1);
+
+ oscillatorNode.connect(gainNode);
+ gainNode.connect(context.destination);
+
+ oscillatorNode.start();
+ oscillatorNode.stop(context.currentTime + 0.1);
+ break;
+ }
+
+ case 'PIXEL_FAILURE': {
+ if (mute) break;
+ const oscillatorNode = context.createOscillator();
+ const gainNode = context.createGain();
+
+ oscillatorNode.type = 'sine';
+ oscillatorNode.detune.value = -900;
+ oscillatorNode.frequency.setValueAtTime(600, context.currentTime);
+ oscillatorNode.frequency.setValueAtTime(1400, context.currentTime + 0.025);
+ oscillatorNode.frequency.setValueAtTime(1200, context.currentTime + 0.05);
+ oscillatorNode.frequency.setValueAtTime(900, context.currentTime + 0.075);
+
+ const lfo = context.createOscillator();
+ lfo.type = 'sine';
+ lfo.frequency.value = 2.0;
+ lfo.connect(gainNode.gain);
+ oscillatorNode.connect(gainNode);
+ gainNode.connect(context.destination);
+
+ oscillatorNode.start();
+ lfo.start();
+ oscillatorNode.stop(context.currentTime + 0.3);
+ break;
+ }
+
+ case 'PLACE_PIXEL' : {
+ if (mute) break;
+ const { color } = action;
+ const clrFreq = 100 + Math.log(color / COLORS_AMOUNT + 1) * 300;
+ const oscillatorNode = context.createOscillator();
+ const gainNode = context.createGain();
+
+ oscillatorNode.type = 'sine';
+ oscillatorNode.frequency.setValueAtTime(clrFreq, context.currentTime);
+ oscillatorNode.frequency.exponentialRampToValueAtTime(1400, context.currentTime + 0.2);
+
+ gainNode.gain.setValueAtTime(0.5, context.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.2, context.currentTime + 0.1);
+
+ oscillatorNode.connect(gainNode);
+ gainNode.connect(context.destination);
+
+ oscillatorNode.start();
+ oscillatorNode.stop(context.currentTime + 0.1);
+ break;
+ }
+
+ case 'COOLDOWN_END' : {
+ if (mute) break;
+ const oscillatorNode = context.createOscillator();
+ const gainNode = context.createGain();
+
+ oscillatorNode.type = 'sine';
+ oscillatorNode.frequency.setValueAtTime(349.23, context.currentTime);
+ oscillatorNode.frequency.setValueAtTime(523.25, context.currentTime + 0.1);
+ oscillatorNode.frequency.setValueAtTime(698.46, context.currentTime + 0.2);
+
+ gainNode.gain.setValueAtTime(0.5, context.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.2, context.currentTime + 0.15);
+
+ oscillatorNode.connect(gainNode);
+ gainNode.connect(context.destination);
+
+ oscillatorNode.start();
+ oscillatorNode.stop(context.currentTime + 0.3);
+ break;
+ }
+
+ case 'RECEIVE_CHAT_MESSAGE': {
+ if (!chatNotify) break;
+ const oscillatorNode = context.createOscillator();
+ const gainNode = context.createGain();
+
+ oscillatorNode.type = 'sine';
+ oscillatorNode.frequency.setValueAtTime(310, context.currentTime);
+ oscillatorNode.frequency.exponentialRampToValueAtTime(355, context.currentTime + 0.025);
+
+ gainNode.gain.setValueAtTime(0.1, context.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.1, context.currentTime + 0.1);
+
+ oscillatorNode.connect(gainNode);
+ gainNode.connect(context.destination);
+
+ oscillatorNode.start();
+ oscillatorNode.stop(context.currentTime + 0.075);
+ break;
+ }
+
+ default:
+ // nothing
+ }
+
+ return next(action);
+};
diff --git a/src/store/configureStore.js b/src/store/configureStore.js
new file mode 100644
index 0000000..ede17d1
--- /dev/null
+++ b/src/store/configureStore.js
@@ -0,0 +1,51 @@
+/* @flow */
+
+import { applyMiddleware, createStore, compose } from 'redux';
+import thunk from 'redux-thunk';
+import { createLogger } from 'redux-logger';
+import { persistStore, autoRehydrate } from 'redux-persist';
+
+import ads from './ads';
+import audio from './audio';
+import analytics from './analytics';
+import array from './array';
+import promise from './promise';
+import notifications from './notifications';
+import title from './title';
+import reducers from '../reducers';
+
+
+const isDebuggingInChrome = __DEV__ && !!window.navigator.userAgent;
+
+const logger = createLogger({
+ predicate: (getState, action) => isDebuggingInChrome,
+ collapsed: true,
+ duration: true,
+});
+
+const store = createStore(
+ reducers,
+ undefined,
+ compose(
+ applyMiddleware(
+ thunk,
+ promise,
+ array,
+ ads,
+ audio,
+ notifications,
+ title,
+ analytics,
+ logger,
+ ),
+ ),
+);
+
+
+export default function configureStore(onComplete: ?() => void) {
+ persistStore(store, null, onComplete);
+ if (isDebuggingInChrome) {
+ window.store = store;
+ }
+ return store;
+}
diff --git a/src/store/notifications.js b/src/store/notifications.js
new file mode 100644
index 0000000..2e18dba
--- /dev/null
+++ b/src/store/notifications.js
@@ -0,0 +1,50 @@
+/**
+ * Notifications
+ *
+ * @flow
+ */
+
+import Push from 'push.js';
+
+function onGranted() {
+
+}
+function onDenied() {
+
+}
+
+
+export default store => next => (action) => {
+ if (!Push.isSupported) return next(action);
+
+ switch (action.type) {
+ case 'PLACE_PIXEL': {
+ // request permission
+ // gives callback error now
+ Push.Permission.request(onGranted, onDenied);
+
+ // clear notifications
+ Push.clear();
+ break;
+ }
+
+ case 'COOLDOWN_END': {
+ Push.create('Your next pixel is now available', {
+ icon: '/tile.png',
+ silent: true,
+ vibrate: [200, 100],
+ onClick() {
+ parent.focus();
+ window.focus();
+ Push.clear();
+ },
+ });
+ break;
+ }
+
+ default:
+ // nothing
+ }
+
+ return next(action);
+};
diff --git a/src/store/promise.js b/src/store/promise.js
new file mode 100644
index 0000000..a3fc04a
--- /dev/null
+++ b/src/store/promise.js
@@ -0,0 +1,31 @@
+/**
+ * Copyright 2016 Facebook, Inc.
+ *
+ * You are hereby granted a non-exclusive, worldwide, royalty-free license to
+ * use, copy, modify, and distribute this software in source code or binary
+ * form for use in connection with the web services and APIs provided by
+ * Facebook.
+ *
+ * As with any software that integrates with the Facebook platform, your use
+ * of this software is subject to the Facebook Developer Principles and
+ * Policies [http://developers.facebook.com/policy/]. This copyright notice
+ * shall be included in all copies or substantial portions of the software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * 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
+ */
+
+function warn(error) {
+ console.warn(error.message || error);
+ throw error; // To let the caller handle the rejection
+}
+
+export default store => next => action =>
+ (typeof action.then === 'function'
+ ? Promise.resolve(action).then(next, warn)
+ : next(action));
diff --git a/src/store/title.js b/src/store/title.js
new file mode 100644
index 0000000..667aebf
--- /dev/null
+++ b/src/store/title.js
@@ -0,0 +1,37 @@
+/**
+ *
+ * @flow
+ */
+
+import {
+ durationToString,
+} from '../core/utils';
+
+
+const TITLE = 'PixelPlanet.fun';
+
+let lastTitle = null;
+
+export default store => next => (action) => {
+ switch (action.type) {
+ case 'COOLDOWN_SET': {
+ const { coolDown } = store.getState().user;
+ const title = `${durationToString(coolDown, true)} | ${TITLE}`;
+ if (lastTitle === title) break;
+ lastTitle = title;
+ document.title = title;
+ break;
+ }
+
+ case 'COOLDOWN_END': {
+ document.title = TITLE;
+ break;
+ }
+
+ default:
+ // nothing
+ }
+
+ return next(action);
+};
+
diff --git a/src/store/track.js b/src/store/track.js
new file mode 100644
index 0000000..010cd73
--- /dev/null
+++ b/src/store/track.js
@@ -0,0 +1,46 @@
+/**
+ * Copyright 2016 Facebook, Inc.
+ *
+ * You are hereby granted a non-exclusive, worldwide, royalty-free license to
+ * use, copy, modify, and distribute this software in source code or binary
+ * form for use in connection with the web services and APIs provided by
+ * Facebook.
+ *
+ * As with any software that integrates with the Facebook platform, your use
+ * of this software is subject to the Facebook Developer Principles and
+ * Policies [http://developers.facebook.com/policy/]. This copyright notice
+ * shall be included in all copies or substantial portions of the software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * 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
+ *
+ * @flow
+ */
+
+import type { Action } from '../actions/types';
+
+
+export default function track(action: Action): void {
+ if (typeof ga === 'undefined') return;
+
+ switch (action.type) {
+ case 'PLACE_PIXEL': {
+ const [x, y] = action.coordinates;
+ ga('send', {
+ hitType: 'event',
+ eventCategory: 'Place',
+ eventAction: action.color,
+ eventLabel: `${x},${y}`,
+ });
+ break;
+ }
+
+ default:
+ // nothing
+ }
+}
diff --git a/src/ui/ChunkRGB.js b/src/ui/ChunkRGB.js
new file mode 100644
index 0000000..bb450e8
--- /dev/null
+++ b/src/ui/ChunkRGB.js
@@ -0,0 +1,142 @@
+/* @flow */
+
+import type { Cell } from '../core/Cell';
+import type { Palette } from '../core/Palette';
+
+import { TILE_SIZE, TILE_LOADING_IMAGE } from '../core/constants';
+
+import loadImage from './loadImage';
+
+
+export let loadingTile = null;
+loadImage(TILE_LOADING_IMAGE).then((img) => { loadingTile = img; });
+
+
+class ChunkRGB {
+ cell: Cell;
+ key: string;
+ image: HTMLCanvasElement;
+ ready: boolean;
+ timestamp: number;
+ palette: Palette;
+ isBasechunk: boolean;
+
+ constructor(palette: Palette, cell: Cell) {
+ // isBasechunk gets set to true by RECEIVE_BIG_CHUNK
+ // if true => chunk got requested from api/chunk and
+ // receives websocket pixel updates
+ // if false => chunk is an zoomed png tile
+ this.isBasechunk = true;
+ this.palette = palette;
+ this.image = document.createElement('canvas');
+ this.image.width = TILE_SIZE;
+ this.image.height = TILE_SIZE;
+ this.cell = cell;
+ this.key = ChunkRGB.getKey(...cell);
+ this.ready = false;
+ this.timestamp = Date.now();
+ }
+
+ /*
+ preLoad(chunks) {
+ // what to display while chunk is loading
+ // NOTE: i tried to create from smaller chunk, but that is
+ // unreasonably slow, so we just default to a loading pic.
+ //
+ // Creating from larger chunk is faster, but still slow.
+ // so i also commented it out.
+ //
+ // Since we now just load the loading tile always
+ // i commented this also out and do it in ui/render instead
+ const ctx = this.image.getContext('2d');
+ const [cz, cx, cy] = this.cell;
+ if (cz > 0) {
+ const target = cz - 1;
+ const key = ChunkRGB.getKey(target, Math.floor(cx / TILE_ZOOM_LEVEL), Math.floor(cy / TILE_ZOOM_LEVEL));
+ console.log("ask for key", key);
+ const chunk = chunks.get(ChunkRGB.getKey(target, Math.floor(cx / TILE_ZOOM_LEVEL), Math.floor(cy / TILE_ZOOM_LEVEL)));
+ if (chunk) {
+ const dx = -mod(cx, TILE_ZOOM_LEVEL) * TILE_SIZE;
+ const dy = -mod(cy, TILE_ZOOM_LEVEL) * TILE_SIZE;
+ console.log("create from larger chunk with dx", dx, "dy", dy);
+ ctx.save();
+ ctx.scale(TILE_ZOOM_LEVEL, TILE_ZOOM_LEVEL);
+ ctx.drawImage(chunk.image, dx / TILE_ZOOM_LEVEL, dy / TILE_ZOOM_LEVEL);
+ ctx.restore();
+ return;
+ }
+ }
+ if (loadingTile) {
+ ctx.drawImage(loadingTile, 0, 0);
+ return;
+ } else {
+ ctx.fillStyle = this.palette.colors[2];
+ ctx.fillRect(0, 0, TILE_SIZE, TILE_SIZE);
+ }
+ }
+ */
+
+ fromBuffer(chunkBuffer: Uint8Array) {
+ this.ready = true;
+ const imageData = new ImageData(TILE_SIZE, TILE_SIZE);
+ const imageView = new Uint32Array(imageData.data.buffer);
+ const colors = this.palette.buffer2ABGR(chunkBuffer);
+ colors.forEach((color, index) => {
+ imageView[index] = color;
+ });
+ const ctx = this.image.getContext('2d');
+ ctx.putImageData(imageData, 0, 0);
+ }
+
+ fromImage(img: Image) {
+ this.ready = true;
+ const ctx = this.image.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ }
+
+ empty() {
+ this.ready = true;
+ const ctx = this.image.getContext('2d');
+ ctx.fillStyle = this.palette.colors[0];
+ ctx.fillRect(0, 0, TILE_SIZE, TILE_SIZE);
+ }
+
+ static getKey(z: number, x: number, y: number) {
+ // this is also hardcoded into core/utils.js at getColorIndexOfPixel
+ // just to prevent whole ChunkRGB to get loaded into web.js
+ // ...could test that at some point if really neccessary
+ return `${z}:${x}:${y}`;
+ }
+
+ static getIndexFromCell([x, y]: Cell): number {
+ return x + (TILE_SIZE * y);
+ }
+
+ getColorIndex(cell: Cell): ColorIndex {
+ const [x, y] = cell;
+ const ctx = this.image.getContext('2d');
+
+ const rgb = ctx.getImageData(x, y, 1, 1).data;
+ return this.palette.getIndexOfColor(rgb[0], rgb[1], rgb[2]);
+ }
+
+ hasColorIn(cell: Cell, color: ColorIndex): boolean {
+ const index = ChunkRGB.getIndexFromCell(cell);
+
+ const ctx = this.image.getContext('2d');
+ const imageData = ctx.getImageData(0, 0, TILE_SIZE, TILE_SIZE);
+ const intView = new Uint32Array(imageData.data.buffer);
+
+ return (intView[index] === this.palette.abgr[color]);
+ }
+
+ setColor(cell: Cell, color: ColorIndex): boolean {
+ const [x, y] = cell;
+ const ctx = this.image.getContext('2d');
+ ctx.fillStyle = this.palette.colors[color];
+ ctx.fillRect(x, y, 1, 1);
+ return true;
+ }
+}
+
+export default ChunkRGB;
diff --git a/src/ui/PixelNotify.js b/src/ui/PixelNotify.js
new file mode 100644
index 0000000..32105a7
--- /dev/null
+++ b/src/ui/PixelNotify.js
@@ -0,0 +1,92 @@
+/*
+ *
+ * Notification when someone places a pixel nearby
+ * Red increasing circle.
+ *
+ * @flow
+ */
+
+import type { State } from '../reducers';
+
+import { clamp, worldToScreen } from '../core/utils';
+
+
+class PixelNotify {
+ static NOTIFICATION_TIME = 1100;
+
+ scale: number;
+ notifcircle: HTMLCanvasElement;
+ notificationRadius: number;
+ pixelList: Array;
+
+ constructor() {
+ // initialise notification circle image
+ // (rendering the circle on a offscreen canvas and then putting this
+ // on the canvas is faster than drawing it every time)
+ this.pixelList = [];
+ this.notificationRadius = 150;
+ this.notifcircle = document.createElement('canvas');
+ this.notifcircle.width = 200;
+ this.notifcircle.height = 200;
+ const notifcontext = this.notifcircle.getContext('2d');
+ if (!notifcontext) return;
+
+ notifcontext.fillStyle = '#FF000055';
+ notifcontext.beginPath();
+ notifcontext.arc(100, 100, 100, 0, 2 * Math.PI);
+ notifcontext.closePath();
+ notifcontext.fill();
+ }
+
+
+ addPixel(x: number, y: number) {
+ if (this.pixelList.length < 300) {
+ this.pixelList.unshift([Date.now(), x, y]);
+ }
+ }
+
+
+ doRender() {
+ return (this.pixelList.length != 0);
+ }
+
+
+ updateScale(scale: number) {
+ this.scale = scale;
+ this.notificationRadius = clamp(this.scale * 10, 20, 400);
+ }
+
+
+ render(
+ state: State,
+ $viewport: HTMLCanvasElement,
+ ) {
+ const viewportCtx = $viewport.getContext('2d');
+ if (!viewportCtx) return;
+
+ const curTime = Date.now();
+ let index = this.pixelList.length;
+ while (index > 0) {
+ index--;
+ const [setTime, x, y] = this.pixelList[index];
+ const timePasseded = curTime - setTime;
+ if (timePasseded > PixelNotify.NOTIFICATION_TIME) {
+ this.pixelList.pop();
+ continue;
+ }
+ const [sx, sy] = worldToScreen(state, $viewport, [x, y])
+ .map(x => x + this.scale / 2);
+
+ const notRadius = timePasseded / PixelNotify.NOTIFICATION_TIME * this.notificationRadius;
+ const circleScale = notRadius / 100;
+ viewportCtx.save();
+ viewportCtx.scale(circleScale, circleScale);
+ viewportCtx.drawImage(this.notifcircle, sx / circleScale - 100, sy / circleScale - 100);
+ viewportCtx.restore();
+ }
+ }
+}
+
+const pixelNotify = new PixelNotify();
+
+export default pixelNotify;
diff --git a/src/ui/Renderer.js b/src/ui/Renderer.js
new file mode 100644
index 0000000..026fd95
--- /dev/null
+++ b/src/ui/Renderer.js
@@ -0,0 +1,348 @@
+/*
+ *
+ * @flow
+ */
+
+import type { Cell } from '../core/Cell';
+import type { State } from '../reducers';
+import { TILE_SIZE } from '../core/constants';
+
+import {
+ getTileOfPixel,
+ getPixelFromChunkOffset,
+} from '../core/utils';
+import store from './store';
+import { fetchChunk, fetchTile } from '../actions';
+
+import {
+ renderGrid,
+ renderPlaceholder,
+ renderPotatoPlaceholder,
+} from './renderelements';
+import ChunkRGB, { loadingTile } from './ChunkRGB';
+
+
+import pixelNotify from './PixelNotify';
+
+// dimensions of offscreen canvas NOT whole canvas
+const CANVAS_WIDTH = screen.width * 2;
+const CANVAS_HEIGHT = screen.height * 2;
+const SCALE_THREASHOLD = Math.min(
+ CANVAS_WIDTH / TILE_SIZE / 3,
+ CANVAS_HEIGHT / TILE_SIZE / 3,
+);
+
+
+class Renderer {
+ lastFetchs: number;
+ centerChunk: Cell;
+ view: Cell;
+ tiledScale: number;
+ tiledZoom: number;
+ hover: boolean;
+ canvasId: number;
+ //--
+ viewport: HTMLCanvasElement = null;
+ //--
+ scale: number;
+ forceNextRender: boolean;
+ forceNextSubrender: boolean;
+ canvas: HTMLCanvasElement;
+ lastFetch: number;
+
+ constructor() {
+ this.lastFetchs = 0;
+ this.centerChunk = [null, null];
+ this.tiledScale = 0;
+ this.tiledZoom = 4;
+ this.hover = false;
+ this.canvasId = null;
+ //--
+ this.scale = 0;
+ this.forceNextRender = true;
+ this.forceNextSubrender = true;
+ this.lastFetch = 0;
+ //--
+ this.canvas = document.createElement('canvas');
+ this.canvas.width = CANVAS_WIDTH;
+ this.canvas.height = CANVAS_HEIGHT;
+
+ const context = this.canvas.getContext('2d');
+ if (!context) return;
+
+ context.fillStyle = '#000000';
+ context.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
+ }
+
+ // HAS to be set before any rendering can happen
+ setViewport(viewport: HTMLCanvasElement) {
+ this.viewport = viewport;
+ }
+
+ renderPixel(
+ i: number,
+ j: number,
+ offset: number,
+ color: ColorIndex,
+ ) {
+ if (this.scale < 0.8) return;
+ const scale = (this.scale > SCALE_THREASHOLD) ? 1 : this.scale;
+
+ const context = this.canvas.getContext('2d');
+ if (!context) return;
+
+ const [x, y] = getPixelFromChunkOffset(i, j, offset, this.canvasSize);
+
+ const [canX, canY] = this.centerChunk
+ .map(z => (z + 0.5) * TILE_SIZE - this.canvasSize / 2);
+ const px = ((x - canX) * scale) + (CANVAS_WIDTH / 2);
+ const py = ((y - canY) * scale) + (CANVAS_HEIGHT / 2);
+ // if not part of our current canvas, do not render
+ if (px < 0 || py >= CANVAS_WIDTH || py < 0 || py >= CANVAS_HEIGHT) return;
+
+ context.fillStyle = this.palette.colors[color];
+ context.fillRect(px, py, scale, scale);
+ pixelNotify.addPixel(x, y);
+
+ this.forceNextSubrender = true;
+ }
+
+
+ isChunkInView(
+ cz: number,
+ cx: number,
+ cy: number,
+ ) {
+ if (cz !== this.tiledZoom) {
+ return false;
+ }
+ const { width, height } = this.viewport;
+ const CHUNK_RENDER_RADIUS_X =
+ Math.ceil(width / TILE_SIZE / 2 / this.relScale);
+ const CHUNK_RENDER_RADIUS_Y =
+ Math.ceil(height / TILE_SIZE / 2 / this.relScale);
+ const [xc, yc] = this.centerChunk;
+ if (Math.abs(cx - xc)
+ <= CHUNK_RENDER_RADIUS_X && Math.abs(cy - yc)
+ <= CHUNK_RENDER_RADIUS_Y
+ ) {
+ return true;
+ }
+ return false;
+ }
+
+
+ renderChunks(
+ chunks,
+ view: Cell,
+ canvasId: number,
+ ) {
+ const context = this.canvas.getContext('2d');
+ if (!context) return;
+
+ const { centerChunk: chunkPosition, scale, tiledScale, tiledZoom } = this;
+ let relScale = this.relScale;
+ // clear rect is just needed for Google Chrome, else it would flash regularly
+ context.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
+
+ // Disable smoothing
+ // making it dependent on the scale is needed for Google Chrome, else scale <1 would look shit
+ if (scale >= 1) {
+ context.msImageSmoothingEnabled = false;
+ context.webkitImageSmoothingEnabled = false;
+ context.imageSmoothingEnabled = false;
+ } else {
+ context.msImageSmoothingEnabled = true;
+ context.webkitImageSmoothingEnabled = true;
+ context.imageSmoothingEnabled = true;
+ }
+ // define how many chunks we will render
+ // don't render chunks outside of viewport
+ const { width, height } = this.viewport;
+ const CHUNK_RENDER_RADIUS_X = Math.ceil(width / TILE_SIZE / 2 / relScale);
+ const CHUNK_RENDER_RADIUS_Y = Math.ceil(height / TILE_SIZE / 2 / relScale);
+ // If scale is so large that neighbouring chunks wouldn't fit in canvas,
+ // do scale = 1 and scale in render()
+ // TODO this is not working
+ if (scale > SCALE_THREASHOLD) relScale = 1.0;
+ // scale
+ context.save();
+ context.fillStyle = '#C4C4C4';
+ context.scale(relScale, relScale);
+ // decide if we will fetch missing chunks
+ // and update the timestamps of accessed chunks
+ const curTime = Date.now();
+ let fetch = false;
+ if (curTime > this.lastFetch + 150) {
+ this.lastFetch = curTime;
+ fetch = true;
+ }
+
+ const xOffset = CANVAS_WIDTH / 2 / relScale - TILE_SIZE / 2;
+ const yOffset = CANVAS_HEIGHT / 2 / relScale - TILE_SIZE / 2;
+
+ const [xc, yc] = chunkPosition; // center chunk
+ // CLEAN margin
+ // draw new chunks. If not existing, just clear.
+ let chunk: ChunkRGB;
+ let key: string;
+ for (let dx = -CHUNK_RENDER_RADIUS_X; dx <= CHUNK_RENDER_RADIUS_X; dx += 1) {
+ for (let dy = -CHUNK_RENDER_RADIUS_Y; dy <= CHUNK_RENDER_RADIUS_Y; dy += 1) {
+ const cx = xc + dx;
+ const cy = yc + dy;
+ const x = xOffset + dx * TILE_SIZE;
+ const y = yOffset + dy * TILE_SIZE;
+
+ const chunkMaxXY = this.canvasSize / TILE_SIZE;
+ if (cx < 0 || cx >= chunkMaxXY * tiledScale || cy < 0 || cy >= chunkMaxXY * tiledScale) {
+ // if out of bounds
+ context.fillRect(x, y, TILE_SIZE, TILE_SIZE);
+ } else {
+ key = ChunkRGB.getKey(tiledZoom, cx, cy);
+ chunk = chunks.get(key);
+ if (chunk) {
+ // render new chunk
+ if (chunk.ready) {
+ context.drawImage(chunk.image, x, y);
+ if (fetch) chunk.timestamp = curTime;
+ } else if (loadingTile) context.drawImage(loadingTile, x, y);
+ else context.fillRect(x, y, TILE_SIZE, TILE_SIZE);
+ } else {
+ // we don't have that chunk
+ if (fetch) {
+ if (tiledZoom === this.canvasMaxTiledZoom) {
+ store.dispatch(fetchChunk(canvasId, [tiledZoom, cx, cy]));
+ } else {
+ store.dispatch(fetchTile(canvasId, [tiledZoom, cx, cy]));
+ }
+ }
+ if (loadingTile) context.drawImage(loadingTile, x, y);
+ else context.fillRect(x, y, TILE_SIZE, TILE_SIZE);
+ }
+ }
+ }
+ }
+ context.restore();
+ }
+
+
+ render() {
+ const viewport = this.viewport;
+ const state: State = store.getState();
+ const {
+ showGrid,
+ showPixelNotify,
+ hover,
+ isPotato,
+ } = state.gui;
+ const {
+ placeAllowed,
+ } = state.user;
+ const {
+ chunks,
+ view,
+ fetchs,
+ viewscale,
+ canvasId,
+ } = state.canvas;
+
+ if (!view || canvasId === null) return;
+
+ if (this.canvasId != canvasId) {
+ const { canvasSize, palette, canvasMaxTiledZoom } = state.canvas;
+ this.canvasSize = canvasSize;
+ this.palette = palette;
+ this.canvasMaxTiledZoom = canvasMaxTiledZoom;
+ this.tiledZoom = this.canvasMaxTiledZoom + Math.log2(this.tiledScale) / 2;
+ }
+
+ const [x, y] = view;
+ let [cx, cy] = this.centerChunk;
+
+ // if we have to render pixelnotify
+ const doRenderPixelnotify = (viewscale >= 0.5 && showPixelNotify && pixelNotify.doRender());
+ // if we have to render offscreen canvas
+ let doRenderCanvas = (this.lastFetchs !== fetchs || this.forceNextRender);
+ if (viewscale !== this.scale) {
+ this.scale = viewscale;
+ pixelNotify.updateScale(viewscale);
+ this.tiledScale = (viewscale > 0.5) ? 0 : Math.round(Math.log2(viewscale) / 2);
+ this.tiledScale = 4 ** this.tiledScale;
+ this.tiledZoom = this.canvasMaxTiledZoom + Math.log2(this.tiledScale) / 2;
+ this.relScale = viewscale / this.tiledScale;
+ doRenderCanvas = true;
+ }
+ // if we have to render placeholder
+ const doRenderPlaceholder = (viewscale >= 3 && placeAllowed && (hover || this.hover) && !isPotato);
+ const doRenderPotatoPlaceholder = (viewscale >= 3 && placeAllowed && (hover !== this.hover || doRenderCanvas || this.forceNextSubrender || doRenderPixelnotify) && isPotato);
+ //--
+ if (view !== this.view) {
+ const [curcx, curcy] = getTileOfPixel(this.tiledScale, [x, y], this.canvasSize);
+ if (cx !== curcx || cy !== curcy) {
+ cx = curcx;
+ cy = curcy;
+ this.centerChunk = [cx, cy];
+ doRenderCanvas = true;
+ }
+ this.view = view;
+ // if we have nothing to render, return
+ // note: this.hover is used to, to render without the placeholder one last time when cursor leaves window
+ } else if (
+ // no full rerender
+ !doRenderCanvas &&
+ // no render placeholder under cursor
+ !doRenderPlaceholder &&
+ !doRenderPotatoPlaceholder &&
+ // no pixelnotification
+ !doRenderPixelnotify &&
+ // no forced just-viewscale render (i.e. when just a pixel got set)
+ !this.forceNextSubrender
+ ) {
+ return;
+ }
+ this.hover = hover;
+
+ if (doRenderCanvas) {
+ this.renderChunks(chunks, view, canvasId);
+ this.lastFetchs = fetchs;
+ }
+ this.forceNextRender = false;
+ this.forceNextSubrender = false;
+
+ const { width, height } = viewport;
+ const viewportCtx = viewport.getContext('2d');
+ if (!viewportCtx) return;
+
+ // canvas optimization: https://www.html5rocks.com/en/tutorials/canvas/performance/
+ viewportCtx.msImageSmoothingEnabled = false;
+ viewportCtx.webkitImageSmoothingEnabled = false;
+ viewportCtx.imageSmoothingEnabled = false;
+ // If scale is so large that neighbouring chunks wouldn't fit in offscreen canvas,
+ // do scale = 1 in renderChunks and scale in render()
+ const canvasCenter = this.canvasSize / 2;
+ if (viewscale > SCALE_THREASHOLD) {
+ viewportCtx.save();
+ viewportCtx.scale(viewscale, viewscale);
+ viewportCtx.drawImage(this.canvas,
+ width / 2 / viewscale - CANVAS_WIDTH / 2 + ((cx + 0.5) * TILE_SIZE - canvasCenter - x),
+ height / 2 / viewscale - CANVAS_HEIGHT / 2 + ((cy + 0.5) * TILE_SIZE - canvasCenter - y),
+ );
+ viewportCtx.restore();
+ } else {
+ viewportCtx.drawImage(this.canvas,
+ Math.floor(width / 2 - CANVAS_WIDTH / 2 + ((cx + 0.5) * TILE_SIZE / this.tiledScale - canvasCenter - x) * viewscale),
+ Math.floor(height / 2 - CANVAS_HEIGHT / 2 + ((cy + 0.5) * TILE_SIZE / this.tiledScale - canvasCenter - y) * viewscale),
+ );
+ }
+
+ if (showGrid && viewscale >= 8) renderGrid(state, viewport, viewscale);
+
+ if (doRenderPixelnotify) pixelNotify.render(state, viewport);
+
+ if (hover && doRenderPlaceholder) renderPlaceholder(state, viewport, viewscale);
+ if (hover && doRenderPotatoPlaceholder) renderPotatoPlaceholder(state, viewport, viewscale);
+ }
+}
+
+
+export default Renderer;
diff --git a/src/ui/ads.js b/src/ui/ads.js
new file mode 100644
index 0000000..804c683
--- /dev/null
+++ b/src/ui/ads.js
@@ -0,0 +1,96 @@
+/**
+ *
+ * Copyright 2017 Google Inc. All Rights Reserved.
+ * You may study, modify, and use this example for any purpose.
+ * Note that this example is provided "as is", WITHOUT WARRANTY
+ * of any kind either expressed or implied.
+ *
+ * @flow
+ */
+
+import 'url-search-params-polyfill';
+
+let adsController;
+let available = false;
+let fetching = false;
+let play = false;
+let outstreamContainer;
+
+const adTagParams = new URLSearchParams();
+adTagParams.set('ad_type', 'video_image_text');
+adTagParams.set('client', 'ca-games-pub-4111661129974554');
+adTagParams.set('videoad_start_delay', 0);
+adTagParams.set('description_url', 'http://pixelplanet.fun/');
+adTagParams.set('max_ad_duration', 20000);
+if (__DEV__) adTagParams.set('adtest', 'on');
+const adTagUrl = `https://googleads.g.doubleclick.net/pagead/ads?${adTagParams.toString()}`;
+
+/**
+ * Request ad. Must be invoked by a user action for mobile devices.
+ */
+export function requestAds() {
+ if (!adsController) return;
+ if (available || fetching) return;
+
+ fetching = true;
+
+ adsController.initialize();
+
+ // Request ads
+ adsController.requestAds(adTagUrl);
+}
+
+/**
+ * Allow resizing of the current ad.
+ */
+function resize() {
+ if (adsController) {
+ const width = window.innerWidth;
+ const height = window.innerHeight;
+ adsController.resize(width, height);
+ }
+}
+
+export function playAd() {
+ if (available) {
+ play = false;
+ available = false;
+ outstreamContainer.style.display = 'block';
+ adsController.showAd();
+ } else {
+ play = true;
+ }
+}
+
+/**
+ * Callback for when ad has completed loading.
+ */
+function onAdLoaded() {
+ // Play ad
+ available = true;
+ fetching = false;
+ if (play) playAd();
+}
+
+/**
+ * Callback for when ad has completed playback.
+ */
+function onDone() {
+ // Show content
+ outstreamContainer.style.display = 'none';
+}
+
+function init() {
+ if (typeof google === 'undefined') return;
+ outstreamContainer = document.getElementById('outstreamContainer');
+
+ adsController = new google.outstream.AdsController(
+ outstreamContainer,
+ onAdLoaded,
+ onDone,
+ );
+}
+
+export default init;
+
+window.addEventListener('resize', resize);
diff --git a/src/ui/keypress.js b/src/ui/keypress.js
new file mode 100644
index 0000000..405889f
--- /dev/null
+++ b/src/ui/keypress.js
@@ -0,0 +1,68 @@
+/*
+ * keypress actions
+ */
+import keycode from 'keycode';
+
+import store from '../ui/store';
+import {
+ toggleGrid,
+ togglePixelNotify,
+ toggleMute,
+ moveNorth,
+ moveWest,
+ moveSouth,
+ moveEast,
+ zoomIn,
+ zoomOut,
+ onViewFinishChange,
+} from '../actions';
+
+
+function onKeyPress(event: KeyboardEvent) {
+ // ignore key presses if modal is open or chat is used
+ if (event.target.nodeName == 'INPUT' || event.target.nodeName == 'TEXTAREA') return;
+
+ switch (keycode(event)) {
+ case 'up':
+ case 'w':
+ store.dispatch(moveNorth());
+ break;
+ case 'left':
+ case 'a':
+ store.dispatch(moveWest());
+ break;
+ case 'down':
+ case 's':
+ store.dispatch(moveSouth());
+ break;
+ case 'right':
+ case 'd':
+ store.dispatch(moveEast());
+ break;
+ case 'g':
+ store.dispatch(toggleGrid());
+ return;
+ case 'c':
+ store.dispatch(togglePixelNotify());
+ return;
+ case 'space':
+ if ($viewport) $viewport.click();
+ return;
+ case 'm':
+ store.dispatch(toggleMute());
+ return;
+ case '+':
+ case 'e':
+ store.dispatch(zoomIn());
+ return;
+ case '-':
+ case 'q':
+ store.dispatch(zoomOut());
+ return;
+ default:
+ return;
+ }
+ store.dispatch(onViewFinishChange());
+}
+
+export default onKeyPress;
diff --git a/src/ui/loadImage.js b/src/ui/loadImage.js
new file mode 100644
index 0000000..20cbe86
--- /dev/null
+++ b/src/ui/loadImage.js
@@ -0,0 +1,14 @@
+/* @flow */
+
+function loadImage(url) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.addEventListener('load', e => resolve(img));
+ img.addEventListener('error', () => {
+ reject(new Error(`Failed to load image's URL: ${url}`));
+ });
+ img.src = url;
+ });
+}
+
+export default loadImage;
diff --git a/src/ui/renderelements.js b/src/ui/renderelements.js
new file mode 100644
index 0000000..ad07e0c
--- /dev/null
+++ b/src/ui/renderelements.js
@@ -0,0 +1,104 @@
+/*
+ * placeholder that shows underneach cursor
+ *
+ * @flow
+ */
+
+import type { State } from '../reducers';
+import { screenToWorld, worldToScreen } from '../core/utils';
+
+const PLACEHOLDER_SIZE = 1.2;
+const PLACEHOLDER_BORDER = 1;
+
+export function renderPlaceholder(
+ state: State,
+ $viewport: HTMLCanvasElement,
+ scale: number,
+) {
+ const viewportCtx = $viewport.getContext('2d');
+
+ const { selectedColor, hover } = state.gui;
+ const { palette } = state.canvas;
+
+ const worldPos = screenToWorld(state, $viewport, hover);
+ const [sx, sy] = worldToScreen(state, $viewport, worldPos);
+
+ viewportCtx.save();
+ viewportCtx.translate(sx + (scale / 2), sy + (scale / 2));
+ const angle = Math.sin(Date.now() / 250) / 4;
+ viewportCtx.rotate(angle);
+ viewportCtx.fillStyle = '#000';
+ viewportCtx.fillRect(
+ -(scale * (PLACEHOLDER_SIZE / 2)) - PLACEHOLDER_BORDER,
+ -(scale * (PLACEHOLDER_SIZE / 2)) - PLACEHOLDER_BORDER,
+ (scale * PLACEHOLDER_SIZE) + (2 * PLACEHOLDER_BORDER),
+ (scale * PLACEHOLDER_SIZE) + (2 * PLACEHOLDER_BORDER),
+ );
+ viewportCtx.fillStyle = palette.colors[selectedColor];
+ viewportCtx.fillRect(
+ -scale * (PLACEHOLDER_SIZE / 2),
+ -scale * (PLACEHOLDER_SIZE / 2),
+ scale * PLACEHOLDER_SIZE,
+ scale * PLACEHOLDER_SIZE,
+ );
+ viewportCtx.restore();
+}
+
+
+export function renderPotatoPlaceholder(
+ state: State,
+ $viewport: HTMLCanvasElement,
+ scale: number,
+) {
+ const viewportCtx = $viewport.getContext('2d');
+
+ const { selectedColor, hover } = state.gui;
+ const { palette } = state.canvas;
+
+ const worldPos = screenToWorld(state, $viewport, hover);
+ const [sx, sy] = worldToScreen(state, $viewport, worldPos);
+
+ viewportCtx.save();
+ viewportCtx.fillStyle = '#000';
+ viewportCtx.fillRect(sx - 1, sy - 1, 4, scale + 2);
+ viewportCtx.fillRect(sx - 1, sy - 1, scale + 2, 4);
+ viewportCtx.fillRect(sx + scale - 2, sy - 1, 4, scale + 2);
+ viewportCtx.fillRect(sx - 1, sy + scale - 2, scale + 1, 4);
+ viewportCtx.fillStyle = palette.colors[selectedColor];
+ viewportCtx.fillRect(sx, sy, 2, scale);
+ viewportCtx.fillRect(sx, sy, scale, 2);
+ viewportCtx.fillRect(sx + scale - 1, sy, 2, scale);
+ viewportCtx.fillRect(sx, sy + scale - 1, scale, 2);
+ viewportCtx.restore();
+}
+
+
+export function renderGrid(
+ state: State,
+ $viewport: HTMLCanvasElement,
+ scale: number,
+) {
+ const { width, height } = $viewport;
+
+ const viewportCtx = $viewport.getContext('2d');
+ if (!viewportCtx) return;
+
+ viewportCtx.globalAlpha = 0.5;
+ viewportCtx.fillStyle = '#222222';
+
+ let [xoff, yoff] = screenToWorld(state, $viewport, [0, 0]);
+ let [x, y] = worldToScreen(state, $viewport, [xoff, yoff]);
+
+ for (; x < width; x += scale) {
+ const thick = (xoff++ % 10 === 0) ? 2 : 1;
+ viewportCtx.fillRect(x, 0, thick, height);
+ }
+
+ for (; y < height; y += scale) {
+ const thick = (yoff++ % 10 === 0) ? 2 : 1;
+ viewportCtx.fillRect(0, y, width, thick);
+ }
+
+ viewportCtx.globalAlpha = 1;
+}
+
diff --git a/src/ui/store.js b/src/ui/store.js
new file mode 100644
index 0000000..6038f86
--- /dev/null
+++ b/src/ui/store.js
@@ -0,0 +1,11 @@
+/**
+ *
+ * @flow
+ */
+
+import configureStore from '../store/configureStore';
+
+
+const store = configureStore();
+
+export default store;
diff --git a/src/utils/Counter.js b/src/utils/Counter.js
new file mode 100644
index 0000000..d07b533
--- /dev/null
+++ b/src/utils/Counter.js
@@ -0,0 +1,28 @@
+/**
+ *
+ * @flow
+ */
+
+
+export default class Counter {
+ map: Map;
+
+ constructor() {
+ this.map = new Map();
+ }
+
+ get(item: T): number {
+ const count = this.map.get(item) || 0;
+ return count;
+ }
+
+ add(item: T): void {
+ const count = this.get(item);
+ this.map.set(item, count + 1);
+ }
+
+ delete(item: T): void {
+ const count = this.get(item);
+ this.map.set(item, count - 1);
+ }
+}
diff --git a/src/utils/Math.js b/src/utils/Math.js
new file mode 100644
index 0000000..acba5bc
--- /dev/null
+++ b/src/utils/Math.js
@@ -0,0 +1,11 @@
+/**
+ *
+ * @flow
+ */
+
+export function sum(values: Array): number {
+ let total = 0;
+ // TODO map reduce
+ values.forEach(value => total += value);
+ return total;
+}
diff --git a/src/utils/Queue.js b/src/utils/Queue.js
new file mode 100644
index 0000000..5bf013d
--- /dev/null
+++ b/src/utils/Queue.js
@@ -0,0 +1,85 @@
+/**
+
+ Queue.js
+
+ A function to represent a queue
+
+ Created by Stephen Morley - http://code.stephenmorley.org/ - and released under
+ the terms of the CC0 1.0 Universal legal code:
+
+ http://creativecommons.org/publicdomain/zero/1.0/legalcode
+
+ @flow
+ */
+
+
+/**
+ * Creates a new queue. A queue is a first-in-first-out (FIFO) data structure -
+ * items are added to the end of the queue and removed from the front.
+ */
+class Queue {
+ queue: Array;
+ offset: number;
+
+ constructor() {
+ this.queue = [];
+ this.offset = 0;
+ }
+
+ /**
+ * Returns the length of the queue.
+ * @returns {number}
+ */
+ getLength(): number {
+ return this.queue.length - this.offset;
+ }
+
+ /**
+ * Returns true if the queue is empty, and false otherwise.
+ * @returns {boolean}
+ */
+ isEmpty(): boolean {
+ return this.queue.length === 0;
+ }
+
+ /**
+ * Enqueues the specified item. The parameter is:
+ * @param item - the item to enqueue
+ */
+ enqueue(item: T) {
+ this.queue.push(item);
+ }
+
+ /**
+ * Dequeues an item and returns it. If the queue is empty, the value
+ * @returns {undefined}
+ */
+ dequeue(): ?T {
+ // if the queue is empty, return immediately
+ if (this.queue.length === 0) return undefined;
+
+ // store the item at the front of the queue
+ const item = this.queue[this.offset];
+
+ // increment the offset and remove the free space if necessary
+ this.offset += 1;
+ if (this.offset * 2 >= this.queue.length) {
+ this.queue = this.queue.slice(this.offset);
+ this.offset = 0;
+ }
+
+ // return the dequeued item
+ return item;
+ }
+
+ /**
+ * Returns the item at the front of the queue (without dequeuing it). If the
+ * queue is empty then undefined is returned.
+ * @returns {undefined}
+ */
+ peek(): ?T {
+ return (this.queue.length > 0 ? this.queue[this.offset] : undefined);
+ }
+}
+
+export default Queue;
diff --git a/src/utils/RateLimiter.js b/src/utils/RateLimiter.js
new file mode 100644
index 0000000..6fd29ea
--- /dev/null
+++ b/src/utils/RateLimiter.js
@@ -0,0 +1,53 @@
+/*
+ * rate limiter utils
+ * @flow
+ */
+
+
+/*
+ * RateLimiter
+ * @param ticks_per_min How many ticks per min are allowed
+ * @param burst Amount of ticks that are allowed before limiter kicks in
+ * @param on_cooldown If we force to wait the whole burst time once the limit is reached
+ */
+class RateLimiter {
+ ms_per_tick: number;
+ burst_time: number;
+ cooldown_completely: boolean;
+ on_cooldown: boolean;
+ wait: number;
+
+ constructor(ticks_per_min = 20, burst = 20, cooldown_completely = false) {
+ this.wait = Date.now();
+ this.ms_per_tick = 60 / ticks_per_min * 1000;
+ this.burst_time = burst * this.ms_per_tick;
+ this.cooldown_completely = cooldown_completely;
+ this.on_cooldown = false;
+ }
+
+ /*
+ * return:
+ * false if rate limiter isn't hit
+ * waitingTime if rate limiter got hit
+ */
+ tick() {
+ const now = Date.now();
+ const waitLeft = this.wait - now;
+ if (waitLeft >= this.burst_time) {
+ this.on_cooldown = true;
+ return waitLeft;
+ }
+ if (waitLeft > 0) {
+ if (this.cooldown_completely && this.on_cooldown) {
+ return waitLeft;
+ }
+ this.wait += this.ms_per_tick;
+ return false;
+ }
+ this.wait = now + this.ms_per_tick;
+ this.on_cooldown = false;
+ return false;
+ }
+}
+
+export default RateLimiter;
diff --git a/src/utils/cellsEquals.js b/src/utils/cellsEquals.js
new file mode 100644
index 0000000..0db28de
--- /dev/null
+++ b/src/utils/cellsEquals.js
@@ -0,0 +1,19 @@
+/**
+ * @flow
+ */
+
+
+/**
+ * http://stackoverflow.com/questions/3115982/how-to-check-if-two-arrays-are-equal-with-javascript
+ * @param a
+ * @param b
+ * @returns {boolean}
+ */
+function cellsEquals(a: Cell, b: Cell): boolean {
+ if (a === b) return true;
+ if (a === null || b === null) return false;
+
+ return (a[0] === b[0]) && (a[1] === b[1]);
+}
+
+export default cellsEquals;
diff --git a/src/utils/cloudflareip.js b/src/utils/cloudflareip.js
new file mode 100644
index 0000000..27e8ff9
--- /dev/null
+++ b/src/utils/cloudflareip.js
@@ -0,0 +1,47 @@
+
+// 3rd
+const Address4 = require('ip-address').Address4;
+const Address6 = require('ip-address').Address6;
+
+const cloudflareIps = [
+ '103.21.244.0/22',
+ '103.22.200.0/22',
+ '103.31.4.0/22',
+ '104.16.0.0/12',
+ '108.162.192.0/18',
+ '131.0.72.0/22',
+ '141.101.64.0/18',
+ '162.158.0.0/15',
+ '172.64.0.0/13',
+ '173.245.48.0/20',
+ '188.114.96.0/20',
+ '190.93.240.0/20',
+ '197.234.240.0/22',
+ '198.41.128.0/17',
+ '2400:cb00::/32',
+ '2405:8100::/32',
+ '2405:b500::/32',
+ '2606:4700::/32',
+ '2803:f800::/32',
+ '2c0f:f248::/32',
+ '2a06:98c0::/29',
+].map(intoAddress);
+
+// returns undefined | Address4 | Address6
+function intoAddress(str) {
+ if (typeof str === 'string') str = str.trim();
+ let ip = new Address6(str);
+ if (ip.v4 && !ip.valid) {
+ ip = new Address4(str);
+ }
+ if (!ip.valid) return;
+ return ip;
+}
+
+// returns bool
+export function isCloudflareIp(testIpString: string): boolean {
+ if (!testIpString) return false;
+ const testIp = intoAddress(testIpString);
+ if (!testIp) return false;
+ return cloudflareIps.some(cf => testIp.isInSubnet(cf));
+}
diff --git a/src/utils/cron.js b/src/utils/cron.js
new file mode 100644
index 0000000..988c39e
--- /dev/null
+++ b/src/utils/cron.js
@@ -0,0 +1,60 @@
+/*
+ * Cron job of argumentless functions that will get run in a specific interval,
+ * per default just one will get created which runs daily,
+ * hook it up to some timer function that causes the least load
+ * @flow
+ */
+import { HOUR, MINUTE } from '../core/constants';
+
+import logger from '../core/logger';
+
+class Cron {
+ last_run: number;
+ interval: number;
+ functions: Array;
+ timeout;
+
+ // interval = how many hours between runs
+ // last_run = when this cron job was last run
+ constructor(interval: number, last_run: number = 0) {
+ this.check_for_execution = this.check_for_execution.bind(this);
+ this.interval = interval;
+ this.last_run = last_run;
+ this.functions = [];
+
+ this.timeout = setInterval(this.check_for_execution, HOUR);
+ }
+
+ check_for_execution() {
+ const cur_time = Date.now();
+ if (cur_time > this.last_run + this.interval * HOUR) {
+ logger.info(`Run cron events for interval: ${this.interval}h`);
+ this.last_run = cur_time;
+ this.functions.forEach(async (item) => {
+ item();
+ });
+ }
+ }
+
+ hook(func) {
+ this.functions.push(func);
+ }
+}
+
+
+function initialize_daily_cron() {
+ const now = new Date();
+ // make it first run at midnight
+ const last_run = now.getTime() - now.getHours() * HOUR;
+ const cron = new Cron(24, last_run);
+ return cron;
+}
+
+function initialize_hourly_cron() {
+ const cron = new Cron(1, Date.now());
+ return cron;
+}
+
+export const DailyCron = initialize_daily_cron();
+
+export const HourlyCron = initialize_hourly_cron();
diff --git a/src/utils/hash.js b/src/utils/hash.js
new file mode 100644
index 0000000..e59de4b
--- /dev/null
+++ b/src/utils/hash.js
@@ -0,0 +1,15 @@
+/*
+ * password hashing
+ * @flow
+ */
+
+import bcrypt from 'bcrypt';
+
+export function generateHash(password: string) {
+ return bcrypt.hashSync(password, bcrypt.genSaltSync(8), null);
+}
+
+export function compareToHash(password: string, hash: string) {
+ if (!password || !hash) return false;
+ return bcrypt.compareSync(password, hash);
+}
diff --git a/src/utils/ip.js b/src/utils/ip.js
new file mode 100644
index 0000000..f2e1501
--- /dev/null
+++ b/src/utils/ip.js
@@ -0,0 +1,53 @@
+/**
+ *
+ * @flow
+ */
+
+import dns from 'dns';
+import { isCloudflareIp } from './cloudflareip';
+import nodeIp from 'ip';
+
+import logger from '../core/logger';
+
+
+function isTrustedProxy(ip: string): boolean {
+ if (ip === '::ffff:127.0.0.1' || ip === '127.0.0.1' || isCloudflareIp(ip)) {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Note: nginx should handle that,
+ * it's not neccessary to do that ourself
+ */
+export async function getIPFromRequest(req): ?string {
+ const { socket, connection, headers } = req;
+
+ const conip = (connection ? connection.remoteAddress : socket.remoteAddress);
+
+ if (!headers['x-forwarded-for'] || !isTrustedProxy(conip)) {
+ logger.warn('Connection not going through nginx and cloudflare! IP:', conip, headers);
+ return conip;
+ }
+
+ const forwardedFor = headers['x-forwarded-for'];
+ const ipList = forwardedFor.split(',').map(str => str.trim());
+
+ let ip = ipList.pop();
+ while (isTrustedProxy(ip) && ipList.length) {
+ ip = ipList.pop();
+ }
+
+ // logger.info('Proxied Connection allowed', ip, forwardedFor);
+ return ip;
+}
+
+export function getIPv6Subnet(ip: string): string {
+ if (ip.includes(':')) {
+ const ipv6sub = `${ip.split(':').slice(0, 4).join(':')}:0000:0000:0000:0000`;
+ // logger.warn("IPv6 subnet: ", ipv6sub);
+ return ipv6sub;
+ }
+ return ip;
+}
diff --git a/src/utils/location.js b/src/utils/location.js
new file mode 100644
index 0000000..4964957
--- /dev/null
+++ b/src/utils/location.js
@@ -0,0 +1,20 @@
+/*
+ * @flow
+ */
+
+import ccCoords from '../data/countrycode-coords-array.json';
+
+
+/*
+ * takes country name in two letter ISO style,
+ * return canvas coords based on pre-made json list
+ * @param cc Two letter country code
+ * @return coords X/Y coordinates of the country on the canvas
+ */
+export function ccToCoords(cc: string) {
+ const country = cc.toLowerCase();
+ const coords = ccCoords[country];
+ return (coords) || [0, 0];
+}
+
+export default ccToCoords;
diff --git a/src/utils/proxiedFetch.js b/src/utils/proxiedFetch.js
new file mode 100644
index 0000000..3005b7a
--- /dev/null
+++ b/src/utils/proxiedFetch.js
@@ -0,0 +1,32 @@
+/*
+ *
+ * implements a fetch that always chooses a random proxy from a list
+ * of http proxies
+ *
+*/
+
+import isoFetch from 'isomorphic-fetch';
+import HttpProxyAgent from 'http-proxy-agent';
+import proxylist from '../proxies.json';
+
+import logger from '../core/logger';
+
+function randomProxyURL() {
+ const rand = proxylist[Math.floor(Math.random() * proxylist.length)];
+ logger.info('choosesn fetch proxy', rand);
+ return rand;
+}
+
+export function fetch(url, options = {}) {
+ if (proxylist.length === 0) {
+ return isoFetch(url, options);
+ }
+ const agent = new HttpProxyAgent(randomProxyURL());
+
+ return isoFetch(url, {
+ ...options,
+ agent,
+ });
+}
+
+export default fetch;
diff --git a/src/utils/random.js b/src/utils/random.js
new file mode 100644
index 0000000..a1daedf
--- /dev/null
+++ b/src/utils/random.js
@@ -0,0 +1,24 @@
+/**
+ * @flow
+ */
+
+
+export function randomChoice(list: Array): T {
+ return list[Math.floor(Math.random() * list.length)];
+}
+
+export function randomDice(p): boolean {
+ return Math.random() < p;
+}
+
+/**
+ * The maximum is exclusive and the minimum is inclusive
+ * @param {*} pmin
+ * @param {*} pmax
+ */
+export function getRandomInt(pmin: number, pmax: number): number {
+ const min = Math.ceil(pmin);
+ const max = Math.floor(pmax);
+ const n = Math.floor(Math.random() * (max - min)) + min;
+ return n | 0;
+}
diff --git a/src/utils/recaptcha.js b/src/utils/recaptcha.js
new file mode 100644
index 0000000..c58a199
--- /dev/null
+++ b/src/utils/recaptcha.js
@@ -0,0 +1,48 @@
+/**
+ *
+ * @flow
+ */
+
+import fetch from 'isomorphic-fetch';
+
+import logger from '../core/logger';
+import { RECAPTCHA_SECRET } from '../core/config';
+
+
+const BASE_ENDPOINT = 'https://www.google.com/recaptcha/api/siteverify';
+const ENDPOINT = `${BASE_ENDPOINT}?secret=${RECAPTCHA_SECRET}`;
+
+/**
+ * https://stackoverflow.com/questions/27297067/google-recaptcha-how-to-get-user-response-and-validate-in-the-server-side
+ *
+ * @param token
+ * @param ip
+ * @returns {Promise.}
+ */
+export async function verifyCaptcha(
+ token: string,
+ ip: string,
+): Promise {
+ try {
+ if (!RECAPTCHA_SECRET) {
+ logger.info(`Got captcha token but reCaptcha isn't configured?!`);
+ return true;
+ }
+ const url = `${ENDPOINT}&response=${token}&remoteip=${ip}`;
+ const response = await fetch(url);
+ if (response.ok) {
+ const { success, challenge_ts, hostname } = await response.json();
+ if (success) {
+ logger.info(`CAPTCHA ${ip} successfully solved captcha`);
+ return true;
+ }
+ logger.info(`CAPTCHA Token for ${ip} not ok`);
+ } else {
+ logger.warn(`CAPTCHA Recapcha answer for ${ip} not ok`);
+ }
+ } catch (error) {
+ logger.error(error);
+ }
+
+ return false;
+}
diff --git a/src/utils/validation.js b/src/utils/validation.js
new file mode 100644
index 0000000..405a7b2
--- /dev/null
+++ b/src/utils/validation.js
@@ -0,0 +1,63 @@
+/*
+ * functionf for validation of user input
+ * @flow
+ */
+
+const mail_tester = /^[-!#$%&'*+\/0-9=?A-Z^_a-z{|}~](\.?[-!#$%&'*+\/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/;
+
+export function validateEMail(email) {
+ if (!email) return "Email can't be empty.";
+ if (email.length < 5) return 'Email should be at least 5 characters long.';
+ if (email.length > 40) return "Email can't be longer than 40 characters.";
+ if (email.indexOf('.') === -1) return 'Email should at least contain a dot';
+ if (email.split('').filter(x => x === '@').length !== 1) return 'Email should contain a @';
+ if (!mail_tester.test(email)) return 'Your Email looks shady';
+ return false;
+}
+
+export function validateName(name) {
+ if (!name) return "Name can't be empty.";
+ if (name.length < 4) return 'Name must be at least 4 characters long';
+ if (name.length > 26) return 'Name must be shorter than 26 characters';
+ if (name.indexOf('@') !== -1 ||
+ name.indexOf('/') !== -1 ||
+ name.indexOf('\\') !== -1 ||
+ name.indexOf('>') !== -1 ||
+ name.indexOf('<') !== -1 ||
+ name.indexOf('#') !== -1) return 'Name contains invalid character like @, /, \\ or #';
+ return false;
+}
+
+export function sanitizeName(name) {
+ name = name.substring(0, 25);
+ // just sanitizes @ for now, other characters do not seem
+ // problematic, even thought that we rule them out in validateName
+ name = name.replace(/@/g, 'at');
+ return name;
+}
+
+export function validatePassword(password) {
+ if (password.length < 6) return 'Password must be at least 6 characters long.';
+ if (password.length > 60) return 'Password must be shorter than 60 characters.';
+ return false;
+}
+
+/*
+ * makes sure that responses from the api
+ * includes errors when failure occures
+ */
+export async function parseAPIresponse(response) {
+ try {
+ const resp = await response.json();
+ if (!response.ok && !resp.errors) {
+ return {
+ errors: ['Could not connect to server, please try again later :('],
+ };
+ }
+ return resp;
+ } catch (e) {
+ return {
+ errors: ['I think we experienced some error :('],
+ };
+ }
+}
diff --git a/src/web.js b/src/web.js
new file mode 100644
index 0000000..bcd2e2b
--- /dev/null
+++ b/src/web.js
@@ -0,0 +1,169 @@
+/* @flow */
+
+import path from 'path';
+import compression from 'compression';
+import express from 'express';
+import http from 'http';
+import etag from 'etag';
+import React from 'react';
+import ReactDOM from 'react-dom/server';
+import expressValidator from 'express-validator';
+
+
+// import baseCss from './components/base.tcss';
+import forceGC from './core/forceGC';
+import Html from './components/Html';
+import assets from './assets.json'; // eslint-disable-line import/no-unresolved
+import logger from './core/logger';
+import models from './data/models';
+
+import {
+ api,
+ tiles,
+ chunks,
+ admintools,
+ resetPassword,
+} from './routes';
+import { SECOND, MONTH } from './core/constants';
+import { PORT, ASSET_SERVER, DISCORD_INVITE } from './core/config';
+
+import { ccToCoords } from './utils/location';
+import { wsupgrade } from './socket/websockets';
+import { startAllCanvasLoops } from './core/tileserver';
+
+startAllCanvasLoops();
+
+
+const app = express();
+app.disable('x-powered-by');
+
+
+// Call Garbage Collector every 30 seconds
+setInterval(forceGC, 15 * 60 * SECOND);
+
+// create websocket
+const server = http.createServer(app);
+server.on('upgrade', wsupgrade);
+
+
+/*
+ * using validator to check user input
+ */
+app.use(expressValidator());
+
+
+//
+// API
+// -----------------------------------------------------------------------------
+app.use('/api', api);
+
+
+//
+// Serving Zoomed Tiless
+// -----------------------------------------------------------------------------
+app.use('/tiles', tiles);
+
+
+/*
+ * use gzip compression for following calls
+/* level from -1 (default, 6) to 0 (no) from 1 (fastest) to 9 (best)
+ * Set custon filter to make sure that .bmp files get compressed
+ */
+app.use(compression({ level: 3,
+ filter: (req, res) => {
+ if (res.getHeader('Content-Type') === 'application/octet-stream') {
+ return true;
+ }
+ return compression.filter(req, res);
+ } }));
+
+
+//
+// public folder
+// (this should be served with nginx or other webserver)
+// -----------------------------------------------------------------------------
+app.use(express.static(path.join(__dirname, 'public'), {
+ maxAge: 3 * MONTH,
+ extensions: ['html'],
+}));
+
+
+//
+// Redirecct to discord
+// -----------------------------------------------------------------------------
+app.use('/discord', (req, res) => {
+ res.redirect(DISCORD_INVITE);
+});
+
+
+//
+// Serving Chunks
+// -----------------------------------------------------------------------------
+app.get('/chunks/:c([0-9]+)/:x([0-9]+)/:y([0-9]+).bmp', chunks);
+
+
+//
+// Admintools
+// -----------------------------------------------------------------------------
+app.use('/admintools', admintools);
+
+//
+// Password Reset Link
+// -----------------------------------------------------------------------------
+app.use('/reset_password', resetPassword);
+
+
+//
+// Register server-side rendering middleware
+// -----------------------------------------------------------------------------
+const data = {
+ title: 'PixelPlanet.fun',
+ description: 'Place color pixels on an map styled canvas ' +
+ 'with other players online',
+ // styles: [
+ // { id: 'css', cssText: baseCss },
+ // ],
+ scripts: [
+ ASSET_SERVER + assets.vendor.js,
+ ASSET_SERVER + assets.client.js,
+ ],
+};
+const indexEtag = etag(
+ `${assets.vendor.js},${assets.client.js}`,
+ { weak: true },
+);
+
+app.get('/', async (req, res) => {
+ res.set({
+ 'Cache-Control': `private, max-age=${15 * 60}`, // seconds
+ ETag: indexEtag,
+ });
+
+ if (req.headers['if-none-match'] === indexEtag) {
+ res.status(304).end();
+ return;
+ }
+
+ // get start coordinates based on cloudflare header country
+ const country = req.headers['cf-ipcountry'];
+ const [x, y] = (country) ? ccToCoords(country) : [0, 0];
+ const code =
+ `window.coordx=${x};window.coordy=${y};window.assetserver="${ASSET_SERVER}";`;
+ const htmldata = { ...data, code };
+ const html = ReactDOM.renderToStaticMarkup( );
+ const index = `${html}`;
+
+ res.send(index);
+});
+
+
+//
+// ip config
+// -----------------------------------------------------------------------------
+const promise = models.sync().catch(err => logger.error(err.stack));
+promise.then(() => {
+ server.listen(PORT, () => {
+ const address = server.address();
+ logger.log('info', `web is running at http://localhost:${address.port}/`);
+ });
+});
diff --git a/tools/build.js b/tools/build.js
new file mode 100644
index 0000000..8d68070
--- /dev/null
+++ b/tools/build.js
@@ -0,0 +1,26 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-present Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+
+import run from './run';
+import clean from './clean';
+import copy from './copy';
+import bundle from './bundle';
+
+/**
+ * Compiles the project from source files into a distributable
+ * format and copies it to the output (build) folder.
+ */
+async function build() {
+ await run(clean);
+ await run(copy);
+ await run(bundle);
+}
+
+export default build;
diff --git a/tools/bundle.js b/tools/bundle.js
new file mode 100644
index 0000000..2d0047b
--- /dev/null
+++ b/tools/bundle.js
@@ -0,0 +1,29 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-present Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+import webpack from 'webpack';
+import webpackConfig from './webpack.config';
+
+/**
+ * Creates application bundles from the source files.
+ */
+function bundle() {
+ return new Promise((resolve, reject) => {
+ webpack(webpackConfig).run((err, stats) => {
+ if (err) {
+ return reject(err);
+ }
+
+ console.log(stats.toString(webpackConfig[0].stats));
+ return resolve();
+ });
+ });
+}
+
+export default bundle;
diff --git a/tools/clean.js b/tools/clean.js
new file mode 100644
index 0000000..a27ae1a
--- /dev/null
+++ b/tools/clean.js
@@ -0,0 +1,25 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-present Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+import { cleanDir } from './lib/fs';
+
+/**
+ * Cleans up the output (build) directory.
+ */
+function clean() {
+ return Promise.all([
+ cleanDir('build/*', {
+ nosort: true,
+ dot: true,
+ ignore: ['build/.git'],
+ }),
+ ]);
+}
+
+export default clean;
diff --git a/tools/copy.js b/tools/copy.js
new file mode 100644
index 0000000..c83d715
--- /dev/null
+++ b/tools/copy.js
@@ -0,0 +1,28 @@
+/**
+ */
+
+import path from 'path';
+import { writeFile, copyFile, makeDir, copyDir, cleanDir } from './lib/fs';
+import pkg from '../package.json';
+
+/**
+ * Copies static files such as robots.txt, favicon.ico to the
+ * output (build) folder.
+ */
+async function copy() {
+ await makeDir('build');
+ await Promise.all([
+ writeFile('build/package.json', JSON.stringify({
+ private: true,
+ engines: pkg.engines,
+ dependencies: pkg.dependencies,
+ scripts: {
+ start: 'node --nouse-idle-notification --expose-gc web.js',
+ },
+ }, null, 2)),
+ copyFile('LICENSE', 'build/LICENSE'),
+ copyDir('public', 'build/public'),
+ ]);
+}
+
+export default copy;
diff --git a/tools/lib/cp.js b/tools/lib/cp.js
new file mode 100644
index 0000000..8fa9968
--- /dev/null
+++ b/tools/lib/cp.js
@@ -0,0 +1,33 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-present Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+import cp from 'child_process';
+
+export const spawn = (command, args, options) => new Promise((resolve, reject) => {
+ cp.spawn(command, args, options).on('close', (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error(`${command} ${args.join(' ')} => ${code} (error)`));
+ }
+ });
+});
+
+export const exec = (command, options) => new Promise((resolve, reject) => {
+ cp.exec(command, options, (err, stdout, stderr) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ resolve({ stdout, stderr });
+ });
+});
+
+export default { spawn, exec };
diff --git a/tools/lib/fs.js b/tools/lib/fs.js
new file mode 100644
index 0000000..0e956e2
--- /dev/null
+++ b/tools/lib/fs.js
@@ -0,0 +1,99 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-present Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import glob from 'glob';
+import mkdirp from 'mkdirp';
+import rimraf from 'rimraf';
+
+export const readFile = file => new Promise((resolve, reject) => {
+ fs.readFile(file, 'utf8', (err, data) => (err ? reject(err) : resolve(data)));
+});
+
+export const writeFile = (file, contents) => new Promise((resolve, reject) => {
+ fs.writeFile(file, contents, 'utf8', err => (err ? reject(err) : resolve()));
+});
+
+export const renameFile = (source, target) => new Promise((resolve, reject) => {
+ fs.rename(source, target, err => (err ? reject(err) : resolve()));
+});
+
+export const copyFile = (source, target) => new Promise((resolve, reject) => {
+ let cbCalled = false;
+ function done(err) {
+ if (!cbCalled) {
+ cbCalled = true;
+ if (err) {
+ reject(err);
+ } else {
+ resolve();
+ }
+ }
+ }
+
+ const rd = fs.createReadStream(source);
+ rd.on('error', err => done(err));
+ const wr = fs.createWriteStream(target);
+ wr.on('error', err => done(err));
+ wr.on('close', err => done(err));
+ rd.pipe(wr);
+});
+
+export const readDir = (pattern, options) => new Promise((resolve, reject) =>
+ glob(pattern, options, (err, result) => (err ? reject(err) : resolve(result))),
+);
+
+export const makeDir = name => new Promise((resolve, reject) => {
+ mkdirp(name, err => (err ? reject(err) : resolve()));
+});
+
+export const moveDir = async (source, target) => {
+ const dirs = await readDir('**/*.*', {
+ cwd: source,
+ nosort: true,
+ dot: true,
+ });
+ await Promise.all(dirs.map(async (dir) => {
+ const from = path.resolve(source, dir);
+ const to = path.resolve(target, dir);
+ await makeDir(path.dirname(to));
+ await renameFile(from, to);
+ }));
+};
+
+export const copyDir = async (source, target) => {
+ const dirs = await readDir('**/*.*', {
+ cwd: source,
+ nosort: true,
+ dot: true,
+ });
+ await Promise.all(dirs.map(async (dir) => {
+ const from = path.resolve(source, dir);
+ const to = path.resolve(target, dir);
+ await makeDir(path.dirname(to));
+ await copyFile(from, to);
+ }));
+};
+
+export const cleanDir = (pattern, options) => new Promise((resolve, reject) =>
+ rimraf(pattern, { glob: options }, (err, result) => (err ? reject(err) : resolve(result))),
+);
+
+export default {
+ readFile,
+ writeFile,
+ renameFile,
+ copyFile,
+ readDir,
+ makeDir,
+ copyDir,
+ moveDir,
+ cleanDir,
+};
diff --git a/tools/run.js b/tools/run.js
new file mode 100644
index 0000000..678f41f
--- /dev/null
+++ b/tools/run.js
@@ -0,0 +1,43 @@
+/**
+ * React Starter Kit (https://www.reactstarterkit.com/)
+ *
+ * Copyright © 2014-present Kriasoft, LLC. All rights reserved.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE.txt file in the root directory of this source tree.
+ */
+
+export function format(time) {
+ return time.toTimeString().replace(/.*(\d{2}:\d{2}:\d{2}).*/, '$1');
+}
+
+function run(fn, options) {
+ const task = typeof fn.default === 'undefined' ? fn : fn.default;
+ const start = new Date();
+ console.info(
+ `[${format(start)}] Starting '${task.name}${options ? ` (${options})` : ''}'...`,
+ );
+ return task(options).then((resolution) => {
+ const end = new Date();
+ const time = end.getTime() - start.getTime();
+ console.info(
+ `[${format(end)}] Finished '${task.name}${options ? ` (${options})` : ''}' after ${time} ms`,
+ );
+ return resolution;
+ });
+}
+
+if (require.main === module && process.argv.length > 2) {
+ // eslint-disable-next-line no-underscore-dangle
+ delete require.cache[__filename];
+
+ // eslint-disable-next-line global-require, import/no-dynamic-require
+ const module = require(`./${process.argv[2]}.js`).default;
+
+ run(module).catch((err) => {
+ console.error(err.stack);
+ process.exit(1);
+ });
+}
+
+export default run;
diff --git a/tools/webpack.config.js b/tools/webpack.config.js
new file mode 100644
index 0000000..a3f0f1b
--- /dev/null
+++ b/tools/webpack.config.js
@@ -0,0 +1,352 @@
+/**
+ */
+
+import path from 'path';
+import webpack from 'webpack';
+import AssetsPlugin from 'assets-webpack-plugin';
+import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
+import pkg from '../package.json';
+
+const isDebug = !process.argv.includes('--release');
+const isVerbose = process.argv.includes('--verbose');
+const isAnalyze = process.argv.includes('--analyze') || process.argv.includes('--analyse');
+
+const config = {
+
+ context: path.resolve(__dirname, '..'),
+
+ mode: (isDebug) ? "development" : "production",
+
+ output: {
+ path: path.resolve(__dirname, '../build/public/assets'),
+ publicPath: '/assets/',
+ pathinfo: isVerbose,
+ },
+
+ module: {
+ rules: [
+ {
+ test: /\.jsx?$/,
+ loader: 'babel-loader',
+ include: [
+ path.resolve(__dirname, '../src'),
+ ],
+ query: {
+ // https://github.com/babel/babel-loader#options
+ cacheDirectory: isDebug,
+
+ // https://babeljs.io/docs/usage/options/
+ babelrc: false,
+ presets: [
+ // A Babel preset that can automatically determine the Babel plugins and polyfills
+ // https://github.com/babel/babel-preset-env
+ ['@babel/preset-env', {
+ targets: {
+ browsers: pkg.browserslist,
+ },
+ modules: false,
+ useBuiltIns: 'usage',
+ corejs: {
+ version: 3,
+ },
+ debug: false,
+ }],
+ // JSX, Flow
+ // https://github.com/babel/babel/tree/master/packages/babel-preset-react
+ '@babel/react',
+ ],
+ plugins: [
+ '@babel/transform-flow-strip-types',
+ ["@babel/plugin-proposal-decorators", { "legacy": true }],
+ "@babel/plugin-proposal-function-sent",
+ "@babel/plugin-proposal-export-namespace-from",
+ "@babel/plugin-proposal-numeric-separator",
+ "@babel/plugin-proposal-throw-expressions",
+ ["@babel/plugin-proposal-class-properties", { "loose": true }],
+ // Adds component stack to warning messages
+ // https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-react-jsx-source
+ ...isDebug ? ['@babel/transform-react-jsx-source'] : [],
+ // Adds __self attribute to JSX which React will use for some warnings
+ // https://github.com/babel/babel/tree/master/packages/babel-plugin-transform-react-jsx-self
+ ...isDebug ? ['@babel/transform-react-jsx-self'] : [],
+ // react-optimize
+ "@babel/transform-react-constant-elements",
+ "@babel/transform-react-inline-elements",
+ "transform-react-remove-prop-types",
+ "transform-react-pure-class-to-function",
+ ],
+ },
+ },
+ {
+ test: /\.svg$/,
+ use: [
+ {
+ loader: "babel-loader"
+ },
+ {
+ loader: "react-svg-loader",
+ options: {
+ svgo: {
+ plugins: [
+ {
+ removeViewBox: false,
+ },
+ {
+ removeDimensions: true,
+ },
+ ],
+ },
+ jsx: false // true outputs JSX tags
+ }
+ }
+ ]
+ },
+ {
+ test: /\.html/,
+ use: [
+ {
+ loader: 'html-loader',
+ options: {
+ attrs: [':data-src']
+ },
+ },
+ ],
+ },
+ {
+ test: /\.tcss/,
+ use: [
+ {
+ loader: 'css-loader',
+ options: {
+ // CSS Loader https://github.com/webpack/css-loader
+ importLoaders: 1,
+ sourceMap: isDebug,
+ // CSS Modules https://github.com/css-modules/css-modules
+ modules: true,
+ localIdentName: isDebug ? '[name]-[local]-[hash:base64:5]' : '[hash:base64:5]',
+ // CSS Nano http://cssnano.co/options/
+ minimize: !isDebug,
+ discardComments: { removeAll: true },
+ },
+ },
+ ],
+ },
+ {
+ test: /\.scss/,
+ use: [
+ 'style-loader',
+ {
+ loader: 'css-loader',
+ options: {
+ // CSS Loader https://github.com/webpack/css-loader
+ importLoaders: 1,
+ sourceMap: isDebug,
+ // CSS Modules https://github.com/css-modules/css-modules
+ modules: false,
+ // CSS Nano http://cssnano.co/options/
+ minimize: !isDebug,
+ discardComments: { removeAll: true },
+ },
+ },
+ 'sass-loader',
+ ],
+ },
+ {
+ test: /\.css/,
+ use: [ 'style-loader',
+ {
+ loader: 'css-loader',
+ options: {
+ // CSS Loader https://github.com/webpack/css-loader
+ sourceMap: isDebug,
+ // CSS Modules https://github.com/css-modules/css-modules
+ modules: false,
+ // CSS Nano http://cssnano.co/options/
+ minimize: !isDebug,
+ discardComments: { removeAll: true },
+ },
+ },
+ ],
+ },
+ {
+ test: /\.md$/,
+ loader: path.resolve(__dirname, './lib/markdown-loader.js'),
+ },
+ {
+ test: /\.txt$/,
+ loader: 'raw-loader',
+ },
+ {
+ test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2)(\?.*)?$/,
+ loader: 'file-loader',
+ query: {
+ name: isDebug ? '[path][name].[ext]?[hash:8]' : '[hash:8].[ext]',
+ },
+ },
+ {
+ test: /\.(mp4|webm|wav|mp3|m4a|aac|oga)(\?.*)?$/,
+ loader: 'url-loader',
+ query: {
+ name: isDebug ? '[path][name].[ext]?[hash:8]' : '[hash:8].[ext]',
+ limit: 10000,
+ },
+ },
+ ],
+ },
+
+ // Don't attempt to continue if there are any errors.
+ bail: !isDebug,
+
+ cache: isDebug,
+
+ stats: {
+ colors: true,
+ reasons: isDebug,
+ hash: isVerbose,
+ version: isVerbose,
+ timings: true,
+ chunks: isVerbose,
+ chunkModules: isVerbose,
+ cached: isVerbose,
+ cachedAssets: isVerbose,
+ },
+};
+
+const clientConfig = {
+ ...config,
+
+ name: 'client',
+ target: 'web',
+
+ devtool: 'source-map',
+
+ entry: {
+ client: ['./src/client.js'],
+ },
+
+ output: {
+ ...config.output,
+ filename: isDebug ? '[name].js' : '[name].[chunkhash:8].js',
+ chunkFilename: isDebug ? '[name].chunk.js' : '[name].[chunkhash:8].js',
+ },
+
+ plugins: [
+ // Define free variables
+ // https://webpack.github.io/docs/list-of-plugins.html#defineplugin
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': isDebug ? '"development"' : '"production"',
+ 'process.env.BROWSER': true,
+ __DEV__: isDebug,
+ }),
+
+ // Emit a file with assets paths
+ // https://github.com/sporto/assets-webpack-plugin#options
+ new AssetsPlugin({
+ path: path.resolve(__dirname, '../build'),
+ filename: 'assets.json',
+ prettyPrint: true,
+ }),
+
+ // Webpack Bundle Analyzer
+ // https://github.com/th0r/webpack-bundle-analyzer
+ ...isAnalyze ? [new BundleAnalyzerPlugin()] : [],
+ ],
+
+ optimization: {
+ splitChunks: {
+ chunks: 'all',
+ name: false,
+ cacheGroups: {
+ default: false,
+ vendors: false,
+
+ vendor: {
+ name: "vendor",
+ chunks: 'all',
+ test: /node_modules/,
+ }
+ }
+ }
+ },
+
+ // Some libraries import Node modules but don't use them in the browser.
+ // Tell Webpack to provide empty mocks for them so importing them works.
+ // https://webpack.github.io/docs/configuration.html#node
+ // https://github.com/webpack/node-libs-browser/tree/master/mock
+ node: {
+ fs: 'empty',
+ net: 'empty',
+ tls: 'empty',
+ },
+};
+
+
+const webConfig = {
+ ...config,
+
+ name: 'web',
+ target: 'node',
+
+ entry: {
+ web: ['./src/web.js'],
+ },
+
+ output: {
+ ...config.output,
+ filename: '../../web.js',
+ libraryTarget: 'commonjs2',
+ },
+
+ module: {
+ ...config.module,
+
+ // Override babel-preset-env configuration for Node.js
+ rules: config.module.rules.map(rule => (rule.loader !== 'babel-loader' ? rule : {
+ ...rule,
+ query: {
+ ...rule.query,
+ presets: rule.query.presets.map(preset => (preset[0] !== '@babel/preset-env' ? preset : ['@babel/preset-env', {
+ targets: {
+ node: pkg.engines.node.replace(/^\D+/g, ''),
+ },
+ modules: false,
+ useBuiltIns: false,
+ debug: false,
+ }])),
+ },
+ })),
+ },
+
+ // needed because webpack tries to pack socket.io
+ externals: [
+ /^\.\/assets\.json$/,
+ (context, request, callback) => {
+ const isExternal =
+ request.match(/^[@a-z][a-z/.\-0-9]*$/i) &&
+ !request.match(/\.(css|less|scss|sss)$/i);
+ callback(null, Boolean(isExternal));
+ },
+ ],
+
+ plugins: [
+ // Define free variables
+ // https://webpack.github.io/docs/list-of-plugins.html#defineplugin
+ new webpack.DefinePlugin({
+ 'process.env.NODE_ENV': isDebug ? '"development"' : '"production"',
+ 'process.env.BROWSER': false,
+ __DEV__: isDebug,
+ }),
+ ],
+
+ node: {
+ console: false,
+ global: false,
+ process: false,
+ Buffer: false,
+ __filename: false,
+ __dirname: false,
+ },
+
+};
+
+export default [clientConfig, webConfig];
diff --git a/utils/README.md b/utils/README.md
new file mode 100644
index 0000000..6fd0f4d
--- /dev/null
+++ b/utils/README.md
@@ -0,0 +1,33 @@
+# Utils for map creation, conversion, 3d models and related stuff
+Note:
+- we use blender 2.8
+- js script are executed with babel-node
+
+## sphere-protection.blend
+This blend file includes the sphere we use to display the globe with two UV maps, one for protection like it's used on many globe textures of the earth like [here](http://blog.mastermaps.com/2013/09/creating-webgl-earth-with-threejs.html) and [here](http://www.shadedrelief.com/natural3/pages/textures.html) and one for our mercator projection that is the same as on OpenStreetMap, with additional changes for poles.
+The shader nodes in the bumpmap material are setup so that they bake from one uv map to another.
+
+If you want to generate the .glb model file for the site thats in public/globe/globe.glb:
+1. delete all materials of the sphere
+2. delete the "fake-mercator" uv map, so that just the mercator one is left
+3. create a new one without textures
+4. name the material "canvas" (this will then be set by the script to the canvas textures)
+5. select the sphere and export as .glb
+
+## ocean-tiles
+Used to generate tiles based on a uv texture that can then be drawn on the canvas, like the oceans and continents.
+
+## country-locations
+Generates a json list of country codes and their coordinates on the canvas based on lat and lon
+
+## redis-convert.js
+Script to convert redis canvas database to different color / different layout
+
+## redis-copy.js
+Script to copy a canvas from one redis to another, with different keys if neccessary
+
+## sql-commandtest.js
+Script that connects to the mysql database and does some stuff, just for testing
+
+## proxy-Convert.sh
+Converts a proxy list in specific txt format to a better readable list
diff --git a/utils/checkProxy.js b/utils/checkProxy.js
new file mode 100644
index 0000000..e03ca07
--- /dev/null
+++ b/utils/checkProxy.js
@@ -0,0 +1,62 @@
+/* @flow */
+// this script checks a ip with the pixelplanet proxychecker
+//
+
+import fetch from '../src/utils/proxiedFetch.js';
+import isoFetch from 'isomorphic-fetch';
+
+/*
+ * check proxycheck.io if IP is proxy
+ * Use proxiedFetch with random proxies
+ * @param ip IP to check
+ * @return true if proxy, false if not
+ */
+async function getProxyCheck(ip: string): Promise {
+ const url = `http://proxycheck.io/v2/${ip}?risk=1&vpn=1&asn=1`;
+ //const url = 'http://pixel.space';
+ console.log('fetching proxycheck', url);
+ const response = await fetch(url, {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36',
+ }
+ });
+ if (!response.ok) {
+ const text = await response.text();
+ console.log('proxycheck not ok ' + response.status + '/' + text);
+ return;
+ }
+ const data = await response.json();
+ console.log('proxycheck.io is proxy?', ip, data);
+ const ret = data.status == 'ok' && data[ip].proxy === 'yes';
+ console.log(ret);
+}
+
+async function getIPIntel(ip: string): Promise {
+ const email = Math.random().toString(36).substring(8) + "-" + Math.random().toString(36).substring(4) + "@gmail.com";
+ const url = `http://check.getipintel.net/check.php?ip=${ip}&contact=${email}&flags=m`;
+ console.log('fetching getipintel', url);
+ const response = await fetch(url, {
+ headers: {
+ Accept: '*/*',
+ 'Accept-Language': 'de,en-US;q=0.7,en;q=0.3',
+ Referer: 'http://check.getipintel.net/',
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36',
+ }
+ });
+ // TODO log response code
+ if (!response.ok) {
+ const text = await response.text();
+ console.log('getipintel not ok ' + response.status + '/' + text);
+ return;
+ }
+ const body = await response.text();
+ console.log('fetch getipintel is proxy?', ip, body);
+ // returns tru iff we found 1 in the response and was ok (http code = 200)
+ const value = parseFloat(body);
+ return value > 0.995;
+}
+
+
+const ip = '188.172.220.70';
+getProxyCheck(ip);
+getIPIntel(ip);
diff --git a/utils/country-locations/README.md b/utils/country-locations/README.md
new file mode 100644
index 0000000..ab0f281
--- /dev/null
+++ b/utils/country-locations/README.md
@@ -0,0 +1,4 @@
+# country codes two coords
+
+Generating a list of country codes -> canvas coordinates in advance makes us more efficient :)
+Output file is moved to src/data
diff --git a/utils/country-locations/convert.js b/utils/country-locations/convert.js
new file mode 100644
index 0000000..b001f7e
--- /dev/null
+++ b/utils/country-locations/convert.js
@@ -0,0 +1,58 @@
+/*
+ * @flow
+ * Convert a list of countrycodes to latlong -> canvas coordinates
+ */
+
+import fs from 'fs';
+import countryCodeLatLong from './countrycode-latlong-array.json';
+import { CANVAS_SIZE, CANVAS_MIN_XY } from '../../src/core/constants';
+
+
+/*
+ * Converts lat/long to canvas coordinates
+ * NOTE: our projection if off by the factor 265/256 in Y direction from common other
+ * common map projections
+ * parses geo coords (lat/long) to canvas coordinates
+ * @param coords lat / long
+ * @return canvas coords x / y
+ */
+function latlong2Coords(coords) {
+ const [ lat, lng ] = coords;
+ const x = Math.floor(CANVAS_SIZE * ((lng + 180) / 360)) + CANVAS_MIN_XY;
+ const y = (Math.floor((1-Math.log(Math.tan(lat*Math.PI/180) + 1/Math.cos(lat*Math.PI/180))/Math.PI)/2 * CANVAS_SIZE) + CANVAS_MIN_XY) * 265/256;
+ return [x, y];
+}
+
+
+/*
+ * gets canvas coords to country
+ * @param countryCode ISO two letter country code
+ * @return canvas coords x / y
+ */
+export function country2Coords(countryCode) {
+ try {
+ const coords = countryCodeLatLong[countryCode].map(z => parseFloat(z));
+ return latlong2Coords(coords)
+ }
+ catch(err) {
+ console.log(`Country ${countryCode} not found.`);
+ return [0, 0];
+ }
+}
+
+
+/*
+ * creates json file with country code to canvas coords
+ * @param countryLatLang array with country codes to lat long
+ * @param filename Output filename
+ */
+function createCoordsJson(filename) {
+ let output = {};
+ for (var cc in countryCodeLatLong) {
+ output[cc] = country2Coords(cc);
+ }
+ fs.writeFile(filename, JSON.stringify(output), 'utf8', (a) => {});
+}
+
+
+createCoordsJson('countrycode-coords-array.json');
diff --git a/utils/country-locations/countrycode-latlong-array.json b/utils/country-locations/countrycode-latlong-array.json
new file mode 100644
index 0000000..313b78f
--- /dev/null
+++ b/utils/country-locations/countrycode-latlong-array.json
@@ -0,0 +1,962 @@
+{
+ "ad": [
+ "42.5000",
+ "1.5000"
+ ],
+ "ae": [
+ "24.0000",
+ "54.0000"
+ ],
+ "af": [
+ "33.0000",
+ "65.0000"
+ ],
+ "ag": [
+ "17.0500",
+ "-61.8000"
+ ],
+ "ai": [
+ "18.2500",
+ "-63.1667"
+ ],
+ "al": [
+ "41.0000",
+ "20.0000"
+ ],
+ "am": [
+ "40.0000",
+ "45.0000"
+ ],
+ "an": [
+ "12.2500",
+ "-68.7500"
+ ],
+ "ao": [
+ "-12.5000",
+ "18.5000"
+ ],
+ "ap": [
+ "35.0000",
+ "105.0000"
+ ],
+ "aq": [
+ "-90.0000",
+ "0.0000"
+ ],
+ "ar": [
+ "-34.0000",
+ "-64.0000"
+ ],
+ "as": [
+ "-14.3333",
+ "-170.0000"
+ ],
+ "at": [
+ "47.3333",
+ "13.3333"
+ ],
+ "au": [
+ "-27.0000",
+ "133.0000"
+ ],
+ "aw": [
+ "12.5000",
+ "-69.9667"
+ ],
+ "az": [
+ "40.5000",
+ "47.5000"
+ ],
+ "ba": [
+ "44.0000",
+ "18.0000"
+ ],
+ "bb": [
+ "13.1667",
+ "-59.5333"
+ ],
+ "bd": [
+ "24.0000",
+ "90.0000"
+ ],
+ "be": [
+ "50.8333",
+ "4.0000"
+ ],
+ "bf": [
+ "13.0000",
+ "-2.0000"
+ ],
+ "bg": [
+ "43.0000",
+ "25.0000"
+ ],
+ "bh": [
+ "26.0000",
+ "50.5500"
+ ],
+ "bi": [
+ "-3.5000",
+ "30.0000"
+ ],
+ "bj": [
+ "9.5000",
+ "2.2500"
+ ],
+ "bm": [
+ "32.3333",
+ "-64.7500"
+ ],
+ "bn": [
+ "4.5000",
+ "114.6667"
+ ],
+ "bo": [
+ "-17.0000",
+ "-65.0000"
+ ],
+ "br": [
+ "-10.0000",
+ "-55.0000"
+ ],
+ "bs": [
+ "24.2500",
+ "-76.0000"
+ ],
+ "bt": [
+ "27.5000",
+ "90.5000"
+ ],
+ "bv": [
+ "-54.4333",
+ "3.4000"
+ ],
+ "bw": [
+ "-22.0000",
+ "24.0000"
+ ],
+ "by": [
+ "53.0000",
+ "28.0000"
+ ],
+ "bz": [
+ "17.2500",
+ "-88.7500"
+ ],
+ "ca": [
+ "60.0000",
+ "-95.0000"
+ ],
+ "cc": [
+ "-12.5000",
+ "96.8333"
+ ],
+ "cd": [
+ "0.0000",
+ "25.0000"
+ ],
+ "cf": [
+ "7.0000",
+ "21.0000"
+ ],
+ "cg": [
+ "-1.0000",
+ "15.0000"
+ ],
+ "ch": [
+ "47.0000",
+ "8.0000"
+ ],
+ "ci": [
+ "8.0000",
+ "-5.0000"
+ ],
+ "ck": [
+ "-21.2333",
+ "-159.7667"
+ ],
+ "cl": [
+ "-30.0000",
+ "-71.0000"
+ ],
+ "cm": [
+ "6.0000",
+ "12.0000"
+ ],
+ "cn": [
+ "35.0000",
+ "105.0000"
+ ],
+ "co": [
+ "4.0000",
+ "-72.0000"
+ ],
+ "cr": [
+ "10.0000",
+ "-84.0000"
+ ],
+ "cu": [
+ "21.5000",
+ "-80.0000"
+ ],
+ "cv": [
+ "16.0000",
+ "-24.0000"
+ ],
+ "cx": [
+ "-10.5000",
+ "105.6667"
+ ],
+ "cy": [
+ "35.0000",
+ "33.0000"
+ ],
+ "cz": [
+ "49.7500",
+ "15.5000"
+ ],
+ "de": [
+ "51.0000",
+ "9.0000"
+ ],
+ "dj": [
+ "11.5000",
+ "43.0000"
+ ],
+ "dk": [
+ "56.0000",
+ "10.0000"
+ ],
+ "dm": [
+ "15.4167",
+ "-61.3333"
+ ],
+ "do": [
+ "19.0000",
+ "-70.6667"
+ ],
+ "dz": [
+ "28.0000",
+ "3.0000"
+ ],
+ "ec": [
+ "-2.0000",
+ "-77.5000"
+ ],
+ "ee": [
+ "59.0000",
+ "26.0000"
+ ],
+ "eg": [
+ "27.0000",
+ "30.0000"
+ ],
+ "eh": [
+ "24.5000",
+ "-13.0000"
+ ],
+ "er": [
+ "15.0000",
+ "39.0000"
+ ],
+ "es": [
+ "40.0000",
+ "-4.0000"
+ ],
+ "et": [
+ "8.0000",
+ "38.0000"
+ ],
+ "eu": [
+ "47.0000",
+ "8.0000"
+ ],
+ "fi": [
+ "64.0000",
+ "26.0000"
+ ],
+ "fj": [
+ "-18.0000",
+ "175.0000"
+ ],
+ "fk": [
+ "-51.7500",
+ "-59.0000"
+ ],
+ "fm": [
+ "6.9167",
+ "158.2500"
+ ],
+ "fo": [
+ "62.0000",
+ "-7.0000"
+ ],
+ "fr": [
+ "46.0000",
+ "2.0000"
+ ],
+ "ga": [
+ "-1.0000",
+ "11.7500"
+ ],
+ "gb": [
+ "54.0000",
+ "-2.0000"
+ ],
+ "gd": [
+ "12.1167",
+ "-61.6667"
+ ],
+ "ge": [
+ "42.0000",
+ "43.5000"
+ ],
+ "gf": [
+ "4.0000",
+ "-53.0000"
+ ],
+ "gh": [
+ "8.0000",
+ "-2.0000"
+ ],
+ "gi": [
+ "36.1833",
+ "-5.3667"
+ ],
+ "gl": [
+ "72.0000",
+ "-40.0000"
+ ],
+ "gm": [
+ "13.4667",
+ "-16.5667"
+ ],
+ "gn": [
+ "11.0000",
+ "-10.0000"
+ ],
+ "gp": [
+ "16.2500",
+ "-61.5833"
+ ],
+ "gq": [
+ "2.0000",
+ "10.0000"
+ ],
+ "gr": [
+ "39.0000",
+ "22.0000"
+ ],
+ "gs": [
+ "-54.5000",
+ "-37.0000"
+ ],
+ "gt": [
+ "15.5000",
+ "-90.2500"
+ ],
+ "gu": [
+ "13.4667",
+ "144.7833"
+ ],
+ "gw": [
+ "12.0000",
+ "-15.0000"
+ ],
+ "gy": [
+ "5.0000",
+ "-59.0000"
+ ],
+ "hk": [
+ "22.2500",
+ "114.1667"
+ ],
+ "hm": [
+ "-53.1000",
+ "72.5167"
+ ],
+ "hn": [
+ "15.0000",
+ "-86.5000"
+ ],
+ "hr": [
+ "45.1667",
+ "15.5000"
+ ],
+ "ht": [
+ "19.0000",
+ "-72.4167"
+ ],
+ "hu": [
+ "47.0000",
+ "20.0000"
+ ],
+ "id": [
+ "-5.0000",
+ "120.0000"
+ ],
+ "ie": [
+ "53.0000",
+ "-8.0000"
+ ],
+ "il": [
+ "31.5000",
+ "34.7500"
+ ],
+ "in": [
+ "20.0000",
+ "77.0000"
+ ],
+ "io": [
+ "-6.0000",
+ "71.5000"
+ ],
+ "iq": [
+ "33.0000",
+ "44.0000"
+ ],
+ "ir": [
+ "32.0000",
+ "53.0000"
+ ],
+ "is": [
+ "65.0000",
+ "-18.0000"
+ ],
+ "it": [
+ "42.8333",
+ "12.8333"
+ ],
+ "jm": [
+ "18.2500",
+ "-77.5000"
+ ],
+ "jo": [
+ "31.0000",
+ "36.0000"
+ ],
+ "jp": [
+ "36.0000",
+ "138.0000"
+ ],
+ "ke": [
+ "1.0000",
+ "38.0000"
+ ],
+ "kg": [
+ "41.0000",
+ "75.0000"
+ ],
+ "kh": [
+ "13.0000",
+ "105.0000"
+ ],
+ "ki": [
+ "1.4167",
+ "173.0000"
+ ],
+ "km": [
+ "-12.1667",
+ "44.2500"
+ ],
+ "kn": [
+ "17.3333",
+ "-62.7500"
+ ],
+ "kp": [
+ "40.0000",
+ "127.0000"
+ ],
+ "kr": [
+ "37.0000",
+ "127.5000"
+ ],
+ "kw": [
+ "29.3375",
+ "47.6581"
+ ],
+ "ky": [
+ "19.5000",
+ "-80.5000"
+ ],
+ "kz": [
+ "48.0000",
+ "68.0000"
+ ],
+ "la": [
+ "18.0000",
+ "105.0000"
+ ],
+ "lb": [
+ "33.8333",
+ "35.8333"
+ ],
+ "lc": [
+ "13.8833",
+ "-61.1333"
+ ],
+ "li": [
+ "47.1667",
+ "9.5333"
+ ],
+ "lk": [
+ "7.0000",
+ "81.0000"
+ ],
+ "lr": [
+ "6.5000",
+ "-9.5000"
+ ],
+ "ls": [
+ "-29.5000",
+ "28.5000"
+ ],
+ "lt": [
+ "56.0000",
+ "24.0000"
+ ],
+ "lu": [
+ "49.7500",
+ "6.1667"
+ ],
+ "lv": [
+ "57.0000",
+ "25.0000"
+ ],
+ "ly": [
+ "25.0000",
+ "17.0000"
+ ],
+ "ma": [
+ "32.0000",
+ "-5.0000"
+ ],
+ "mc": [
+ "43.7333",
+ "7.4000"
+ ],
+ "md": [
+ "47.0000",
+ "29.0000"
+ ],
+ "me": [
+ "42.0000",
+ "19.0000"
+ ],
+ "mg": [
+ "-20.0000",
+ "47.0000"
+ ],
+ "mh": [
+ "9.0000",
+ "168.0000"
+ ],
+ "mk": [
+ "41.8333",
+ "22.0000"
+ ],
+ "ml": [
+ "17.0000",
+ "-4.0000"
+ ],
+ "mm": [
+ "22.0000",
+ "98.0000"
+ ],
+ "mn": [
+ "46.0000",
+ "105.0000"
+ ],
+ "mo": [
+ "22.1667",
+ "113.5500"
+ ],
+ "mp": [
+ "15.2000",
+ "145.7500"
+ ],
+ "mq": [
+ "14.6667",
+ "-61.0000"
+ ],
+ "mr": [
+ "20.0000",
+ "-12.0000"
+ ],
+ "ms": [
+ "16.7500",
+ "-62.2000"
+ ],
+ "mt": [
+ "35.8333",
+ "14.5833"
+ ],
+ "mu": [
+ "-20.2833",
+ "57.5500"
+ ],
+ "mv": [
+ "3.2500",
+ "73.0000"
+ ],
+ "mw": [
+ "-13.5000",
+ "34.0000"
+ ],
+ "mx": [
+ "23.0000",
+ "-102.0000"
+ ],
+ "my": [
+ "2.5000",
+ "112.5000"
+ ],
+ "mz": [
+ "-18.2500",
+ "35.0000"
+ ],
+ "na": [
+ "-22.0000",
+ "17.0000"
+ ],
+ "nc": [
+ "-21.5000",
+ "165.5000"
+ ],
+ "ne": [
+ "16.0000",
+ "8.0000"
+ ],
+ "nf": [
+ "-29.0333",
+ "167.9500"
+ ],
+ "ng": [
+ "10.0000",
+ "8.0000"
+ ],
+ "ni": [
+ "13.0000",
+ "-85.0000"
+ ],
+ "nl": [
+ "52.5000",
+ "5.7500"
+ ],
+ "no": [
+ "62.0000",
+ "10.0000"
+ ],
+ "np": [
+ "28.0000",
+ "84.0000"
+ ],
+ "nr": [
+ "-0.5333",
+ "166.9167"
+ ],
+ "nu": [
+ "-19.0333",
+ "-169.8667"
+ ],
+ "nz": [
+ "-41.0000",
+ "174.0000"
+ ],
+ "om": [
+ "21.0000",
+ "57.0000"
+ ],
+ "pa": [
+ "9.0000",
+ "-80.0000"
+ ],
+ "pe": [
+ "-10.0000",
+ "-76.0000"
+ ],
+ "pf": [
+ "-15.0000",
+ "-140.0000"
+ ],
+ "pg": [
+ "-6.0000",
+ "147.0000"
+ ],
+ "ph": [
+ "13.0000",
+ "122.0000"
+ ],
+ "pk": [
+ "30.0000",
+ "70.0000"
+ ],
+ "pl": [
+ "52.0000",
+ "20.0000"
+ ],
+ "pm": [
+ "46.8333",
+ "-56.3333"
+ ],
+ "pr": [
+ "18.2500",
+ "-66.5000"
+ ],
+ "ps": [
+ "32.0000",
+ "35.2500"
+ ],
+ "pt": [
+ "39.5000",
+ "-8.0000"
+ ],
+ "pw": [
+ "7.5000",
+ "134.5000"
+ ],
+ "py": [
+ "-23.0000",
+ "-58.0000"
+ ],
+ "qa": [
+ "25.5000",
+ "51.2500"
+ ],
+ "re": [
+ "-21.1000",
+ "55.6000"
+ ],
+ "ro": [
+ "46.0000",
+ "25.0000"
+ ],
+ "rs": [
+ "44.0000",
+ "21.0000"
+ ],
+ "ru": [
+ "60.0000",
+ "100.0000"
+ ],
+ "rw": [
+ "-2.0000",
+ "30.0000"
+ ],
+ "sa": [
+ "25.0000",
+ "45.0000"
+ ],
+ "sb": [
+ "-8.0000",
+ "159.0000"
+ ],
+ "sc": [
+ "-4.5833",
+ "55.6667"
+ ],
+ "sd": [
+ "15.0000",
+ "30.0000"
+ ],
+ "se": [
+ "62.0000",
+ "15.0000"
+ ],
+ "sg": [
+ "1.3667",
+ "103.8000"
+ ],
+ "sh": [
+ "-15.9333",
+ "-5.7000"
+ ],
+ "si": [
+ "46.0000",
+ "15.0000"
+ ],
+ "sj": [
+ "78.0000",
+ "20.0000"
+ ],
+ "sk": [
+ "48.6667",
+ "19.5000"
+ ],
+ "sl": [
+ "8.5000",
+ "-11.5000"
+ ],
+ "sm": [
+ "43.7667",
+ "12.4167"
+ ],
+ "sn": [
+ "14.0000",
+ "-14.0000"
+ ],
+ "so": [
+ "10.0000",
+ "49.0000"
+ ],
+ "sr": [
+ "4.0000",
+ "-56.0000"
+ ],
+ "st": [
+ "1.0000",
+ "7.0000"
+ ],
+ "sv": [
+ "13.8333",
+ "-88.9167"
+ ],
+ "sy": [
+ "35.0000",
+ "38.0000"
+ ],
+ "sz": [
+ "-26.5000",
+ "31.5000"
+ ],
+ "tc": [
+ "21.7500",
+ "-71.5833"
+ ],
+ "td": [
+ "15.0000",
+ "19.0000"
+ ],
+ "tf": [
+ "-43.0000",
+ "67.0000"
+ ],
+ "tg": [
+ "8.0000",
+ "1.1667"
+ ],
+ "th": [
+ "15.0000",
+ "100.0000"
+ ],
+ "tj": [
+ "39.0000",
+ "71.0000"
+ ],
+ "tk": [
+ "-9.0000",
+ "-172.0000"
+ ],
+ "tm": [
+ "40.0000",
+ "60.0000"
+ ],
+ "tn": [
+ "34.0000",
+ "9.0000"
+ ],
+ "to": [
+ "-20.0000",
+ "-175.0000"
+ ],
+ "tr": [
+ "39.0000",
+ "35.0000"
+ ],
+ "tt": [
+ "11.0000",
+ "-61.0000"
+ ],
+ "tv": [
+ "-8.0000",
+ "178.0000"
+ ],
+ "tw": [
+ "23.5000",
+ "121.0000"
+ ],
+ "tz": [
+ "-6.0000",
+ "35.0000"
+ ],
+ "ua": [
+ "49.0000",
+ "32.0000"
+ ],
+ "ug": [
+ "1.0000",
+ "32.0000"
+ ],
+ "um": [
+ "19.2833",
+ "166.6000"
+ ],
+ "us": [
+ "38.0000",
+ "-97.0000"
+ ],
+ "uy": [
+ "-33.0000",
+ "-56.0000"
+ ],
+ "uz": [
+ "41.0000",
+ "64.0000"
+ ],
+ "va": [
+ "41.9000",
+ "12.4500"
+ ],
+ "vc": [
+ "13.2500",
+ "-61.2000"
+ ],
+ "ve": [
+ "8.0000",
+ "-66.0000"
+ ],
+ "vg": [
+ "18.5000",
+ "-64.5000"
+ ],
+ "vi": [
+ "18.3333",
+ "-64.8333"
+ ],
+ "vn": [
+ "16.0000",
+ "106.0000"
+ ],
+ "vu": [
+ "-16.0000",
+ "167.0000"
+ ],
+ "wf": [
+ "-13.3000",
+ "-176.2000"
+ ],
+ "ws": [
+ "-13.5833",
+ "-172.3333"
+ ],
+ "ye": [
+ "15.0000",
+ "48.0000"
+ ],
+ "yt": [
+ "-12.8333",
+ "45.1667"
+ ],
+ "za": [
+ "-29.0000",
+ "24.0000"
+ ],
+ "zm": [
+ "-15.0000",
+ "30.0000"
+ ],
+ "zw": [
+ "-20.0000",
+ "30.0000"
+ ]
+}
\ No newline at end of file
diff --git a/utils/geoiplookup.sh b/utils/geoiplookup.sh
new file mode 100755
index 0000000..122d671
--- /dev/null
+++ b/utils/geoiplookup.sh
@@ -0,0 +1,6 @@
+email="cxyvsf@gmail.com"
+while read i; do
+ RESULT=`geoiplookup $i | sed -e 's/.*, //'`
+ #PROXY=`wget "http://check.getipintel.net/check.php?ip=$i&contact=alpha@gmail.com" -qO -`
+ echo "$i $RESULT"
+done < ./ips
diff --git a/utils/getipintel.sh b/utils/getipintel.sh
new file mode 100755
index 0000000..34dfa92
--- /dev/null
+++ b/utils/getipintel.sh
@@ -0,0 +1,4 @@
+email="cxyvsf2@gmail.com"
+RESULT=`geoiplookup $1 | sed -e 's/.*, //'`
+PROXY=`wget "http://check.getipintel.net/check.php?ip=$1&contact=alpha@gmail.com" -qO -`
+echo "$1 $RESULT $PROXY"
diff --git a/utils/ocean-tiles/README.md b/utils/ocean-tiles/README.md
new file mode 100644
index 0000000..c01235f
--- /dev/null
+++ b/utils/ocean-tiles/README.md
@@ -0,0 +1,20 @@
+# ocean tiles
+In order to have the ocean and land on the canvas, or any other background pic, we have to create tiles that we can later upload to the canvas with redisBackground.js.
+Those are the commands to create tiles in subfolders:
+
+- create folder for tiles:
+mkdir ./ocean
+cd ocean
+- to split image into tiles:
+convert ../ocean.png -crop 128x128 +adjoin ocean_tiles%02d.png
+- upscale and convert to black and white
+mogrify -resize 2048x2048 -colors 2 -white-threshold 80% -black-threshold 80% ocean_tiles*.png
+or without dithering:
+mogrify +dither -resize 2048x2048 -colors 2 -white-threshold 80% -black-threshold 80% ocean_tiles*.png
+- create subfolders
+for i in {0..31}; do mkdir $i; done
+- put into subfolders
+for file in ./ocean_tiles*.png; do NUM=`echo $file | sed -e 's/.*ocean_tiles//' -e 's/.png//'`; Y=$(expr $NUM / 32); X=$(expr $NUM % 32); newfile="$X/$Y.png"; mv $file $newfile; done
+
+- to remove the subfolders again
+for i in {0..31}; do rm -r $i; done
diff --git a/utils/ocean-tiles/drawOcean.js b/utils/ocean-tiles/drawOcean.js
new file mode 100644
index 0000000..1b7c207
--- /dev/null
+++ b/utils/ocean-tiles/drawOcean.js
@@ -0,0 +1,37 @@
+/* @flow
+ * this script takes black/withe tiles and sets their colors on the canvas
+ * its used to set the land area on the planet.
+ *
+ * The tiles it uses are explained in 3dmodels/ocean-tiles
+ *
+ * run this script with --expose-gc or you run out of RAM
+ */
+
+
+import { CANVAS_SIZE, CANVAS_MIN_XY, CANVAS_MAX_XY } from '../../src/core/constants';
+import { imagemask2Canvas } from '../../src/core/Image';
+import sharp from 'sharp';
+import fs from 'fs';
+
+const TILEFOLDER = './ocean';
+const TILE_SIZE = 2048;
+
+
+async function applyMasks() {
+ for (let ty = 0; ty < CANVAS_SIZE / TILE_SIZE; ty += 1) {
+ for (let tx = 0; tx < CANVAS_SIZE / TILE_SIZE; tx += 1) {
+ const [ x, y ] = [tx, ty].map(z => z * TILE_SIZE + CANVAS_MIN_XY);
+ const filename = `${TILEFOLDER}/${tx}/${ty}.png`;
+ console.log(`Checking tile ${filename}`);
+ if (!fs.existsSync(filename)) continue;
+ let tile = await sharp(filename).removeAlpha().raw().toBuffer();
+ await imagemask2Canvas(x, y, tile, TILE_SIZE, TILE_SIZE, (clr) => {
+ return 1; //set color to index 1 -> land
+ });
+ tile = null;
+ }
+ global.gc();
+ }
+}
+
+applyMasks();
diff --git a/utils/ocean-tiles/ocean-dithered.tar.xz b/utils/ocean-tiles/ocean-dithered.tar.xz
new file mode 100644
index 0000000..a85f67b
Binary files /dev/null and b/utils/ocean-tiles/ocean-dithered.tar.xz differ
diff --git a/utils/ocean-tiles/ocean.png b/utils/ocean-tiles/ocean.png
new file mode 100644
index 0000000..8c65769
Binary files /dev/null and b/utils/ocean-tiles/ocean.png differ
diff --git a/utils/ocean-tiles/ocean.tar.xz b/utils/ocean-tiles/ocean.tar.xz
new file mode 100644
index 0000000..4468f34
Binary files /dev/null and b/utils/ocean-tiles/ocean.tar.xz differ
diff --git a/utils/osm-tiles/LICENSE b/utils/osm-tiles/LICENSE
new file mode 100644
index 0000000..252dc13
--- /dev/null
+++ b/utils/osm-tiles/LICENSE
@@ -0,0 +1,7 @@
+Tiles are taken and converted from openstreetmap.org.
+OpenStreetMap is open data, licensed under the Open Data Commons Database License (ODbL) by the OpenStreetMap Foundation.
+The cartography in the map tiles is licensed under the Creative Commons Attribution-ShareAlike 2.0 license (CC BY-SA).
+
+Licensing Details can be viewed under https://www.openstreetmap.org/copyright
+
+You are free to copy, distribute, transmit and adapt our data, as long as you credit OpenStreetMap and its contributors. If you alter or build upon our data, you may distribute the result only under the same licence. The full legal code explains your rights and responsibilities.
diff --git a/utils/osm-tiles/README.md b/utils/osm-tiles/README.md
new file mode 100644
index 0000000..b3813f3
--- /dev/null
+++ b/utils/osm-tiles/README.md
@@ -0,0 +1,62 @@
+# OSM tiles
+This osm tiles and pictures are just informational, they don't get used for creating the map on directly on pixelplanet.fun.
+
+## Download zoomlevel 5
+1. download tiles
+```bash
+for i in {0..31}; do mkdir $i; for u in {0..31}; do wget https://b.tile.thunderforest.com/mobile-atlas/5/$i/$u.png?apikey=7c352c8ff1244dd8b732e349e0b0fe8d -O $i/$u.png; done ; done
+```
+2. combine tiles
+```bash
+for i in {0..31}; do convert -append "$i/%d.png[0-31]" $i.png; done
+convert +append "%d.png[0-31]" out.png
+```
+3. fix to our custom projection
+- scale x: 1
+- scale y: 1060 / 1024 = 530 / 512 = 265 / 256
+- centered
+```bash
+convert out.png -resize 8192x8480\! final.png
+# (8480 - 8192) / 2 = 144
+mogrify -shave 0x144 final.png
+```
+4. clean up
+```bash
+for i in {0..31}; do rm $i.png; done
+for i in {0..31}; do rm -r $i; done
+rm out.png
+```
+
+## Download zoomlevel 8
+1. downloading it in columns
+```bash
+mkdir tmp
+for i in {0..255}; do for u in {0..255}; do wget https://a.tile.openstreetmap.org/8/$i/$u.png -O tmp/$u.png; done; convert -append "tmp/%d.png[0-255]" $i.png; rm tmp/*; done
+rm -r tmp
+```
+2. conbining straps t 2048 width, fix scale to our projection like above with zoomlevel 5
+```bash
+for i in {0..31}; do LOW=$(expr $i "*" 8); UP=$(expr $LOW + 7); echo "Generating s$i.png from $LOW to $UP"; convert +append "%d.png[${LOW}-${UP}]" s$i.png; done
+```
+3. delete old stripes
+```bash
+for i in {0..255}; do rm $i.png; done
+```
+4. convert to our projection
+```bash
+for i in {0..31}; do echo "Generating c$i.png"; convert s$i.png -resize 2048x67840\! -shave 0x1152 c$i.png; done
+```
+5. create subfolders for tiles
+```bash
+mkdir osm
+for i in {0..31}; do mkdir osm/$i; done
+```
+6. split into tiles
+```bash
+for i in {0..32}; do echo "Generating tiles for x=$i"; convert c$i.png -crop 2048x2048 +adjoin osm/$i/%02d.png; done
+```
+7. clean up
+```bash
+rm s*.png
+rm c*.png
+```
diff --git a/utils/osm-tiles/borders-combined.png b/utils/osm-tiles/borders-combined.png
new file mode 100644
index 0000000..47474aa
Binary files /dev/null and b/utils/osm-tiles/borders-combined.png differ
diff --git a/utils/osm-tiles/overview.png b/utils/osm-tiles/overview.png
new file mode 100644
index 0000000..7296eea
Binary files /dev/null and b/utils/osm-tiles/overview.png differ
diff --git a/utils/proxyConvert.sh b/utils/proxyConvert.sh
new file mode 100755
index 0000000..d308966
--- /dev/null
+++ b/utils/proxyConvert.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+#Basic shell script to convert text proxy list to readable list
+
+echo "" > proxies.txt
+for i in `cat ips-static.txt`; do
+ HOST=`echo $i | sed 's/\(.*\):.*:.*:.*/\1/'`
+ PORT=`echo $i | sed 's/.*:\(.*\):.*:.*/\1/'`
+ USER=`echo $i | sed 's/.*:.*:\(.*\):.*/\1/'`
+ IP=`echo $USER | sed 's/.*-\(.*\)/\1/'`
+ PASSWORD=`echo $i | sed 's/.*:.*:.*:\(.*\)/\1/'`
+ #COUNTRY=`geoiplookup $IP`
+ echo "http://$USER:$PASSWORD@$HOST:$PORT" >> proxies.txt
+done
diff --git a/utils/redisConvert.js b/utils/redisConvert.js
new file mode 100644
index 0000000..22e6865
--- /dev/null
+++ b/utils/redisConvert.js
@@ -0,0 +1,167 @@
+/* @flow */
+// this scripts converts the old 64x64 chunks that were organiced relative to the center to 256x256 chunks with 0.0 being top-left corner
+// it also goes from 2 pixel per byte to 1 pixel per byte
+// old colors are converted to new order
+
+import { TILE_SIZE, CANVAS_SIZE, CANVAS_MIN_XY, CANVAS_MAX_XY } from '../src/core/constants';
+
+import redis from 'redis';
+import bluebird from 'bluebird';
+bluebird.promisifyAll(redis.RedisClient.prototype);
+bluebird.promisifyAll(redis.Multi.prototype);
+//ATTENTION Make suer to set the rdis URLs right!!!
+const oldurl = "redis://localhost:6380";
+const oldredis = redis.createClient(oldurl, { return_buffers: true });
+const newurl = "redis://localhost:6379";
+const newredis = redis.createClient(newurl, { return_buffers: true });
+
+const CHUNK_SIZE = 64; //old chunk size
+const CHUNKS_IN_BASETILE = TILE_SIZE / CHUNK_SIZE;
+const CHUNK_MIN_XY = Math.floor(CANVAS_MIN_XY / CHUNK_SIZE);
+const CHUNK_MAX_XY = Math.floor(CANVAS_MAX_XY / CHUNK_SIZE);
+
+
+import { COLORS_ABGR } from '../src/core/Color';
+
+//-----------------------------
+// old colors
+const OLD_COLORS_RGB: Uint8Array = new Uint8Array( [
+ 202, 227, 255, //first color is unset pixel in ocean
+ 255, 255, 255, //second color is unset pixel on land
+ 255, 255, 255, //white
+ 228, 228, 228, //light gray
+ 136, 136, 136, //dark gray
+ 78, 78, 78, //darker gray
+ 0, 0, 0, //black
+ 244, 179, 174, //light pink
+ 255, 167, 209, //pink
+ 255, 101, 101, //peach
+ 229, 0, 0, //red
+ 254, 164, 96, //light brown
+ 229, 149, 0, //orange
+ 160, 106, 66, //brown
+ 245, 223, 176, //sand
+ 229, 217, 0, //yellow
+ 148, 224, 68, //light green
+ 2, 190, 1, //green
+ 0, 101, 19, //dark green
+ 202, 227, 255, //sky blue
+ 0, 211, 221, //light blue
+ 0, 131, 199, //dark blue
+ 0, 0, 234, //blue
+ 25, 25, 115, //darker blue
+ 207, 110, 228, //light violette
+ 130, 0, 128, //violette
+ ]
+);
+export const OLD_COLORS_ABGR: Uint32Array = new Uint32Array(OLD_COLORS_RGB.length / 3);
+let cnt = 0;
+for (let index = 0; index < OLD_COLORS_ABGR.length; index += 1) {
+ const r = OLD_COLORS_RGB[cnt++];
+ const g = OLD_COLORS_RGB[cnt++];
+ const b = OLD_COLORS_RGB[cnt++];
+ OLD_COLORS_ABGR[index] = (0xFF000000) | (b << 16) | (g << 8) | (r);
+}
+cnt = null;
+//-----------------------------
+
+
+
+/*
+ * convert new color to old color
+ * @param clr Color index of old color
+ * @return Color index of new, converted color
+ */
+function colorConvert(clr: number): number {
+ clr = clr & 0x1F; //this removes protections
+ if (clr == 2) return 2; //hardcoded exception for
+ if (clr == 19) return 25; //the valid white and ocean blue
+ const oldClr = OLD_COLORS_ABGR[clr];
+ const newClr = COLORS_ABGR.indexOf(oldClr);
+ return newClr;
+}
+
+/*
+ * Creating new basechunk if new size is a multiple of the old size
+ * @param x x coordinates of chunk (in chunk coordinates, not pixel coordinates)
+ * @param y y coordinates of chunk (in chunk coordinates, not pixel coordinates)
+ */
+async function createBasechunkFromMultipleOldChunks(x: number, y: number): Uint8Array {
+ const chunkBuffer = new Uint8Array(TILE_SIZE * TILE_SIZE);
+
+ const xabs = x * CHUNKS_IN_BASETILE + CHUNK_MIN_XY;
+ const yabs = y * CHUNKS_IN_BASETILE + CHUNK_MIN_XY;
+
+ let na = 0;
+ for (let dy = 0; dy < CHUNKS_IN_BASETILE; dy += 1) {
+ for (let dx = 0; dx < CHUNKS_IN_BASETILE; dx += 1) {
+ const smallchunk = await oldredis.getAsync(`chunk:${xabs + dx}:${yabs + dy}`);
+ if (!smallchunk) {
+ na++;
+ continue;
+ }
+ const chunk = new Uint8Array(smallchunk);
+ const chunkOffset = (dx + dy * CHUNKS_IN_BASETILE * CHUNK_SIZE) * CHUNK_SIZE; //offset in pixels
+ let pos = 0;
+ for (let row = 0; row < CHUNK_SIZE; row += 1) {
+ let pixelOffset = (chunkOffset + row * CHUNK_SIZE * CHUNKS_IN_BASETILE);
+ const max = pixelOffset + CHUNK_SIZE;
+ while (pixelOffset < max) {
+ let color = chunk[pos++];
+ chunkBuffer[pixelOffset++] = colorConvert(color >> 4);
+ chunkBuffer[pixelOffset++] = colorConvert(color & 0x0F);
+ }
+ }
+ }
+ }
+
+ if (na != CHUNKS_IN_BASETILE * CHUNKS_IN_BASETILE) {
+ const key = `chunk:${x}:${y}`;
+ const setNXArgs = [key, Buffer.from(chunkBuffer.buffer).toString('binary')]
+ await newredis.sendCommandAsync('SETNX', setNXArgs);
+ console.log("Created Chunk ", key, "with", na, "empty chunks");
+ }
+}
+
+/*
+ * Creating new basechunk if the sizes are the same, just the colors chaned
+ * @param x x coordinates of chunk (in chunk coordinates, not pixel coordinates)
+ * @param y y coordinates of chunk (in chunk coordinates, not pixel coordinates)
+ */
+async function createBasechunk(x: number, y: number): Uint8Array {
+ const key = `chunk:${x}:${y}`;
+ const newChunk = new Uint8Array(TILE_SIZE * TILE_SIZE);
+
+ const smallchunk = await oldredis.getAsync(key);
+ if (!smallchunk) {
+ return
+ }
+
+ const oldChunk = new Uint8Array(smallchunk);
+ if (oldChunk.length != newChunk.length || oldChunk.length != TILE_SIZE * TILE_SIZE) {
+ console.log(`ERROR: Chunk length ${oldChunk.length} of chunk ${x},${y} not of correct size!`);
+ }
+
+ for (let px = 0; px < oldChunk.length; px += 1) {
+ newChunk[px] = colorConvert(oldChunk[px]);
+ }
+
+ const setNXArgs = [key, Buffer.from(newChunk.buffer).toString('binary')]
+ await newredis.sendCommandAsync('SETNX', setNXArgs);
+ console.log("Created Chunk ", key);
+}
+
+
+/*
+ * Convert redis canvas
+ */
+async function convert() {
+ for (let x = 0; x < CANVAS_SIZE / TILE_SIZE; x++) {
+ console.log(x);
+ for (let y = 0; y < CANVAS_SIZE / TILE_SIZE; y++) {
+ await createBasechunk(x, y);
+ }
+ }
+}
+
+convert();
diff --git a/utils/redisCopy.js b/utils/redisCopy.js
new file mode 100644
index 0000000..4e8d944
--- /dev/null
+++ b/utils/redisCopy.js
@@ -0,0 +1,62 @@
+/* @flow */
+//this script just copies chunks from one redis to another with a different key as needed
+
+import redis from 'redis';
+import bluebird from 'bluebird';
+bluebird.promisifyAll(redis.RedisClient.prototype);
+bluebird.promisifyAll(redis.Multi.prototype);
+//ATTENTION Make suer to set the rdis URLs right!!!
+const oldurl = "redis://localhost:6379";
+const oldredis = redis.createClient(oldurl, { return_buffers: true });
+const newurl = "redis://localhost:6380";
+const newredis = redis.createClient(newurl, { return_buffers: true });
+
+const CANVAS_SIZE = 256 * 256;
+const TILE_SIZE = 256;
+const CHUNKS_XY = CANVAS_SIZE / TILE_SIZE;
+
+async function copyChunks() {
+ for (let x = 0; x < CHUNKS_XY; x++) {
+ for (let y = 0; y < CHUNKS_XY; y++) {
+ const oldkey = `chunk:${x}:${y}`;
+ const newkey = `ch:0:${i}:${j}`;
+ const chunk = await oldredis.getAsync(oldkey);
+ if (chunk) {
+ const setNXArgs = [newkey, chunk];
+ await newredis.sendCommandAsync('SETNX', setNXArgs);
+ console.log("Created Chunk ", key);
+ }
+ }
+ }
+}
+
+/*
+ * Creating new basechunk if the sizes are the same, just the colors chaned
+ * @param x x coordinates of chunk (in chunk coordinates, not pixel coordinates)
+ * @param y y coordinates of chunk (in chunk coordinates, not pixel coordinates)
+ */
+async function createBasechunk(x: number, y: number): Uint8Array {
+ const key = `chunk:${x}:${y}`;
+ const newChunk = new Uint8Array(TILE_SIZE * TILE_SIZE);
+
+ const smallchunk = await oldredis.getAsync(key);
+ if (!smallchunk) {
+ return
+ }
+
+ const oldChunk = new Uint8Array(smallchunk);
+ if (oldChunk.length != newChunk.length || oldChunk.length != TILE_SIZE * TILE_SIZE) {
+ console.log(`ERROR: Chunk length ${oldChunk.length} of chunk ${x},${y} not of correct size!`);
+ }
+
+ for (let px = 0; px < oldChunk.length; px += 1) {
+ newChunk[px] = colorConvert(oldChunk[px]);
+ }
+
+ const setNXArgs = [key, Buffer.from(newChunk.buffer).toString('binary')]
+ await newredis.sendCommandAsync('SETNX', setNXArgs);
+ console.log("Created Chunk ", key);
+}
+
+
+copyChunks();
diff --git a/utils/sphere-projection.blend b/utils/sphere-projection.blend
new file mode 100644
index 0000000..3be3e8f
Binary files /dev/null and b/utils/sphere-projection.blend differ
diff --git a/utils/sql-commandtest.js b/utils/sql-commandtest.js
new file mode 100644
index 0000000..cf82479
--- /dev/null
+++ b/utils/sql-commandtest.js
@@ -0,0 +1,202 @@
+/*
+ * Just a testscript for sequelize sql stuff,
+ *
+ */
+
+import Sequelize from 'sequelize';
+import DataType from 'sequelize';
+import Model from 'sequelize';
+import bcrypt from 'bcrypt';
+
+const mysql_host = "localhost";
+const mysql_user = "user";
+const mysql_password = "password";
+const mysql_db = "database";
+
+const Op = Sequelize.Op;
+const operatorsAliases = {
+ $eq: Op.eq,
+ $ne: Op.ne,
+ $gte: Op.gte,
+ $gt: Op.gt,
+ $lte: Op.lte,
+ $lt: Op.lt,
+ $not: Op.not,
+ $in: Op.in,
+ $notIn: Op.notIn,
+ $is: Op.is,
+ $like: Op.like,
+ $notLike: Op.notLike,
+ $iLike: Op.iLike,
+ $notILike: Op.notILike,
+ $regexp: Op.regexp,
+ $notRegexp: Op.notRegexp,
+ $iRegexp: Op.iRegexp,
+ $notIRegexp: Op.notIRegexp,
+ $between: Op.between,
+ $notBetween: Op.notBetween,
+ $overlap: Op.overlap,
+ $contains: Op.contains,
+ $contained: Op.contained,
+ $adjacent: Op.adjacent,
+ $strictLeft: Op.strictLeft,
+ $strictRight: Op.strictRight,
+ $noExtendRight: Op.noExtendRight,
+ $noExtendLeft: Op.noExtendLeft,
+ $and: Op.and,
+ $or: Op.or,
+ $any: Op.any,
+ $all: Op.all,
+ $values: Op.values,
+ $col: Op.col
+};
+
+const sequelize = new Sequelize(mysql_db, mysql_user, mysql_password, {
+ host: mysql_host,
+ dialect: 'mysql',
+ pool: {
+ min: 5,
+ max: 25,
+ idle: 10000,
+ acquire: 10000,
+ },
+ dialectOptions: {
+ connectTimeout: 10000,
+ multipleStatements: true,
+ },
+ operatorsAliases: operatorsAliases, // use Sequelize.Op
+ multipleStatements: true,
+ //operatorsAliases: false,
+});
+
+
+const RegUser = sequelize.define('User', {
+ id: {
+ type: DataType.INTEGER.UNSIGNED,
+ autoIncrement: true,
+ primaryKey: true,
+ },
+
+ email: {
+ type: DataType.CHAR(40),
+ allowNull: true,
+ },
+
+ name: {
+ type: DataType.CHAR(32),
+ allowNull: false,
+ },
+
+ //null if external oauth authentification
+ password: {
+ type: DataType.CHAR(60),
+ allowNull: true,
+ },
+
+ totalPixels: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: false,
+ defaultValue: 0,
+ },
+
+ dailyTotalPixels: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: false,
+ defaultValue: 0,
+ },
+
+ ranking: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: true,
+ },
+
+ dailyRanking: {
+ type: DataType.INTEGER.UNSIGNED,
+ allowNull: true,
+ },
+
+ //mail verified
+ verified: {
+ type: DataType.BOOLEAN,
+ allowNull: false,
+ defaultValue: false,
+ },
+
+ discordid: {
+ type: DataType.CHAR(18),
+ allowNull: true,
+ },
+
+ redditid: {
+ type: DataType.CHAR(10),
+ allowNull: true,
+ },
+
+ //when mail verification got requested,
+ //used for purging unverified accounts
+ verificationReqAt: {
+ type: DataType.DATE,
+ allowNull: true,
+ },
+
+ lastLogIn: {
+ type: DataType.DATE,
+ allowNull: true,
+ },
+}, {
+ multipleStatements: true,
+ timestamps: true,
+ updatedAt: false,
+
+ setterMethods: {
+ password(value: string): string {
+ if(value) this.setDataValue('password', generateHash(value));
+ },
+ },
+
+});
+
+async function recalculate() {
+ //multiple sql statements at once,
+ //important here, because splitting them would cause different thread pools with different @r to get used
+ await sequelize.query("SET @r=0; UPDATE Users SET ranking= @r:= (@r + 1) ORDER BY totalPixels DESC;");
+ await sequelize.query("SET @r=0; UPDATE Users SET dailyRanking= @r:= (@r + 1) ORDER BY dailyTotalPixels DESC;");
+
+ //delete all rows with timestamp older than 4 days
+ RegUser.destroy({
+ where: {
+ verificationReqAt: {
+ $lt: Sequelize.literal('CURRENT_TIMESTAMP - INTERVAL 4 DAY')
+ },
+ verified: 0,
+ }
+ })
+
+ //update whole column
+ RegUser.update({dailyTotalPixels: 0},{where:{}});
+
+ //select command that does also print datediff
+ RegUser.findAll({
+ attributes: [ 'name', 'totalPixels', 'ranking' , 'dailyRanking', 'dailyTotalPixels', 'createdAt', [Sequelize.fn('DATEDIFF', Sequelize.literal('CURRENT_TIMESTAMP'), Sequelize.col('createdAt')),'age']],
+ limit: 10,
+ order: ['ranking'],
+ }).then((users) =>{
+ console.log("All users:", JSON.stringify(users, null, 4));
+ return;
+ const ranking = [];
+ users.forEach((user) => {
+ const createdAt = new Date(user.createdAt);
+ const registeredSince = createdAt.getDate() + "." + (createdAt.getMonth()+1) + "." + createdAt.getFullYear();
+ ranking.push({
+ rank: user.ranking,
+ name: user.name,
+ totalPixels: user.totalPixels,
+ dailyRanking: user.dailyRanking,
+ dailyTotalPixels: user.dailyTotalPixels,
+ registeredSince,
+ });
+ });
+ console.log(ranking);
+ });
+}
+setTimeout(recalculate, 2000);
diff --git a/utils/websockettest.py b/utils/websockettest.py
new file mode 100755
index 0000000..b6e586c
--- /dev/null
+++ b/utils/websockettest.py
@@ -0,0 +1,39 @@
+#!/usr/bin/python3
+
+import websocket
+import json
+try:
+ import thread
+except ImportError:
+ import _thread as thread
+import time
+
+def on_message(ws, message):
+ print("Got message: " + str(message))
+
+def on_error(ws, error):
+ print(error)
+
+def on_close(ws):
+ print("### closed ###")
+
+def on_open(ws):
+ def run(*args):
+ ws.send(json.dumps(["sub", "chat"]))
+ ws.send(json.dumps(["chat", "name", "message"]))
+ ws.send(json.dumps(["linkacc", "someid", "mymcname", "hallo"]))
+ #ws.send(json.dumps(["login", "someid", "mymcname", "127.0.0.1"]))
+ time.sleep(120)
+ ws.close()
+ thread.start_new_thread(run, ())
+
+
+if __name__ == "__main__":
+ websocket.enableTrace(True)
+ ws = websocket.WebSocketApp("wss://old.pixelplanet.fun/mcws",
+ on_message = on_message,
+ on_error = on_error,
+ on_close = on_close,
+ header = { "Authorization": "Bearer APISOCKETKEY"})
+ ws.on_open = on_open
+ ws.run_forever()
|