commit 4ca2ba06ab438b01933aa97bf8029bc6022d6b75 Author: Jill "oatmealine" Monoids Date: Fri Dec 30 19:04:27 2022 +0300 some minor refactoring diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..163eb75 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*.cr] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..023fdea --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +DATABASE_URL=sqlite3://./crystalgauntlet.db \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfc6371 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/lib/ +/bin/ +/.shards/ +*.dwarf +.env +crystalgauntlet.db \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..91884f0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "crystal-lang.completion": true, + "crystal-lang.hover": true, + "crystal-lang.implementations": true, + "crystal-lang.mainFile": "${workspaceRoot}/src/crystal-gauntlet.cr" +} \ No newline at end of file diff --git a/Cakefile b/Cakefile new file mode 100644 index 0000000..1873b9c --- /dev/null +++ b/Cakefile @@ -0,0 +1,16 @@ +# todo: move inside executable + +require "log" +require "dotenv" +require "sqlite3" +require "migrate" + +Dotenv.load + +desc "Migrate database to the latest version" +task :dbmigrate do + migrator = Migrate::Migrator.new( + DB.open(ENV["DATABASE_URL"]) + ) + migrator.to_latest +end \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4888d00 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Jill "oatmealine" Monoids + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed41011 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# crystal-gauntlet + +among balls + +## build + +`shards build` + +you may need to head into `lib/` to fix deps. i'm Very sorry + +## setup + +copy `.env.example` to `.env` and fill it out + +run `cake db:migrate` (must have [cake](https://github.com/axvm/cake/)) + +**schemas are highly unstable so you will be offered 0 support in migrating databases for now**, however in the future you'll want to run this each time you update + +then `bin/crystal-gauntlet` (or `shards run`) + +### real + +![real](docs/crystal-gauntlet.jpg) \ No newline at end of file diff --git a/db/migrations/1_levels.sql b/db/migrations/1_levels.sql new file mode 100644 index 0000000..e9d656b --- /dev/null +++ b/db/migrations/1_levels.sql @@ -0,0 +1,47 @@ +-- +migrate up +CREATE TABLE levels ( + id SERIAL PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now')), + modified_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now')), + + name TEXT NOT NULL, + user_id INTEGER NOT NULL references users(id), + description TEXT NOT NULL DEFAULT "", + original INTEGER, + + game_version INTEGER NOT NULL, + binary_version INTEGER NOT NULL, + + password TEXT, + requested_stars INTEGER, + unlisted INTEGER NOT NULL DEFAULT 0, + + version INTEGER NOT NULL DEFAULT 0, + level_data BLOB NOT NULL, + extra_data BLOB NOT NULL, + level_info BLOB NOT NULL, + + -- checksums, presumably + wt1 TEXT NOT NULL, + wt2 TEXT NOT NULL, + + song_id INTEGER NOT NULL, + + length INTEGER NOT NULL, + objects INTEGER NOT NULL, + coins INTEGER NOT NULL DEFAULT 0, + has_ldm INTEGER NOT NULL DEFAULT 0, + two_player INTEGER NOT NULL DEFAULT 0, + + downloads INTEGER NOT NULL DEFAULT 0, + likes INTEGER NOT NULL DEFAULT 0, + difficulty INTEGER, + demon_difficulty INTEGER, + stars INTEGER, + featured INTEGER NOT NULL DEFAULT 0, + epic INTEGER NOT NULL DEFAULT 0, + rated_coins INTEGER NOT NULL DEFAULT 0 +); + +-- +migrate down +DROP TABLE levels; \ No newline at end of file diff --git a/db/migrations/2_users.sql b/db/migrations/2_users.sql new file mode 100644 index 0000000..5a3151e --- /dev/null +++ b/db/migrations/2_users.sql @@ -0,0 +1,49 @@ +-- +migrate up +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + -- on a registered account, account_id refers to the + -- account ID - however, pre 2.0, instead udid referred + -- to the user's UUID or UDID, depending on platform. + -- UUID and UDID are unique ids assigned for green + -- username users + -- + -- in short, if `registered`, use account_id, else, use udid + udid TEXT, + account_id INTEGER references accounts(id), + registered INTEGER NOT NULL, + + username TEXT NOT NULL, + + stars INTEGER NOT NULL DEFAULT 0, + demons INTEGER NOT NULL DEFAULT 0, + coins INTEGER NOT NULL DEFAULT 0, + user_coins INTEGER NOT NULL DEFAULT 0, + diamonds INTEGER NOT NULL DEFAULT 0, + orbs INTEGER NOT NULL DEFAULT 0, + creator_points INTEGER NOT NULL DEFAULT 0, + + completed_levels INTEGER NOT NULL DEFAULT 0, + + icon_type INTEGER NOT NULL DEFAULT 0, -- icon to display in comments, etc + color1 INTEGER NOT NULL DEFAULT 0, + color2 INTEGER NOT NULL DEFAULT 3, + cube INTEGER NOT NULL DEFAULT 0, + ship INTEGER NOT NULL DEFAULT 0, + ball INTEGER NOT NULL DEFAULT 0, + ufo INTEGER NOT NULL DEFAULT 0, + wave INTEGER NOT NULL DEFAULT 0, + robot INTEGER NOT NULL DEFAULT 0, + spider INTEGER NOT NULL DEFAULT 0, + explosion INTEGER NOT NULL DEFAULT 0, + special INTEGER NOT NULL DEFAULT 0, + glow INTEGER NOT NULL DEFAULT 0, + + created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now')), + last_played TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now')), + + is_banned INTEGER NOT NULL DEFAULT 0, + is_banned_upload INTEGER NOT NULL DEFAULT 0 +); + +-- +migrate down +DROP TABLE users; \ No newline at end of file diff --git a/db/migrations/3_accounts.sql b/db/migrations/3_accounts.sql new file mode 100644 index 0000000..dc68628 --- /dev/null +++ b/db/migrations/3_accounts.sql @@ -0,0 +1,24 @@ +-- +migrate up +CREATE TABLE accounts ( + id SERIAL PRIMARY KEY, + + username TEXT NOT NULL, + password TEXT NOT NULL, -- bcrypt hashed + gjp2 TEXT NOT NULL, + email TEXT NOT NULL, + + is_admin INTEGER NOT NULL DEFAULT 0, + + messages_enabled INTEGER NOT NULL DEFAULT 1, -- messages from non-friends enabled + friend_requests_enabled INTEGER NOT NULL DEFAULT 1, -- frs enabled + comments_enabled INTEGER NOT NULL DEFAULT 0, -- able to see user's comments + + youtube_url TEXT, + twitter_url TEXT, + twitch_url TEXT, + + created_at TEXT NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'now')) +); + +-- +migrate down +DROP TABLE accounts; \ No newline at end of file diff --git a/docs/crystal-gauntlet.jpg b/docs/crystal-gauntlet.jpg new file mode 100644 index 0000000..6b22d97 Binary files /dev/null and b/docs/crystal-gauntlet.jpg differ diff --git a/docs/exe-penis.txt b/docs/exe-penis.txt new file mode 100644 index 0000000..562a961 --- /dev/null +++ b/docs/exe-penis.txt @@ -0,0 +1,5 @@ +http://www.boomlings.com/database -> +http://localhost:8080/asdfasdfasd + +aHR0cDovL3d3dy5ib29tbGluZ3MuY29tL2RhdGFiYXNl -> base64 http://localhost:8080/asdfasdfasd +aHR0cDovL2xvY2FsaG9zdDo4MDgwL2FzZGZhc2RmYXNk \ No newline at end of file diff --git a/shard.lock b/shard.lock new file mode 100644 index 0000000..07867a2 --- /dev/null +++ b/shard.lock @@ -0,0 +1,22 @@ +version: 2.0 +shards: + db: + git: https://github.com/crystal-lang/crystal-db.git + version: 0.6.0 + + dotenv: + git: https://github.com/gdotdesign/cr-dotenv.git + version: 0.1.0 + + migrate: + git: https://github.com/vladfaust/migrate.cr.git + version: 0.5.0 + + sqlite3: + git: https://github.com/crystal-lang/crystal-sqlite3.git + version: 0.13.0 + + time_format: + git: https://github.com/vladfaust/time_format.cr.git + version: 0.1.1 + diff --git a/shard.yml b/shard.yml new file mode 100644 index 0000000..a3c8ba1 --- /dev/null +++ b/shard.yml @@ -0,0 +1,23 @@ +name: crystal-gauntlet +version: 0.1.0 + +authors: + - Jill "oatmealine" Monoids + - winter + +targets: + crystal-gauntlet: + main: src/crystal-gauntlet.cr + +dependencies: + sqlite3: + github: crystal-lang/crystal-sqlite3 + migrate: + github: vladfaust/migrate.cr + version: ~> 0.5.0 + dotenv: + github: gdotdesign/cr-dotenv + +crystal: 1.6.2 + +license: MIT diff --git a/spec/crystal-gauntlet_spec.cr b/spec/crystal-gauntlet_spec.cr new file mode 100644 index 0000000..16847e9 --- /dev/null +++ b/spec/crystal-gauntlet_spec.cr @@ -0,0 +1,9 @@ +require "./spec_helper" + +describe Crystal::Gauntlet do + # TODO: Write tests + + it "works" do + false.should eq(true) + end +end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr new file mode 100644 index 0000000..c8b2175 --- /dev/null +++ b/spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../src/crystal-gauntlet" diff --git a/src/accounts.cr b/src/accounts.cr new file mode 100644 index 0000000..9b7217e --- /dev/null +++ b/src/accounts.cr @@ -0,0 +1,31 @@ +require "uri" + +include CrystalGauntlet + +module CrystalGauntlet::Accounts + extend self + + def get_ext_id_from_params(params : URI::Params) : String + return "1" + if params.has_key?("udid") && params["udid"] != "" + # todo: numeric id check + params["udid"] + elsif params.has_key?("account_id") && params["account_id"] != "" && params["account_id"] != "0" + # todo: validate password + params["account_id"] + else + "-1" + end + end + + def get_user_id(username : String, ext_id : String) : Int32 + return 1 + DATABASE.query("select id from users where udid = ? or account_id = ?", ext_id, ext_id) do |rs| + if rs.column_count > 0 + return rs.read(Int32) + else + raise "no user associated with account?!" + end + end + end +end diff --git a/src/crystal-gauntlet.cr b/src/crystal-gauntlet.cr new file mode 100644 index 0000000..142432b --- /dev/null +++ b/src/crystal-gauntlet.cr @@ -0,0 +1,56 @@ +require "http/server" +require "uri" +require "sqlite3" +require "migrate" +require "dotenv" + +require "./enums" +require "./hash" +require "./format" +require "./accounts" +require "./gjp" + +Dotenv.load + +module CrystalGauntlet + VERSION = "0.1.0" + + APPEND_PATH = "asdfasdfasd/" + DATABASE = DB.open(ENV["DATABASE_URL"]) + + @@endpoints = Hash(String, (String -> String)).new + + def self.endpoints + @@endpoints + end + + def self.run() + server = HTTP::Server.new do |context| + # expunge trailing slashes + path = context.request.path.chomp("/") + + path = path.sub(APPEND_PATH, "") + body = context.request.body + + if !body + puts "no body :(" + elsif @@endpoints.has_key?(path) + func = @@endpoints[path] + value = func.call(body.gets_to_end) + context.response.content_type = "text/plain" + context.response.print value + puts "#{path} -> #{value}" + else + context.response.respond_with_status(404, "endpoint not found") + puts "#{path} -> 404" + end + end + + puts "Listening on http://127.0.0.1:8080" + server.listen(8080) + end +end + +require "./endpoints/**" + +CrystalGauntlet.run() diff --git a/src/endpoints/accounts/loginAccount.cr b/src/endpoints/accounts/loginAccount.cr new file mode 100644 index 0000000..634d62f --- /dev/null +++ b/src/endpoints/accounts/loginAccount.cr @@ -0,0 +1,27 @@ +require "uri" +require "base64" +require "crypto/bcrypt/password" + +include CrystalGauntlet + +CrystalGauntlet.endpoints["/accounts/loginGJAccount.php"] = ->(body : String): String { + params = URI::Params.parse(body) + puts params.inspect + + username = params["userName"] + password = params["password"] + result = DATABASE.query_all("select id, password from accounts", as: {Int32, String}) + if result.size > 0 + account_id, hash = result[0] + bcrypt = Crypto::Bcrypt::Password.new(hash) + + if bcrypt.verify(password) + user_id = Accounts.get_user_id(username, account_id.to_s) + "#{account_id},#{user_id}" + else + return "-12" + end + else + return "-1" + end +} diff --git a/src/endpoints/accounts/registerAccount.cr b/src/endpoints/accounts/registerAccount.cr new file mode 100644 index 0000000..8d153bf --- /dev/null +++ b/src/endpoints/accounts/registerAccount.cr @@ -0,0 +1,28 @@ +require "uri" +require "base64" +require "crypto/bcrypt/password" + +include CrystalGauntlet + +CrystalGauntlet.endpoints["/accounts/registerGJAccount.php"] = ->(body : String): String { + params = URI::Params.parse(body) + puts params.inspect + + username = params["userName"] + password = params["password"] + email = params["email"] + + username_exists = DATABASE.scalar "select count(*) from accounts where username = ?", username + if username_exists != 0 + return "-2" + end + + password_hash = Crypto::Bcrypt::Password.create(password, cost: 10).to_s + gjp2 = CrystalGauntlet::GJP.hash(password) + next_id = (DATABASE.scalar("select max(id) from accounts").as(Int64 | Nil) || 0) + 1 + DATABASE.exec "insert into accounts (id, username, password, email, gjp2) values (?, ?, ?, ?, ?)", next_id, username, password_hash, email, gjp2 + + user_id = (DATABASE.scalar("select max(id) from users").as(Int64 | Nil) || 0) + 1 + DATABASE.exec "insert into users (id, account_id, username, registered) values (?, ?, ?, 1)", user_id, next_id, username + "1" +} diff --git a/src/endpoints/levels/downloadLevels.cr b/src/endpoints/levels/downloadLevels.cr new file mode 100644 index 0000000..0022f9b --- /dev/null +++ b/src/endpoints/levels/downloadLevels.cr @@ -0,0 +1,111 @@ +require "uri" +require "base64" + +include CrystalGauntlet + +CrystalGauntlet.endpoints["/downloadGJLevel22.php"] = ->(body : String): String { + params = URI::Params.parse(body) + puts params.inspect + + response = "" + + DATABASE.query("select levels.id, levels.name, levels.level_data, levels.extra_data, levels.level_info, levels.password, levels.user_id, levels.description, levels.original, levels.game_version, levels.requested_stars, levels.version, levels.song_id, levels.length, levels.objects, levels.coins, levels.has_ldm, levels.two_player, levels.downloads, levels.likes, levels.difficulty, levels.demon_difficulty, levels.stars, levels.featured, levels.epic, levels.rated_coins, users.username, users.udid, users.account_id, users.registered from levels join users on levels.user_id = users.id where levels.id = ?", params["levelID"].to_i32) do |rs| + if rs.move_next + id = rs.read(Int32) + name = rs.read(String) + level_data = rs.read(String) + extra_data = rs.read(String) + level_info = rs.read(String) + password = rs.read(String | Nil) + user_id = rs.read(Int32) + description = rs.read(String) + original = rs.read(Int32 | Nil) + game_version = rs.read(Int32) + requested_stars = rs.read(Int32 | Nil) + version = rs.read(Int32) + song_id = rs.read(Int32) + length = rs.read(Int32) + objects = rs.read(Int32) + coins = rs.read(Int32) + has_ldm = rs.read(Bool) + two_player = rs.read(Bool) + downloads = rs.read(Int32) + likes = rs.read(Int32) + difficulty_int = rs.read(Int32 | Nil) + difficulty = difficulty_int && LevelDifficulty.new(difficulty_int) + demon_difficulty_int = rs.read(Int32 | Nil) + demon_difficulty = demon_difficulty_int && DemonDifficulty.new(demon_difficulty_int) + stars = rs.read(Int32 | Nil) + featured = rs.read(Bool) + epic = rs.read(Bool) + rated_coins = rs.read(Bool) + + user_username = rs.read(String) + user_udid = rs.read(String | Nil) + user_account_id = rs.read(Int32 | Nil) + user_registered = rs.read(Bool) + + xor_pass = "0" + if !password + password = "0" + elsif params["gameVersion"].to_i >= 20 + xor_pass = GDBase64.encode(XorCrypt.encrypt_string(password, "26364")) + else + xor_pass = password + end + + # https://github.com/Cvolton/GMDprivateServer/blob/master/incl/levels/getGJLevels.php#L266 + response += CrystalGauntlet::Format.fmt_hash({ + 1 => id, + 2 => name, + 3 => Base64.encode(description).sub('/', '_').sub('+', '-').strip("\n"), + 4 => level_data, + 5 => version, + 6 => user_id, + 8 => 10, + 9 => difficulty ? difficulty.to_star_difficulty : 0, # 0=N/A 10=EASY 20=NORMAL 30=HARD 40=HARDER 50=INSANE 50=AUTO 50=DEMON + 10 => downloads, + 11 => 1, + 12 => song_id < 50 ? song_id : 0, + 13 => game_version, + 14 => likes, + 17 => difficulty && difficulty.demon?, + 43 => (demon_difficulty || DemonDifficulty::Hard).to_demon_difficulty, + 25 => difficulty && difficulty.auto?, + 18 => stars || 0, + 19 => featured, + 42 => epic, + 45 => objects, + 15 => length, + 30 => original || 0, + 31 => two_player, + 28 => "1", + 29 => "1", + 35 => song_id >= 50 ? song_id : 0, + 36 => extra_data, + 37 => coins, + 38 => rated_coins, + 39 => requested_stars || 0, + 46 => 1, + 47 => 2, + 40 => has_ldm, + 27 => xor_pass, + # 0 for n/a, 10 for easy, 20, for medium, ... + }) + + if params.has_key?("extras") + response += ":26:" + level_info + end + + response += "#" + Hashes.gen_solo(level_data) + + thing = [user_id, stars || 0, difficulty && difficulty.demon?, id, rated_coins, featured, password, 0].map! { |x| Format.fmt_value(x) } + puts thing.join(",") + response += "#" + Hashes.gen_solo_2(thing.join(",")) + else + response += "-1" + end + end + + response +} diff --git a/src/endpoints/levels/getLevels.cr b/src/endpoints/levels/getLevels.cr new file mode 100644 index 0000000..eec24c1 --- /dev/null +++ b/src/endpoints/levels/getLevels.cr @@ -0,0 +1,93 @@ +require "uri" +require "base64" + +include CrystalGauntlet + +CrystalGauntlet.endpoints["/getGJLevels21.php"] = ->(body : String): String { + params = URI::Params.parse(body) + puts params.inspect + + results = [] of String + users = [] of String + songs = [] of String + + hash_data = [] of Tuple(Int32, Int32, Bool) + + DATABASE.query "select levels.id, levels.name, levels.user_id, levels.description, levels.original, levels.game_version, levels.requested_stars, levels.version, levels.song_id, levels.length, levels.objects, levels.coins, levels.has_ldm, levels.two_player, levels.downloads, levels.likes, levels.difficulty, levels.demon_difficulty, levels.stars, levels.featured, levels.epic, levels.rated_coins, users.username, users.udid, users.account_id, users.registered from levels join users on levels.user_id = users.id" do |rs| + rs.each do + id = rs.read(Int32) + name = rs.read(String) + user_id = rs.read(Int32) + description = rs.read(String) + original = rs.read(Int32 | Nil) + game_version = rs.read(Int32) + requested_stars = rs.read(Int32 | Nil) + version = rs.read(Int32) + song_id = rs.read(Int32) + length = rs.read(Int32) + objects = rs.read(Int32) + coins = rs.read(Int32) + has_ldm = rs.read(Bool) + two_player = rs.read(Bool) + downloads = rs.read(Int32) + likes = rs.read(Int32) + difficulty_int = rs.read(Int32 | Nil) + difficulty = difficulty_int && LevelDifficulty.new(difficulty_int) + demon_difficulty_int = rs.read(Int32 | Nil) + demon_difficulty = demon_difficulty_int && DemonDifficulty.new(demon_difficulty_int) + stars = rs.read(Int32 | Nil) + featured = rs.read(Bool) + epic = rs.read(Bool) + rated_coins = rs.read(Bool) + + user_username = rs.read(String) + user_udid = rs.read(String | Nil) + user_account_id = rs.read(Int32 | Nil) + user_registered = rs.read(Bool) + + # https://github.com/Cvolton/GMDprivateServer/blob/master/incl/levels/getGJLevels.php#L266 + results << CrystalGauntlet::Format.fmt_hash({ + 1 => id, + 2 => name, + 5 => version, + 6 => user_id, + 8 => 10, + 9 => difficulty ? difficulty.to_star_difficulty : 0, # 0=N/A 10=EASY 20=NORMAL 30=HARD 40=HARDER 50=INSANE 50=AUTO 50=DEMON + 10 => downloads, + 12 => song_id < 50 ? song_id : 0, + 13 => game_version, + 14 => likes, + 17 => difficulty && difficulty.demon?, + 43 => (demon_difficulty || DemonDifficulty::Hard).to_demon_difficulty, + 25 => difficulty && difficulty.auto?, + 18 => stars || 0, + 19 => featured, + 42 => epic, + 45 => objects, + 3 => Base64.encode(description).sub('/', '_').sub('+', '-').strip("\n"), + 15 => length, + 30 => original || 0, + 31 => two_player, + 37 => coins, + 38 => rated_coins, + 39 => requested_stars || 0, + 46 => 1, + 47 => 2, + 40 => has_ldm, + 35 => song_id >= 50 ? song_id : 0, # 0 for n/a, 10 for easy, 20, for medium, ... + }) + + users << "#{user_id}:#{user_username}:#{user_registered ? user_account_id : user_udid}" + + hash_data << {id, stars || 0, rated_coins} + end + end + + # `:${offset}:${levelsPerPage}` + searchMeta = "#{results.size}:0:10" + + res = [results.join("|"), users.join("|"), songs.join("|"), searchMeta, CrystalGauntlet::Hashes.gen_multi(hash_data)].join("#") + puts res + + res +} diff --git a/src/endpoints/levels/uploadLevel.cr b/src/endpoints/levels/uploadLevel.cr new file mode 100644 index 0000000..e5c125f --- /dev/null +++ b/src/endpoints/levels/uploadLevel.cr @@ -0,0 +1,35 @@ +require "uri" + +include CrystalGauntlet + +# URI::Params{"gameVersion" => ["21"], "binaryVersion" => ["35"], "gdw" => ["0"], "accountID" => ["8369"], "gjp" => ["Vw9mW0FBUgN_VXtZ"], "userName" => ["oatmealine"], "levelID" => ["0"], "levelName" => ["security"], "levelDesc" => [""], "levelVersion" => ["1"], "levelLength" => ["1"], "audioTrack" => ["0"], "auto" => ["0"], "password" => ["0"], "original" => ["0"], "twoPlayer" => ["0"], "songID" => ["1050575"], "objects" => ["207"], "coins" => ["0"], "requestedStars" => ["0"], "unlisted" => ["0"], "wt" => ["709"], "wt2" => ["0"], "ldm" => ["0"], "extraString" => ["0_73_0_39_0_0_0_0_0_0_0_0_66_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0_0"], "seed" => ["SbpPMJ9vjn"], "seed2" => ["UFIKAAQCA1RTB1cABFEMUlBRDwFRUgMPAlAHVFYMUQFXAwEGCAAPDQ=="], "levelString" => ["H4sIAAAAAAAAC61Xy5HYIAxtyLuDJCTB5JQatgAKSAspPvxssC1lc8jBZngPhNAP-PVF6YASpWBhKlQkFoCCzAVwNNSbWD6gSIEQQtECBbj9UgklFfgNtcX6Uf2-mQ7udAhYxuhvRGRTRBszJvyTkLrfYusyBAE2Qe3_jSD-X4LEEXT8-gl0hNbwaGQ08ah_OaB1dECzSa35otx72P9DQid-xv4fbJ3dGzjCDzhAYzzwoHDQAbyA3IDYgdoD4qtL1HiQhmj91dU-ucIHNDbCTmIl8MB46JjZydxICHOq9rldlUAXLXlMCBVB7BMApjLViLtumDpdl8QmI8SuRlhMTEe1WRpLILbdxqnCpXGkKaQh0BCBU-xLzS7j4iuEXfM1oyr8IW3bH7TPPjcg-_Ktr6dFth0MkYNRWDuoqAZjfMPJwcWTPyXt8gdOb7zvWofzaNipxcnYdO5AMzoE2UyJHUm6mWpCp602u5xTL8NAyPaOANAj2COSQyB4RPQIfahJjkNqAHuE5ZJOGDvGsK_ysFnEhzLRs0D0LMCWBYa_gZGW-JGg3LefX8EEHF_ELAdh1IOKpFE88i2FZx-2RUScRYQ8IryIUT9A-WGiCWzLaXKkKjpEAo94W2ESL7uR9IIXljFmCRxbOdXN-a5_zSHbkxgc32MwfI8hbNQ9rBCcrEBwsgLBqmK9fIeH-uhkBaKTFYhGVkTdhMsqzw0lz0DkGYic5MDoGSJahuj-w1gLZ6Uwr_MUuSuKtUbK8ZEvTQc8CypRn86yYRuQ-R7cfbA88v8EZAHpyr4ecKh6P0D3PvZz8xlwacvXljwXpNeQjGPI00yZHTy98Sl6iFKTYp9Kb6rfbMBUgEJ0cH3jYWPydSNYi89FLL3mOjaltsoQbNX2a1jvizMue7adok1thnSbEp_K9h7QjgdCOx4I3_EgEpakRNtVM2wKoBstcy2bcqKFnGghJ1rIiJa5BPkxQX5MkBMTlLNTbirVyk3aLmtTVjTc1nE0ZI17sUGcwhxHRydYIzm4E7TRD9roR2Y04nmtsmroFN9i3BBinTu3bd85yuMVgfrZSOFP5A2OaMJsj1Z7dLqPPtVh6wA7OefUI07G3teMty_YSVJ2i_acYvqI_QxlJw3nVgyN2XcjG2f4NCcH08oMpk-Y7NHRHi326DxggNjef_sDjsS5VJA4tysy34hL1NtV4hQs8QuW-AVLjKp0Uu9aNi0gYdgr3Qxwkoh_IelvM8V0gyTTDZIfidRAdWqWOjXLe3GT9-Qm883dCetJO02pfp1T_9xW_3DWd82eZlEwraV2SVO7pKld0tQuafosaUv5daVRzU8P1IOvEmm-2Wo0jNszmijRwHv9qGrsGBtY2rBxmibQ_TT9A3vViWgxFQAA"], "levelInfo" => ["H4sIAAAAAAAACyXQyxHAIAgE0I6YAPJx7L-v7MIl5ImA-snRp--T0w_f8EFcIs8gB7WZGvSiY9CD68Stx35P5WM1QhM2y-JBzESEImaiSvI_d1cZcZkw-dDMN-Mz3Xegy0XNke8CR0wJ60EYUTro816yUhEuthV7KoIGMUcr8SQiBolMb-sWw8UUz8DeiqugH0LOuW2zJoXVH1ldIOdMAQAA"], "secret" => ["Wmfd2893gb7"]} + +CrystalGauntlet.endpoints["/uploadGJLevel21.php"] = ->(body : String): String { + params = URI::Params.parse(body) + puts params.inspect + + ext_id = Accounts.get_ext_id_from_params(params) + if ext_id == "-1" + return "-1" + end + user_id = Accounts.get_user_id(params["userName"], ext_id) + + song_id = params["songID"] == "0" ? params["audioTrack"] : params["songID"] + + description = params["levelDesc"] + if params["gameVersion"].to_i >= 20 # 2.0 + description = GDBase64.decode description + end + + if DATABASE.scalar("select count(*) from levels where id = ? and user_id = ?", params["levelID"], params["accountID"]).as(Int64) > 0 + # update existing level + raise "not implemented" + else + # create new level + next_id = (DATABASE.scalar("select max(id) from levels").as(Int64 | Nil) || 0) + 1 + + DATABASE.exec("insert into levels (id, name, user_id, description, original, game_version, binary_version, password, requested_stars, unlisted, version, level_data, extra_data, level_info, wt1, wt2, song_id, length, objects, coins, has_ldm, two_player) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", next_id, params["levelName"], user_id, description, params["original"].to_i32, params["gameVersion"].to_i32, params["binaryVersion"].to_i32, params["password"] == "0" ? nil : params["password"].to_i32, params["requestedStars"].to_i32, params["unlisted"].to_i32, params["levelVersion"].to_i32, params["levelString"], params["extraString"], params["levelInfo"], params["wt"], params["wt2"], song_id.to_i32, params["levelLength"].to_i32, params["objects"].to_i32, params["coins"].to_i32, params["ldm"].to_i32, params["twoPlayer"].to_i32) + + next_id.to_s + end +} diff --git a/src/endpoints/users/getUser.cr b/src/endpoints/users/getUser.cr new file mode 100644 index 0000000..79836c0 --- /dev/null +++ b/src/endpoints/users/getUser.cr @@ -0,0 +1,89 @@ +require "uri" + +include CrystalGauntlet + +# URI::Params{"gameVersion" => ["21"], "binaryVersion" => ["35"], "gdw" => ["0"], "accountID" => ["1"], "gjp" => ["XFZBX1NSW1xcUw=="], "targetAccountID" => ["1"], "secret" => ["Wmfd2893gb7"]} + +CrystalGauntlet.endpoints["/getGJUserInfo20.php"] = ->(body : String): String { + params = URI::Params.parse(body) + puts params.inspect + + DATABASE.query("select accounts.id, accounts.username, is_admin, messages_enabled, friend_requests_enabled, comments_enabled, youtube_url, twitter_url, twitch_url, accounts.created_at, users.id, stars, demons, coins, user_coins, diamonds, orbs, creator_points, icon_type, color1, color2, glow, cube, ship, ball, ufo, wave, robot, spider, explosion from accounts join users on accounts.id = users.account_id where accounts.id = ?", params["targetAccountID"]) do |rs| + if rs.move_next + id = rs.read(Int32) + username = rs.read(String) + is_admin = rs.read(Int32) + messages_enabled = rs.read(Int32) + friend_requests_enabled = rs.read(Int32) + comments_enabled = rs.read(Int32) + youtube_url = rs.read(String | Nil) + twitter_url = rs.read(String | Nil) + twitch_url = rs.read(String | Nil) + created_at = rs.read(String) + user_id = rs.read(Int32) + stars = rs.read(Int32) + demons = rs.read(Int32) + coins = rs.read(Int32) + user_coins = rs.read(Int32) + diamonds = rs.read(Int32) + orbs = rs.read(Int32) + creator_points = rs.read(Int32) + icon_type = rs.read(Int32) + color1 = rs.read(Int32) + color2 = rs.read(Int32) + glow = rs.read(Int32) + cube = rs.read(Int32) + ship = rs.read(Int32) + ball = rs.read(Int32) + ufo = rs.read(Int32) + wave = rs.read(Int32) + robot = rs.read(Int32) + spider = rs.read(Int32) + explosion = rs.read(Int32) + + return CrystalGauntlet::Format.fmt_hash({ + 1 => username, + 2 => user_id, + 13 => coins, + 17 => user_coins, + 10 => color1, + 11 => color2, + 3 => stars, + 46 => diamonds, + 4 => demons, + 8 => creator_points, + 18 => !messages_enabled, + 19 => !friend_requests_enabled, + 50 => !comments_enabled, + 20 => youtube_url || "", + 21 => cube, + 22 => ship, + 23 => ball, + 24 => ufo, + 25 => wave, + 26 => robot, + 28 => glow, + 43 => spider, + 47 => explosion, + 30 => 1, # rank; todo + 16 => id, + # 31 = isnt (0) or is (1) friend or (3) incoming request or (4) outgoing request + # todo + 31 => 0, + # also w/ friend requests: + # 32 => id, + # 35 => comment, + # 37 => date, + 44 => twitter_url || "", + 45 => twitch_url || "", + 29 => 1, + # badge, todo + 49 => 0 + }) + end + end + + "-1" + + # echo "1:".$user["userName"].":2:".$user["userID"].":13:".$user["coins"].":17:".$user["userCoins"].":10:".$user["color1"].":11:".$user["color2"].":3:".$user["stars"].":46:".$user["diamonds"].":4:".$user["demons"].":8:".$creatorpoints.":18:".$msgstate.":19:".$reqsstate.":50:".$commentstate.":20:".$accinfo["youtubeurl"].":21:".$user["accIcon"].":22:".$user["accShip"].":23:".$user["accBall"].":24:".$user["accBird"].":25:".$user["accDart"].":26:".$user["accRobot"].":28:".$user["accGlow"].":43:".$user["accSpider"].":47:".$user["accExplosion"].":30:".$rank.":16:".$user["extID"].":31:".$friendstate.":44:".$accinfo["twitter"].":45:".$accinfo["twitch"].":29:1:49:".$badge . $appendix; +} diff --git a/src/endpoints/users/updateUser.cr b/src/endpoints/users/updateUser.cr new file mode 100644 index 0000000..57109fc --- /dev/null +++ b/src/endpoints/users/updateUser.cr @@ -0,0 +1,17 @@ +require "uri" + +include CrystalGauntlet + +# URI::Params{"gameVersion" => ["21"], "binaryVersion" => ["35"], "gdw" => ["0"], "accountID" => ["1"], "gjp" => ["XFZBX1NSW1xcUw=="], "targetAccountID" => ["1"], "secret" => ["Wmfd2893gb7"]} + +CrystalGauntlet.endpoints["/updateGJUserScore22.php"] = ->(body : String): String { + params = URI::Params.parse(body) + puts params.inspect + + account_id = Accounts.get_ext_id_from_params(params) + user_id = Accounts.get_user_id(params["userName"], account_id) + + DATABASE.exec("update users set username=?, stars=?, demons=?, coins=?, user_coins=?, diamonds=?, icon_type=?, color1=?, color2=?, cube=?, ship=?, ball=?, ufo=?, wave=?, robot=?, spider=?, explosion=?, special=?, glow=?, last_played=? where id=?", params["userName"], params["stars"], params["demons"], params["coins"], params["userCoins"], params["diamonds"], params["iconType"], params["color1"], params["color2"], params["accIcon"], params["accShip"], params["accBall"], params["accBird"], params["accDart"], params["accRobot"], params["accSpider"], params["accExplosion"], params["special"], params["accGlow"], Time.utc.to_s("%Y-%m-%d %H:%M:%S"), user_id) + + user_id.to_s +} diff --git a/src/enums.cr b/src/enums.cr new file mode 100644 index 0000000..1d457f4 --- /dev/null +++ b/src/enums.cr @@ -0,0 +1,63 @@ +module CrystalGauntlet + enum LevelLength + Tiny + Short + Medium + Long + XL + end + + enum LevelDifficulty + Auto + Easy + Normal + Hard + Harder + Insane + Demon + + def to_star_difficulty + case self + when .auto? + 50 + when .easy? + 10 + when .normal? + 20 + when .hard? + 30 + when .harder? + 40 + when .insane? + 50 + when .demon? + 50 + end + end + end + + enum DemonDifficulty + Easy + Medium + Hard + Insane + Extreme + # unsafe + #Tarsorado + + def to_demon_difficulty + case self + when .easy? + 3 + when .medium? + 4 + when .hard? + 0 + when .insane? + 5 + when .extreme? + 6 + end + end + end +end diff --git a/src/format.cr b/src/format.cr new file mode 100644 index 0000000..241941e --- /dev/null +++ b/src/format.cr @@ -0,0 +1,50 @@ +module CrystalGauntlet::Format + extend self + + def fmt_value(v) : String + case v + when Bool + v ? "1" : "0" + when String + v + else + v.to_s + end + end + + def fmt_hash(hash) : String + hash.map_with_index{ |(i, v)| "#{i}:#{fmt_value(v)}" }.join(":") + end +end + +module CrystalGauntlet::GDBase64 + extend self + + def encode(v) + Base64.encode(v).sub('/', '_').sub('+', '-') + end + + def decode(v) + Base64.decode_string(v.sub('_', '/').sub('-', '+')) + end +end + +module CrystalGauntlet::XorCrypt + extend self + + def encrypt(x : Bytes, key : Bytes) : Bytes + result = Bytes.new(x.size) + x.each.with_index() do |chr, index| + result[index] = (chr ^ key[index % key.size]) + end + result + end + + def encrypt_string(x : String, key : String) : Bytes + result = Bytes.new(x.bytesize) + x.bytes.each.with_index() do |chr, index| + result[index] = (chr ^ key.byte_at(index % key.bytesize)) + end + result + end +end diff --git a/src/gjp.cr b/src/gjp.cr new file mode 100644 index 0000000..c65a51b --- /dev/null +++ b/src/gjp.cr @@ -0,0 +1,34 @@ +require "crypto/bcrypt/password" +require "base64" + +module CrystalGauntlet::GJP + extend self + + XOR_KEY = "37526" + + def decrypt(pass : String) + pwd = Base64.decode_string(pass.sub('_', '/').sub('-', '+')) + decrypted = "" + + pwd.each.with_index() do |chr, index| + decrypted += (chr ^ XOR_KEY.byte_at(index % XOR_KEY.bytesize)).unsafe_chr + end + + decrypted + end + + def encrypt(pass : String) + encrypted = Bytes.new(pass.bytesize) + + pass.bytes.each.with_index() do |chr, index| + encrypted[index] = chr ^ XOR_KEY.byte_at(index % XOR_KEY.bytesize) + end + + Base64.encode(encrypted).sub('/', '_').sub('+', '-') + end + + def hash(pass : String) + gjp2_hash = Digest::SHA1.hexdigest(pass + "mI29fmAnxgTs") + Crypto::Bcrypt::Password.create(gjp2_hash, cost: 10).to_s + end +end diff --git a/src/hash.cr b/src/hash.cr new file mode 100644 index 0000000..48569fe --- /dev/null +++ b/src/hash.cr @@ -0,0 +1,56 @@ +require "digest/sha1" +require "crypto/bcrypt" + +module CrystalGauntlet::Hashes + extend self + + def gen_multi(level_hash_data : Array(Tuple(Int32, Int32, Bool))) + Digest::SHA1.hexdigest do |ctx| + level_hash_data.each.with_index() do |val, index| + level_id, stars, coins = val + level_id_str = level_id.to_s + ctx.update "#{level_id_str[0]}#{level_id_str[-1]}#{stars}#{coins ? 1 : 0}" + end + + ctx.update "xI25fpAapCQg" + end + end + + def gen_solo(level_string : String) : String + hash = "" + divided : Int32 = (level_string.size / 40).to_i + i = 0 + k : Int32 = 0 + while k < level_string.size + if i > 39 + break + end + + hash += level_string.char_at(k) + i += 1 + k += divided + end + Digest::SHA1.hexdigest(hash.ljust(5, 'a') + "xI25fpAapCQg") + end + + def gen_solo_2(level_multi_string : String) : String + Digest::SHA1.hexdigest do |ctx| + ctx.update level_multi_string + ctx.update "xI25fpAapCQg" + end + end + + def gen_solo_3(level_multi_string : String) : String + Digest::SHA1.hexdigest do |ctx| + ctx.update level_multi_string + ctx.update "oC36fpYaPtdg" + end + end + + def gen_solo_4(level_multi_string : String) : String + Digest::SHA1.hexdigest do |ctx| + ctx.update level_multi_string + ctx.update "pC26fpYaQCtg" + end + end +end