├── .venv ├── .dockerignore ├── docs ├── installing.md ├── index.rst ├── postman.md ├── benchmark.md ├── overview.md ├── pruning.md └── hosting.md ├── config ├── loadTestSharedClans.yaml ├── faulty.yaml ├── perf.yaml ├── test.yaml ├── default.yaml └── local.yaml ├── db ├── migrate.go ├── migrations │ ├── 20160627110742_LoadUUIDModule.sql │ ├── 20160708192007_CreateOwnerIndex.sql │ ├── 20180517112014_ChangeIDSequenceType.sql │ ├── 20160628181530_CreateClanMembershipCount.sql │ ├── 20160728195902_CreateMembershipMessageField.sql │ ├── 20210401151842_ChangeEncryptedPlayersIDType.sql │ ├── 20210323185959_CreateEncryptionTable.sql │ ├── 20160728180524_CreateMaxPendingInvitesField.sql │ ├── 20160713191703_CreateMembershipDenierField.sql │ ├── 20160708161944_CreateMembershipApproverField.sql │ ├── 20160627155249_CreatePlayerMembershipAndOwnershipCount.sql │ ├── 20160713185332_CreateGameCooldownAfterDenyAndDelete.sql │ ├── 20160729184159_CreateCooldownAfterInviteField.sql │ ├── 20160819145352_CreateHookTriggerFieldsMetadata.sql │ ├── 20160627153918_CreateRetrieveClanIndexes.sql │ ├── 20160621161411_CreateHooksTable.sql │ ├── 20160608150958_CreatePlayerTable.sql │ ├── 20160608174439_CreateClanTable.sql │ ├── 20160608182307_CreateMembershipTable.sql │ └── 20160608133902_CreateGameTable.sql ├── drop-perf.sql ├── drop-test.sql ├── create-perf.sql ├── create-test.sql ├── drop.sql └── dbconf.yml ├── Procfile ├── util ├── errors.go ├── util_suite_test.go ├── version.go ├── time.go ├── level.go ├── secure.go ├── json.go └── secure_test.go ├── .codeclimate.yml ├── api ├── errors.go ├── api_suite_test.go ├── status.go ├── healthcheck.go ├── status_test.go ├── healthcheck_test.go ├── easyjson-bootstrap338594267.go ├── easyjson-bootstrap399122019.go ├── player_helpers.go ├── game_helpers.go ├── hook.go ├── hook_test.go ├── clan_helpers.go ├── helpers.go └── middleware.go ├── bench ├── bench.go ├── game_test.go ├── player_test.go └── helpers.go ├── cmd ├── root_test.go ├── version_test.go ├── cmd_suite_test.go ├── version.go ├── migrate_test.go ├── worker.go ├── encryption_script.go ├── root.go ├── loadtest.go ├── start.go ├── prune_test.go ├── migrate.go └── migrate_mongo.go ├── loadtest ├── operation.go ├── app_suite_test.go ├── error.go ├── player.go ├── app_test.go ├── helpers.go ├── membership.go ├── unordered_string_map.go └── README.md ├── lib ├── lib_suite_test.go └── interface.go ├── caches ├── caches_suite_test.go ├── clan.go └── clan_test.go ├── docker └── start-khan.sh ├── main.go ├── pmd.sh ├── queues └── queues.go ├── run_migrations_and_start.sh ├── postman └── local.postman_environment.json ├── models ├── encrypted_player.go ├── models_suite_test.go ├── helpers_test.go ├── prune_test.go ├── es_worker.go ├── mongo_worker.go ├── helpers.go ├── hook.go ├── clan_easyjson.go ├── hook_test.go └── prune.go ├── Dockerfile ├── testing ├── mongo.go ├── extensions.go └── helpers.go ├── .gitignore ├── hooks └── pre-commit.sh ├── scripts └── docker-compose.yml ├── PruneDockerfile ├── mongo ├── helpers.go └── mongo_client.go ├── dev ├── Dockerfile └── docker-entrypoint.sh ├── convert.sh ├── LICENSE ├── Sidecarfile ├── log └── log.go ├── get_latest_tag.py ├── push_to_docker.sh ├── CHANGELOG.md ├── es └── es_client.go ├── go.mod ├── docker-compose.yml └── README.md /.venv: -------------------------------------------------------------------------------- 1 | khan 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | docker-compose.yml 3 | -------------------------------------------------------------------------------- /docs/installing.md: -------------------------------------------------------------------------------- 1 | Installing Khan 2 | =============== 3 | 4 | TBW. 5 | -------------------------------------------------------------------------------- /config/loadTestSharedClans.yaml: -------------------------------------------------------------------------------- 1 | clans: 2 | - "clan1publicID" 3 | - "clan2publicID" 4 | -------------------------------------------------------------------------------- /db/migrate.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | //go:generate go-bindata -pkg $GOPACKAGE -o ./migrations.go ./migrations/... 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: /bin/bash -c '/go/bin/khan worker --config /go/src/github.com/topfreegames/khan/config/default.yaml' 2 | -------------------------------------------------------------------------------- /util/errors.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | type TokenSizeError struct { 4 | Msg string 5 | } 6 | 7 | func (t *TokenSizeError) Error() string { 8 | return t.Msg 9 | } 10 | -------------------------------------------------------------------------------- /db/migrations/20160627110742_LoadUUIDModule.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 4 | 5 | 6 | -- +goose Down 7 | DROP EXTENSION IF EXISTS "uuid-ossp"; 8 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | gofmt: 3 | enabled: true 4 | golint: 5 | enabled: true 6 | govet: 7 | enabled: true 8 | 9 | ratings: 10 | paths: 11 | - "**.go" 12 | exclude_paths: 13 | - vendor/ 14 | -------------------------------------------------------------------------------- /api/errors.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | -------------------------------------------------------------------------------- /bench/bench.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package bench 9 | -------------------------------------------------------------------------------- /cmd/root_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd 9 | -------------------------------------------------------------------------------- /loadtest/operation.go: -------------------------------------------------------------------------------- 1 | package loadtest 2 | 3 | // operation represents a Khan operation to be tested in the load tests 4 | type operation struct { 5 | probability float64 6 | canExecute func() (bool, error) 7 | execute func() error 8 | } 9 | -------------------------------------------------------------------------------- /cmd/version_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd 9 | -------------------------------------------------------------------------------- /lib/lib_suite_test.go: -------------------------------------------------------------------------------- 1 | package lib_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestLib(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Lib Suite") 13 | } 14 | -------------------------------------------------------------------------------- /util/util_suite_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestKhan(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Khan Suite") 13 | } 14 | -------------------------------------------------------------------------------- /caches/caches_suite_test.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestApi(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Khan - Caches Suite") 13 | } 14 | -------------------------------------------------------------------------------- /loadtest/app_suite_test.go: -------------------------------------------------------------------------------- 1 | package loadtest 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestApi(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Khan - Load Test Suite") 13 | } 14 | -------------------------------------------------------------------------------- /docker/start-khan.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ "$KHAN_RUN_WORKER" != "true" ]; then 3 | /go/bin/khan start --bind 0.0.0.0 --port 80 --fast --config /go/src/github.com/topfreegames/khan/config/default.yaml 4 | else 5 | /go/bin/khan worker --config /go/src/github.com/topfreegames/khan/config/default.yaml 6 | fi 7 | -------------------------------------------------------------------------------- /loadtest/error.go: -------------------------------------------------------------------------------- 1 | package loadtest 2 | 3 | import "fmt" 4 | 5 | // GenericError represents a generic error 6 | type GenericError struct { 7 | Type string 8 | Description string 9 | } 10 | 11 | func (e *GenericError) Error() string { 12 | return fmt.Sprintf("%s: %s", e.Type, e.Description) 13 | } 14 | -------------------------------------------------------------------------------- /db/migrations/20160708192007_CreateOwnerIndex.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | CREATE INDEX clans_owner_id ON clans(owner_id); 5 | 6 | -- +goose Down 7 | -- SQL section 'Down' is executed when this migration is rolled back 8 | DROP INDEX IF EXISTS clans_owner_id; 9 | -------------------------------------------------------------------------------- /db/migrations/20180517112014_ChangeIDSequenceType.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- SQL in section 'Up' is executed when this migration is applied 3 | ALTER TABLE players ALTER COLUMN id TYPE BIGINT; 4 | 5 | -- +goose Down 6 | -- SQL section 'Down' is executed when this migration is rolled back 7 | ALTER TABLE players ALTER COLUMN id TYPE INTEGER; 8 | -------------------------------------------------------------------------------- /util/version.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package util 9 | 10 | // VERSION identifies Khan's current version 11 | var VERSION = "5.2.2" 12 | -------------------------------------------------------------------------------- /db/drop-perf.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | REVOKE ALL ON SCHEMA public FROM khan_perf; 9 | DROP DATABASE IF EXISTS khan_perf; 10 | DROP ROLE khan_perf; 11 | -------------------------------------------------------------------------------- /db/drop-test.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | REVOKE ALL ON SCHEMA public FROM khan_test; 9 | DROP DATABASE IF EXISTS khan_test; 10 | DROP ROLE khan_test; 11 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package main 9 | 10 | import "github.com/topfreegames/khan/cmd" 11 | 12 | func main() { 13 | cmd.Execute(cmd.RootCmd) 14 | } 15 | -------------------------------------------------------------------------------- /db/migrations/20160628181530_CreateClanMembershipCount.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE clans ADD COLUMN membership_count integer NOT NULL DEFAULT 1; 5 | 6 | -- +goose Down 7 | -- SQL section 'Down' is executed when this migration is rolled back 8 | ALTER TABLE clans DROP COLUMN membership_count; 9 | -------------------------------------------------------------------------------- /db/migrations/20160728195902_CreateMembershipMessageField.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE memberships ADD COLUMN message varchar(255) NULL; 5 | 6 | 7 | -- +goose Down 8 | -- SQL section 'Down' is executed when this migration is rolled back 9 | ALTER TABLE memberships DROP COLUMN message; 10 | -------------------------------------------------------------------------------- /pmd.sh: -------------------------------------------------------------------------------- 1 | export PMD=pmd-bin-5.4.2 2 | export PMDUrl="http://downloads.sourceforge.net/project/pmd/pmd/5.4.2/pmd-bin-5.4.2.zip?r=https\%3A\%2F\%2Fsourceforge.net\%2Fprojects\%2Fpmd\%2Ffiles\%2Fpmd\%2F5.4.2\%2F&ts=1465934375&use_mirror=tenet" 3 | 4 | cd "/tmp" 5 | 6 | if [ ! -d $PMD ]; then 7 | curl -o $PMD.zip -L -O $PMDUrl 8 | [ -e $PMD.zip ] && unzip $PMD.zip 9 | fi 10 | -------------------------------------------------------------------------------- /config/faulty.yaml: -------------------------------------------------------------------------------- 1 | postgres: 2 | user: "khan_tet" 3 | dbName: "khan_test" 4 | host: "localhost" 5 | port: 5433 6 | sslMode: "disable" 7 | 8 | jaeger: 9 | disabled: false 10 | samplingProbability: 1.0 11 | serviceName: "khan" 12 | 13 | extensions: 14 | dogstatsd: 15 | host: localhost:8125 16 | prefix: khan. 17 | tags_prefix: "" 18 | rate: 1 19 | -------------------------------------------------------------------------------- /queues/queues.go: -------------------------------------------------------------------------------- 1 | package queues 2 | 3 | // KhanQueue is the queue that will receive khan webhooks 4 | const KhanQueue = "khan_webhooks" 5 | 6 | // KhanESQueue is the queue that will receive ElasticSearch updates 7 | const KhanESQueue = "khan_es_updater" 8 | 9 | // KhanMongoQueue is the queue that will receive Mongo updates 10 | const KhanMongoQueue = "khan_mongo_updater" 11 | -------------------------------------------------------------------------------- /run_migrations_and_start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | /go/bin/khan migrate -c /go/src/github.com/topfreegames/khan/config/default.yaml 4 | /bin/bash -c 'if [ "$KHAN_RUN_WORKER" != "true" ]; then /go/bin/khan start --bind 0.0.0.0 --port 8080 --config /go/src/github.com/topfreegames/khan/config/default.yaml; else /go/bin/khan worker --config /go/src/github.com/topfreegames/khan/config/default.yaml; fi' 5 | -------------------------------------------------------------------------------- /db/migrations/20210401151842_ChangeEncryptedPlayersIDType.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE encrypted_players ALTER COLUMN player_id TYPE BIGINT; 5 | 6 | 7 | -- +goose Down 8 | -- SQL section 'Down' is executed when this migration is rolled back 9 | ALTER TABLE encrypted_players ALTER COLUMN player_id TYPE INTEGER; 10 | 11 | -------------------------------------------------------------------------------- /db/migrations/20210323185959_CreateEncryptionTable.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | CREATE TABLE IF NOT EXISTS encrypted_players ( 5 | player_id integer PRIMARY KEY REFERENCES players (id) 6 | ); 7 | 8 | -- +goose Down 9 | -- SQL section 'Down' is executed when this migration is rolled back 10 | DROP TABLE IF EXISTS encrypted_players; 11 | -------------------------------------------------------------------------------- /postman/local.postman_environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "c3c23c7c-41f9-40bf-7abe-4fe96567267a", 3 | "name": "Khan - Local", 4 | "values": [ 5 | { 6 | "key": "baseKhanURL", 7 | "value": "http://localhost:8888/", 8 | "type": "text", 9 | "enabled": true 10 | } 11 | ], 12 | "timestamp": 1470951765684, 13 | "synced": false, 14 | "syncedFilename": "", 15 | "team": null, 16 | "isDeleted": false 17 | } -------------------------------------------------------------------------------- /util/time.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package util 9 | 10 | import "time" 11 | 12 | // NowMilli returns now in milliseconds since epoch 13 | func NowMilli() int64 { 14 | return time.Now().UnixNano() / 1000000 15 | } 16 | -------------------------------------------------------------------------------- /models/encrypted_player.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package models 9 | 10 | // EncryptedPlayer identifies uniquely one player in a given game 11 | type EncryptedPlayer struct { 12 | PlayerID int64 `db:"player_id"` 13 | } 14 | -------------------------------------------------------------------------------- /db/migrations/20160728180524_CreateMaxPendingInvitesField.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE games ADD COLUMN max_pending_invites integer DEFAULT -1 NOT NULL; 5 | UPDATE games SET max_pending_invites=-1; 6 | 7 | 8 | -- +goose Down 9 | -- SQL section 'Down' is executed when this migration is rolled back 10 | ALTER TABLE games DROP COLUMN max_pending_invites; 11 | -------------------------------------------------------------------------------- /api/api_suite_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api_test 9 | 10 | import ( 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | 14 | "testing" 15 | ) 16 | 17 | func TestApi(t *testing.T) { 18 | RegisterFailHandler(Fail) 19 | RunSpecs(t, "Khan - API Suite") 20 | } 21 | -------------------------------------------------------------------------------- /cmd/cmd_suite_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd_test 9 | 10 | import ( 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | 14 | "testing" 15 | ) 16 | 17 | func TestApi(t *testing.T) { 18 | RegisterFailHandler(Fail) 19 | RunSpecs(t, "Khan - CMD Suite") 20 | } 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16.3-alpine as build 2 | 3 | LABEL TFG Co 4 | 5 | WORKDIR /khan 6 | 7 | COPY Makefile . 8 | COPY go.mod go.sum . 9 | 10 | RUN apk --update add make gcc && \ 11 | make setup 12 | 13 | COPY . . 14 | 15 | RUN make build 16 | 17 | FROM alpine:3.12 18 | 19 | COPY --from=build /khan/bin/khan / 20 | COPY --from=build /khan/config/default.yaml / 21 | 22 | RUN chmod +x /khan 23 | 24 | ENTRYPOINT [ "/khan", "-c", "/default.yaml" ] 25 | -------------------------------------------------------------------------------- /models/models_suite_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package models 9 | 10 | import ( 11 | . "github.com/onsi/ginkgo" 12 | . "github.com/onsi/gomega" 13 | 14 | "testing" 15 | ) 16 | 17 | func TestApi(t *testing.T) { 18 | RegisterFailHandler(Fail) 19 | RunSpecs(t, "Khan - Models Suite") 20 | } 21 | -------------------------------------------------------------------------------- /db/migrations/20160713191703_CreateMembershipDenierField.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE memberships ADD COLUMN denier_id integer NULL REFERENCES players (id); 5 | ALTER TABLE memberships ADD COLUMN denied_at bigint NULL; 6 | 7 | 8 | -- +goose Down 9 | -- SQL section 'Down' is executed when this migration is rolled back 10 | ALTER TABLE memberships DROP COLUMN denier_id; 11 | ALTER TABLE memberships DROP COLUMN denied_at; 12 | -------------------------------------------------------------------------------- /db/migrations/20160708161944_CreateMembershipApproverField.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE memberships ADD COLUMN approver_id integer NULL REFERENCES players (id); 5 | ALTER TABLE memberships ADD COLUMN approved_at bigint NULL; 6 | 7 | 8 | -- +goose Down 9 | -- SQL section 'Down' is executed when this migration is rolled back 10 | ALTER TABLE memberships DROP COLUMN approver_id; 11 | ALTER TABLE memberships DROP COLUMN approved_at; 12 | -------------------------------------------------------------------------------- /db/migrations/20160627155249_CreatePlayerMembershipAndOwnershipCount.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE players ADD COLUMN membership_count integer NOT NULL DEFAULT 0; 5 | ALTER TABLE players ADD COLUMN ownership_count integer NOT NULL DEFAULT 0; 6 | 7 | -- +goose Down 8 | -- SQL section 'Down' is executed when this migration is rolled back 9 | ALTER TABLE players DROP COLUMN membership_count; 10 | ALTER TABLE players DROP COLUMN ownership_count; 11 | -------------------------------------------------------------------------------- /db/create-perf.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | CREATE ROLE khan_perf LOGIN 9 | SUPERUSER INHERIT CREATEDB CREATEROLE; 10 | 11 | CREATE DATABASE khan_perf 12 | WITH OWNER = khan_perf 13 | ENCODING = 'UTF8' 14 | TABLESPACE = pg_default 15 | TEMPLATE = template0; 16 | 17 | GRANT ALL ON SCHEMA public TO khan_perf; 18 | -------------------------------------------------------------------------------- /db/create-test.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | CREATE ROLE khan_test LOGIN 9 | SUPERUSER INHERIT CREATEDB CREATEROLE; 10 | 11 | CREATE DATABASE khan_test 12 | WITH OWNER = khan_test 13 | ENCODING = 'UTF8' 14 | TABLESPACE = pg_default 15 | TEMPLATE = template0; 16 | 17 | GRANT ALL ON SCHEMA public TO khan_test; 18 | -------------------------------------------------------------------------------- /db/migrations/20160713185332_CreateGameCooldownAfterDenyAndDelete.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE games ADD COLUMN cooldown_after_deny integer NOT NULL DEFAULT 0; 5 | ALTER TABLE games ADD COLUMN cooldown_after_delete integer NOT NULL DEFAULT 0; 6 | 7 | -- +goose Down 8 | -- SQL section 'Down' is executed when this migration is rolled back 9 | ALTER TABLE games DROP COLUMN cooldown_after_deny; 10 | ALTER TABLE games DROP COLUMN cooldown_after_delete; 11 | -------------------------------------------------------------------------------- /db/migrations/20160729184159_CreateCooldownAfterInviteField.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | ALTER TABLE games ADD COLUMN cooldown_before_invite integer NOT NULL DEFAULT 0; 5 | ALTER TABLE games ADD COLUMN cooldown_before_apply integer NOT NULL DEFAULT 3600; 6 | 7 | -- +goose Down 8 | -- SQL section 'Down' is executed when this migration is rolled back 9 | ALTER TABLE games DROP COLUMN cooldown_before_invite; 10 | ALTER TABLE games DROP COLUMN cooldown_before_apply; 11 | -------------------------------------------------------------------------------- /db/migrations/20160819145352_CreateHookTriggerFieldsMetadata.sql: -------------------------------------------------------------------------------- 1 | -- +goose Up 2 | -- SQL in section 'Up' is executed when this migration is applied 3 | ALTER TABLE games ADD COLUMN clan_metadata_fields_whitelist varchar(2000) DEFAULT ''; 4 | ALTER TABLE games ADD COLUMN player_metadata_fields_whitelist varchar(2000) DEFAULT ''; 5 | 6 | -- +goose Down 7 | -- SQL section 'Down' is executed when this migration is rolled back 8 | ALTER TABLE games DROP COLUMN clan_metadata_fields_whitelist; 9 | ALTER TABLE games DROP COLUMN player_metadata_fields_whitelist; 10 | -------------------------------------------------------------------------------- /config/perf.yaml: -------------------------------------------------------------------------------- 1 | postgres: 2 | user: "khan_perf" 3 | dbName: "khan_perf" 4 | host: "localhost" 5 | port: 5433 6 | sslMode: "disable" 7 | 8 | mongodb: 9 | enabled: true 10 | url: mongodb://localhost:27017 11 | databaseName: "khan" 12 | database: "khan" 13 | collectionTemplate: "clans_%s" 14 | 15 | search: 16 | pageSize: 10 17 | 18 | jaeger: 19 | disabled: true 20 | samplingProbability: 0.001 21 | serviceName: "khan" 22 | 23 | extensions: 24 | dogstatsd: 25 | host: localhost:8125 26 | prefix: khan. 27 | tags_prefix: "" 28 | rate: 1 29 | -------------------------------------------------------------------------------- /testing/mongo.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | "github.com/topfreegames/extensions/v9/mongo/interfaces" 6 | "github.com/topfreegames/khan/mongo" 7 | ) 8 | 9 | // GetTestMongo returns a mongo instance for testing 10 | func GetTestMongo() (interfaces.MongoDB, error) { 11 | config := viper.New() 12 | config.SetConfigType("yaml") 13 | config.SetConfigFile("../config/test.yaml") 14 | err := config.ReadInConfig() 15 | if err != nil { 16 | return nil, err 17 | } 18 | logger := NewMockLogger() 19 | return mongo.GetMongo(logger, config) 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | .DS_Store 26 | coverage*.out 27 | vendor/ 28 | /khan 29 | docs/_build 30 | /docs/_build 31 | /bin 32 | khan-perf.dump 33 | /dev/default.yaml 34 | *.coverprofile 35 | dev/khan-linux-x86_64 36 | *.swp 37 | /docker-data -------------------------------------------------------------------------------- /db/drop.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | REVOKE ALL ON SCHEMA public FROM khan; 9 | DROP DATABASE IF EXISTS khan; 10 | 11 | DROP ROLE khan; 12 | 13 | CREATE ROLE khan LOGIN 14 | SUPERUSER INHERIT CREATEDB CREATEROLE; 15 | 16 | CREATE DATABASE khan 17 | WITH OWNER = khan 18 | ENCODING = 'UTF8' 19 | TABLESPACE = pg_default 20 | TEMPLATE = template0; 21 | 22 | GRANT ALL ON SCHEMA public TO khan; 23 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Khan documentation master file, created by 2 | sphinx-quickstart on Fri Jun 17 16:42:31 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Khan documentation 7 | ================== 8 | 9 | Contents: 10 | 11 | .. _docs: 12 | 13 | .. toctree:: 14 | :maxdepth: 2 15 | 16 | overview 17 | installing 18 | hosting 19 | game 20 | using_webhooks 21 | API 22 | pruning 23 | postman 24 | benchmark 25 | 26 | 27 | Indices and tables 28 | ================== 29 | 30 | * :ref:`genindex` 31 | * :ref:`modindex` 32 | * :ref:`search` 33 | -------------------------------------------------------------------------------- /hooks/pre-commit.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Redirect output to stderr. 4 | exec 1>&2 5 | # enable user input 6 | exec < /dev/tty 7 | 8 | forbiddenregexp='^\+.*[XF](It|Describe)[(]' 9 | # CHECK 10 | if test $(git diff --cached | egrep $forbiddenregexp | wc -l) != 0 11 | then 12 | echo "Proposed diff:" 13 | exec git diff --cached | egrep -ne $forbiddenregexp 14 | echo 15 | echo "In the above diff, there's at least one occurrence of:" 16 | echo " * XIt;" 17 | echo " * FIt;" 18 | echo " * XDescribe;" 19 | echo " * FDescribe." 20 | echo 21 | echo "Please remove it before continuing!" 22 | exit 1; 23 | fi 24 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd 9 | 10 | import ( 11 | "fmt" 12 | 13 | "github.com/spf13/cobra" 14 | "github.com/topfreegames/khan/util" 15 | ) 16 | 17 | // versionCmd represents the version command 18 | var versionCmd = &cobra.Command{ 19 | Use: "version", 20 | Short: "returns Khan version", 21 | Long: `returns Khan version`, 22 | Run: func(cmd *cobra.Command, args []string) { 23 | fmt.Printf("Khan v%s\n", util.VERSION) 24 | }, 25 | } 26 | 27 | func init() { 28 | RootCmd.AddCommand(versionCmd) 29 | } 30 | -------------------------------------------------------------------------------- /db/dbconf.yml: -------------------------------------------------------------------------------- 1 | development: 2 | driver: postgres 3 | open: user=khan dbname=khan sslmode=disable 4 | 5 | test: 6 | driver: postgres 7 | open: user=khan_test dbname=khan_test sslmode=disable 8 | 9 | ci: 10 | driver: postgres 11 | open: user=khan_test dbname=khan_test sslmode=disable 12 | 13 | nopassword: 14 | driver: postgres 15 | open: host=$KHAN_POSTGRES_HOST user=$KHAN_POSTGRES_USER dbname=$KHAN_POSTGRES_DBNAME sslmode=$KHAN_POSTGRES_SSLMODE port=$KHAN_POSTGRES_PORT 16 | 17 | withpassword: 18 | driver: postgres 19 | open: host=$KHAN_POSTGRES_HOST user=$KHAN_POSTGRES_USER dbname=$KHAN_POSTGRES_DBNAME sslmode=$KHAN_POSTGRES_SSLMODE port=$KHAN_POSTGRES_PORT password=$KHAN_POSTGRES_PASSWORD 20 | -------------------------------------------------------------------------------- /scripts/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | postgres: 5 | container_name: khan_postgres_1 6 | image: postgres:9.5 7 | ports: 8 | - "5433:5432" 9 | environment: 10 | - POSTGRES_HOST_AUTH_METHOD=trust 11 | elasticsearch: 12 | container_name: khan_elasticsearch_1 13 | image: elasticsearch:6.8.14 14 | ports: 15 | - "9200:9200" 16 | environment: 17 | - http.host=0.0.0.0 18 | - transport.host=127.0.0.1 19 | - xpack.security.enabled=false 20 | redis: 21 | container_name: khan_redis_1 22 | image: redis 23 | ports: 24 | - "50505:6379" 25 | mongo: 26 | container_name: khan_mongo_1 27 | image: mongo 28 | ports: 29 | - "27017:27017" 30 | -------------------------------------------------------------------------------- /PruneDockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.15.2-alpine 2 | 3 | MAINTAINER TFG Co 4 | 5 | RUN apk update 6 | RUN apk add git make g++ apache2-utils 7 | RUN apk add --update bash 8 | 9 | RUN go get -u github.com/golang/dep/... 10 | 11 | ADD . /go/src/github.com/topfreegames/khan 12 | 13 | WORKDIR /go/src/github.com/topfreegames/khan 14 | RUN go mod tidy 15 | RUN go install github.com/topfreegames/khan 16 | 17 | ENV KHAN_POSTGRES_HOST 0.0.0.0 18 | ENV KHAN_POSTGRES_PORT 5432 19 | ENV KHAN_POSTGRES_USER khan 20 | ENV KHAN_POSTGRES_PASSWORD "" 21 | ENV KHAN_POSTGRES_DBNAME khan 22 | ENV KHAN_PRUNING_SLEEP 3600 23 | 24 | CMD /bin/bash -lc 'while true; do /go/bin/khan prune --config /go/src/github.com/topfreegames/khan/config/default.yaml; sleep $KHAN_PRUNING_SLEEP; done' 25 | -------------------------------------------------------------------------------- /db/migrations/20160627153918_CreateRetrieveClanIndexes.sql: -------------------------------------------------------------------------------- 1 | 2 | -- +goose Up 3 | -- SQL in section 'Up' is executed when this migration is applied 4 | CREATE INDEX memberships_approved ON memberships(approved); 5 | CREATE INDEX memberships_banned ON memberships(banned); 6 | CREATE INDEX memberships_denied ON memberships(denied); 7 | CREATE INDEX memberships_pending ON memberships(approved, banned, denied); 8 | CREATE INDEX memberships_clan ON memberships(clan_id); 9 | 10 | 11 | -- +goose Down 12 | -- SQL section 'Down' is executed when this migration is rolled back 13 | DROP INDEX IF EXISTS memberships_approved; 14 | DROP INDEX IF EXISTS memberships_banned; 15 | DROP INDEX IF EXISTS memberships_denied; 16 | DROP INDEX IF EXISTS memberships_pending; 17 | DROP INDEX IF EXISTS memberships_clan; 18 | -------------------------------------------------------------------------------- /api/status.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | 10 | import ( 11 | "encoding/json" 12 | "net/http" 13 | 14 | "github.com/labstack/echo" 15 | ) 16 | 17 | //StatusHandler is the handler responsible for reporting khan status 18 | func StatusHandler(app *App) func(c echo.Context) error { 19 | return func(c echo.Context) error { 20 | c.Set("route", "Status") 21 | payload := map[string]interface{}{ 22 | "app": map[string]interface{}{ 23 | "errorRate": app.Errors.Rate(), 24 | }, 25 | } 26 | 27 | payloadJSON, _ := json.Marshal(payload) 28 | return c.String(http.StatusOK, string(payloadJSON)) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /models/helpers_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package models_test 9 | 10 | import ( 11 | egorp "github.com/topfreegames/extensions/v9/gorp/interfaces" 12 | "github.com/topfreegames/khan/models" 13 | ) 14 | 15 | // GetTestDB returns a connection to the test database 16 | func GetTestDB() (egorp.Database, error) { 17 | return models.GetDB("localhost", "khan_test", 5433, "disable", "khan_test", "") 18 | } 19 | 20 | // GetFaultyTestDB returns an ill-configured test database 21 | func GetFaultyTestDB() models.DB { 22 | faultyDb, _ := models.InitDb("localhost", "khan_tet", 5433, "disable", "khan_test", "") 23 | return faultyDb 24 | } 25 | -------------------------------------------------------------------------------- /db/migrations/20160621161411_CreateHooksTable.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | -- +goose Up 9 | -- SQL in section 'Up' is executed when this migration is applied 10 | CREATE TABLE hooks ( 11 | id serial PRIMARY KEY, 12 | game_id varchar(36) NOT NULL REFERENCES games (public_id), 13 | public_id varchar(36) NOT NULL, 14 | event_type integer NOT NULL, 15 | url text NOT NULL, 16 | created_at bigint NOT NULL, 17 | updated_at bigint NULL, 18 | 19 | CONSTRAINT hookid_publicid UNIQUE(game_id, public_id) 20 | ); 21 | 22 | -- +goose Down 23 | -- SQL section 'Down' is executed when this migration is rolled back 24 | DROP TABLE hooks; 25 | -------------------------------------------------------------------------------- /mongo/helpers.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/globalsign/mgo/bson" 7 | ) 8 | 9 | // GetClanNameTextIndexCommand returns a mongo command to create the clan names text index. 10 | func GetClanNameTextIndexCommand(gameID string, background bool) bson.D { 11 | return bson.D{ 12 | {Name: "createIndexes", Value: fmt.Sprintf("clans_%s", gameID)}, 13 | {Name: "indexes", Value: []interface{}{ 14 | bson.M{ 15 | "key": bson.M{ 16 | "name": "text", 17 | "namePrefixes": "text", 18 | }, 19 | "weights": bson.M{ 20 | "name": 256, 21 | "namePrefixes": 1, 22 | }, 23 | "name": fmt.Sprintf("clans_%s_name_text_namePrefixes_text_index", gameID), 24 | "background": background, 25 | "default_language": "none", 26 | }, 27 | }}, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /db/migrations/20160608150958_CreatePlayerTable.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | -- +goose Up 9 | -- SQL in section 'Up' is executed when this migration is applied 10 | CREATE TABLE players ( 11 | id serial PRIMARY KEY, 12 | public_id varchar(255) NOT NULL, 13 | game_id varchar(36) NOT NULL REFERENCES games (public_id), 14 | name varchar(255) NOT NULL, 15 | metadata JSONB NOT NULL DEFAULT '{}'::JSONB, 16 | created_at bigint NOT NULL, 17 | updated_at bigint NULL, 18 | 19 | CONSTRAINT gameid_playerid UNIQUE(game_id, public_id) 20 | ); 21 | 22 | -- +goose Down 23 | -- SQL section 'Down' is executed when this migration is rolled back 24 | DROP TABLE players; 25 | -------------------------------------------------------------------------------- /dev/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM kiasaki/alpine-postgres 2 | 3 | ENV KHAN_BIN khan-linux 4 | ENV KHAN_PORT 8080 5 | 6 | EXPOSE $KHAN_PORT 7 | 8 | RUN apk update 9 | RUN apk add curl 10 | 11 | # http://stackoverflow.com/questions/34729748/installed-go-binary-not-found-in-path-on-alpine-linux-docker 12 | RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 13 | 14 | # Get khan 15 | ADD ./khan-linux-x86_64 /go/bin/$KHAN_BIN 16 | 17 | ENV POSTGRES_DB khan 18 | ENV POSTGRES_USER khan 19 | 20 | ENV KHAN_POSTGRES_HOST 0.0.0.0 21 | ENV KHAN_POSTGRES_PORT 5432 22 | ENV KHAN_POSTGRES_USER khan 23 | ENV KHAN_POSTGRES_DBNAME khan 24 | 25 | COPY default.yaml . 26 | COPY docker-entrypoint.sh / 27 | RUN chmod +x /docker-entrypoint.sh 28 | 29 | ENTRYPOINT /bin/sh -c "/docker-entrypoint.sh && su postgres -c '/usr/bin/pg_ctl start' && sleep 5 && /bin/$KHAN_BIN migrate --config default.yaml && /go/bin/$KHAN_BIN start --bind 0.0.0.0 --port $KHAN_PORT --config default.yaml" 30 | -------------------------------------------------------------------------------- /mongo/mongo_client.go: -------------------------------------------------------------------------------- 1 | package mongo 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/spf13/viper" 7 | "github.com/topfreegames/extensions/v9/mongo" 8 | "github.com/topfreegames/extensions/v9/mongo/interfaces" 9 | "github.com/uber-go/zap" 10 | ) 11 | 12 | var once sync.Once 13 | var client *mongo.Client 14 | 15 | // GetMongo gets a mongo database model 16 | func GetMongo(logger zap.Logger, config *viper.Viper) (interfaces.MongoDB, error) { 17 | var err error 18 | 19 | once.Do(func() { 20 | client, err = mongo.NewClient("mongodb", config) 21 | if err != nil { 22 | message := err.Error() 23 | logger.Error(message) 24 | return 25 | } 26 | 27 | logger.Info("mongo client configured successfully") 28 | }) 29 | 30 | return client.MongoDB, err 31 | } 32 | 33 | // GetConfiguredMongoClient gets a configured mongo client 34 | func GetConfiguredMongoClient() interfaces.MongoDB { 35 | if client != nil { 36 | return client.MongoDB 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /loadtest/player.go: -------------------------------------------------------------------------------- 1 | package loadtest 2 | 3 | func (app *App) configurePlayerOperations() { 4 | app.appendOperation(app.getCreatePlayerOperation()) 5 | } 6 | 7 | func (app *App) getCreatePlayerOperation() operation { 8 | operationKey := "createPlayer" 9 | app.setOperationProbabilityConfigDefault(operationKey, 1) 10 | return operation{ 11 | probability: app.getOperationProbabilityConfig(operationKey), 12 | canExecute: func() (bool, error) { 13 | return true, nil 14 | }, 15 | execute: func() error { 16 | playerPublicID := getRandomPublicID() 17 | 18 | createdPublicID, err := app.client.CreatePlayer(nil, playerPublicID, getRandomPlayerName(), getMetadataWithRandomScore()) 19 | if err != nil { 20 | return err 21 | } 22 | if createdPublicID != playerPublicID { 23 | return &GenericError{"WrongPublicIDError", "Operation createPlayer returned no error with public ID different from requested."} 24 | } 25 | 26 | return app.cache.createPlayer(playerPublicID) 27 | }, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /testing/extensions.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package testing 9 | 10 | import ( 11 | "fmt" 12 | "time" 13 | 14 | "github.com/onsi/ginkgo" 15 | ) 16 | 17 | //BeforeOnce runs the before each block only once 18 | func BeforeOnce(beforeBlock func()) { 19 | hasRun := false 20 | 21 | ginkgo.BeforeEach(func() { 22 | if !hasRun { 23 | beforeBlock() 24 | hasRun = true 25 | } 26 | }) 27 | } 28 | 29 | //WaitForFunc waits for a given function to finish without error or a timeout 30 | func WaitForFunc(timeout int, f func() error) error { 31 | var err error 32 | 33 | start := time.Now() 34 | 35 | for err = f(); err != nil || int(time.Now().Sub(start).Seconds()) > timeout; err = f() { 36 | time.Sleep(time.Millisecond) 37 | } 38 | if int(time.Now().Sub(start).Seconds()) > timeout { 39 | return fmt.Errorf("Timeout") 40 | } 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /db/migrations/20160608174439_CreateClanTable.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | -- +goose Up 9 | -- SQL in section 'Up' is executed when this migration is applied 10 | CREATE TABLE clans ( 11 | id serial PRIMARY KEY, 12 | public_id varchar(255) NOT NULL, 13 | game_id varchar(36) NOT NULL REFERENCES games (public_id), 14 | name varchar(255) NOT NULL, 15 | metadata JSONB NOT NULL DEFAULT '{}'::JSONB, 16 | allow_application boolean NOT NULL DEFAULT true, 17 | auto_join boolean NOT NULL DEFAULT false, 18 | created_at bigint NOT NULL, 19 | updated_at bigint NULL, 20 | deleted_at bigint NULL, 21 | owner_id integer NOT NULL REFERENCES players (id), 22 | 23 | CONSTRAINT gameid_clanid UNIQUE(game_id, public_id) 24 | ); 25 | 26 | -- +goose Down 27 | -- SQL section 'Down' is executed when this migration is rolled back 28 | DROP TABLE clans; 29 | -------------------------------------------------------------------------------- /cmd/migrate_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd_test 9 | 10 | import ( 11 | "os/exec" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | . "github.com/topfreegames/khan/cmd" 16 | ) 17 | 18 | func dropDB() error { 19 | cmd := exec.Cmd{ 20 | Dir: "../", 21 | Path: "/usr/bin/make", 22 | Args: []string{ 23 | "drop-test", 24 | }, 25 | } 26 | _, err := cmd.CombinedOutput() 27 | if err != nil { 28 | return err 29 | } 30 | 31 | return nil 32 | } 33 | 34 | var _ = Describe("Migrate Command", func() { 35 | BeforeEach(func() { 36 | err := dropDB() 37 | Expect(err).NotTo(HaveOccurred()) 38 | }) 39 | 40 | Describe("Migrate Cmd", func() { 41 | It("Should run migrations up", func() { 42 | ConfigFile = "../config/test.yaml" 43 | InitConfig() 44 | err := RunMigrations(-1) 45 | Expect(err).NotTo(HaveOccurred()) 46 | }) 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /convert.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Replacing in $1..." 4 | echo 5 | 6 | sed -i "" "s/package\s*\(.*\)/package \1_test/" $1 7 | sed -i "" "s/g.Assert/Expect/" $1 8 | sed -i "" "s/g.Describe/Describe/" $1 9 | sed -i "" "s/g.It/It/" $1 10 | sed -i "" "s/Expect[(]err != nil[)].IsTrue[(][)]/Expect(err).To(HaveOccurred())/" $1 11 | sed -i "" "s/Expect[(]err == nil[)].IsTrue[(][)]/Expect(err).NotTo(HaveOccurred())/" $1 12 | sed -i "" "s@\s*[>]\s*\(.*\)[)].IsTrue[(][)]@).To(BeNumerically(\">\", \1))@" $1 13 | sed -i "" "s@\s*[!][=]\s*\(.*\)[)].IsTrue[(][)]@).NotTo(BeEquivalentTo(\1))@" $1 14 | sed -i "" "s/Expect.err == nil..IsFalse../Expect(err).To(HaveOccurred())/" $1 15 | sed -i "" "s@[= ]*nil[)].IsTrue[(][)]@).To(BeNil())@" $1 16 | sed -i "" "s@[)][.]\(Equal.*[)]\)@).To(\1)@" $1 17 | sed -i "" "s@[)][.]\(Equal.*\)@).To(\1@" $1 18 | sed -i "" "s@[(]int[(]\(.*\)[)][)].To[(]Equal@(\1).To(BeEquivalentTo@" $1 19 | sed -i "" "s@IsFalse[(][)]@To(BeFalse())@" $1 20 | sed -i "" "s@IsTrue[(][)]@To(BeTrue())@" $1 21 | sed -i "" "s@Equal[(]nil[)]@To(BeNil())@" $1 22 | sed -i "" "s/To[(]To[(]/To(/" $1 23 | -------------------------------------------------------------------------------- /api/healthcheck.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | 10 | import ( 11 | "fmt" 12 | "net/http" 13 | "strings" 14 | 15 | "github.com/labstack/echo" 16 | ) 17 | 18 | //HealthCheckHandler is the handler responsible for validating that the app is still up 19 | func HealthCheckHandler(app *App) func(c echo.Context) error { 20 | return func(c echo.Context) error { 21 | c.Set("route", "Healthcheck") 22 | db, err := app.GetCtxDB(c) 23 | if err != nil { 24 | return FailWith(http.StatusInternalServerError, err.Error(), c) 25 | } 26 | 27 | workingString := app.Config.GetString("healthcheck.workingText") 28 | 29 | _, err = db.SelectInt("select count(*) from games") 30 | if err != nil { 31 | return FailWith(http.StatusInternalServerError, fmt.Sprintf("Error connecting to database: %s", err), c) 32 | } 33 | 34 | workingString = strings.TrimSpace(workingString) 35 | return c.String(http.StatusOK, workingString) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /util/level.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package util 9 | 10 | import "sort" 11 | 12 | func getIntLevel(level interface{}) int { 13 | switch level.(type) { 14 | case float64: 15 | return int(level.(float64)) 16 | default: 17 | return level.(int) 18 | } 19 | } 20 | 21 | // SortLevels sorts levels 22 | func SortLevels(levels map[string]interface{}) LevelsList { 23 | ll := make(LevelsList, len(levels)) 24 | i := 0 25 | for k, v := range levels { 26 | ll[i] = Level{k, getIntLevel(v)} 27 | i++ 28 | } 29 | sort.Sort(ll) 30 | return ll 31 | } 32 | 33 | // Level maps levels 34 | type Level struct { 35 | Key string 36 | Value int 37 | } 38 | 39 | // LevelsList allows sorting levels by the int value 40 | type LevelsList []Level 41 | 42 | func (l LevelsList) Len() int { return len(l) } 43 | func (l LevelsList) Less(i, j int) bool { return l[i].Value < l[j].Value } 44 | func (l LevelsList) Swap(i, j int) { l[i], l[j] = l[j], l[i] } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Top Free Games 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sidecarfile: -------------------------------------------------------------------------------- 1 | cmd: 2 | - name: jaeger-agent 3 | command: 4 | - /go/bin/agent-linux 5 | - --collector.host-port=jaeger-collector.jaeger.svc.cluster.local:14267 6 | image: jaegertracing/jaeger-agent:1.10 7 | imagePullPolicy: IfNotPresent 8 | ports: 9 | - containerPort: 5775 10 | protocol: UDP 11 | - containerPort: 5778 12 | protocol: TCP 13 | - containerPort: 6831 14 | protocol: UDP 15 | - containerPort: 6832 16 | protocol: UDP 17 | resources: 18 | limits: 19 | cpu: 50m 20 | memory: 50Mi 21 | requests: 22 | cpu: 25m 23 | memory: 15Mi 24 | worker: 25 | - name: jaeger-agent 26 | command: 27 | - /go/bin/agent-linux 28 | - --collector.host-port=jaeger-collector.jaeger.svc.cluster.local:14267 29 | image: jaegertracing/jaeger-agent:1.10 30 | imagePullPolicy: IfNotPresent 31 | ports: 32 | - containerPort: 5775 33 | protocol: UDP 34 | - containerPort: 5778 35 | protocol: TCP 36 | - containerPort: 6831 37 | protocol: UDP 38 | - containerPort: 6832 39 | protocol: UDP 40 | resources: 41 | limits: 42 | cpu: 50m 43 | memory: 50Mi 44 | requests: 45 | cpu: 25m 46 | memory: 15Mi -------------------------------------------------------------------------------- /api/status_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api_test 9 | 10 | import ( 11 | "encoding/json" 12 | "net/http" 13 | 14 | . "github.com/onsi/ginkgo" 15 | . "github.com/onsi/gomega" 16 | ) 17 | 18 | var _ = Describe("Status API Handler", func() { 19 | Describe("Status Handler", func() { 20 | It("Should respond with status", func() { 21 | a := GetDefaultTestApp() 22 | status, body := Get(a, "/status") 23 | 24 | Expect(status).To(Equal(http.StatusOK)) 25 | 26 | var result map[string]interface{} 27 | json.Unmarshal([]byte(body), &result) 28 | 29 | Expect(result["app"]).NotTo(BeEquivalentTo(nil)) 30 | app := result["app"].(map[string]interface{}) 31 | Expect(app["errorRate"]).To(Equal(0.0)) 32 | }) 33 | 34 | It("Should respond with 401 Unauthorized", func() { 35 | a := GetTestAppWithBasicAuth("basicauthuser", "basicauthpass") 36 | status, _ := Get(a, "/status") 37 | 38 | Expect(status).To(Equal(http.StatusUnauthorized)) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /db/migrations/20160608182307_CreateMembershipTable.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | -- +goose Up 9 | -- SQL in section 'Up' is executed when this migration is applied 10 | CREATE TABLE memberships ( 11 | id serial PRIMARY KEY, 12 | game_id varchar(36) NOT NULL REFERENCES games (public_id), 13 | clan_id integer NOT NULL REFERENCES clans (id), 14 | player_id integer NOT NULL REFERENCES players (id), 15 | membership_level varchar(36) NOT NULL, 16 | approved boolean NOT NULL DEFAULT false, 17 | denied boolean NOT NULL DEFAULT false, 18 | banned boolean NOT NULL DEFAULT false, 19 | requestor_id integer NOT NULL REFERENCES players (id), 20 | created_at bigint NOT NULL, 21 | updated_at bigint NULL, 22 | deleted_by integer NULL, 23 | deleted_at bigint NULL, 24 | 25 | CONSTRAINT playerid_clanid UNIQUE(player_id, clan_id) 26 | ); 27 | 28 | -- +goose Down 29 | -- SQL section 'Down' is executed when this migration is rolled back 30 | DROP TABLE memberships; 31 | -------------------------------------------------------------------------------- /config/test.yaml: -------------------------------------------------------------------------------- 1 | postgres: 2 | user: "khan_test" 3 | dbName: "khan_test" 4 | host: "localhost" 5 | port: 5433 6 | sslMode: "disable" 7 | 8 | elasticsearch: 9 | host: "localhost" 10 | enabled: true 11 | port: 9200 12 | sniff: false 13 | index: "khan" 14 | 15 | mongodb: 16 | enabled: true 17 | url: mongodb://localhost:27017 18 | databaseName: "khan" 19 | collectionTemplate: "clans_%s" 20 | 21 | khan: 22 | maxPendingInvites: -1 23 | defaultCooldownBeforeInvite: 0 24 | defaultCooldownBeforeApply: 3600 25 | 26 | search: 27 | pageSize: 10 28 | 29 | healthcheck: 30 | workingText: "WORKING" 31 | 32 | webhooks: 33 | timeout: 500 34 | 35 | jaeger: 36 | disabled: false 37 | samplingProbability: 1.0 38 | serviceName: "khan" 39 | 40 | redis: 41 | host: 0.0.0.0 42 | port: 50505 43 | database: 0 44 | pool: 30 45 | password: "" 46 | 47 | webhooks: 48 | timeout: 2 49 | workers: 5 50 | statsPort: 9999 51 | runStats: false 52 | logToBuf: true 53 | 54 | extensions: 55 | dogstatsd: 56 | host: localhost:8125 57 | prefix: khan. 58 | tags_prefix: "" 59 | rate: 1 60 | 61 | security: 62 | encryptionKey: "00000000000000000000000000000000" 63 | -------------------------------------------------------------------------------- /config/default.yaml: -------------------------------------------------------------------------------- 1 | postgres: 2 | user: "postgres" 3 | dbName: "khan" 4 | host: "localhost" 5 | port: 5433 6 | sslMode: "disable" 7 | 8 | elasticsearch: 9 | enabled: true 10 | host: "localhost" 11 | port: 9200 12 | sniff: false 13 | index: "khan" 14 | 15 | mongodb: 16 | enabled: false 17 | url: mongodb://localhost:27017 18 | databaseName: "khan" 19 | collectionTemplate: "clans_%s" 20 | 21 | search: 22 | pageSize: 50 23 | 24 | khan: 25 | maxPendingInvites: -1 26 | defaultCooldownBeforeInvite: 0 27 | defaultCooldownBeforeApply: 3600 28 | 29 | healthcheck: 30 | workingText: "WORKING" 31 | 32 | webhooks: 33 | timeout: 500 34 | workers: 5 35 | statsPort: 9999 36 | runStats: true 37 | 38 | jaeger: 39 | disabled: true 40 | samplingProbability: 0.001 41 | serviceName: "khan" 42 | 43 | redis: 44 | host: 0.0.0.0 45 | port: 6379 46 | database: 0 47 | pool: 30 48 | password: "" 49 | 50 | extensions: 51 | dogstatsd: 52 | host: localhost:8125 53 | prefix: khan. 54 | tags_prefix: "" 55 | rate: 1 56 | 57 | caches: 58 | getGame: 59 | ttl: 1m 60 | cleanupInterval: 1m 61 | clansSummaries: 62 | ttl: 1m 63 | cleanupInterval: 1m 64 | -------------------------------------------------------------------------------- /api/healthcheck_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api_test 9 | 10 | import ( 11 | "net/http" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | ) 16 | 17 | var _ = Describe("Healthcheck API Handler", func() { 18 | Describe("Healthcheck Handler", func() { 19 | It("Should respond with default WORKING string", func() { 20 | a := GetDefaultTestApp() 21 | status, body := Get(a, "/healthcheck") 22 | Expect(status).To(Equal(http.StatusOK)) 23 | Expect(body).To(Equal("WORKING")) 24 | }) 25 | 26 | It("Should respond with customized WORKING string", func() { 27 | a := GetDefaultTestApp() 28 | a.Config.Set("healthcheck.workingText", "OTHERWORKING") 29 | status, body := Get(a, "/healthcheck") 30 | Expect(status).To(Equal(http.StatusOK)) 31 | Expect(body).To(Equal("OTHERWORKING")) 32 | }) 33 | 34 | It("Should ignore basic auth", func() { 35 | a := GetTestAppWithBasicAuth("basicauthuser", "basicauthpass") 36 | status, body := Get(a, "/healthcheck") 37 | Expect(status).To(Equal(http.StatusOK)) 38 | Expect(body).To(Equal("WORKING")) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /util/secure.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | 7 | "github.com/wildlife-studios/crypto" 8 | ) 9 | 10 | //EncryptData is a func that use wildlife crypto module to cipher the data 11 | // the key must have 32 bytes length 12 | func EncryptData(data string, key []byte) (string, error) { 13 | if len(key) != 32 { 14 | return "", &TokenSizeError{Msg: "The key length is different than 32"} 15 | } 16 | 17 | xChacha := crypto.NewXChacha() 18 | encrypted, err := xChacha.Encrypt([]byte(data), key) 19 | if err != nil { 20 | return "", err 21 | } 22 | 23 | encoded := base64.StdEncoding.EncodeToString(encrypted) 24 | 25 | return encoded, nil 26 | } 27 | 28 | //DecryptData is a func that use wildlife crypto to decipher the data 29 | // the key must have 32 bytes length 30 | func DecryptData(encodedData string, key []byte) (string, error) { 31 | if len(key) != 32 { 32 | return "", &TokenSizeError{Msg: "The key length is different than 32"} 33 | } 34 | 35 | cipheredData, err := base64.StdEncoding.DecodeString(encodedData) 36 | if err != nil { 37 | return "", err 38 | } 39 | 40 | xChacha := crypto.NewXChacha() 41 | data, err := xChacha.Decrypt([]byte(cipheredData), key) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | return fmt.Sprintf("%s", data), nil 47 | } 48 | -------------------------------------------------------------------------------- /util/json.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package util 9 | 10 | import ( 11 | "encoding/json" 12 | "errors" 13 | 14 | "github.com/go-gorp/gorp" 15 | ) 16 | 17 | //map[string]interface{} type 18 | //type map[string]interface{} map[string]interface{} 19 | 20 | //TypeConverter type 21 | type TypeConverter struct{} 22 | 23 | // ToDb converts val from json to string 24 | func (tc TypeConverter) ToDb(val interface{}) (interface{}, error) { 25 | switch val.(type) { 26 | case map[string]interface{}: 27 | return json.Marshal(val) 28 | } 29 | return val, nil 30 | } 31 | 32 | // FromDb converts target from string to json 33 | func (tc TypeConverter) FromDb(target interface{}) (gorp.CustomScanner, bool) { 34 | switch target.(type) { 35 | case *map[string]interface{}: 36 | binder := func(holder, target interface{}) error { 37 | s, ok := holder.(*string) 38 | if !ok { 39 | return errors.New("FromDb: Unable to convert map[string]interface{} to *string") 40 | } 41 | b := []byte(*s) 42 | return json.Unmarshal(b, target) 43 | } 44 | return gorp.CustomScanner{new(string), target, binder}, true 45 | } 46 | return gorp.CustomScanner{}, false 47 | } 48 | -------------------------------------------------------------------------------- /docs/postman.md: -------------------------------------------------------------------------------- 1 | Using Postman with Khan 2 | ======================= 3 | 4 | Khan supports [Postman](https://www.getpostman.com) to make it easier on users to test their Khan server. 5 | 6 | Using [Postman](https://www.getpostman.com) with Khan is as simple as importing it's [operations](https://raw.githubusercontent.com/topfreegames/khan/master/postman/operations.postman_collection.json) and [environment](https://raw.githubusercontent.com/topfreegames/khan/master/postman/local.postman_environment.json) into [Postman](https://www.getpostman.com). 7 | 8 | ## Importing Khan's environment 9 | 10 | To import Khan's environment, download it's [environment file](https://raw.githubusercontent.com/topfreegames/khan/master/postman/local.postman_environment.json) and [import it in Postman](https://www.getpostman.com/docs/environments). 11 | 12 | ## Importing Khan's operations 13 | 14 | To import Khan's operations, download it's [operations file](https://raw.githubusercontent.com/topfreegames/khan/master/postman/operations.postman_collection.json) and [import it in Postman](https://www.getpostman.com/docs/collections). 15 | 16 | ## Running Khan's operations with a different environment 17 | 18 | Just configure a new environment and make sure it contains the `baseKhanURL` variable with a value like `http://my-khan-server/`. Do not forget the ending slash or it won't work. 19 | -------------------------------------------------------------------------------- /db/migrations/20160608133902_CreateGameTable.sql: -------------------------------------------------------------------------------- 1 | -- khan 2 | -- https://github.com/topfreegames/khan 3 | -- 4 | -- Licensed under the MIT license: 5 | -- http://www.opensource.org/licenses/mit-license 6 | -- Copyright © 2016 Top Free Games 7 | 8 | -- +goose Up 9 | -- SQL in section 'Up' is executed when this migration is applied 10 | CREATE TABLE games ( 11 | id serial PRIMARY KEY, 12 | public_id varchar(36) NOT NULL, 13 | name varchar(255) NOT NULL, 14 | min_membership_level integer NOT NULL, 15 | max_membership_level integer NOT NULL, 16 | min_level_to_accept_application integer NOT NULL, 17 | min_level_to_create_invitation integer NOT NULL, 18 | min_level_to_remove_member integer NOT NULL, 19 | min_level_offset_to_remove_member integer NOT NULL, 20 | min_level_offset_to_promote_member integer NOT NULL, 21 | min_level_offset_to_demote_member integer NOT NULL, 22 | max_clans_per_player integer NOT NULL, 23 | max_members integer NOT NULL, 24 | membership_levels JSONB NOT NULL DEFAULT '{}'::JSONB, 25 | metadata JSONB NOT NULL DEFAULT '{}'::JSONB, 26 | created_at bigint NOT NULL, 27 | updated_at bigint NULL, 28 | 29 | CONSTRAINT public_id UNIQUE(public_id) 30 | ); 31 | 32 | -- +goose Down 33 | -- SQL section 'Down' is executed when this migration is rolled back 34 | DROP TABLE games; 35 | -------------------------------------------------------------------------------- /api/easyjson-bootstrap338594267.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | // TEMPORARY AUTOGENERATED FILE: easyjson bootstapping code to launch 4 | // the actual generator. 5 | 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | 12 | "github.com/mailru/easyjson/gen" 13 | 14 | pkg "github.com/topfreegames/khan/api" 15 | ) 16 | 17 | func main() { 18 | g := gen.NewGenerator("payload_easyjson.go") 19 | g.SetPkg("api", "github.com/topfreegames/khan/api") 20 | g.NoStdMarshalers() 21 | g.Add(pkg.EasyJSON_exporter_ApplyForMembershipPayload(nil)) 22 | g.Add(pkg.EasyJSON_exporter_ApproveOrDenyMembershipInvitationPayload(nil)) 23 | g.Add(pkg.EasyJSON_exporter_BasePayloadWithRequestorAndPlayerPublicIDs(nil)) 24 | g.Add(pkg.EasyJSON_exporter_CreateClanPayload(nil)) 25 | g.Add(pkg.EasyJSON_exporter_CreateGamePayload(nil)) 26 | g.Add(pkg.EasyJSON_exporter_CreatePlayerPayload(nil)) 27 | g.Add(pkg.EasyJSON_exporter_HookPayload(nil)) 28 | g.Add(pkg.EasyJSON_exporter_InviteForMembershipPayload(nil)) 29 | g.Add(pkg.EasyJSON_exporter_TransferClanOwnershipPayload(nil)) 30 | g.Add(pkg.EasyJSON_exporter_UpdateClanPayload(nil)) 31 | g.Add(pkg.EasyJSON_exporter_UpdateGamePayload(nil)) 32 | g.Add(pkg.EasyJSON_exporter_UpdatePlayerPayload(nil)) 33 | g.Add(pkg.EasyJSON_exporter_Validation(nil)) 34 | if err := g.Run(os.Stdout); err != nil { 35 | fmt.Fprintln(os.Stderr, err) 36 | os.Exit(1) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /api/easyjson-bootstrap399122019.go: -------------------------------------------------------------------------------- 1 | // +build ignore 2 | 3 | // TEMPORARY AUTOGENERATED FILE: easyjson bootstapping code to launch 4 | // the actual generator. 5 | 6 | package main 7 | 8 | import ( 9 | "fmt" 10 | "os" 11 | 12 | "github.com/mailru/easyjson/gen" 13 | 14 | pkg "github.com/topfreegames/khan/api" 15 | ) 16 | 17 | func main() { 18 | g := gen.NewGenerator("payload_easyjson.go") 19 | g.SetPkg("api", "github.com/topfreegames/khan/api") 20 | g.NoStdMarshalers() 21 | g.Add(pkg.EasyJSON_exporter_ApplyForMembershipPayload(nil)) 22 | g.Add(pkg.EasyJSON_exporter_ApproveOrDenyMembershipInvitationPayload(nil)) 23 | g.Add(pkg.EasyJSON_exporter_BasePayloadWithRequestorAndPlayerPublicIDs(nil)) 24 | g.Add(pkg.EasyJSON_exporter_CreateClanPayload(nil)) 25 | g.Add(pkg.EasyJSON_exporter_CreateGamePayload(nil)) 26 | g.Add(pkg.EasyJSON_exporter_CreatePlayerPayload(nil)) 27 | g.Add(pkg.EasyJSON_exporter_HookPayload(nil)) 28 | g.Add(pkg.EasyJSON_exporter_InviteForMembershipPayload(nil)) 29 | g.Add(pkg.EasyJSON_exporter_TransferClanOwnershipPayload(nil)) 30 | g.Add(pkg.EasyJSON_exporter_UpdateClanPayload(nil)) 31 | g.Add(pkg.EasyJSON_exporter_UpdateGamePayload(nil)) 32 | g.Add(pkg.EasyJSON_exporter_UpdatePlayerPayload(nil)) 33 | g.Add(pkg.EasyJSON_exporter_Validation(nil)) 34 | if err := g.Run(os.Stdout); err != nil { 35 | fmt.Fprintln(os.Stderr, err) 36 | os.Exit(1) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import "github.com/uber-go/zap" 4 | 5 | //CM is a Checked Message like 6 | type CM interface { 7 | Write(fields ...zap.Field) 8 | OK() bool 9 | } 10 | 11 | //D is a debug logger 12 | func D(logger zap.Logger, message string, callback ...func(l CM)) { 13 | log(logger, zap.DebugLevel, message, callback...) 14 | } 15 | 16 | //I is a info logger 17 | func I(logger zap.Logger, message string, callback ...func(l CM)) { 18 | log(logger, zap.InfoLevel, message, callback...) 19 | } 20 | 21 | //W is a warn logger 22 | func W(logger zap.Logger, message string, callback ...func(l CM)) { 23 | log(logger, zap.WarnLevel, message, callback...) 24 | } 25 | 26 | //E is a error logger 27 | func E(logger zap.Logger, message string, callback ...func(l CM)) { 28 | log(logger, zap.ErrorLevel, message, callback...) 29 | } 30 | 31 | //P is a panic logger 32 | func P(logger zap.Logger, message string, callback ...func(l CM)) { 33 | log(logger, zap.PanicLevel, message, callback...) 34 | } 35 | 36 | //F is a fatal logger 37 | func F(logger zap.Logger, message string, callback ...func(l CM)) { 38 | log(logger, zap.FatalLevel, message, callback...) 39 | } 40 | 41 | func defaultWrite(l CM) { 42 | l.Write() 43 | } 44 | 45 | func log(logger zap.Logger, logLevel zap.Level, message string, callback ...func(l CM)) { 46 | cb := defaultWrite 47 | if len(callback) == 1 { 48 | cb = callback[0] 49 | } 50 | if cm := logger.Check(logLevel, message); cm.OK() { 51 | cb(cm) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/interface.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import "context" 4 | 5 | // KhanInterface defines the interface for the khan client 6 | type KhanInterface interface { 7 | ApplyForMembership(context.Context, *ApplicationPayload) (*ClanApplyResult, error) 8 | ApproveDenyMembershipApplication(context.Context, *ApplicationApprovalPayload) (*Result, error) 9 | ApproveDenyMembershipInvitation(context.Context, *InvitationApprovalPayload) (*Result, error) 10 | CreateClan(context.Context, *ClanPayload) (string, error) 11 | CreatePlayer(context.Context, string, string, interface{}) (string, error) 12 | DeleteMembership(context.Context, *DeleteMembershipPayload) (*Result, error) 13 | InviteForMembership(context.Context, *InvitationPayload) (*Result, error) 14 | LeaveClan(context.Context, string) (*LeaveClanResult, error) 15 | PromoteDemote(context.Context, *PromoteDemotePayload) (*Result, error) 16 | RetrieveClan(context.Context, string) (*Clan, error) 17 | RetrieveClansSummary(context.Context, []string) ([]*ClanSummary, error) 18 | RetrieveClanMembers(context.Context, string) (*ClanMembers, error) 19 | RetrieveClanSummary(context.Context, string) (*ClanSummary, error) 20 | RetrievePlayer(context.Context, string) (*Player, error) 21 | TransferOwnership(context.Context, string, string) (*TransferOwnershipResult, error) 22 | UpdateClan(context.Context, *ClanPayload) (*Result, error) 23 | UpdatePlayer(context.Context, string, string, interface{}) (*Result, error) 24 | SearchClans(context.Context, string) (*SearchClansResult, error) 25 | } 26 | -------------------------------------------------------------------------------- /get_latest_tag.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # khan 3 | # https://github.com/topfreegames/khan 4 | # Licensed under the MIT license: 5 | # http://www.opensource.org/licenses/mit-license 6 | # Copyright © 2016 Top Free Games 7 | 8 | import urllib 9 | import urllib2 10 | import json 11 | 12 | def main(): 13 | url = "https://auth.docker.io/token?service=registry.docker.io&scope=repository:tfgco/khan:pull,push" 14 | response = urllib.urlopen(url) 15 | token = json.loads(response.read())['token'] 16 | 17 | url = "https://registry-1.docker.io/v2/tfgco/khan/tags/list" 18 | req = urllib2.Request(url, None, { 19 | "Authorization": "Bearer %s" % token, 20 | }) 21 | response = urllib2.urlopen(req) 22 | tags = json.loads(response.read()) 23 | last_tag = get_last_tag(tags['tags']) 24 | print last_tag 25 | 26 | 27 | def get_tag_value(tag): 28 | if "latest" in tag: 29 | return 0 30 | 31 | while len(tag) < 4: 32 | tag.append('0') 33 | 34 | total_value = 0 35 | for index, tag_part in enumerate(tag): 36 | power = pow(100, len(tag) - index) 37 | try: 38 | total_value += int(tag_part) * power 39 | except: 40 | pass 41 | 42 | return total_value 43 | 44 | 45 | def get_last_tag(tags): 46 | return '.'.join( 47 | max([ 48 | (get_tag_value(tag), tag) for tag in 49 | [t.split('.') for t in tags] 50 | ], key=lambda i: i[0] 51 | )[1] 52 | ) 53 | 54 | 55 | if __name__ == "__main__": 56 | main() 57 | -------------------------------------------------------------------------------- /loadtest/app_test.go: -------------------------------------------------------------------------------- 1 | package loadtest 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | func getNewTestSampleSpace() []operation { 9 | var sampleSpace []operation 10 | sampleSpace = append(sampleSpace, operation{ 11 | probability: 0.2, 12 | }) 13 | sampleSpace = append(sampleSpace, operation{ 14 | probability: 0.3, 15 | }) 16 | return sampleSpace 17 | } 18 | 19 | var _ = Describe("Load Test Application", func() { 20 | Describe("getRandomOperationFromSampleSpace()", func() { 21 | It("Should return first operation", func() { 22 | sampleSpace := getNewTestSampleSpace() 23 | operation := getRandomOperationFromSampleSpace(sampleSpace, 0.15) 24 | Expect(operation.probability).To(Equal(sampleSpace[0].probability)) 25 | }) 26 | 27 | It("Should return second operation", func() { 28 | sampleSpace := getNewTestSampleSpace() 29 | operation := getRandomOperationFromSampleSpace(sampleSpace, 0.25) 30 | Expect(operation.probability).To(Equal(sampleSpace[1].probability)) 31 | }) 32 | 33 | It("Should return last operation", func() { 34 | sampleSpace := getNewTestSampleSpace() 35 | operation := getRandomOperationFromSampleSpace(sampleSpace, 0.5) 36 | Expect(operation.probability).To(Equal(sampleSpace[1].probability)) 37 | }) 38 | 39 | It("Should return the first operation because dice is larger than one", func() { 40 | sampleSpace := getNewTestSampleSpace() 41 | operation := getRandomOperationFromSampleSpace(sampleSpace, 1.1) 42 | Expect(operation.probability).To(Equal(sampleSpace[0].probability)) 43 | }) 44 | }) 45 | }) 46 | -------------------------------------------------------------------------------- /push_to_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | VERSION=$(cat ./util/version.go | grep "var VERSION" | awk ' { print $4 } ' | sed s/\"//g) 4 | 5 | cp ./config/default.yaml ./dev 6 | 7 | docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD" 8 | 9 | docker build -t khan . 10 | docker tag khan:latest tfgco/khan:$VERSION.$TRAVIS_BUILD_NUMBER 11 | docker tag khan:latest tfgco/khan:$VERSION 12 | docker tag khan:latest tfgco/khan:latest 13 | docker push tfgco/khan:$VERSION.$TRAVIS_BUILD_NUMBER 14 | docker push tfgco/khan:$VERSION 15 | docker push tfgco/khan:latest 16 | 17 | docker build -t khan-dev ./dev 18 | docker tag khan-dev:latest tfgco/khan-dev:$VERSION.$TRAVIS_BUILD_NUMBER 19 | docker tag khan-dev:latest tfgco/khan-dev:$VERSION 20 | docker tag khan-dev:latest tfgco/khan-dev:latest 21 | docker push tfgco/khan-dev:$VERSION.$TRAVIS_BUILD_NUMBER 22 | docker push tfgco/khan-dev:$VERSION 23 | docker push tfgco/khan-dev:latest 24 | 25 | docker build -t khan-prune -f PruneDockerfile . 26 | docker tag khan-prune:latest tfgco/khan-prune:$VERSION.$TRAVIS_BUILD_NUMBER 27 | docker tag khan-prune:latest tfgco/khan-prune:$VERSION 28 | docker tag khan-prune:latest tfgco/khan-prune:latest 29 | docker push tfgco/khan-prune:$VERSION.$TRAVIS_BUILD_NUMBER 30 | docker push tfgco/khan-prune:$VERSION 31 | docker push tfgco/khan-prune:latest 32 | 33 | 34 | DOCKERHUB_LATEST=$(python get_latest_tag.py) 35 | 36 | if [ "$DOCKERHUB_LATEST" != "$VERSION.$TRAVIS_BUILD_NUMBER" ]; then 37 | echo "Last version is not in docker hub!" 38 | echo "docker hub: $DOCKERHUB_LATEST, expected: $VERSION.$TRAVIS_BUILD_NUMBER" 39 | exit 1 40 | fi 41 | -------------------------------------------------------------------------------- /loadtest/helpers.go: -------------------------------------------------------------------------------- 1 | package loadtest 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "math/rand" 7 | "os" 8 | "strings" 9 | 10 | uuid "github.com/satori/go.uuid" 11 | ) 12 | 13 | var dictionary []string 14 | 15 | // LoadRandomWords loads random words into memory for random clan name generation 16 | func LoadRandomWords() { 17 | if dictionary != nil { 18 | return 19 | } 20 | file, err := os.Open("/usr/share/dict/words") 21 | if err != nil { 22 | panic(err) 23 | } 24 | bytes, err := ioutil.ReadAll(file) 25 | if err != nil { 26 | panic(err) 27 | } 28 | dictionary = strings.Split(string(bytes), "\n") 29 | } 30 | 31 | func getRandomScore() int { 32 | return rand.Intn(1000) 33 | } 34 | 35 | func getRandomPlayerName() string { 36 | return fmt.Sprintf("PlayerName-%s", uuid.NewV4().String()[:8]) 37 | } 38 | 39 | func getRandomClanName() string { 40 | if dictionary == nil { 41 | return fmt.Sprintf("ClanName-%s", uuid.NewV4().String()[:8]) 42 | } 43 | numberOfWords := rand.Intn(3) + 1 44 | pieces := []string{} 45 | for i := 0; i < numberOfWords; i++ { 46 | pieces = append(pieces, dictionary[rand.Intn(len(dictionary))]) 47 | } 48 | return strings.Join(pieces, " ") 49 | } 50 | 51 | func getRandomPublicID() string { 52 | return uuid.NewV4().String() 53 | } 54 | 55 | func getScoreFromMetadata(metadata interface{}) int { 56 | if metadata != nil { 57 | metadataMap := metadata.(map[string]interface{}) 58 | if score, ok := metadataMap["score"]; ok { 59 | return int(score.(float64)) 60 | } 61 | } 62 | return 0 63 | } 64 | 65 | func getMetadataWithRandomScore() map[string]interface{} { 66 | return map[string]interface{}{ 67 | "score": getRandomScore(), 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /testing/helpers.go: -------------------------------------------------------------------------------- 1 | package testing 2 | 3 | import ( 4 | "time" 5 | 6 | . "github.com/onsi/gomega" 7 | "github.com/topfreegames/khan/caches" 8 | "github.com/topfreegames/khan/util" 9 | 10 | gocache "github.com/patrickmn/go-cache" 11 | "github.com/topfreegames/khan/models" 12 | ) 13 | 14 | var testDB models.DB 15 | 16 | // GetTestDB returns a connection to the test database. 17 | func GetTestDB() (models.DB, error) { 18 | if testDB != nil { 19 | return testDB, nil 20 | } 21 | db, err := models.GetDB( 22 | "localhost", // host 23 | "khan_test", // user 24 | 5433, // port 25 | "disable", // sslMode 26 | "khan_test", // dbName 27 | "", // password 28 | ) 29 | 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | testDB = db 35 | 36 | return testDB, nil 37 | } 38 | 39 | // GetTestClansSummariesCache returns a test cache for clans summaries. 40 | func GetTestClansSummariesCache(ttl, cleanupInterval time.Duration) *caches.ClansSummaries { 41 | return &caches.ClansSummaries{ 42 | Cache: gocache.New(ttl, cleanupInterval), 43 | } 44 | } 45 | 46 | // DecryptTestPlayer replaces the encrypted name by the plain text name in the player object 47 | func DecryptTestPlayer(encryptionKey []byte, player *models.Player) { 48 | name, err := util.DecryptData(player.Name, encryptionKey) 49 | Expect(err).NotTo(HaveOccurred()) 50 | player.Name = name 51 | } 52 | 53 | //UpdateEncryptingTestPlayer encrypt player name and save it to database 54 | func UpdateEncryptingTestPlayer(db models.DB, encryptionKey []byte, player *models.Player) { 55 | name, err := util.EncryptData(player.Name, encryptionKey) 56 | Expect(err).NotTo(HaveOccurred()) 57 | player.Name = name 58 | rows, err := db.Update(player) 59 | Expect(err).NotTo(HaveOccurred()) 60 | Expect(rows).To(BeEquivalentTo(1)) 61 | 62 | } 63 | -------------------------------------------------------------------------------- /cmd/worker.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd 9 | 10 | import ( 11 | "github.com/spf13/cobra" 12 | "github.com/topfreegames/khan/api" 13 | "github.com/topfreegames/khan/log" 14 | "github.com/uber-go/zap" 15 | ) 16 | 17 | var workerDebug bool 18 | var workerQuiet bool 19 | 20 | // workerCmd represents the start command 21 | var workerCmd = &cobra.Command{ 22 | Use: "worker", 23 | Short: "starts the khan hook dispatching worker", 24 | Long: `Starts khan hook dispatching worker with the specified arguments. You can use 25 | environment variables to override configuration keys.`, 26 | Run: func(cmd *cobra.Command, args []string) { 27 | ll := zap.InfoLevel 28 | if debug { 29 | ll = zap.DebugLevel 30 | } 31 | if quiet { 32 | ll = zap.ErrorLevel 33 | } 34 | l := zap.New( 35 | zap.NewJSONEncoder(), // drop timestamps in tests 36 | ll, 37 | ) 38 | 39 | cmdL := l.With( 40 | zap.String("source", "workerCmd"), 41 | zap.String("operation", "Run"), 42 | zap.String("host", host), 43 | zap.Int("port", port), 44 | zap.Bool("debug", debug), 45 | ) 46 | 47 | log.D(cmdL, "Creating application...") 48 | app := api.GetApp( 49 | host, 50 | port, 51 | ConfigFile, 52 | debug, 53 | l, 54 | false, 55 | false, 56 | ) 57 | log.D(cmdL, "Application created successfully.") 58 | 59 | log.D(cmdL, "Starting dispatcher...") 60 | app.StartWorkers() 61 | }, 62 | } 63 | 64 | func init() { 65 | RootCmd.AddCommand(workerCmd) 66 | 67 | workerCmd.Flags().BoolVarP(&workerDebug, "debug", "d", false, "Debug mode") 68 | workerCmd.Flags().BoolVarP(&workerQuiet, "quiet", "q", false, "Quiet mode (log level error)") 69 | } 70 | -------------------------------------------------------------------------------- /config/local.yaml: -------------------------------------------------------------------------------- 1 | postgres: 2 | user: "postgres" 3 | dbName: "khan" 4 | password: "123456" 5 | host: "localhost" 6 | port: 5433 7 | sslMode: "disable" 8 | 9 | elasticsearch: 10 | enabled: true 11 | host: "localhost" 12 | port: 9200 13 | sniff: false 14 | index: "khan" 15 | 16 | mongodb: 17 | enabled: true 18 | url: mongodb://localhost:27017 19 | databaseName: "khan" 20 | collectionTemplate: "clans_%s" 21 | 22 | search: 23 | pageSize: 50 24 | 25 | webhooks: 26 | timeout: 500 27 | workers: 5 28 | statsPort: 9999 29 | runStats: true 30 | logToBuf: false 31 | 32 | healthcheck: 33 | workingText: "WORKING" 34 | 35 | jaeger: 36 | disabled: false 37 | samplingProbability: 1.0 38 | serviceName: "khan" 39 | 40 | redis: 41 | host: 0.0.0.0 42 | port: 6379 43 | database: 0 44 | pool: 30 45 | password: "" 46 | 47 | extensions: 48 | dogstatsd: 49 | host: localhost:9125 50 | prefix: khan. 51 | tags_prefix: "" 52 | rate: 1 53 | 54 | loadtest: 55 | game: 56 | membershipLevel: "member" 57 | maxMembers: 50 58 | client: 59 | url: "http://localhost:8080" 60 | gameid: "epiccardgame" 61 | operations: 62 | amount: 1 63 | interval: 64 | duration: "1s" 65 | updateSharedClanScore: 66 | probability: 1 67 | createPlayer: 68 | probability: 1 69 | createClan: 70 | probability: 1 71 | autoJoin: "false" 72 | retrieveClan: 73 | probability: 1 74 | leaveClan: 75 | probability: 1 76 | transferClanOwnership: 77 | probability: 1 78 | applyForMembership: 79 | probability: 1 80 | selfDeleteMembership: 81 | probability: 1 82 | searchClans: 83 | probability: 1 84 | retrieveClansSummaries: 85 | probability: 1 86 | 87 | caches: 88 | getGame: 89 | ttl: 1m 90 | cleanupInterval: 1m 91 | clansSummaries: 92 | ttl: 1m 93 | cleanupInterval: 1m 94 | -------------------------------------------------------------------------------- /cmd/encryption_script.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd 9 | 10 | import ( 11 | "github.com/spf13/cobra" 12 | "github.com/topfreegames/khan/log" 13 | "github.com/topfreegames/khan/services" 14 | "github.com/uber-go/zap" 15 | ) 16 | 17 | var encryptionScriptDebug bool 18 | var encryptionScriptQuiet bool 19 | 20 | // encryptionScriptCmd represents the encryption script 21 | var encryptionScriptCmd = &cobra.Command{ 22 | Use: "encryption-script", 23 | Short: "start the khan encryption-scription script", 24 | Long: `Starts khan encryption-scription script that encrypt player names. 25 | You can use environment variables to override configuration keys.`, 26 | Run: func(cmd *cobra.Command, args []string) { 27 | ll := zap.InfoLevel 28 | if encryptionScriptDebug { 29 | ll = zap.DebugLevel 30 | } 31 | if encryptionScriptQuiet { 32 | ll = zap.ErrorLevel 33 | } 34 | logger := zap.New( 35 | zap.NewJSONEncoder(), // drop timestamps in tests 36 | ll, 37 | ) 38 | 39 | cmdL := logger.With( 40 | zap.String("source", "encryptionScriptCmd"), 41 | zap.String("operation", "Run"), 42 | zap.Bool("debug", encryptionScriptDebug), 43 | ) 44 | 45 | log.D(cmdL, "Creating application...") 46 | script := services.GetEncryptionScript( 47 | ConfigFile, 48 | encryptionScriptDebug, 49 | logger, 50 | ) 51 | log.D(cmdL, "Application created successfully.") 52 | 53 | log.D(cmdL, "Starting script...") 54 | script.Start() 55 | }, 56 | } 57 | 58 | func init() { 59 | RootCmd.AddCommand(encryptionScriptCmd) 60 | 61 | encryptionScriptCmd.Flags().BoolVarP(&encryptionScriptDebug, "debug", "d", false, "Debug mode") 62 | encryptionScriptCmd.Flags().BoolVarP(&encryptionScriptQuiet, "quiet", "q", false, "Quiet mode (log level error)") 63 | } 64 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd 9 | 10 | import ( 11 | "fmt" 12 | "os" 13 | "strings" 14 | 15 | "github.com/spf13/cobra" 16 | "github.com/spf13/viper" 17 | ) 18 | 19 | // ConfigFile is the configuration file used for running a command 20 | var ConfigFile string 21 | 22 | // Verbose determines how verbose khan will run under 23 | var Verbose int 24 | 25 | // RootCmd is the root command for khan CLI application 26 | var RootCmd = &cobra.Command{ 27 | Use: "khan", 28 | Short: "khan handles clans", 29 | Long: `Use khan to handle clans for your game.`, 30 | } 31 | 32 | // Execute runs RootCmd to initialize khan CLI application 33 | func Execute(cmd *cobra.Command) { 34 | if err := cmd.Execute(); err != nil { 35 | fmt.Println(err) 36 | os.Exit(-1) 37 | } 38 | } 39 | 40 | func init() { 41 | // cobra.OnInitialize(initConfig) 42 | RootCmd.PersistentFlags().IntVarP( 43 | &Verbose, "verbose", "v", 0, 44 | "Verbosity level => v0: Error, v1=Warning, v2=Info, v3=Debug", 45 | ) 46 | 47 | RootCmd.PersistentFlags().StringVarP( 48 | &ConfigFile, "config", "c", "./config/local.yaml", 49 | "config file", 50 | ) 51 | } 52 | 53 | // InitConfig reads in config file and ENV variables if set. 54 | func InitConfig() { 55 | if ConfigFile != "" { // enable ability to specify config file via flag 56 | viper.SetConfigFile(ConfigFile) 57 | } 58 | viper.SetConfigType("yaml") 59 | viper.SetEnvPrefix("khan") 60 | viper.AddConfigPath(".") 61 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 62 | viper.AutomaticEnv() 63 | 64 | // If a config file is found, read it in. 65 | if err := viper.ReadInConfig(); err != nil { 66 | fmt.Printf("Config file %s failed to load: %s.\n", ConfigFile, err.Error()) 67 | panic("Failed to load config file") 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /dev/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Original: https://github.com/kiasaki/docker-alpine-postgres 3 | chown -R postgres "$PGDATA" 4 | 5 | if [ -z "$(ls -A "$PGDATA")" ]; then 6 | gosu postgres initdb 7 | sed -ri "s/^#(listen_addresses\s*=\s*)\S+/\1'*'/" "$PGDATA"/postgresql.conf 8 | 9 | : ${POSTGRES_USER:="postgres"} 10 | : ${POSTGRES_DB:=$POSTGRES_USER} 11 | 12 | if [ "$POSTGRES_PASSWORD" ]; then 13 | pass="PASSWORD '$POSTGRES_PASSWORD'" 14 | authMethod=md5 15 | else 16 | echo "===============================" 17 | echo "!!! Use \$POSTGRES_PASSWORD env var to secure your database !!!" 18 | echo "===============================" 19 | pass= 20 | authMethod=trust 21 | fi 22 | echo 23 | 24 | 25 | if [ "$POSTGRES_DB" != 'postgres' ]; then 26 | createSql="CREATE DATABASE $POSTGRES_DB;" 27 | echo $createSql | gosu postgres postgres --single -jE 28 | echo 29 | fi 30 | 31 | if [ "$POSTGRES_USER" != 'postgres' ]; then 32 | op=CREATE 33 | else 34 | op=ALTER 35 | fi 36 | 37 | userSql="$op USER $POSTGRES_USER WITH SUPERUSER $pass;" 38 | echo $userSql | gosu postgres postgres --single -jE 39 | echo 40 | 41 | # internal start of server in order to allow set-up using psql-client 42 | # does not listen on TCP/IP and waits until start finishes 43 | gosu postgres pg_ctl -D "$PGDATA" \ 44 | -o "-c listen_addresses=''" \ 45 | -w start 46 | 47 | echo 48 | for f in /docker-entrypoint-initdb.d/*; do 49 | case "$f" in 50 | *.sh) echo "$0: running $f"; . "$f" ;; 51 | *.sql) echo "$0: running $f"; psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < "$f" && echo ;; 52 | *) echo "$0: ignoring $f" ;; 53 | esac 54 | echo 55 | done 56 | 57 | gosu postgres pg_ctl -D "$PGDATA" -m fast -w stop 58 | 59 | { echo; echo "host all all 0.0.0.0/0 $authMethod"; } >> "$PGDATA"/pg_hba.conf 60 | fi 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [5.3.5] 2 | ### Changed 3 | - Fix `ApprovedAt` field update. 4 | 5 | ## [5.3.4] 6 | ### Changed 7 | - Fix and improve logs in membership handlers 8 | 9 | ## [5.3.3] 10 | ### Changed 11 | - Add weights in mongo text index 12 | 13 | ## [5.3.2] 14 | ### Changed 15 | - Update `jaeger-client-go` to version `v2.29.1` 16 | 17 | ## [5.3.1] 18 | ### Changed 19 | - Remove encryption entry when don't have encryption key 20 | 21 | ## [5.3.0] 22 | ### Changed 23 | - Update `jaeger` to use official client 24 | - Change `Makefile` to use `go mod download` instead `go mod tidy` 25 | 26 | ### Removed 27 | - Encryption obligation 28 | 29 | ## [5.2.2] 30 | ### Changed 31 | - Update go.mod 32 | 33 | ### Removed 34 | - Profiling routes 35 | 36 | ## [5.1.1] 37 | ### Changed 38 | - Fix encrypted players id type (#74) 39 | - Explicit database creation (#67) 40 | - Add docker-compose for khan (#72) 41 | 42 | ## [5.1.0] 43 | ### Added 44 | - Create script to encrypt player name 45 | 46 | ## [5.0.1] 47 | ### Added 48 | - Transactions on write player routes 49 | 50 | ## [5.0.0] 51 | ### Added 52 | - Encryption to write player models functions `CreatePlayer` and `UpdatePlayer` 53 | - Create `encrypted_players` table to trace the encryption proccess and support the future `encryption_script` 54 | 55 | ## [4.4.0] 56 | ### Added 57 | - Go modules and `go.mod`, `go.sum` 58 | - `util.security.go` 59 | - Github actions workflow 60 | - `EncryptionKey` to `api.App` 61 | - Encryption to models function `Serialize` of `clanDetailsDAO` 62 | - Encryption to models function `Serialize` of `PlayerDetailsDAO` 63 | - Encryptiion to models functions `GetPlayerByID`, `GetPlayerByPublicID`, `GetPlayerDetails` and `GetClanDetails` 64 | 65 | ## Changed 66 | - Update docker image base image to `1.15.2-alpine` 67 | - Apply go modules changes on Makefile 68 | - Centralize on `models/player.go` file the player serialization in favor of easiness encryption implementation 69 | 70 | ## Removed 71 | - `Gopkg.toml` and `Gopkg.lock` 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /models/prune_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package models_test 9 | 10 | import ( 11 | "time" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | . "github.com/topfreegames/khan/models" 16 | "github.com/topfreegames/khan/models/fixtures" 17 | "github.com/uber-go/zap" 18 | ) 19 | 20 | var _ = Describe("Prune Stale Data Model", func() { 21 | var testDb DB 22 | var logger zap.Logger 23 | 24 | BeforeEach(func() { 25 | var err error 26 | testDb, err = GetTestDB() 27 | Expect(err).NotTo(HaveOccurred()) 28 | 29 | logger = zap.New( 30 | zap.NewJSONEncoder(), // drop timestamps in tests 31 | zap.FatalLevel, 32 | ) 33 | }) 34 | 35 | Describe("Prune Stale Data Model", func() { 36 | Describe("Pruning Stale data", func() { 37 | It("Should remove pending applications", func() { 38 | gameID, err := fixtures.GetTestClanWithStaleData(testDb, 5, 6, 7, 8) 39 | Expect(err).NotTo(HaveOccurred()) 40 | 41 | expiration := int((2 * time.Hour).Seconds()) 42 | options := &PruneOptions{ 43 | GameID: gameID, 44 | PendingApplicationsExpiration: expiration, 45 | PendingInvitesExpiration: expiration, 46 | DeniedMembershipsExpiration: expiration, 47 | DeletedMembershipsExpiration: expiration, 48 | } 49 | pruneStats, err := PruneStaleData(options, testDb, logger) 50 | Expect(err).NotTo(HaveOccurred()) 51 | Expect(pruneStats).NotTo(BeNil()) 52 | Expect(pruneStats.PendingApplicationsPruned).To(Equal(5)) 53 | Expect(pruneStats.PendingInvitesPruned).To(Equal(6)) 54 | Expect(pruneStats.DeniedMembershipsPruned).To(Equal(7)) 55 | Expect(pruneStats.DeletedMembershipsPruned).To(Equal(8)) 56 | 57 | count, err := testDb.SelectInt(`SELECT COUNT(*) FROM memberships WHERE game_id=$1`, gameID) 58 | Expect(err).NotTo(HaveOccurred()) 59 | Expect(int(count)).To(Equal(52)) 60 | }) 61 | }) 62 | }) 63 | }) 64 | -------------------------------------------------------------------------------- /cmd/loadtest.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/topfreegames/khan/loadtest" 6 | "github.com/topfreegames/khan/log" 7 | "github.com/uber-go/zap" 8 | ) 9 | 10 | var sharedClansFile string 11 | var nGoroutines int 12 | 13 | var loadtestCmd = &cobra.Command{ 14 | Use: "loadtest", 15 | Short: "runs a load test against a remote Khan API", 16 | Long: `Runs a load test against a remote Khan API with the specified arguments. 17 | You can use environment variables to override configuration keys.`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | loadtest.LoadRandomWords() 20 | logger := zap.New(zap.NewJSONEncoder(), zap.InfoLevel) 21 | 22 | exitChannel := make(chan bool) 23 | routine := func() { 24 | logger := logger.With( 25 | zap.String("source", "cmd/loadtest.go"), 26 | zap.String("operation", "loadtestCmd.Run/goroutine"), 27 | ) 28 | 29 | app := loadtest.GetApp(ConfigFile, sharedClansFile, logger) 30 | if err := app.Run(); err != nil { 31 | log.E(logger, "Goroutine exited with error. Restarting...", func(cm log.CM) { 32 | cm.Write(zap.String("error", err.Error())) 33 | }) 34 | exitChannel <- false 35 | } else { 36 | log.I(logger, "Goroutine exited without errors.") 37 | exitChannel <- true 38 | } 39 | } 40 | for i := 0; i < nGoroutines; i++ { 41 | go routine() 42 | } 43 | for i := 0; i < nGoroutines; { 44 | if ok := <-exitChannel; ok { 45 | i++ 46 | } else { 47 | go routine() 48 | } 49 | } 50 | 51 | logger = logger.With( 52 | zap.String("source", "cmd/loadtest.go"), 53 | zap.String("operation", "loadtestCmd.Run"), 54 | ) 55 | log.I(logger, "Application exited.") 56 | }, 57 | } 58 | 59 | func init() { 60 | RootCmd.AddCommand(loadtestCmd) 61 | 62 | loadtestCmd.Flags().StringVar( 63 | &sharedClansFile, 64 | "clans", 65 | "./config/loadTestSharedClans.yaml", 66 | "shared clans list for load test", 67 | ) 68 | 69 | loadtestCmd.Flags().IntVar( 70 | &nGoroutines, 71 | "goroutines", 72 | 1, 73 | "number of goroutines to spawn for concurrent load tests", 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /cmd/start.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd 9 | 10 | import ( 11 | "github.com/spf13/cobra" 12 | "github.com/topfreegames/khan/api" 13 | "github.com/topfreegames/khan/log" 14 | "github.com/uber-go/zap" 15 | ) 16 | 17 | var host string 18 | var port int 19 | var debug bool 20 | var quiet bool 21 | var fast bool 22 | 23 | // startCmd represents the start command 24 | var startCmd = &cobra.Command{ 25 | Use: "start", 26 | Short: "starts the khan API server", 27 | Long: `Starts khan server with the specified arguments. You can use 28 | environment variables to override configuration keys.`, 29 | Run: func(cmd *cobra.Command, args []string) { 30 | ll := zap.InfoLevel 31 | if debug { 32 | ll = zap.DebugLevel 33 | } 34 | if quiet { 35 | ll = zap.ErrorLevel 36 | } 37 | l := zap.New( 38 | zap.NewJSONEncoder(), // drop timestamps in tests 39 | ll, 40 | ) 41 | 42 | cmdL := l.With( 43 | zap.String("source", "startCmd"), 44 | zap.String("operation", "Run"), 45 | zap.String("host", host), 46 | zap.Int("port", port), 47 | zap.Bool("debug", debug), 48 | ) 49 | 50 | log.D(cmdL, "Creating application...") 51 | app := api.GetApp( 52 | host, 53 | port, 54 | ConfigFile, 55 | debug, 56 | l, 57 | fast, 58 | false, 59 | ) 60 | log.D(cmdL, "Application created successfully.") 61 | 62 | log.D(cmdL, "Starting application...") 63 | app.Start() 64 | }, 65 | } 66 | 67 | func init() { 68 | RootCmd.AddCommand(startCmd) 69 | 70 | startCmd.Flags().StringVarP(&host, "bind", "b", "0.0.0.0", "Host to bind khan to") 71 | startCmd.Flags().IntVarP(&port, "port", "p", 8888, "Port to bind khan to") 72 | startCmd.Flags().BoolVarP(&debug, "debug", "d", false, "Debug mode") 73 | startCmd.Flags().BoolVarP(&quiet, "quiet", "q", false, "Quiet mode (log level error)") 74 | startCmd.Flags().BoolVarP(&fast, "fast", "f", false, "Use FastHTTP as the Engine. If false, the net/http engine will be used") 75 | } 76 | -------------------------------------------------------------------------------- /docs/benchmark.md: -------------------------------------------------------------------------------- 1 | Khan's Benchmarks 2 | ================= 3 | 4 | You can see khan's benchmarks in our [CI server](https://travis-ci.org/topfreegames/khan/) as they get run with every build. 5 | 6 | ## Creating the performance database 7 | 8 | To create a new database for running your benchmarks, just run: 9 | 10 | ``` 11 | $ make drop-perf migrate-perf 12 | ``` 13 | 14 | ## Running Benchmarks 15 | 16 | If you want to run your own benchmarks, just download the project, and run: 17 | 18 | ``` 19 | $ make run-test-khan run-perf 20 | ``` 21 | 22 | ## Generating test data 23 | 24 | If you want to run your perf tests against a database with more volume of data, just run this command prior to running the above one: 25 | 26 | ``` 27 | $ make drop-perf migrate-perf db-perf 28 | ``` 29 | 30 | **Warning**: This will take a long time running (around 30m). 31 | 32 | ## Results 33 | 34 | The results should be similar to these: 35 | 36 | ``` 37 | BenchmarkCreateClan-2 2000 3053999 ns/op 38 | BenchmarkUpdateClan-2 2000 2000650 ns/op 39 | BenchmarkRetrieveClan-2 500 10522248 ns/op 40 | BenchmarkRetrieveClanSummary-2 5000 1187486 ns/op 41 | BenchmarkSearchClan-2 5000 1205325 ns/op 42 | BenchmarkListClans-2 5000 1135555 ns/op 43 | BenchmarkLeaveClan-2 1000 3824284 ns/op 44 | BenchmarkTransferOwnership-2 500 8642818 ns/op 45 | BenchmarkCreateGame-2 3000 1248042 ns/op 46 | BenchmarkUpdateGame-2 2000 2141705 ns/op 47 | BenchmarkApplyForMembership-2 1000 5695344 ns/op 48 | BenchmarkInviteForMembership-2 500 8916792 ns/op 49 | BenchmarkApproveMembershipApplication-2 500 13480574 ns/op 50 | BenchmarkApproveMembershipInvitation-2 1000 10517905 ns/op 51 | BenchmarkDeleteMembership-2 500 9548314 ns/op 52 | BenchmarkPromoteMembership-2 500 8961424 ns/op 53 | BenchmarkDemoteMembership-2 500 9202060 ns/op 54 | BenchmarkCreatePlayer-2 3000 1344267 ns/op 55 | BenchmarkUpdatePlayer-2 3000 1829329 ns/op 56 | BenchmarkRetrievePlayer-2 300 14412830 ns/op 57 | ``` 58 | -------------------------------------------------------------------------------- /api/player_helpers.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | 10 | import ( 11 | "strings" 12 | 13 | "github.com/topfreegames/khan/log" 14 | "github.com/topfreegames/khan/models" 15 | "github.com/uber-go/zap" 16 | ) 17 | 18 | func validateUpdatePlayerDispatch(game *models.Game, sourcePlayer *models.Player, player *models.Player, metadata map[string]interface{}, logger zap.Logger) bool { 19 | cl := logger.With( 20 | zap.String("playerUpdateMetadataFieldsHookTriggerWhitelist", game.PlayerUpdateMetadataFieldsHookTriggerWhitelist), 21 | ) 22 | 23 | if sourcePlayer == nil { 24 | log.D(cl, "Player did not exist before. Dispatching event...") 25 | return true 26 | } 27 | 28 | changedName := player.Name != sourcePlayer.Name 29 | if changedName { 30 | log.D(cl, "Player name changed") 31 | return true 32 | } 33 | 34 | if game.PlayerUpdateMetadataFieldsHookTriggerWhitelist == "" { 35 | log.D(cl, "Player has no metadata whitelist for update hook") 36 | return false 37 | } 38 | 39 | log.D(cl, "Verifying fields for player update hook dispatch...") 40 | fields := strings.Split(game.PlayerUpdateMetadataFieldsHookTriggerWhitelist, ",") 41 | for _, field := range fields { 42 | oldVal, existsOld := sourcePlayer.Metadata[field] 43 | newVal, existsNew := metadata[field] 44 | log.D(logger, "Verifying field for change...", func(cm log.CM) { 45 | cm.Write( 46 | zap.Bool("existsOld", existsOld), 47 | zap.Bool("existsNew", existsNew), 48 | zap.Object("oldVal", oldVal), 49 | zap.Object("newVal", newVal), 50 | zap.String("field", field), 51 | ) 52 | }) 53 | //fmt.Println("field", field, "existsOld", existsOld, "oldVal", oldVal, "existsNew", existsNew, "newVal", newVal) 54 | 55 | if existsOld != existsNew { 56 | log.D(logger, "Found difference in field. Dispatching hook...", func(cm log.CM) { 57 | cm.Write(zap.String("field", field)) 58 | }) 59 | return true 60 | } 61 | 62 | if existsOld && oldVal != newVal { 63 | log.D(logger, "Found difference in field. Dispatching hook...", func(cm log.CM) { 64 | cm.Write(zap.String("field", field)) 65 | }) 66 | return true 67 | } 68 | } 69 | 70 | return false 71 | } 72 | -------------------------------------------------------------------------------- /bench/game_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package bench 9 | 10 | import ( 11 | "fmt" 12 | "net/http" 13 | "testing" 14 | 15 | "github.com/Pallinder/go-randomdata" 16 | uuid "github.com/satori/go.uuid" 17 | "github.com/topfreegames/khan/models" 18 | "github.com/topfreegames/khan/models/fixtures" 19 | ) 20 | 21 | var gameResult *http.Response 22 | 23 | func getGamePayload(publicID, name string) map[string]interface{} { 24 | if publicID == "" { 25 | publicID = randomdata.FullName(randomdata.RandomGender) 26 | } 27 | if name == "" { 28 | name = randomdata.FullName(randomdata.RandomGender) 29 | } 30 | return map[string]interface{}{ 31 | "publicID": publicID, 32 | "name": name, 33 | "membershipLevels": map[string]interface{}{"Member": 1, "Elder": 2, "CoLeader": 3}, 34 | "metadata": map[string]interface{}{"x": "a"}, 35 | "minLevelToAcceptApplication": 1, 36 | "minLevelToCreateInvitation": 1, 37 | "minLevelToRemoveMember": 1, 38 | "minLevelOffsetToRemoveMember": 1, 39 | "minLevelOffsetToPromoteMember": 1, 40 | "minLevelOffsetToDemoteMember": 1, 41 | "maxMembers": 100, 42 | "maxClansPerPlayer": 1, 43 | "cooldownAfterDeny": 30, 44 | "cooldownAfterDelete": 30, 45 | } 46 | } 47 | 48 | func BenchmarkCreateGame(b *testing.B) { 49 | b.ResetTimer() 50 | 51 | for i := 0; i < b.N; i++ { 52 | gameID := uuid.NewV4().String() 53 | 54 | route := getRoute("/games") 55 | res, err := postTo(route, getGamePayload(gameID, gameID)) 56 | validateResp(res, err) 57 | res.Body.Close() 58 | 59 | gameResult = res 60 | } 61 | } 62 | 63 | func BenchmarkUpdateGame(b *testing.B) { 64 | db, err := models.GetPerfDB() 65 | if err != nil { 66 | panic(err.Error()) 67 | } 68 | 69 | var games []*models.Game 70 | for i := 0; i < b.N; i++ { 71 | game := fixtures.GameFactory.MustCreateWithOption(map[string]interface{}{ 72 | "PublicID": uuid.NewV4().String(), 73 | "MaxClansPerPlayer": 999999, 74 | }).(*models.Game) 75 | err := db.Insert(game) 76 | if err != nil { 77 | panic(err.Error()) 78 | } 79 | games = append(games, game) 80 | } 81 | 82 | b.ResetTimer() 83 | 84 | for i := 0; i < b.N; i++ { 85 | route := getRoute(fmt.Sprintf("/games/%s", games[i].PublicID)) 86 | res, err := putTo(route, getGamePayload(games[i].PublicID, games[i].Name)) 87 | validateResp(res, err) 88 | res.Body.Close() 89 | 90 | result = res 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /caches/clan.go: -------------------------------------------------------------------------------- 1 | package caches 2 | 3 | import ( 4 | "fmt" 5 | 6 | gocache "github.com/patrickmn/go-cache" 7 | "github.com/topfreegames/khan/models" 8 | ) 9 | 10 | // ClansSummaries represents a cache for the RetrieveClansSummaries operation. 11 | type ClansSummaries struct { 12 | // Cache points to an instance of gocache.Cache used as the backend cache object. 13 | Cache *gocache.Cache 14 | } 15 | 16 | // GetClansSummaries is a cache in front of models.GetClansSummaries() with the exact same interface. 17 | // Like models.GetClansSummaries(), this function may return partial results + CouldNotFindAllClansError. 18 | // The map[string]interface{} return type represents a summary of one clan with the following keys/values: 19 | // "membershipCount": int 20 | // "publicID": string 21 | // "metadata": map[string]interface{} (user-defined arbitrary JSON object with clan metadata) 22 | // "name": string 23 | // "allowApplication": bool 24 | // "autoJoin": bool 25 | // TODO(matheuscscp): replace this map with a richer type 26 | func (c *ClansSummaries) GetClansSummaries(db models.DB, gameID string, publicIDs []string) ([]map[string]interface{}, error) { 27 | // first, assemble a result map with cached payloads. also assemble a missingPublicIDs string slice 28 | idToPayload := make(map[string]map[string]interface{}) 29 | var missingPublicIDs []string 30 | for _, publicID := range publicIDs { 31 | if clanPayload, present := c.Cache.Get(c.getClanSummaryCacheKey(gameID, publicID)); present { 32 | idToPayload[publicID] = clanPayload.(map[string]interface{}) 33 | } else { 34 | missingPublicIDs = append(missingPublicIDs, publicID) 35 | } 36 | } 37 | 38 | // fetch and cache missing clans 39 | var err error 40 | if len(missingPublicIDs) > 0 { 41 | // fetch 42 | var clans []map[string]interface{} 43 | clans, err = models.GetClansSummaries(db, gameID, missingPublicIDs) 44 | if err != nil { 45 | if _, ok := err.(*models.CouldNotFindAllClansError); !ok { 46 | return nil, err 47 | } 48 | } 49 | 50 | // cache 51 | for _, clanPayload := range clans { 52 | publicID := clanPayload["publicID"].(string) 53 | idToPayload[publicID] = clanPayload 54 | c.Cache.Set(c.getClanSummaryCacheKey(gameID, publicID), clanPayload, gocache.DefaultExpiration) 55 | } 56 | } 57 | 58 | // assemble final result with input order 59 | var result []map[string]interface{} 60 | for _, publicID := range publicIDs { 61 | if summary, ok := idToPayload[publicID]; ok { 62 | result = append(result, summary) 63 | } 64 | } 65 | return result, err 66 | } 67 | 68 | func (c *ClansSummaries) getClanSummaryCacheKey(gameID, publicID string) string { 69 | return fmt.Sprintf("%s/%s", gameID, publicID) 70 | } 71 | -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | Overview 2 | ======== 3 | 4 | What is Khan? Khan is an HTTP "resty" API for managing clans for games. It could be used to manage groups of people, but our aim is players in a game. 5 | 6 | Khan allows your app to focus on the interaction required to creating clans and managing applications, instead of the backend required for actually doing it. 7 | 8 | ## Features 9 | 10 | * **Multi-tenant** - Khan already works for as many games as you need, just keep adding new games; 11 | * **Clan Management** - Create and manage clans, their metadata as well as promote and demote people in their rosters; 12 | * **Player Management** - Manage players and their metadata, as well as their applications to clans; 13 | * **Applications** - Khan handles the work involved with applying to clans, inviting people to clans, accepting, denying and kicking; 14 | * **Clan Search** - Search a list of clans to present your player with relevant options; 15 | * **Top Clans** - Choose from a specific dimension to return a list of the top clans in that specific range (SOON); 16 | * **Web Hooks** - Need to integrate your clan system with another application? We got your back! Use our web hooks sytem and plug into whatever events you need; 17 | * **Auditing Trail** - Track every action coming from your games (SOON); 18 | * **New Relic Support** - Natively support new relic with segments in each API route for easy detection of bottlenecks; 19 | * **Easy to deploy** - Khan comes with containers already exported to docker hub for every single of our successful builds. Just pick your choice! 20 | 21 | ## Architecture 22 | 23 | Khan is based on the premise that you have a backend server for your game. That means we do not employ any means of authentication. 24 | 25 | There's no validation if the actions you are performing are valid as well. We have TONS of validation around the operations themselves being valid. 26 | 27 | What we don't have are validations that test whether the source of the request can perform the request (remember the authentication bit?). 28 | 29 | Khan also offers a JSON `metadata` field in its Player and Clan models. This means that your game can store relevant information that can later be used to sort players, for example. 30 | 31 | ## The Stack 32 | 33 | For the devs out there, our code is in Go, but more specifically: 34 | 35 | * Web Framework - [Echo](https://github.com/labstack/echo) based on the insanely fast [FastHTTP](https://github.com/valyala/fasthttp); 36 | * Database - Postgres >= 9.5; 37 | * Cache - Redis. 38 | 39 | ## Who's Using it 40 | 41 | Well, right now, only us at TFG Co, are using it, but it would be great to get a community around the project. Hope to hear from you guys soon! 42 | 43 | ## How To Contribute? 44 | 45 | Just the usual: Fork, Hack, Pull Request. Rinse and Repeat. Also don't forget to include tests and docs (we are very fond of both). 46 | -------------------------------------------------------------------------------- /api/game_helpers.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | 10 | import ( 11 | "encoding/json" 12 | 13 | "github.com/labstack/echo" 14 | "github.com/uber-go/zap" 15 | ) 16 | 17 | type optionalParams struct { 18 | maxPendingInvites int 19 | cooldownBeforeApply int 20 | cooldownBeforeInvite int 21 | clanUpdateMetadataFieldsHookTriggerWhitelist string 22 | playerUpdateMetadataFieldsHookTriggerWhitelist string 23 | } 24 | 25 | func getOptionalParameters(app *App, c echo.Context) (*optionalParams, error) { 26 | data, err := GetRequestBody(c) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | var jsonPayload map[string]interface{} 32 | err = json.Unmarshal(data, &jsonPayload) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | var maxPendingInvites int 38 | if val, ok := jsonPayload["maxPendingInvites"]; ok { 39 | maxPendingInvites = int(val.(float64)) 40 | } else { 41 | maxPendingInvites = app.Config.GetInt("khan.maxPendingInvites") 42 | } 43 | 44 | var cooldownBeforeInvite int 45 | if val, ok := jsonPayload["cooldownBeforeInvite"]; ok { 46 | cooldownBeforeInvite = int(val.(float64)) 47 | } else { 48 | cooldownBeforeInvite = app.Config.GetInt("khan.defaultCooldownBeforeInvite") 49 | } 50 | 51 | var cooldownBeforeApply int 52 | if val, ok := jsonPayload["cooldownBeforeApply"]; ok { 53 | cooldownBeforeApply = int(val.(float64)) 54 | } else { 55 | cooldownBeforeApply = app.Config.GetInt("khan.defaultCooldownBeforeApply") 56 | } 57 | 58 | var clanWhitelist string 59 | if val, ok := jsonPayload["clanHookFieldsWhitelist"]; ok { 60 | clanWhitelist = val.(string) 61 | } else { 62 | clanWhitelist = "" 63 | } 64 | 65 | var playerWhitelist string 66 | if val, ok := jsonPayload["playerHookFieldsWhitelist"]; ok { 67 | playerWhitelist = val.(string) 68 | } else { 69 | playerWhitelist = "" 70 | } 71 | 72 | return &optionalParams{ 73 | maxPendingInvites: maxPendingInvites, 74 | cooldownBeforeInvite: cooldownBeforeInvite, 75 | cooldownBeforeApply: cooldownBeforeApply, 76 | clanUpdateMetadataFieldsHookTriggerWhitelist: clanWhitelist, 77 | playerUpdateMetadataFieldsHookTriggerWhitelist: playerWhitelist, 78 | }, nil 79 | } 80 | 81 | func getCreateGamePayload(app *App, c echo.Context, logger zap.Logger) (*CreateGamePayload, *optionalParams, error) { 82 | var payload CreateGamePayload 83 | if err := LoadJSONPayload(&payload, c, logger); err != nil { 84 | return nil, nil, err 85 | } 86 | optional, err := getOptionalParameters(app, c) 87 | if err != nil { 88 | return nil, nil, err 89 | } 90 | 91 | return &payload, optional, nil 92 | } 93 | -------------------------------------------------------------------------------- /es/es_client.go: -------------------------------------------------------------------------------- 1 | package es 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sync" 7 | 8 | eelastic "github.com/topfreegames/extensions/v9/elastic" 9 | "github.com/topfreegames/khan/log" 10 | "github.com/uber-go/zap" 11 | "gopkg.in/olivere/elastic.v5" 12 | ) 13 | 14 | // Client is the struct of an elasticsearch client 15 | type Client struct { 16 | Debug bool 17 | Host string 18 | Port int 19 | Index string 20 | Logger zap.Logger 21 | Sniff bool 22 | Client *elastic.Client 23 | } 24 | 25 | var once sync.Once 26 | var client *Client 27 | 28 | // GetIndexName returns the name of the index 29 | func (es *Client) GetIndexName(gameID string) string { 30 | if es.Index != "" { 31 | return fmt.Sprintf("%s-%s", es.Index, gameID) 32 | } 33 | return "khan-test" 34 | } 35 | 36 | // GetClient returns an elasticsearch client configured with the given the arguments 37 | func GetClient(host string, port int, index string, sniff bool, logger zap.Logger, debug bool) *Client { 38 | once.Do(func() { 39 | client = &Client{ 40 | Debug: debug, 41 | Host: host, 42 | Port: port, 43 | Logger: logger, 44 | Index: index, 45 | Sniff: sniff, 46 | } 47 | client.configure() 48 | }) 49 | return client 50 | } 51 | 52 | // GetTestClient returns a test elasticsearch client configured with the given the arguments 53 | func GetTestClient(host string, port int, index string, sniff bool, logger zap.Logger, debug bool) *Client { 54 | client = &Client{ 55 | Debug: debug, 56 | Host: host, 57 | Port: port, 58 | Logger: logger, 59 | Index: index, 60 | Sniff: sniff, 61 | } 62 | client.configure() 63 | return client 64 | } 65 | 66 | // GetConfiguredClient returns an elasticsearch client with no extra configs 67 | func GetConfiguredClient() *Client { 68 | return client 69 | } 70 | 71 | func (es *Client) configure() { 72 | es.configureClient() 73 | } 74 | 75 | // DestroyClient sets the elasticsearch client value to nil 76 | func DestroyClient() { 77 | client = nil 78 | } 79 | 80 | func (es *Client) configureClient() { 81 | logger := es.Logger.With( 82 | zap.String("source", "elasticsearch"), 83 | zap.String("operation", "configureClient"), 84 | ) 85 | log.I(logger, "Connecting to elasticsearch...", func(cm log.CM) { 86 | cm.Write( 87 | zap.String("elasticsearch.url", fmt.Sprintf("http://%s:%d/%s", es.Host, es.Port, es.Index)), 88 | zap.Bool("sniff", es.Sniff), 89 | ) 90 | }) 91 | var err error 92 | es.Client, err = eelastic.NewClient( 93 | elastic.SetURL(fmt.Sprintf("http://%s:%d", es.Host, es.Port)), 94 | elastic.SetSniff(es.Sniff), 95 | ) 96 | 97 | if err != nil { 98 | log.E(logger, "Failed to connect to elasticsearch!", func(cm log.CM) { 99 | cm.Write( 100 | zap.String("elasticsearch.url", fmt.Sprintf("http://%s:%d/%s", es.Host, es.Port, es.Index)), 101 | zap.Error(err), 102 | ) 103 | }) 104 | os.Exit(1) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /bench/player_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package bench 9 | 10 | import ( 11 | "fmt" 12 | "net/http" 13 | "testing" 14 | 15 | uuid "github.com/satori/go.uuid" 16 | "github.com/topfreegames/khan/models" 17 | "github.com/topfreegames/khan/models/fixtures" 18 | khanTesting "github.com/topfreegames/khan/testing" 19 | ) 20 | 21 | var playerResult *http.Response 22 | 23 | func BenchmarkCreatePlayer(b *testing.B) { 24 | db, err := models.GetPerfDB() 25 | if err != nil { 26 | panic(err.Error()) 27 | } 28 | 29 | mongoDB, err := khanTesting.GetTestMongo() 30 | if err != nil { 31 | panic(err.Error()) 32 | } 33 | 34 | game, _, err := getGameAndPlayer(db, mongoDB) 35 | if err != nil { 36 | panic(err.Error()) 37 | } 38 | 39 | b.ResetTimer() 40 | 41 | for i := 0; i < b.N; i++ { 42 | route := getRoute(fmt.Sprintf("/games/%s/players", game.PublicID)) 43 | res, err := postTo(route, getPlayerPayload(uuid.NewV4().String())) 44 | validateResp(res, err) 45 | res.Body.Close() 46 | 47 | playerResult = res 48 | } 49 | } 50 | 51 | func BenchmarkUpdatePlayer(b *testing.B) { 52 | db, err := models.GetPerfDB() 53 | if err != nil { 54 | panic(err.Error()) 55 | } 56 | 57 | mongoDB, err := khanTesting.GetTestMongo() 58 | if err != nil { 59 | panic(err.Error()) 60 | } 61 | 62 | game, _, err := getGameAndPlayer(db, mongoDB) 63 | if err != nil { 64 | panic(err.Error()) 65 | } 66 | 67 | var players []*models.Player 68 | for i := 0; i < b.N; i++ { 69 | player := fixtures.PlayerFactory.MustCreateWithOption(map[string]interface{}{ 70 | "GameID": game.PublicID, 71 | }).(*models.Player) 72 | err = db.Insert(player) 73 | if err != nil { 74 | panic(err.Error()) 75 | } 76 | players = append(players, player) 77 | } 78 | 79 | b.ResetTimer() 80 | 81 | for i := 0; i < b.N; i++ { 82 | playerPublicID := players[i].PublicID 83 | route := getRoute(fmt.Sprintf("/games/%s/players/%s", game.PublicID, playerPublicID)) 84 | res, err := putTo(route, getPlayerPayload(playerPublicID)) 85 | validateResp(res, err) 86 | res.Body.Close() 87 | 88 | playerResult = res 89 | } 90 | } 91 | 92 | func BenchmarkRetrievePlayer(b *testing.B) { 93 | db, err := models.GetPerfDB() 94 | if err != nil { 95 | panic(err.Error()) 96 | } 97 | 98 | gameID := uuid.NewV4().String() 99 | _, player, err := fixtures.GetTestPlayerWithMemberships(db, gameID, 50, 20, 30, 80) 100 | if err != nil { 101 | panic(err.Error()) 102 | } 103 | 104 | b.ResetTimer() 105 | 106 | for i := 0; i < b.N; i++ { 107 | route := getRoute(fmt.Sprintf("/games/%s/players/%s", gameID, player.PublicID)) 108 | res, err := get(route) 109 | validateResp(res, err) 110 | res.Body.Close() 111 | 112 | playerResult = res 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /util/secure_test.go: -------------------------------------------------------------------------------- 1 | package util_test 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | 10 | . "github.com/topfreegames/khan/util" 11 | "github.com/wildlife-studios/crypto" 12 | ) 13 | 14 | var encryptionKey []byte = []byte("a91j39s833hncy61alp0qb6e0s72pql14") 15 | var data string = "some_data_test" 16 | 17 | var _ = Describe("Security package", func() { 18 | Describe("EncryptData", func() { 19 | It("Should encrypt with XChacha and encode to base64", func() { 20 | xChacha := crypto.NewXChacha() 21 | 22 | encryptedData, err := EncryptData(data, encryptionKey[:32]) 23 | Expect(err).NotTo(HaveOccurred()) 24 | 25 | decoded, err := base64.StdEncoding.DecodeString(encryptedData) 26 | Expect(err).NotTo(HaveOccurred()) 27 | 28 | decryptedData, err := xChacha.Decrypt([]byte(decoded), encryptionKey[:32]) 29 | 30 | Expect(data).To(Equal(string(decryptedData))) 31 | 32 | }) 33 | 34 | It("Should return in error case encryptionKey length is different than 32 bytes", func() { 35 | _, err := EncryptData(data, encryptionKey[:31]) 36 | Expect(err).To(HaveOccurred()) 37 | 38 | if _, ok := err.(*TokenSizeError); !ok { 39 | Fail("Error is not TokenSizeError") 40 | } 41 | 42 | Expect(err.Error()).To(Equal("The key length is different than 32")) 43 | 44 | _, err = EncryptData(data, encryptionKey[:33]) 45 | Expect(err).To(HaveOccurred()) 46 | 47 | if _, ok := err.(*TokenSizeError); !ok { 48 | Fail("Error is not TokenSizeError") 49 | } 50 | 51 | Expect(err.Error()).To(Equal("The key length is different than 32")) 52 | }) 53 | }) 54 | 55 | Describe("DecryptData", func() { 56 | It("Should decode with base64 after decrypt with XChacha", func() { 57 | encryptedData, err := EncryptData(data, encryptionKey[:32]) 58 | Expect(err).NotTo(HaveOccurred()) 59 | 60 | cipheredData, err := base64.StdEncoding.DecodeString(encryptedData) 61 | Expect(err).NotTo(HaveOccurred()) 62 | 63 | xChacha := crypto.NewXChacha() 64 | decrypted, err := xChacha.Decrypt([]byte(cipheredData), encryptionKey[:32]) 65 | Expect(err).NotTo(HaveOccurred()) 66 | 67 | decryptedData, err := DecryptData(encryptedData, encryptionKey[:32]) 68 | Expect(err).NotTo(HaveOccurred()) 69 | 70 | Expect(decryptedData).To(Equal(fmt.Sprintf("%s", decrypted))) 71 | Expect(decryptedData).To(Equal(data)) 72 | 73 | }) 74 | 75 | It("Should return in error case encryptionKey length is less than 32 bytes", func() { 76 | _, err := DecryptData(data, encryptionKey[:31]) 77 | Expect(err).To(HaveOccurred()) 78 | 79 | if _, ok := err.(*TokenSizeError); !ok { 80 | Fail("Error is not TokenSizeError") 81 | } 82 | 83 | Expect(err.Error()).To(Equal("The key length is different than 32")) 84 | 85 | _, err = DecryptData(data, encryptionKey[:33]) 86 | Expect(err).To(HaveOccurred()) 87 | 88 | if _, ok := err.(*TokenSizeError); !ok { 89 | Fail("Error is not TokenSizeError") 90 | } 91 | 92 | Expect(err.Error()).To(Equal("The key length is different than 32")) 93 | }) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/topfreegames/khan 2 | 3 | go 1.15 4 | 5 | require ( 6 | cloud.google.com/go v0.79.0 // indirect 7 | github.com/Pallinder/go-randomdata v0.0.0-20160927131605-01563c9f5c2d 8 | github.com/asaskevich/govalidator v0.0.0-20180315120708-ccb8e960c48f // indirect 9 | github.com/bluele/factory-go v0.0.0-20160811033936-8a28e9752dbc 10 | github.com/globalsign/mgo v0.0.0-20180615134936-113d3961e731 11 | github.com/go-gorp/gorp v2.2.0+incompatible 12 | github.com/golang/mock v1.5.0 13 | github.com/gosuri/uilive v0.0.0-20160202011846-efb88ccd0599 // indirect 14 | github.com/gosuri/uiprogress v0.0.0-20160202012259-a9f819bfc744 15 | github.com/jarcoal/httpmock v1.0.4 16 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a // indirect 17 | github.com/jrallison/go-workers v0.0.0-20180112190529-dbf81d0b75bb 18 | github.com/jteeuwen/go-bindata v3.0.7+incompatible // indirect 19 | github.com/klauspost/compress v0.0.0-20161025140425-8df558b6cb6f // indirect 20 | github.com/klauspost/cpuid v0.0.0-20160302075316-09cded8978dc // indirect 21 | github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6 // indirect 22 | github.com/kylelemons/go-gypsy v0.0.0-20160905020020-08cad365cd28 // indirect 23 | github.com/labstack/echo v2.2.0+incompatible 24 | github.com/lib/pq v1.0.0 25 | github.com/mailru/easyjson v0.7.7 26 | github.com/onsi/ginkgo v1.16.4 27 | github.com/onsi/gomega v1.11.0 28 | github.com/opentracing/opentracing-go v1.2.0 29 | github.com/patrickmn/go-cache v2.1.0+incompatible 30 | github.com/rcrowley/go-metrics v0.0.0-20180125231941-8732c616f529 31 | github.com/satori/go.uuid v1.2.0 32 | github.com/spf13/afero v1.5.1 // indirect 33 | github.com/spf13/cobra v0.0.6 34 | github.com/spf13/viper v1.4.0 35 | github.com/stretchr/objx v0.3.0 // indirect 36 | github.com/stretchr/testify v1.7.0 // indirect 37 | github.com/topfreegames/extensions/v9 v9.0.0 38 | github.com/topfreegames/goose v0.0.0-20160616205307-c7f6dd34057c 39 | github.com/uber-go/atomic v1.0.0 // indirect 40 | github.com/uber-go/zap v0.0.0-20160809182253-d11d2851fcab 41 | github.com/uber/jaeger-client-go v2.29.1+incompatible 42 | github.com/uber/jaeger-lib v2.4.1+incompatible // indirect 43 | github.com/valyala/fasthttp v0.0.0-20160818100357-834fb48f1040 // indirect 44 | github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 45 | github.com/wildlife-studios/crypto v1.1.0 46 | github.com/ziutek/mymysql v1.5.5-0.20160909221029-df6241f6355c // indirect 47 | go.uber.org/atomic v1.8.0 // indirect 48 | golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83 // indirect 49 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22 // indirect 50 | golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d // indirect 51 | golang.org/x/tools v0.1.4 // indirect 52 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 53 | gopkg.in/olivere/elastic.v5 v5.0.66 54 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 55 | ) 56 | 57 | replace github.com/jrallison/go-workers v1.0.0 => github.com/topfreegames/go-workers v1.0.0 58 | 59 | replace github.com/codahale/hdrhistogram => github.com/HdrHistogram/hdrhistogram-go v0.0.0-20200919145931-8dac23c8dac1 60 | -------------------------------------------------------------------------------- /models/es_worker.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "time" 7 | 8 | "github.com/jrallison/go-workers" 9 | opentracing "github.com/opentracing/opentracing-go" 10 | "github.com/topfreegames/extensions/v9/tracing" 11 | "github.com/topfreegames/khan/es" 12 | "github.com/uber-go/zap" 13 | ) 14 | 15 | // ESWorker is the worker that will update elasticsearch 16 | type ESWorker struct { 17 | Logger zap.Logger 18 | ES *es.Client 19 | } 20 | 21 | // NewESWorker creates and returns a new elasticsearch worker 22 | func NewESWorker(logger zap.Logger) *ESWorker { 23 | w := &ESWorker{ 24 | Logger: logger, 25 | } 26 | w.configureESWorker() 27 | return w 28 | } 29 | 30 | func (w *ESWorker) configureESWorker() { 31 | w.ES = es.GetConfiguredClient() 32 | } 33 | 34 | // PerformUpdateES updates the clan into elasticsearc 35 | func (w *ESWorker) PerformUpdateES(m *workers.Msg) { 36 | tags := opentracing.Tags{"component": "go-workers"} 37 | span := opentracing.StartSpan("PerformUpdateES", tags) 38 | defer span.Finish() 39 | defer tracing.LogPanic(span) 40 | ctx := opentracing.ContextWithSpan(context.Background(), span) 41 | 42 | item := m.Args() 43 | data := item.MustMap() 44 | 45 | index := data["index"].(string) 46 | op := data["op"].(string) 47 | clan := data["clan"].(map[string]interface{}) 48 | clanID := data["clanID"].(string) 49 | 50 | logger := w.Logger.With( 51 | zap.String("index", index), 52 | zap.String("operation", op), 53 | zap.String("clanId", clanID), 54 | zap.String("source", "PerformUpdateES"), 55 | ) 56 | 57 | if w.ES != nil { 58 | start := time.Now() 59 | if op == "index" { 60 | body, er := json.Marshal(clan) 61 | if er != nil { 62 | logger.Error("Failed to get clan JSON and index into Elastic Search", zap.Error(er)) 63 | return 64 | } 65 | _, err := w.ES.Client. 66 | Index(). 67 | Index(index). 68 | Type("clan"). 69 | Id(clanID). 70 | BodyString(string(body)). 71 | Do(ctx) 72 | if err != nil { 73 | logger.Error("Failed to index clan into Elastic Search") 74 | return 75 | } 76 | 77 | logger.Debug("Successfully indexed clan into Elastic Search.", zap.Duration("latency", time.Now().Sub(start))) 78 | } else if op == "update" { 79 | _, err := w.ES.Client. 80 | Update(). 81 | Index(index). 82 | Type("clan"). 83 | Id(clanID). 84 | Doc(clan). 85 | Do(ctx) 86 | if err != nil { 87 | logger.Error("Failed to update clan from Elastic Search.", zap.Error(err)) 88 | } 89 | 90 | logger.Debug("Successfully updated clan from Elastic Search.", zap.Duration("latency", time.Now().Sub(start))) 91 | } else if op == "delete" { 92 | _, err := w.ES.Client. 93 | Delete(). 94 | Index(index). 95 | Type("clan"). 96 | Id(clanID). 97 | Do(ctx) 98 | 99 | if err != nil { 100 | logger.Error("Failed to delete clan from Elastic Search.", zap.Error(err)) 101 | } 102 | 103 | logger.Debug("Successfully deleted clan from Elastic Search.", zap.Duration("latency", time.Now().Sub(start))) 104 | } 105 | } 106 | 107 | return 108 | } 109 | -------------------------------------------------------------------------------- /api/hook.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | 10 | import ( 11 | "net/http" 12 | "time" 13 | 14 | "github.com/labstack/echo" 15 | "github.com/topfreegames/khan/log" 16 | "github.com/topfreegames/khan/models" 17 | "github.com/uber-go/zap" 18 | ) 19 | 20 | //CreateHookHandler is the handler responsible for creating new hooks 21 | func CreateHookHandler(app *App) func(c echo.Context) error { 22 | return func(c echo.Context) error { 23 | c.Set("route", "CreateHook") 24 | start := time.Now() 25 | gameID := c.Param("gameID") 26 | 27 | db := app.Db(c.StdContext()) 28 | 29 | logger := app.Logger.With( 30 | zap.String("source", "CreateHookHandler"), 31 | zap.String("operation", "createHook"), 32 | zap.String("gameID", gameID), 33 | ) 34 | 35 | var payload HookPayload 36 | 37 | if err := LoadJSONPayload(&payload, c, logger); err != nil { 38 | log.E(logger, "Failed to parse json payload.", func(cm log.CM) { 39 | cm.Write(zap.Error(err)) 40 | }) 41 | return FailWith(http.StatusBadRequest, err.Error(), c) 42 | } 43 | 44 | log.D(logger, "Creating hook...") 45 | hook, err := models.CreateHook( 46 | db, 47 | gameID, 48 | payload.Type, 49 | payload.HookURL, 50 | ) 51 | 52 | if err != nil { 53 | log.E(logger, "Failed to create the hook.", func(cm log.CM) { 54 | cm.Write(zap.Error(err)) 55 | }) 56 | return FailWith(http.StatusInternalServerError, err.Error(), c) 57 | } 58 | 59 | log.I(logger, "Created hook successfully.", func(cm log.CM) { 60 | cm.Write( 61 | zap.String("hookPublicID", hook.PublicID), 62 | zap.Duration("duration", time.Now().Sub(start)), 63 | ) 64 | }) 65 | return SucceedWith(map[string]interface{}{ 66 | "publicID": hook.PublicID, 67 | }, c) 68 | } 69 | } 70 | 71 | // RemoveHookHandler is the handler responsible for removing existing hooks 72 | func RemoveHookHandler(app *App) func(c echo.Context) error { 73 | return func(c echo.Context) error { 74 | c.Set("route", "RemoveHook") 75 | start := time.Now() 76 | gameID := c.Param("gameID") 77 | publicID := c.Param("publicID") 78 | 79 | db := app.Db(c.StdContext()) 80 | 81 | logger := app.Logger.With( 82 | zap.String("source", "RemoveHookHandler"), 83 | zap.String("operation", "removeHook"), 84 | zap.String("gameID", gameID), 85 | zap.String("hookPublicID", publicID), 86 | ) 87 | 88 | log.D(logger, "Removing hook...") 89 | err := models.RemoveHook( 90 | db, 91 | gameID, 92 | publicID, 93 | ) 94 | 95 | if err != nil { 96 | log.E(logger, "Failed to remove hook.", func(cm log.CM) { 97 | cm.Write(zap.Error(err)) 98 | }) 99 | return FailWith(http.StatusInternalServerError, err.Error(), c) 100 | } 101 | 102 | log.I(logger, "Hook removed successfully.", func(cm log.CM) { 103 | cm.Write(zap.Duration("duration", time.Now().Sub(start))) 104 | }) 105 | return SucceedWith(map[string]interface{}{}, c) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /loadtest/membership.go: -------------------------------------------------------------------------------- 1 | package loadtest 2 | 3 | import ( 4 | "github.com/topfreegames/khan/lib" 5 | ) 6 | 7 | func (app *App) configureMembershipOperations() { 8 | app.appendOperation(app.getApplyForMembershipOperation()) 9 | app.appendOperation(app.getSelfDeleteMembershipOperation()) 10 | } 11 | 12 | func (app *App) getApplyForMembershipOperation() operation { 13 | operationKey := "applyForMembership" 14 | app.setOperationProbabilityConfigDefault(operationKey, 1) 15 | membershipLevel := app.config.GetString("loadtest.game.membershipLevel") 16 | return operation{ 17 | probability: app.getOperationProbabilityConfig(operationKey), 18 | canExecute: func() (bool, error) { 19 | count, err := app.cache.getFreePlayersCount() 20 | if err != nil { 21 | return false, err 22 | } 23 | if count == 0 { 24 | return false, nil 25 | } 26 | count, err = app.cache.getNotFullClansCount() 27 | return count > 0, err 28 | }, 29 | execute: func() error { 30 | playerPublicID, err := app.cache.chooseRandomFreePlayer() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | clanPublicID, err := app.cache.chooseRandomNotFullClan() 36 | if err != nil { 37 | return err 38 | } 39 | 40 | clanApplyResult, err := app.client.ApplyForMembership(nil, &lib.ApplicationPayload{ 41 | ClanID: clanPublicID, 42 | Message: "", 43 | Level: membershipLevel, 44 | PlayerPublicID: playerPublicID, 45 | }) 46 | if err != nil { 47 | return err 48 | } 49 | if clanApplyResult == nil { 50 | return &GenericError{"NilPayloadError", "Operation applyForMembership returned no error with nil payload."} 51 | } 52 | if !clanApplyResult.Success { 53 | return &GenericError{"FailurePayloadError", "Operation applyForMembership returned no error with failure payload."} 54 | } 55 | 56 | return app.cache.applyForMembership(clanPublicID, playerPublicID) 57 | }, 58 | } 59 | } 60 | 61 | func (app *App) getSelfDeleteMembershipOperation() operation { 62 | operationKey := "selfDeleteMembership" 63 | app.setOperationProbabilityConfigDefault(operationKey, 1) 64 | return operation{ 65 | probability: app.getOperationProbabilityConfig(operationKey), 66 | canExecute: func() (bool, error) { 67 | count, err := app.cache.getMemberPlayersCount() 68 | if err != nil { 69 | return false, nil 70 | } 71 | return count > 0, nil 72 | }, 73 | execute: func() error { 74 | playerPublicID, clanPublicID, err := app.cache.chooseRandomMemberPlayerAndClan() 75 | if err != nil { 76 | return err 77 | } 78 | 79 | result, err := app.client.DeleteMembership(nil, &lib.DeleteMembershipPayload{ 80 | ClanID: clanPublicID, 81 | PlayerPublicID: playerPublicID, 82 | RequestorPublicID: playerPublicID, 83 | }) 84 | if err != nil { 85 | return err 86 | } 87 | if result == nil { 88 | return &GenericError{"NilPayloadError", "Operation deleteMembership returned no error with nil payload."} 89 | } 90 | if !result.Success { 91 | return &GenericError{"FailurePayloadError", "Operation deleteMembership returned no error with failure payload."} 92 | } 93 | 94 | return app.cache.deleteMembership(clanPublicID, playerPublicID) 95 | }, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /docs/pruning.md: -------------------------------------------------------------------------------- 1 | Pruning Stale Data 2 | ================== 3 | 4 | Depending on your usage of Khan, you will accumulate some stale data. Some examples include: 5 | 6 | * Pending Applications to a clan; 7 | * Pending Invitations to a clan; 8 | * Deleted Memberships (member left or was banned); 9 | * Denied Memberships. 10 | 11 | While there's nothing wrong with keeping this data in the Data Store, it will slow Khan down considerably depending on your usage of it. 12 | 13 | ## Gotchas 14 | 15 | One good example of how this sort of stale data can go wrong is when building a clan suggestion for players. If you always suggest the same clans, eventually those clans will have thousands of unfulfilled applications. 16 | 17 | That way, anytime someone requests info for these clans, Khan will have a hard time to fulfill that request. 18 | 19 | ## Pruning Stale Data 20 | 21 | Khan has a `prune` command built-in, designed for the purpose of keeping your data balanced. It looks for some configuration keys in your games, and then decides on what data should be deleted. 22 | 23 | ### WARNING 24 | 25 | This command performs a **HARD** delete on the memberships row and can't be undone. Please ensure you have frequent backups of your data store before applying pruning. 26 | 27 | ## Configuring Games to be Pruned 28 | 29 | Configuring a game to be pruned is as easy as including some keys in the game's metadata property: 30 | 31 | * `pendingApplicationsExpiration`: the number of **SECONDS** to wait before deleting a pending application; 32 | * `pendingInvitesExpiration`: the number of **SECONDS** to wait before deleting a pending invitation; 33 | * `deniedMembershipsExpiration`: the number of **SECONDS** to wait before deleting a denied membership; 34 | * `deletedMembershipsExpiration`: the number of **SECONDS** to wait before deleting a deleted membership (either the member left or was banned). 35 | 36 | **PLEASE** take note that all the expirations are in **SECONDS**. The timestamp used to compare the expiration to is the `updated_at` field of memberships. 37 | 38 | Khan will delete any membership that meets one of the criteria above **AND** has an `updated_at` timestamp older than the relevant configuration subtracted in seconds from NOW. 39 | 40 | ### NOTICE 41 | 42 | If you want a game to be pruned, **ALL** expiration keys **MUST** be set. Otherwise, Khan will ignore that game as far as pruning goes. 43 | 44 | ## Periodically Running Pruning 45 | 46 | Khan's command line for pruning is: 47 | 48 | ``` 49 | $ khan prune -c /path/to/config.yaml 50 | ``` 51 | 52 | Khan will use the connection details in your specified config file. Double-check the config file being used to ensure that you won't lose any unwanted information. 53 | 54 | ## Pruning with a Container 55 | 56 | Since Khan has container offers, you can also use a container for running pruning in any PaaS that supports Docker containers. 57 | 58 | In order to use it, you need to configure these environment variables in the container: 59 | 60 | * `KHAN_POSTGRES_HOST` - PostgreSQL to prune hostname; 61 | * `KHAN_POSTGRES_PORT` - PostgreSQL to prune port; 62 | * `KHAN_POSTGRES_USER` - PostgreSQL to prune username; 63 | * `KHAN_POSTGRES_PASSWORD` - PostgreSQL to prune password; 64 | * `KHAN_POSTGRES_DBNAME` - PostgreSQL to prune database name; 65 | * `KHAN_PRUNING_SLEEP` - Number of seconds to sleep between pruning operations. Defaults to 3600. 66 | 67 | The image can be found at our [official Docker Hub repository](https://hub.docker.com/r/tfgco/khan-prune/). 68 | -------------------------------------------------------------------------------- /models/mongo_worker.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | 9 | "github.com/jrallison/go-workers" 10 | opentracing "github.com/opentracing/opentracing-go" 11 | "github.com/spf13/viper" 12 | "github.com/topfreegames/extensions/v9/mongo/interfaces" 13 | "github.com/topfreegames/extensions/v9/tracing" 14 | "github.com/topfreegames/khan/mongo" 15 | "github.com/uber-go/zap" 16 | ) 17 | 18 | // MongoWorker is the worker that will update mongo 19 | type MongoWorker struct { 20 | Logger zap.Logger 21 | MongoDB interfaces.MongoDB 22 | MongoCollectionTemplate string 23 | } 24 | 25 | // NewMongoWorker creates and returns a new mongo worker 26 | func NewMongoWorker(logger zap.Logger, config *viper.Viper) *MongoWorker { 27 | w := &MongoWorker{ 28 | Logger: logger, 29 | } 30 | w.configureMongoWorker(config) 31 | return w 32 | } 33 | 34 | func (w *MongoWorker) configureMongoWorker(config *viper.Viper) { 35 | w.MongoCollectionTemplate = config.GetString("mongodb.collectionTemplate") 36 | w.MongoDB = mongo.GetConfiguredMongoClient() 37 | } 38 | 39 | // PerformUpdateMongo updates the clan into mongodb 40 | func (w *MongoWorker) PerformUpdateMongo(m *workers.Msg) { 41 | tags := opentracing.Tags{"component": "go-workers"} 42 | span := opentracing.StartSpan("PerformUpdateMongo", tags) 43 | defer span.Finish() 44 | defer tracing.LogPanic(span) 45 | ctx := opentracing.ContextWithSpan(context.Background(), span) 46 | 47 | item := m.Args() 48 | data := item.MustMap() 49 | game := data["game"].(string) 50 | op := data["op"].(string) 51 | clan := data["clan"].(map[string]interface{}) 52 | clanID := data["clanID"].(string) 53 | 54 | w.updateClanIntoMongoDB(ctx, game, op, clan, clanID) 55 | } 56 | 57 | // InsertGame creates a game inside Mongo 58 | func (w *MongoWorker) InsertGame(ctx context.Context, gameID string, clan *Clan) error { 59 | clanWithNamePrefixes := clan.NewClanWithNamePrefixes() 60 | clanJSON, err := json.Marshal(clanWithNamePrefixes) 61 | if err != nil { 62 | return errors.New("Could not serialize clan") 63 | } 64 | 65 | var clanMap map[string]interface{} 66 | json.Unmarshal(clanJSON, &clanMap) 67 | 68 | w.updateClanIntoMongoDB(ctx, gameID, "update", clanMap, clan.PublicID) 69 | 70 | return nil 71 | } 72 | 73 | func (w *MongoWorker) updateClanIntoMongoDB( 74 | ctx context.Context, gameID string, op string, clan map[string]interface{}, clanID string, 75 | ) { 76 | 77 | logger := w.Logger.With( 78 | zap.String("game", gameID), 79 | zap.String("operation", op), 80 | zap.String("clanId", clanID), 81 | zap.String("source", "PerformUpdateMongo"), 82 | ) 83 | 84 | if w.MongoDB != nil { 85 | mongoCol, mongoSess := w.MongoDB.WithContext(ctx).C(fmt.Sprintf(w.MongoCollectionTemplate, gameID)) 86 | defer mongoSess.Close() 87 | 88 | if op == "update" { 89 | logger.Debug(fmt.Sprintf("updating clan %s into mongodb", clanID)) 90 | info, err := mongoCol.UpsertId(clanID, clan) 91 | if err != nil { 92 | panic(err) 93 | } else { 94 | logger.Debug(fmt.Sprintf("ChangeInfo: updated %d, removed %d, matched %d", info.Updated, info.Removed, info.Matched)) 95 | } 96 | } else if op == "delete" { 97 | logger.Debug(fmt.Sprintf("deleting clan %s from mongodb", clanID)) 98 | err := mongoCol.RemoveId(clanID) 99 | if err != nil { 100 | panic(err) 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /docs/hosting.md: -------------------------------------------------------------------------------- 1 | Hosting Khan 2 | ============ 3 | 4 | There are three ways to host Khan: docker, binaries or from source. 5 | 6 | ## Docker 7 | 8 | Running Khan with docker is rather simple. Our docker container image comes bundled with the API binary. All you need to do is load balance all the containers and you're good to go. The API runs at port `8080` in the docker image. 9 | 10 | Khan uses PostgreSQL to store clans information. The container takes environment variables to specify this connection: 11 | 12 | * `KHAN_POSTGRES_HOST` - PostgreSQL host to connect to; 13 | * `KHAN_POSTGRES_PORT` - PostgreSQL port to connect to; 14 | * `KHAN_POSTGRES_USER` - Password of the PostgreSQL Server to connect to; 15 | * `KHAN_POSTGRES_DBNAME` - Database name of the PostgreSQL Server to connect to; 16 | * `KHAN_POSTGRES_SSLMODE` - SSL Mode to connect to postgres with; 17 | 18 | Other than that, there are a couple more configurations you can pass using environment variables: 19 | 20 | * `KHAN_EXTENSIONS_DOGSTATSD_HOST` - If you have a [statsd datadog daemon](https://docs.datadoghq.com/developers/dogstatsd/), Podium will publish metrics to the given host at a certain port. Ex. localhost:8125; 21 | * `KHAN_EXTENSIONS_DOGSTATSD_RATE` - If you have a [statsd daemon](https://docs.datadoghq.com/developers/dogstatsd/), Podium will export metrics to the deamon at the given rate; 22 | * `KHAN_EXTENSIONS_DOGSTATSD_TAGS_PREFIX` - If you have a [statsd daemon](https://docs.datadoghq.com/developers/dogstatsd/), you may set a prefix to every tag sent to the daemon; 23 | 24 | If you want to expose Khan outside your internal network it's advised to use Basic Authentication. You can specify basic authentication parameters with the following environment variables: 25 | 26 | * `KHAN_BASICAUTH_USERNAME` - If you specify this key, Khan will be configured to use basic auth with this user; 27 | * `KHAN_BASICAUTH_PASSWORD` - If you specify `BASICAUTH_USERNAME`, Khan will be configured to use basic auth with this password; 28 | 29 | ### Example command for running with Docker 30 | 31 | ``` 32 | $ docker pull tfgco/khan 33 | $ docker run -t --rm -e "KHAN_POSTGRES_HOST=" -e "KHAN_POSTGRES_PORT=" -p 8080:80 tfgco/khan 34 | ``` 35 | 36 | In order to run Khan's workers using docker you just need to send the `KHAN_RUN_WORKER` environment variable as `true`. 37 | 38 | ### Example command for running workers with Docker 39 | 40 | ``` 41 | $ docker pull tfgco/khan 42 | $ docker run -t --rm -e "KHAN_POSTGRES_HOST=" -e "KHAN_POSTGRES_PORT=" -e "KHAN_RUN_WORKERS=true" -p 9999:80 tfgco/khan 43 | ``` 44 | 45 | 46 | ## Binaries 47 | 48 | Whenever we publish a new version of Khan, we'll always supply binaries for both Linux and Darwin, on i386 and x86_64 architectures. If you'd rather run your own servers instead of containers, just use the binaries that match your platform and architecture. 49 | 50 | The API server is the `khan` binary. It takes a configuration yaml file that specifies the connection to PostgreSQL and some additional parameters. You can learn more about it at [default.yaml](https://github.com/topfreegames/khan/blob/master/config/default.yaml). 51 | 52 | The workers can be started using the same `khan` binary. It takes a configuration yaml file that specifies the connection to PostgreSQL and some additional parameters. You can learn more about it at [default.yaml](https://github.com/topfreegames/khan/blob/master/config/default.yaml). 53 | 54 | ## Source 55 | 56 | Left as an exercise to the reader. 57 | -------------------------------------------------------------------------------- /loadtest/unordered_string_map.go: -------------------------------------------------------------------------------- 1 | package loadtest 2 | 3 | import "fmt" 4 | 5 | type unorderedStringMapValue struct { 6 | index int 7 | content interface{} 8 | } 9 | 10 | // UnorderedStringMap represents a map from strings to interface{}s that can be looped using zero-based indexes with order based on previously executed Set/Remove operations 11 | type UnorderedStringMap struct { 12 | stringToInt map[string]unorderedStringMapValue 13 | intToString []string 14 | } 15 | 16 | // UnorderedStringMapOutOfBoundsError represents the error when trying to read unexisting position in the key space 17 | type UnorderedStringMapOutOfBoundsError struct { 18 | Size int 19 | Index int 20 | } 21 | 22 | func (e *UnorderedStringMapOutOfBoundsError) Error() string { 23 | return fmt.Sprintf("Trying to access invalid position '%v' in UnorderedStringMap with size '%v'.", e.Index, e.Size) 24 | } 25 | 26 | // NewUnorderedStringMap returns a new UnorderedStringMap 27 | func NewUnorderedStringMap() *UnorderedStringMap { 28 | return &UnorderedStringMap{ 29 | stringToInt: make(map[string]unorderedStringMapValue), 30 | } 31 | } 32 | 33 | // Get returns the interface{} content for a key 34 | func (d *UnorderedStringMap) Get(key string) interface{} { 35 | value, ok := d.stringToInt[key] 36 | if ok { 37 | return value.content 38 | } 39 | return nil 40 | } 41 | 42 | // Set maps a string to a value 43 | func (d *UnorderedStringMap) Set(key string, content interface{}) { 44 | if value, ok := d.stringToInt[key]; !ok { 45 | idx := d.Len() 46 | d.stringToInt[key] = unorderedStringMapValue{idx, content} 47 | d.intToString = append(d.intToString, key) 48 | } else { 49 | d.stringToInt[key] = unorderedStringMapValue{value.index, content} 50 | } 51 | } 52 | 53 | // Remove removes a string key 54 | func (d *UnorderedStringMap) Remove(key string) { 55 | if value, ok := d.stringToInt[key]; ok { 56 | sz := d.Len() 57 | movedKey := d.intToString[sz-1] 58 | movedKeyContent := d.stringToInt[movedKey].content 59 | movedKeyNewIndex := value.index 60 | 61 | // map update 62 | d.stringToInt[movedKey] = unorderedStringMapValue{movedKeyNewIndex, movedKeyContent} 63 | delete(d.stringToInt, key) // key may be equal to movedKey, so we delete after the update 64 | 65 | // slice update 66 | d.intToString[movedKeyNewIndex] = movedKey 67 | d.intToString[sz-1] = "" // prevent potential memory leak 68 | d.intToString = d.intToString[:sz-1] // sz-1 may be equal to movedKeyNewIndex, so we delete after the update 69 | } 70 | } 71 | 72 | // Len returns the number of elements 73 | func (d *UnorderedStringMap) Len() int { 74 | return len(d.stringToInt) 75 | } 76 | 77 | // GetKey returns the string key at the specified integer index 78 | func (d *UnorderedStringMap) GetKey(idx int) (string, error) { 79 | sz := d.Len() 80 | if 0 <= idx && idx < sz { 81 | return d.intToString[idx], nil 82 | } 83 | return "", &UnorderedStringMapOutOfBoundsError{ 84 | Size: sz, 85 | Index: idx, 86 | } 87 | } 88 | 89 | // GetValue returns the interface{} content at the specified integer index 90 | func (d *UnorderedStringMap) GetValue(idx int) (interface{}, error) { 91 | key, err := d.GetKey(idx) 92 | if err != nil { 93 | return nil, err 94 | } 95 | return d.stringToInt[key].content, nil 96 | } 97 | 98 | // Has returns a boolean telling whether a string is in the key space or not 99 | func (d *UnorderedStringMap) Has(key string) bool { 100 | _, ok := d.stringToInt[key] 101 | return ok 102 | } 103 | -------------------------------------------------------------------------------- /api/hook_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api_test 9 | 10 | import ( 11 | "encoding/json" 12 | "fmt" 13 | "net/http" 14 | 15 | . "github.com/onsi/ginkgo" 16 | . "github.com/onsi/gomega" 17 | "github.com/topfreegames/khan/models" 18 | "github.com/topfreegames/khan/models/fixtures" 19 | ) 20 | 21 | var _ = Describe("Hook API Handler", func() { 22 | var testDb models.DB 23 | 24 | BeforeEach(func() { 25 | var err error 26 | testDb, err = GetTestDB() 27 | Expect(err).NotTo(HaveOccurred()) 28 | }) 29 | 30 | Describe("Create Hook Handler", func() { 31 | It("Should create hook", func() { 32 | a := GetDefaultTestApp() 33 | db := a.Db(nil) 34 | game := fixtures.GameFactory.MustCreate().(*models.Game) 35 | err := db.Insert(game) 36 | Expect(err).NotTo(HaveOccurred()) 37 | 38 | payload := map[string]interface{}{ 39 | "type": models.GameUpdatedHook, 40 | "hookURL": "http://test/create", 41 | } 42 | status, body := PostJSON(a, GetGameRoute(game.PublicID, "/hooks"), payload) 43 | 44 | Expect(status).To(Equal(http.StatusOK)) 45 | var result map[string]interface{} 46 | json.Unmarshal([]byte(body), &result) 47 | Expect(result["success"]).To(BeTrue()) 48 | Expect(result["publicID"]).NotTo(BeEquivalentTo("")) 49 | 50 | dbHook, err := models.GetHookByPublicID( 51 | db, game.PublicID, result["publicID"].(string), 52 | ) 53 | Expect(err).NotTo(HaveOccurred()) 54 | Expect(dbHook.GameID).To(Equal(game.PublicID)) 55 | Expect(dbHook.PublicID).To(Equal(result["publicID"])) 56 | Expect(dbHook.EventType).To(Equal(payload["type"])) 57 | Expect(dbHook.URL).To(Equal(payload["hookURL"])) 58 | }) 59 | 60 | It("Should not create hook if missing parameters", func() { 61 | a := GetDefaultTestApp() 62 | route := GetGameRoute("game-id", "/hooks") 63 | status, body := PostJSON(a, route, map[string]interface{}{}) 64 | 65 | Expect(status).To(Equal(http.StatusBadRequest)) 66 | var result map[string]interface{} 67 | json.Unmarshal([]byte(body), &result) 68 | Expect(result["success"]).To(BeFalse()) 69 | Expect(result["reason"]).To(Equal("hookURL is required")) 70 | }) 71 | 72 | It("Should not create hook if invalid payload", func() { 73 | a := GetDefaultTestApp() 74 | route := GetGameRoute("game-id", "/hooks") 75 | status, body := Post(a, route, "invalid") 76 | 77 | Expect(status).To(Equal(http.StatusBadRequest)) 78 | var result map[string]interface{} 79 | json.Unmarshal([]byte(body), &result) 80 | Expect(result["success"]).To(BeFalse()) 81 | Expect(result["reason"].(string)).To(ContainSubstring(InvalidJSONError)) 82 | }) 83 | }) 84 | 85 | Describe("Delete Hook Handler", func() { 86 | It("Should delete hook", func() { 87 | a := GetDefaultTestApp() 88 | 89 | hook, err := fixtures.CreateHookFactory(testDb, "", models.GameUpdatedHook, "http://test/update") 90 | Expect(err).NotTo(HaveOccurred()) 91 | 92 | status, body := Delete(a, GetGameRoute(hook.GameID, fmt.Sprintf("/hooks/%s", hook.PublicID))) 93 | 94 | Expect(status).To(Equal(http.StatusOK)) 95 | 96 | var result map[string]interface{} 97 | json.Unmarshal([]byte(body), &result) 98 | Expect(result["success"]).To(BeTrue()) 99 | 100 | number, err := testDb.SelectInt("select count(*) from hooks where id=$1", hook.ID) 101 | Expect(err).NotTo(HaveOccurred()) 102 | Expect(number == 0).To(BeTrue()) 103 | }) 104 | }) 105 | }) 106 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | 3 | services: 4 | migrate: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | command: "migrate" 9 | depends_on: 10 | postgres: 11 | condition: service_healthy 12 | links: 13 | - postgres 14 | networks: 15 | - wildlife-services 16 | environment: 17 | - KHAN_POSTGRES_HOST=postgres 18 | - KHAN_POSTGRES_USER=postgres 19 | - KHAN_POSTGRES_PORT=5432 20 | - KHAN_POSTGRES_PASSWORD=123456 21 | healthcheck: 22 | test: ["CMD-SHELL", "curl --silent --fail http://localhost/healthcheck || exit 1"] 23 | interval: 30s 24 | timeout: 30s 25 | retries: 3 26 | 27 | khan: 28 | build: 29 | context: . 30 | dockerfile: Dockerfile 31 | command: "start" 32 | depends_on: 33 | postgres: 34 | condition: service_healthy 35 | redis: 36 | condition: service_healthy 37 | elasticsearch: 38 | condition: service_healthy 39 | migrate: 40 | condition: service_started 41 | links: 42 | - postgres 43 | - redis 44 | - elasticsearch 45 | networks: 46 | - wildlife-services 47 | ports: 48 | - "80:80" 49 | environment: 50 | - KHAN_POSTGRES_HOST=postgres 51 | - KHAN_POSTGRES_USER=postgres 52 | - KHAN_POSTGRES_PORT=5432 53 | - KHAN_POSTGRES_PASSWORD=123456 54 | - KHAN_REDIS_HOST=redis 55 | - KHAN_ELASTICSEARCH_HOST=elasticsearch 56 | healthcheck: 57 | test: ["CMD-SHELL", "curl --silent --fail http://localhost/healthcheck || exit 1"] 58 | interval: 30s 59 | timeout: 30s 60 | retries: 3 61 | 62 | khan-worker: 63 | build: 64 | context: . 65 | dockerfile: Dockerfile 66 | command: "worker" 67 | depends_on: 68 | postgres: 69 | condition: service_healthy 70 | redis: 71 | condition: service_healthy 72 | elasticsearch: 73 | condition: service_healthy 74 | migrate: 75 | condition: service_started 76 | links: 77 | - postgres 78 | - redis 79 | - elasticsearch 80 | networks: 81 | - wildlife-services 82 | ports: 83 | - "8080:8080" 84 | environment: 85 | - KHAN_POSTGRES_HOST=postgres 86 | - KHAN_POSTGRES_USER=postgres 87 | - KHAN_POSTGRES_PORT=5432 88 | - KHAN_POSTGRES_PASSWORD=123456 89 | - KHAN_REDIS_HOST=redis 90 | - KHAN_ELASTICSEARCH_HOST=elasticsearch 91 | 92 | postgres: 93 | image: postgres:12 94 | restart: always 95 | environment: 96 | - POSTGRES_PASSWORD=123456 97 | - POSTGRES_USER=postgres 98 | - POSTGRES_DB=khan 99 | ports: 100 | - "5432:5432" 101 | volumes: 102 | - ./docker-data/postgres:/var/lib/postgresql/data 103 | networks: 104 | - wildlife-services 105 | healthcheck: 106 | test: ["CMD-SHELL", "pg_isready -U postgres"] 107 | interval: 5s 108 | timeout: 5s 109 | retries: 5 110 | 111 | redis: 112 | image: redis:4 113 | restart: always 114 | ports: 115 | - "6379:6379" 116 | networks: 117 | - wildlife-services 118 | healthcheck: 119 | test: ["CMD", "redis-cli", "ping"] 120 | interval: 3s 121 | timeout: 3s 122 | retries: 30 123 | 124 | elasticsearch: 125 | image: elasticsearch:7.6.1 126 | ports: 127 | - '9200:9200' 128 | - '9300:9300' 129 | networks: 130 | - wildlife-services 131 | volumes: 132 | - ./docker-data/elasticsearch:/usr/share/elasticsearch/data 133 | environment: 134 | - xpack.security.enabled=false 135 | - discovery.type=single-node 136 | healthcheck: 137 | test: ["CMD-SHELL", "curl --silent --fail localhost:9200/_cluster/health || exit 1"] 138 | interval: 10s 139 | timeout: 30s 140 | retries: 3 141 | 142 | networks: 143 | wildlife-services: 144 | -------------------------------------------------------------------------------- /models/helpers.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package models 9 | 10 | import ( 11 | "database/sql" 12 | "fmt" 13 | 14 | "github.com/go-gorp/gorp" 15 | _ "github.com/lib/pq" //This is required to use postgres with database/sql 16 | egorp "github.com/topfreegames/extensions/v9/gorp" 17 | "github.com/topfreegames/extensions/v9/gorp/interfaces" 18 | "github.com/topfreegames/khan/util" 19 | ) 20 | 21 | // DB is the contract for all the operations we use from either a connection or transaction 22 | // This is required for automatic transactions 23 | type DB interface { 24 | Get(interface{}, ...interface{}) (interface{}, error) 25 | Select(interface{}, string, ...interface{}) ([]interface{}, error) 26 | SelectOne(interface{}, string, ...interface{}) error 27 | SelectInt(string, ...interface{}) (int64, error) 28 | Insert(...interface{}) error 29 | Update(...interface{}) (int64, error) 30 | Delete(...interface{}) (int64, error) 31 | Exec(string, ...interface{}) (sql.Result, error) 32 | } 33 | 34 | var _db interfaces.Database 35 | 36 | // GetDefaultDB returns a connection to the default database 37 | func GetDefaultDB() (interfaces.Database, error) { 38 | return GetDB("localhost", "khan", 5433, "disable", "khan", "") 39 | } 40 | 41 | // GetPerfDB returns a connection to the perf database 42 | func GetPerfDB() (interfaces.Database, error) { 43 | return GetDB("localhost", "khan_perf", 5433, "disable", "khan_perf", "") 44 | } 45 | 46 | // GetDB returns a DbMap connection to the database specified in the arguments 47 | func GetDB(host string, user string, port int, sslmode string, dbName string, password string) (interfaces.Database, error) { 48 | if _db == nil { 49 | var err error 50 | _db, err = InitDb(host, user, port, sslmode, dbName, password) 51 | if err != nil { 52 | _db = nil 53 | return nil, err 54 | } 55 | } 56 | 57 | return _db, nil 58 | } 59 | 60 | // InitDb initializes a connection to the database 61 | func InitDb(host string, user string, port int, sslmode string, dbName string, password string) (interfaces.Database, error) { 62 | connStr := fmt.Sprintf( 63 | "host=%s user=%s port=%d sslmode=%s dbname=%s", 64 | host, user, port, sslmode, dbName, 65 | ) 66 | if password != "" { 67 | connStr += fmt.Sprintf(" password=%s", password) 68 | } 69 | db, err := sql.Open("postgres", connStr) 70 | if err != nil { 71 | return nil, err 72 | } 73 | 74 | db.SetMaxIdleConns(5) 75 | db.SetMaxOpenConns(10) 76 | 77 | dbmap := &gorp.DbMap{ 78 | Db: db, 79 | Dialect: gorp.PostgresDialect{}, 80 | TypeConverter: util.TypeConverter{}, 81 | } 82 | 83 | dbmap.AddTableWithName(Game{}, "games").SetKeys(true, "ID") 84 | dbmap.AddTableWithName(Player{}, "players").SetKeys(true, "ID") 85 | dbmap.AddTableWithName(EncryptedPlayer{}, "encrypted_players") 86 | dbmap.AddTableWithName(Clan{}, "clans").SetKeys(true, "ID") 87 | dbmap.AddTableWithName(Membership{}, "memberships").SetKeys(true, "ID") 88 | dbmap.AddTableWithName(Hook{}, "hooks").SetKeys(true, "ID") 89 | 90 | // dbmap.TraceOn("[gorp]", log.New(os.Stdout, "KHAN:", log.Lmicroseconds)) 91 | return egorp.New(dbmap, dbName), nil 92 | } 93 | 94 | // Returns value or 0 95 | func nullOrInt(value sql.NullInt64) int64 { 96 | if value.Valid { 97 | v, err := value.Value() 98 | if err == nil { 99 | return v.(int64) 100 | } 101 | } 102 | return 0 103 | } 104 | 105 | // Returns value or "" 106 | func nullOrString(value sql.NullString) string { 107 | if value.Valid { 108 | v, err := value.Value() 109 | if err == nil { 110 | return v.(string) 111 | } 112 | } 113 | return "" 114 | } 115 | 116 | // Returns value or false 117 | func nullOrBool(value sql.NullBool) bool { 118 | if value.Valid { 119 | v, err := value.Value() 120 | if err == nil { 121 | return v.(bool) 122 | } 123 | } 124 | return false 125 | } 126 | -------------------------------------------------------------------------------- /cmd/prune_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd_test 9 | 10 | import ( 11 | "math/rand" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | "github.com/spf13/viper" 16 | . "github.com/topfreegames/khan/cmd" 17 | "github.com/topfreegames/khan/models" 18 | "github.com/topfreegames/khan/models/fixtures" 19 | ) 20 | 21 | var _ = Describe("Prune Command", func() { 22 | var db models.DB 23 | var err error 24 | 25 | BeforeEach(func() { 26 | ConfigFile = "../config/test.yaml" 27 | InitConfig() 28 | 29 | host := viper.GetString("postgres.host") 30 | user := viper.GetString("postgres.user") 31 | dbName := viper.GetString("postgres.dbname") 32 | password := viper.GetString("postgres.password") 33 | port := viper.GetInt("postgres.port") 34 | sslMode := viper.GetString("postgres.sslMode") 35 | 36 | db, err = models.GetDB(host, user, port, sslMode, dbName, password) 37 | Expect(err).NotTo(HaveOccurred()) 38 | 39 | _, err = db.Exec("TRUNCATE TABLE memberships CASCADE") 40 | Expect(err).NotTo(HaveOccurred()) 41 | _, err = db.Exec("TRUNCATE TABLE players CASCADE") 42 | Expect(err).NotTo(HaveOccurred()) 43 | _, err = db.Exec("TRUNCATE TABLE clans CASCADE") 44 | Expect(err).NotTo(HaveOccurred()) 45 | _, err = db.Exec("TRUNCATE TABLE games CASCADE") 46 | Expect(err).NotTo(HaveOccurred()) 47 | }) 48 | 49 | Describe("Prune Cmd", func() { 50 | It("Should prune old data", func() { 51 | totalApps := 0 52 | totalInvites := 0 53 | totalDenies := 0 54 | totalDeletes := 0 55 | 56 | for i := 0; i < 5; i++ { 57 | apps := rand.Intn(10) 58 | invites := rand.Intn(10) 59 | denies := rand.Intn(10) 60 | deletes := rand.Intn(10) 61 | _, err := fixtures.GetTestClanWithStaleData(db, apps, invites, denies, deletes) 62 | Expect(err).NotTo(HaveOccurred()) 63 | totalApps += apps 64 | totalInvites += invites 65 | totalDenies += denies 66 | totalDeletes += deletes 67 | } 68 | stats, err := PruneStaleData(false, true) 69 | Expect(err).NotTo(HaveOccurred()) 70 | 71 | Expect(stats.PendingApplicationsPruned).To(Equal(totalApps)) 72 | Expect(stats.PendingInvitesPruned).To(Equal(totalInvites)) 73 | Expect(stats.DeniedMembershipsPruned).To(Equal(totalDenies)) 74 | Expect(stats.DeletedMembershipsPruned).To(Equal(totalDeletes)) 75 | 76 | count, err := db.SelectInt("select count(*) from memberships") 77 | Expect(err).NotTo(HaveOccurred()) 78 | Expect(int(count)).To(Equal((totalApps + totalInvites + totalDenies + totalDeletes) * 2)) 79 | }) 80 | 81 | It("Should not prune games without metadata", func() { 82 | totalApps := 0 83 | totalInvites := 0 84 | totalDenies := 0 85 | totalDeletes := 0 86 | 87 | for i := 0; i < 5; i++ { 88 | apps := rand.Intn(10) 89 | invites := rand.Intn(10) 90 | denies := rand.Intn(10) 91 | deletes := rand.Intn(10) 92 | gameID, err := fixtures.GetTestClanWithStaleData(db, apps, invites, denies, deletes) 93 | Expect(err).NotTo(HaveOccurred()) 94 | 95 | _, err = db.Exec("UPDATE games SET metadata='{}' WHERE public_id=$1", gameID) 96 | Expect(err).NotTo(HaveOccurred()) 97 | 98 | totalApps += apps 99 | totalInvites += invites 100 | totalDenies += denies 101 | totalDeletes += deletes 102 | } 103 | stats, err := PruneStaleData(false, true) 104 | Expect(err).NotTo(HaveOccurred()) 105 | 106 | Expect(stats.PendingApplicationsPruned).To(Equal(0)) 107 | Expect(stats.PendingInvitesPruned).To(Equal(0)) 108 | Expect(stats.DeniedMembershipsPruned).To(Equal(0)) 109 | Expect(stats.DeletedMembershipsPruned).To(Equal(0)) 110 | 111 | count, err := db.SelectInt("select count(*) from memberships") 112 | Expect(err).NotTo(HaveOccurred()) 113 | Expect(int(count)).To(Equal((totalApps + totalInvites + totalDenies + totalDeletes) * 3)) 114 | }) 115 | }) 116 | }) 117 | -------------------------------------------------------------------------------- /bench/helpers.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package bench 9 | 10 | import ( 11 | "bytes" 12 | "encoding/json" 13 | "fmt" 14 | "io/ioutil" 15 | "net/http" 16 | 17 | uuid "github.com/satori/go.uuid" 18 | "github.com/topfreegames/extensions/v9/mongo/interfaces" 19 | "github.com/topfreegames/khan/models" 20 | "github.com/topfreegames/khan/models/fixtures" 21 | "github.com/topfreegames/khan/mongo" 22 | ) 23 | 24 | func getRoute(url string) string { 25 | return fmt.Sprintf("http://localhost:8888%s", url) 26 | } 27 | 28 | func get(url string) (*http.Response, error) { 29 | return sendTo("GET", url, nil) 30 | } 31 | 32 | func postTo(url string, payload map[string]interface{}) (*http.Response, error) { 33 | return sendTo("POST", url, payload) 34 | } 35 | 36 | func putTo(url string, payload map[string]interface{}) (*http.Response, error) { 37 | return sendTo("PUT", url, payload) 38 | } 39 | 40 | func sendTo(method, url string, payload map[string]interface{}) (*http.Response, error) { 41 | payloadJSON, err := json.Marshal(payload) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | var req *http.Request 47 | 48 | if payload != nil { 49 | req, err = http.NewRequest(method, url, bytes.NewBuffer(payloadJSON)) 50 | if err != nil { 51 | return nil, err 52 | } 53 | } else { 54 | req, err = http.NewRequest(method, url, nil) 55 | if err != nil { 56 | return nil, err 57 | } 58 | } 59 | 60 | req.Header.Set("Content-Type", "application/json") 61 | 62 | client := &http.Client{} 63 | resp, err := client.Do(req) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | return resp, nil 69 | } 70 | 71 | func getClanPayload(ownerID, clanPublicID string) map[string]interface{} { 72 | return map[string]interface{}{ 73 | "publicID": clanPublicID, 74 | "name": clanPublicID, 75 | "ownerPublicID": ownerID, 76 | "metadata": map[string]interface{}{"x": "a"}, 77 | "allowApplication": true, 78 | "autoJoin": true, 79 | } 80 | } 81 | 82 | func getPlayerPayload(playerPublicID string) map[string]interface{} { 83 | return map[string]interface{}{ 84 | "publicID": playerPublicID, 85 | "name": playerPublicID, 86 | "metadata": map[string]interface{}{"x": "a"}, 87 | } 88 | } 89 | 90 | func getGameAndPlayer(db models.DB, mongoDB interfaces.MongoDB) (*models.Game, *models.Player, error) { 91 | game := fixtures.GameFactory.MustCreateWithOption(map[string]interface{}{ 92 | "PublicID": uuid.NewV4().String(), 93 | "MaxClansPerPlayer": 999999, 94 | }).(*models.Game) 95 | err := db.Insert(game) 96 | if err != nil { 97 | return nil, nil, err 98 | } 99 | player := fixtures.PlayerFactory.MustCreateWithOption(map[string]interface{}{ 100 | "GameID": game.PublicID, 101 | "PublicID": uuid.NewV4().String(), 102 | }).(*models.Player) 103 | err = db.Insert(player) 104 | if err != nil { 105 | return nil, nil, err 106 | } 107 | 108 | err = mongoDB.Run(mongo.GetClanNameTextIndexCommand(game.PublicID, false), nil) 109 | if err != nil { 110 | return nil, nil, err 111 | } 112 | 113 | return game, player, nil 114 | } 115 | 116 | func validateResp(res *http.Response, err error) { 117 | if err != nil { 118 | panic(err) 119 | } 120 | if res.StatusCode != 200 { 121 | bts, _ := ioutil.ReadAll(res.Body) 122 | fmt.Printf("Request failed with status code %d\n", res.StatusCode) 123 | panic(string(bts)) 124 | } 125 | } 126 | 127 | func createClans(db models.DB, game *models.Game, owner *models.Player, numberOfClans int) ([]*models.Clan, error) { 128 | var clans []*models.Clan 129 | for i := 0; i < numberOfClans; i++ { 130 | clan := fixtures.ClanFactory.MustCreateWithOption(map[string]interface{}{ 131 | "GameID": game.PublicID, 132 | "PublicID": uuid.NewV4().String(), 133 | "OwnerID": owner.ID, 134 | }).(*models.Clan) 135 | 136 | err := db.Insert(clan) 137 | if err != nil { 138 | return nil, err 139 | } 140 | clans = append(clans, clan) 141 | } 142 | 143 | return clans, nil 144 | } 145 | -------------------------------------------------------------------------------- /loadtest/README.md: -------------------------------------------------------------------------------- 1 | Load Test for Khan API 2 | ====================== 3 | 4 | This application performs a random sequence of a specified amount of operations on a remote Khan API server, with a specified time interval between two consecutive operations. It also allows multiple goroutines for local concurrency (multiple concurrent random sequences). Usage: `../khan loadtest --help` 5 | 6 | # Game parameters 7 | Khan does not offer a route to get game information, so the membership level for application and the maximum number of members per clan are defined under the key `loadtest.game` within `../config/local.yaml`: 8 | ``` 9 | loadtest: 10 | game: 11 | membershipLevel: "member" 12 | maxMembers: 50 13 | ``` 14 | Or setting the following environment variables: 15 | ``` 16 | KHAN_LOADTEST_GAME_MEMBERSHIPLEVEL (default: "") 17 | KHAN_LOADTEST_GAME_MAXMEMBERS (default: 0) 18 | ``` 19 | 20 | # Client parameters 21 | Client parameters are defined under the key `loadtest.client` within `../config/local.yaml`: 22 | ``` 23 | loadtest: 24 | client: 25 | url: "http://localhost:8080" 26 | gameid: "epiccardgame" 27 | ``` 28 | Or setting the following environment variables: 29 | ``` 30 | KHAN_LOADTEST_CLIENT_URL: URL including protocol (http/https) and port to remote Khan API server (default: "") 31 | KHAN_LOADTEST_CLIENT_USER: basic auth username (default: "") 32 | KHAN_LOADTEST_CLIENT_PASS: basic auth password (default: "") 33 | KHAN_LOADTEST_CLIENT_GAMEID: game public ID (default: "") 34 | KHAN_LOADTEST_CLIENT_TIMEOUT: nanoseconds to wait before timing out a request (default: 500 ms) 35 | KHAN_LOADTEST_CLIENT_MAXIDLECONNS: max keep-alive connections to keep among all hosts (default: 100) 36 | KHAN_LOADTEST_CLIENT_MAXIDLECONNSPERHOST: max keep-alive connections to keep per-host (default: 2) 37 | ``` 38 | 39 | # Operation parameters 40 | The amount of operations per sequence/goroutine, the time interval between two consecutive operations and the configurations for the operations themselves are defined under the key `loadtest.operations` within `../config/local.yaml`: 41 | ``` 42 | loadtest: 43 | operations: 44 | amount: 1 45 | interval: 46 | duration: "1s" 47 | updateSharedClanScore: 48 | probability: 1 49 | createPlayer: 50 | probability: 1 51 | createClan: 52 | probability: 1 53 | autoJoin: "false" 54 | retrieveClan: 55 | probability: 1 56 | leaveClan: 57 | probability: 1 58 | transferClanOwnership: 59 | probability: 1 60 | applyForMembership: 61 | probability: 1 62 | selfDeleteMembership: 63 | probability: 1 64 | searchClans: 65 | probability: 1 66 | retrieveClansSummaries: 67 | probability: 1 68 | ``` 69 | Or setting the following environment variables: 70 | ``` 71 | KHAN_LOADTEST_OPERATIONS_AMOUNT (default: 0) 72 | KHAN_LOADTEST_OPERATIONS_INTERVAL_DURATION (default: 0) 73 | KHAN_LOADTEST_OPERATIONS_UPDATESHAREDCLANSCORE_PROBABILITY (default: 1) 74 | KHAN_LOADTEST_OPERATIONS_CREATEPLAYER_PROBABILITY (default: 1) 75 | KHAN_LOADTEST_OPERATIONS_CREATECLAN_PROBABILITY (default: 1) 76 | KHAN_LOADTEST_OPERATIONS_CREATECLAN_AUTOJOIN (default: true) 77 | KHAN_LOADTEST_OPERATIONS_RETRIEVECLAN_PROBABILITY (default: 1) 78 | KHAN_LOADTEST_OPERATIONS_LEAVECLAN_PROBABILITY (default: 1) 79 | KHAN_LOADTEST_OPERATIONS_TRANSFERCLANOWNERSHIP_PROBABILITY (default: 1) 80 | KHAN_LOADTEST_OPERATIONS_APPLYFORMEMBERSHIP_PROBABILITY (default: 1) 81 | KHAN_LOADTEST_OPERATIONS_SELFDELETEMEMBERSHIP_PROBABILITY (default: 1) 82 | KHAN_LOADTEST_OPERATIONS_SEARCHCLANS_PROBABILITY (default: 1) 83 | KHAN_LOADTEST_OPERATIONS_RETRIEVECLANSSUMMARIES_PROBABILITY (default: 1) 84 | ``` 85 | 86 | # Operations with clans shared among different load test processes 87 | Some operations are targeted to a set of clans that should be shared among different processes/goroutines. One of these operations is supposed to test the most common sequence of requests (`updatePlayer`, then `getClan`, then `updateClan`) in its most common use case, which is a number of different clients updating the score of a particular clan within its metadata field. Use the file `../config/loadTestSharedClans.yaml` to specify the list of public IDs for shared clans: 88 | 89 | ``` 90 | clans: 91 | - "clan1publicID" 92 | - "clan2publicID" 93 | ``` 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Khan 2 | 3 | [![Khan](https://github.com/topfreegames/khan/actions/workflows/go.yml/badge.svg)](https://github.com/topfreegames/khan/actions/workflows/go.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/topfreegames/khan/badge.svg?branch=master)](https://coveralls.io/github/topfreegames/khan?branch=master) 5 | [![Code Climate](https://codeclimate.com/github/topfreegames/khan/badges/gpa.svg)](https://codeclimate.com/github/topfreegames/khan) 6 | [![Go Report Card](https://goreportcard.com/badge/github.com/topfreegames/khan)](https://goreportcard.com/report/github.com/topfreegames/khan) 7 | [![Docs](https://readthedocs.org/projects/khan-api/badge/?version=latest 8 | )](http://khan-api.readthedocs.io/en/latest/) 9 | [![](https://imagelayers.io/badge/tfgco/khan:latest.svg)](https://imagelayers.io/?images=tfgco/khan:latest 'Khan Image Layers') 10 | 11 | Khan will drive all your enemies to the sea (and also take care of your game's clans)! 12 | 13 | What is Khan? Khan is an HTTP "resty" API for managing clans for games. It could be used to manage groups of people, but our aim is players in a game. 14 | 15 | Khan allows your app to focus on the interaction required to creating clans and managing applications, instead of the backend required for actually doing it. 16 | 17 | ## Features 18 | 19 | * **Multi-tenant** - Khan already works for as many games as you need, just keep adding new games; 20 | * **Clan Management** - Create and manage clans, their metadata as well as promote and demote people in their rosters; 21 | * **Player Management** - Manage players and their metadata, as well as their applications to clans; 22 | * **Applications** - Khan handles the work involved with applying to clans, inviting people to clans, accepting, denying and kicking; 23 | * **Clan Search** - Search a list of clans to present your player with relevant options; 24 | * **Top Clans** - Choose from a specific dimension to return a list of the top clans in that specific range (SOON); 25 | * **Web Hooks** - Need to integrate your clan system with another application? We got your back! Use our web hooks sytem and plug into whatever events you need; 26 | * **Auditing Trail** - Track every action coming from your games (SOON); 27 | * **New Relic Support** - Natively support new relic with segments in each API route for easy detection of bottlenecks; 28 | * **Easy to deploy** - Khan comes with containers already exported to docker hub for every single of our successful builds. Just pick your choice! 29 | 30 | Read more about Khan in our [comprehensive documentation](http://khan-api.readthedocs.io/). 31 | 32 | ## Hacking Khan 33 | 34 | ### Setup 35 | 36 | Make sure you have go installed on your machine. 37 | If you use homebrew you can install it with `brew install go`. 38 | 39 | Run `make setup`. 40 | 41 | ### Running the application 42 | 43 | Create the development database with `make migrate` (first time only). 44 | 45 | Run the api with `make run`. 46 | 47 | ### Running with docker 48 | 49 | Provided you have docker installed, to build Khan's image run: 50 | 51 | $ make build-docker 52 | 53 | To run a new khan instance, run: 54 | 55 | $ make run-docker 56 | 57 | ### Running with docker-compose 58 | 59 | We already provide a docker-compose.yml as well with all dependencies configured for you to run. To run Khan and all its dependencies, run: 60 | 61 | ```sh 62 | $ docker-compose up 63 | ``` 64 | 65 | **Note** If you are running it on MacOS, you will need to update the amount of RAM docker has access to. Docker, by default, can use 2GB of RAM, however, Khan uses an instance of ElasticSearch and it needs at least 2GB of RAM to work properly. So, if you are experiencing problems while connecting to the elastic search, this might be the root cause of the problem. 66 | 67 | ### Tests 68 | 69 | Running tests can be done with `make test`, while creating the test database can be accomplished with `make drop-test` and `make db-test`. 70 | 71 | ### Benchmark 72 | 73 | Running benchmarks can be done with `make ci-perf`. 74 | 75 | ### Coverage 76 | 77 | Getting coverage data can be achieved with `make coverage`, while reading the actual results can be done with `make coverage-html`. 78 | 79 | ### Static Analysis 80 | 81 | Khan goes through some static analysis tools for go. To run them just use `make static`. 82 | 83 | Right now, gocyclo can't process the vendor folder, so we just ignore the exit code for it, while maintaining the output for anything not in the vendor folder. 84 | 85 | ## Security 86 | 87 | If you have found a security vulnerability, please email security@tfgco.com 88 | -------------------------------------------------------------------------------- /cmd/migrate.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package cmd 9 | 10 | import ( 11 | "fmt" 12 | "io/ioutil" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | 17 | "github.com/spf13/cobra" 18 | "github.com/spf13/viper" 19 | "github.com/topfreegames/extensions/v9/gorp" 20 | "github.com/topfreegames/goose/lib/goose" 21 | "github.com/topfreegames/khan/db" 22 | "github.com/topfreegames/khan/models" 23 | ) 24 | 25 | var migrationVersion int64 26 | 27 | func createTempDbDir() (string, error) { 28 | dir, err := ioutil.TempDir("", "migrations") 29 | if err != nil { 30 | fmt.Println(err.Error()) 31 | return "", err 32 | } 33 | 34 | fmt.Printf("Created temporary directory %s.\n", dir) 35 | assetNames := db.AssetNames() 36 | for _, assetName := range assetNames { 37 | asset, err := db.Asset(assetName) 38 | if err != nil { 39 | return "", err 40 | } 41 | fileName := strings.SplitN(assetName, "/", 2)[1] // remove migrations folder from fileName 42 | err = ioutil.WriteFile(filepath.Join(dir, fileName), asset, 0777) 43 | if err != nil { 44 | return "", err 45 | } 46 | fmt.Printf("Wrote migration file %s.\n", fileName) 47 | } 48 | return dir, nil 49 | } 50 | 51 | func getDatabase() (*gorp.Database, error) { 52 | viper.SetEnvPrefix("khan") 53 | viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 54 | viper.AutomaticEnv() 55 | host := viper.GetString("postgres.host") 56 | user := viper.GetString("postgres.user") 57 | dbName := viper.GetString("postgres.dbname") 58 | password := viper.GetString("postgres.password") 59 | port := viper.GetInt("postgres.port") 60 | sslMode := viper.GetString("postgres.sslMode") 61 | 62 | fmt.Printf( 63 | "\nConnecting to %s:%d as %s using sslMode=%s to db %s...\n\n", 64 | host, port, user, sslMode, dbName, 65 | ) 66 | db, err := models.GetDB(host, user, port, sslMode, dbName, password) 67 | return db.(*gorp.Database), err 68 | } 69 | 70 | func getGooseConf() *goose.DBConf { 71 | migrationsDir, err := createTempDbDir() 72 | 73 | if err != nil { 74 | panic("Could not create migration files...") 75 | } 76 | 77 | return &goose.DBConf{ 78 | MigrationsDir: migrationsDir, 79 | Env: "production", 80 | Driver: goose.DBDriver{ 81 | Name: "postgres", 82 | OpenStr: "", 83 | Dialect: &goose.PostgresDialect{}, 84 | }, 85 | } 86 | } 87 | 88 | // MigrationError identified rigrations running error 89 | type MigrationError struct { 90 | Message string 91 | } 92 | 93 | func (err *MigrationError) Error() string { 94 | return fmt.Sprintf("Could not run migrations: %s", err.Message) 95 | } 96 | 97 | //RunMigrations in selected DB 98 | func RunMigrations(migrationVersion int64) error { 99 | conf := getGooseConf() 100 | defer os.RemoveAll(conf.MigrationsDir) 101 | db, err := getDatabase() 102 | if err != nil { 103 | return &MigrationError{fmt.Sprintf("could not connect to database: %s", err.Error())} 104 | } 105 | 106 | targetVersion := migrationVersion 107 | if targetVersion == -1 { 108 | // Get the latest possible migration 109 | latest, err := goose.GetMostRecentDBVersion(conf.MigrationsDir) 110 | if err != nil { 111 | return &MigrationError{fmt.Sprintf("could not get migrations at %s: %s", conf.MigrationsDir, err.Error())} 112 | } 113 | targetVersion = latest 114 | } 115 | 116 | // Migrate up to the latest version 117 | err = goose.RunMigrationsOnDb(conf, conf.MigrationsDir, targetVersion, db.Inner().Db) 118 | if err != nil { 119 | return &MigrationError{fmt.Sprintf("could not run migrations to %d: %s", targetVersion, err.Error())} 120 | } 121 | fmt.Printf("Migrated database successfully to version %d.\n", targetVersion) 122 | return nil 123 | } 124 | 125 | // migrateCmd represents the migrate command 126 | var migrateCmd = &cobra.Command{ 127 | Use: "migrate", 128 | Short: "migrates the database up or down", 129 | Long: `Migrate the database specified in the configuration file to the given version (or latest if none provided)`, 130 | Run: func(cmd *cobra.Command, args []string) { 131 | InitConfig() 132 | err := RunMigrations(migrationVersion) 133 | if err != nil { 134 | panic(err.Error()) 135 | } 136 | }, 137 | } 138 | 139 | func init() { 140 | RootCmd.AddCommand(migrateCmd) 141 | 142 | migrateCmd.Flags().Int64VarP(&migrationVersion, "target", "t", -1, "Version to run up to or down to") 143 | } 144 | -------------------------------------------------------------------------------- /api/clan_helpers.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | 10 | import ( 11 | "strings" 12 | 13 | "github.com/topfreegames/khan/log" 14 | "github.com/topfreegames/khan/models" 15 | "github.com/uber-go/zap" 16 | ) 17 | 18 | func dispatchClanOwnershipChangeHook(app *App, hookType int, clan *models.Clan, previousOwner *models.Player, newOwner *models.Player) error { 19 | newOwnerPublicID := "" 20 | if newOwner != nil { 21 | newOwnerPublicID = newOwner.PublicID 22 | } 23 | 24 | logger := app.Logger.With( 25 | zap.String("source", "clanHandler"), 26 | zap.String("operation", "dispatchClanOwnershipChangeHook"), 27 | zap.Int("hookType", hookType), 28 | zap.String("gameID", clan.GameID), 29 | zap.String("clanPublicID", clan.PublicID), 30 | zap.String("newOwnerPublicID", newOwnerPublicID), 31 | zap.String("previousOwnerPublicID", previousOwner.PublicID), 32 | ) 33 | 34 | previousOwnerJSON := previousOwner.Serialize(app.EncryptionKey) 35 | delete(previousOwnerJSON, "gameID") 36 | 37 | clanJSON := clan.Serialize() 38 | delete(clanJSON, "gameID") 39 | 40 | result := map[string]interface{}{ 41 | "gameID": clan.GameID, 42 | "clan": clanJSON, 43 | "previousOwner": previousOwnerJSON, 44 | "newOwner": nil, 45 | "isDeleted": true, 46 | } 47 | 48 | if newOwner != nil { 49 | newOwnerJSON := newOwner.Serialize(app.EncryptionKey) 50 | delete(newOwnerJSON, "gameID") 51 | result["newOwner"] = newOwnerJSON 52 | result["isDeleted"] = false 53 | } 54 | 55 | log.D(logger, "Dispatching hook...") 56 | app.DispatchHooks(clan.GameID, hookType, result) 57 | log.D(logger, "Hook dispatch succeeded.") 58 | 59 | return nil 60 | } 61 | 62 | func serializeClans(clans []models.Clan, includePublicID bool) []map[string]interface{} { 63 | serializedClans := make([]map[string]interface{}, len(clans)) 64 | for i, clan := range clans { 65 | serializedClans[i] = serializeClan(&clan, includePublicID) 66 | } 67 | 68 | return serializedClans 69 | } 70 | 71 | func serializeClan(clan *models.Clan, includePublicID bool) map[string]interface{} { 72 | serial := map[string]interface{}{ 73 | "name": clan.Name, 74 | "metadata": clan.Metadata, 75 | "allowApplication": clan.AllowApplication, 76 | "autoJoin": clan.AutoJoin, 77 | "membershipCount": clan.MembershipCount, 78 | } 79 | 80 | if includePublicID { 81 | serial["publicID"] = clan.PublicID 82 | } 83 | 84 | return serial 85 | } 86 | 87 | func validateUpdateClanDispatch(game *models.Game, sourceClan *models.Clan, clan *models.Clan, metadata map[string]interface{}, logger zap.Logger) bool { 88 | cl := logger.With( 89 | zap.String("clanUpdateMetadataFieldsHookTriggerWhitelist", game.ClanUpdateMetadataFieldsHookTriggerWhitelist), 90 | ) 91 | 92 | changedName := clan.Name != sourceClan.Name 93 | changedAllowApplication := clan.AllowApplication != sourceClan.AllowApplication 94 | changedAutoJoin := clan.AutoJoin != sourceClan.AutoJoin 95 | if changedName || changedAllowApplication || changedAutoJoin { 96 | log.D(cl, "One of the main clan properties changed") 97 | return true 98 | } 99 | 100 | if game.ClanUpdateMetadataFieldsHookTriggerWhitelist == "" { 101 | log.D(cl, "Clan has no metadata whitelist for update hook") 102 | return false 103 | } 104 | 105 | log.D(cl, "Verifying fields for clan update hook dispatch...") 106 | fields := strings.Split(game.ClanUpdateMetadataFieldsHookTriggerWhitelist, ",") 107 | for _, field := range fields { 108 | oldVal, existsOld := sourceClan.Metadata[field] 109 | newVal, existsNew := metadata[field] 110 | log.D(logger, "Verifying field for change...", func(cm log.CM) { 111 | cm.Write( 112 | zap.Bool("existsOld", existsOld), 113 | zap.Bool("existsNew", existsNew), 114 | zap.Object("oldVal", oldVal), 115 | zap.Object("newVal", newVal), 116 | zap.String("field", field), 117 | ) 118 | }) 119 | 120 | if existsOld != existsNew { 121 | log.D(logger, "Found difference in field. Dispatching hook...", func(cm log.CM) { 122 | cm.Write(zap.String("field", field)) 123 | }) 124 | return true 125 | } 126 | 127 | if existsOld && oldVal != newVal { 128 | log.D(logger, "Found difference in field. Dispatching hook...", func(cm log.CM) { 129 | cm.Write(zap.String("field", field)) 130 | }) 131 | return true 132 | } 133 | } 134 | 135 | return false 136 | } 137 | -------------------------------------------------------------------------------- /models/hook.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package models 9 | 10 | import ( 11 | "github.com/satori/go.uuid" 12 | "github.com/topfreegames/khan/util" 13 | 14 | "github.com/go-gorp/gorp" 15 | ) 16 | 17 | const ( 18 | //GameUpdatedHook happens when a game is updated 19 | GameUpdatedHook = 0 20 | 21 | //PlayerCreatedHook happens when a new player is created 22 | PlayerCreatedHook = 1 23 | 24 | //PlayerUpdatedHook happens when a player is updated 25 | PlayerUpdatedHook = 2 26 | 27 | //ClanCreatedHook happens when a clan is created 28 | ClanCreatedHook = 3 29 | 30 | //ClanUpdatedHook happens when a clan is updated 31 | ClanUpdatedHook = 4 32 | 33 | //ClanLeftHook happens when a clan owner rage quits 34 | ClanLeftHook = 5 35 | 36 | //ClanOwnershipTransferredHook happens when a clan owner transfers ownership to another player 37 | ClanOwnershipTransferredHook = 6 38 | 39 | //MembershipApplicationCreatedHook happens when a new application or invite to a clan is created 40 | MembershipApplicationCreatedHook = 7 41 | 42 | //MembershipApprovedHook happens when a clan membership is approved 43 | MembershipApprovedHook = 8 44 | 45 | //MembershipDeniedHook happens when a clan membership is denied 46 | MembershipDeniedHook = 9 47 | 48 | //MembershipPromotedHook happens when a clan member is promoted 49 | MembershipPromotedHook = 10 50 | 51 | //MembershipDemotedHook happens when a clan member is demoted 52 | MembershipDemotedHook = 11 53 | 54 | //MembershipLeftHook happens when a player leaves a clan 55 | MembershipLeftHook = 12 56 | ) 57 | 58 | // Hook identifies a webhook for a given event 59 | type Hook struct { 60 | ID int `db:"id"` 61 | GameID string `db:"game_id"` 62 | PublicID string `db:"public_id"` 63 | EventType int `db:"event_type"` 64 | URL string `db:"url"` 65 | CreatedAt int64 `db:"created_at"` 66 | UpdatedAt int64 `db:"updated_at"` 67 | } 68 | 69 | // PreInsert populates fields before inserting a new hook 70 | func (h *Hook) PreInsert(s gorp.SqlExecutor) error { 71 | h.CreatedAt = util.NowMilli() 72 | h.UpdatedAt = h.CreatedAt 73 | return nil 74 | } 75 | 76 | // PreUpdate populates fields before updating a hook 77 | func (h *Hook) PreUpdate(s gorp.SqlExecutor) error { 78 | h.UpdatedAt = util.NowMilli() 79 | return nil 80 | } 81 | 82 | // GetHookByID returns a hook by id 83 | func GetHookByID(db DB, id int) (*Hook, error) { 84 | obj, err := db.Get(Hook{}, id) 85 | if err != nil || obj == nil { 86 | return nil, &ModelNotFoundError{"Hook", id} 87 | } 88 | 89 | hook := obj.(*Hook) 90 | return hook, nil 91 | } 92 | 93 | // GetHookByPublicID returns a hook by game id and public id 94 | func GetHookByPublicID(db DB, gameID string, publicID string) (*Hook, error) { 95 | var hook Hook 96 | err := db.SelectOne(&hook, "SELECT * FROM hooks WHERE game_id=$1 AND public_id=$2", gameID, publicID) 97 | if err != nil || &hook == nil { 98 | return nil, &ModelNotFoundError{"Hook", publicID} 99 | } 100 | return &hook, nil 101 | } 102 | 103 | // GetHookByDetails returns a hook by its details (GameID, EventType and Hook URL) 104 | // If no hook is found returns nil. 105 | func GetHookByDetails(db DB, gameID string, eventType int, hookURL string) *Hook { 106 | var hook Hook 107 | err := db.SelectOne(&hook, "SELECT * FROM hooks WHERE game_id=$1 AND event_type=$2 AND url=$3", gameID, eventType, hookURL) 108 | if err != nil || &hook == nil { 109 | return nil 110 | } 111 | return &hook 112 | } 113 | 114 | // CreateHook returns a newly created event hook 115 | func CreateHook(db DB, gameID string, eventType int, url string) (*Hook, error) { 116 | hook := GetHookByDetails(db, gameID, eventType, url) 117 | 118 | if hook != nil { 119 | return hook, nil 120 | } 121 | 122 | publicID := uuid.NewV4().String() 123 | hook = &Hook{ 124 | GameID: gameID, 125 | PublicID: publicID, 126 | EventType: eventType, 127 | URL: url, 128 | } 129 | err := db.Insert(hook) 130 | if err != nil { 131 | return nil, err 132 | } 133 | return hook, nil 134 | } 135 | 136 | // RemoveHook removes a hook by public ID 137 | func RemoveHook(db DB, gameID string, publicID string) error { 138 | hook, err := GetHookByPublicID(db, gameID, publicID) 139 | if err != nil { 140 | return err 141 | } 142 | _, err = db.Delete(hook) 143 | return err 144 | } 145 | 146 | // GetAllHooks returns all the available hooks 147 | func GetAllHooks(db DB) ([]*Hook, error) { 148 | var hooks []*Hook 149 | _, err := db.Select(&hooks, "SELECT * FROM hooks") 150 | if err != nil { 151 | return nil, err 152 | } 153 | return hooks, nil 154 | } 155 | -------------------------------------------------------------------------------- /caches/clan_test.go: -------------------------------------------------------------------------------- 1 | package caches_test 2 | 3 | import ( 4 | "time" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | uuid "github.com/satori/go.uuid" 9 | "github.com/topfreegames/khan/models" 10 | "github.com/topfreegames/khan/models/fixtures" 11 | "github.com/topfreegames/khan/testing" 12 | ) 13 | 14 | var _ = Describe("Clan Cache", func() { 15 | var testDb models.DB 16 | 17 | BeforeEach(func() { 18 | var err error 19 | testDb, err = testing.GetTestDB() 20 | Expect(err).NotTo(HaveOccurred()) 21 | 22 | fixtures.ConfigureAndStartGoWorkers() 23 | }) 24 | 25 | Describe("Clans Summaries", func() { 26 | getPublicIDsAndIDToIndexMap := func(clans []*models.Clan) ([]string, map[string]int) { 27 | var publicIDs []string 28 | idToIdx := make(map[string]int) 29 | for i, clan := range clans { 30 | publicIDs = append(publicIDs, clan.PublicID) 31 | idToIdx[clan.PublicID] = i + 1 32 | } 33 | return publicIDs, idToIdx 34 | } 35 | 36 | assertFirstCacheCall := func(clans []*models.Clan, idToIdx map[string]int, clansSummaries []map[string]interface{}) { 37 | Expect(len(clansSummaries)).To(Equal(len(clans))) 38 | for _, clanPayload := range clansSummaries { 39 | // assert public ID 40 | publicID, ok := clanPayload["publicID"].(string) 41 | Expect(ok).To(BeTrue()) 42 | Expect(idToIdx[publicID]).To(BeNumerically(">", 0)) 43 | 44 | // assert name 45 | name, ok := clanPayload["name"].(string) 46 | Expect(ok).To(BeTrue()) 47 | Expect(name).To(Equal(clans[idToIdx[publicID]-1].Name)) 48 | } 49 | } 50 | 51 | updateClan := func(db models.DB, clan *models.Clan) { 52 | clan.Name = "different name" 53 | _, err := db.Update(clan) 54 | Expect(err).NotTo(HaveOccurred()) 55 | } 56 | 57 | assertSecondCacheCall := func(clans []*models.Clan, clansSummaries, secondClansSummaries []map[string]interface{}, shouldBeChanged bool) { 58 | Expect(len(secondClansSummaries)).To(Equal(len(clans))) 59 | 60 | // assert public ID 61 | secondPayload := secondClansSummaries[0] 62 | secondPublicID, ok := secondPayload["publicID"].(string) 63 | Expect(ok).To(BeTrue()) 64 | Expect(secondPublicID).To(Equal(clans[0].PublicID)) 65 | 66 | // assert name 67 | firstName, ok := clansSummaries[0]["name"].(string) 68 | Expect(ok).To(BeTrue()) 69 | secondName, ok := secondPayload["name"].(string) 70 | Expect(ok).To(BeTrue()) 71 | if shouldBeChanged { 72 | Expect(secondName).NotTo(Equal(firstName)) 73 | Expect(secondName).To(Equal(clans[0].Name)) 74 | } else { 75 | Expect(secondName).To(Equal(firstName)) 76 | Expect(secondName).NotTo(Equal(clans[0].Name)) 77 | } 78 | } 79 | 80 | It("Should return a cached payload for a second call made immediately after the first", func() { 81 | mongoDB, err := testing.GetTestMongo() 82 | Expect(err).NotTo(HaveOccurred()) 83 | 84 | gameID := uuid.NewV4().String() 85 | _, clans, err := fixtures.CreateTestClans(testDb, mongoDB, gameID, "test-sort-clan", 10, fixtures.EnqueueClanForMongoUpdate) 86 | Expect(err).NotTo(HaveOccurred()) 87 | 88 | publicIDs, idToIdx := getPublicIDsAndIDToIndexMap(clans) 89 | 90 | cache := testing.GetTestClansSummariesCache(time.Minute, time.Minute) 91 | 92 | // first call 93 | clansSummaries, err := cache.GetClansSummaries(testDb, gameID, publicIDs) 94 | Expect(err).NotTo(HaveOccurred()) 95 | assertFirstCacheCall(clans, idToIdx, clansSummaries) 96 | 97 | // update a clan 98 | updateClan(testDb, clans[0]) 99 | 100 | // second call 101 | secondClansSummaries, err := cache.GetClansSummaries(testDb, gameID, publicIDs) 102 | Expect(err).NotTo(HaveOccurred()) 103 | assertSecondCacheCall(clans, clansSummaries, secondClansSummaries, false) 104 | }) 105 | 106 | It("Should return fresh information after expiration time is reached", func() { 107 | mongoDB, err := testing.GetTestMongo() 108 | Expect(err).NotTo(HaveOccurred()) 109 | 110 | gameID := uuid.NewV4().String() 111 | _, clans, err := fixtures.CreateTestClans(testDb, mongoDB, gameID, "test-sort-clan", 10, fixtures.EnqueueClanForMongoUpdate) 112 | Expect(err).NotTo(HaveOccurred()) 113 | 114 | publicIDs, idToIdx := getPublicIDsAndIDToIndexMap(clans) 115 | 116 | cache := testing.GetTestClansSummariesCache(time.Second/4, time.Minute) 117 | 118 | // first call 119 | clansSummaries, err := cache.GetClansSummaries(testDb, gameID, publicIDs) 120 | Expect(err).NotTo(HaveOccurred()) 121 | assertFirstCacheCall(clans, idToIdx, clansSummaries) 122 | 123 | // update a clan 124 | updateClan(testDb, clans[0]) 125 | time.Sleep(time.Second / 2) 126 | 127 | // second call 128 | secondClansSummaries, err := cache.GetClansSummaries(testDb, gameID, publicIDs) 129 | Expect(err).NotTo(HaveOccurred()) 130 | assertSecondCacheCall(clans, clansSummaries, secondClansSummaries, true) 131 | }) 132 | }) 133 | }) 134 | -------------------------------------------------------------------------------- /api/helpers.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | 10 | import ( 11 | "encoding/json" 12 | "errors" 13 | "fmt" 14 | "io/ioutil" 15 | "net/http" 16 | "reflect" 17 | "strings" 18 | 19 | "github.com/spf13/viper" 20 | 21 | "github.com/labstack/echo" 22 | "github.com/mailru/easyjson/jlexer" 23 | "github.com/mailru/easyjson/jwriter" 24 | "github.com/topfreegames/khan/log" 25 | "github.com/topfreegames/khan/models" 26 | "github.com/uber-go/zap" 27 | ) 28 | 29 | //EasyJSONUnmarshaler describes a struct able to unmarshal json 30 | type EasyJSONUnmarshaler interface { 31 | UnmarshalEasyJSON(l *jlexer.Lexer) 32 | } 33 | 34 | //EasyJSONMarshaler describes a struct able to marshal json 35 | type EasyJSONMarshaler interface { 36 | MarshalEasyJSON(w *jwriter.Writer) 37 | } 38 | 39 | // FailWith fails with the specified message 40 | func FailWith(status int, message string, c echo.Context) error { 41 | payload := map[string]interface{}{ 42 | "success": false, 43 | "reason": message, 44 | } 45 | return c.JSON(status, payload) 46 | } 47 | 48 | // FailWithError fails with the specified error 49 | func FailWithError(err error, c echo.Context) error { 50 | t := reflect.TypeOf(err) 51 | status, ok := map[string]int{ 52 | "*models.ModelNotFoundError": http.StatusNotFound, 53 | "*models.PlayerReachedMaxInvitesError": http.StatusBadRequest, 54 | "*models.ForbiddenError": http.StatusForbidden, 55 | "*models.PlayerCannotPerformMembershipActionError": http.StatusForbidden, 56 | "*models.AlreadyHasValidMembershipError": http.StatusConflict, 57 | "*models.CannotApproveOrDenyMembershipAlreadyProcessedError": http.StatusConflict, 58 | "*models.CannotPromoteOrDemoteMemberLevelError": http.StatusConflict, 59 | }[t.String()] 60 | 61 | if !ok { 62 | status = http.StatusInternalServerError 63 | } 64 | 65 | return FailWith(status, err.Error(), c) 66 | } 67 | 68 | // SucceedWith sends payload to user with status 200 69 | func SucceedWith(payload map[string]interface{}, c echo.Context) error { 70 | f := func() error { 71 | payload["success"] = true 72 | return c.JSON(http.StatusOK, payload) 73 | } 74 | return f() 75 | } 76 | 77 | //LoadJSONPayload loads the JSON payload to the given struct validating all fields are not null 78 | func LoadJSONPayload(payloadStruct interface{}, c echo.Context, logger zap.Logger) error { 79 | log.D(logger, "Loading payload...") 80 | 81 | data, err := GetRequestBody(c) 82 | if err != nil { 83 | log.E(logger, "Loading payload failed.", func(cm log.CM) { 84 | cm.Write(zap.Error(err)) 85 | }) 86 | return err 87 | } 88 | 89 | unmarshaler, ok := payloadStruct.(EasyJSONUnmarshaler) 90 | if !ok { 91 | err := fmt.Errorf("Can't unmarshal specified payload since it does not implement easyjson interface") 92 | log.E(logger, "Loading payload failed.", func(cm log.CM) { 93 | cm.Write(zap.Error(err)) 94 | }) 95 | return err 96 | } 97 | 98 | lexer := jlexer.Lexer{Data: []byte(data)} 99 | unmarshaler.UnmarshalEasyJSON(&lexer) 100 | if err = lexer.Error(); err != nil { 101 | log.E(logger, "Loading payload failed.", func(cm log.CM) { 102 | cm.Write(zap.Error(err)) 103 | }) 104 | return err 105 | } 106 | 107 | if validatable, ok := payloadStruct.(Validatable); ok { 108 | missingFieldErrors := validatable.Validate() 109 | 110 | if len(missingFieldErrors) != 0 { 111 | err := errors.New(strings.Join(missingFieldErrors[:], ", ")) 112 | log.E(logger, "Loading payload failed.", func(cm log.CM) { 113 | cm.Write(zap.Error(err)) 114 | }) 115 | return err 116 | } 117 | } 118 | 119 | log.D(logger, "Payload loaded successfully.") 120 | return nil 121 | } 122 | 123 | //GetRequestBody from echo context 124 | func GetRequestBody(c echo.Context) ([]byte, error) { 125 | bodyCache := c.Get("requestBody") 126 | if bodyCache != nil { 127 | return bodyCache.([]byte), nil 128 | } 129 | body := c.Request().Body() 130 | b, err := ioutil.ReadAll(body) 131 | if err != nil { 132 | return nil, err 133 | } 134 | c.Set("requestBody", b) 135 | return b, nil 136 | } 137 | 138 | //GetRequestJSON as the specified interface from echo context 139 | func GetRequestJSON(payloadStruct interface{}, c echo.Context) error { 140 | body, err := GetRequestBody(c) 141 | if err != nil { 142 | return err 143 | } 144 | 145 | err = json.Unmarshal(body, payloadStruct) 146 | if err != nil { 147 | return err 148 | } 149 | 150 | return nil 151 | } 152 | 153 | // SetRetrieveClanHandlerConfigurationDefaults sets the default configs for RetrieveClanHandler 154 | func SetRetrieveClanHandlerConfigurationDefaults(config *viper.Viper) { 155 | config.SetDefault(models.MaxPendingApplicationsKey, 100) 156 | config.SetDefault(models.MaxPendingInvitesKey, 100) 157 | config.SetDefault(models.PendingApplicationsOrderKey, models.Newest) 158 | config.SetDefault(models.PendingInvitesOrderKey, models.Newest) 159 | } 160 | -------------------------------------------------------------------------------- /models/clan_easyjson.go: -------------------------------------------------------------------------------- 1 | // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. 2 | 3 | package models 4 | 5 | import ( 6 | json "encoding/json" 7 | easyjson "github.com/mailru/easyjson" 8 | jlexer "github.com/mailru/easyjson/jlexer" 9 | jwriter "github.com/mailru/easyjson/jwriter" 10 | ) 11 | 12 | // suppress unused package warning 13 | var ( 14 | _ *json.RawMessage 15 | _ *jlexer.Lexer 16 | _ *jwriter.Writer 17 | _ easyjson.Marshaler 18 | ) 19 | 20 | func easyjson91eb9988DecodeGithubComTopfreegamesKhanModels(in *jlexer.Lexer, out *Clan) { 21 | isTopLevel := in.IsStart() 22 | if in.IsNull() { 23 | if isTopLevel { 24 | in.Consumed() 25 | } 26 | in.Skip() 27 | return 28 | } 29 | in.Delim('{') 30 | for !in.IsDelim('}') { 31 | key := in.UnsafeFieldName(false) 32 | in.WantColon() 33 | if in.IsNull() { 34 | in.Skip() 35 | in.WantComma() 36 | continue 37 | } 38 | switch key { 39 | case "id": 40 | out.ID = int64(in.Int64()) 41 | case "gameId": 42 | out.GameID = string(in.String()) 43 | case "publicId": 44 | out.PublicID = string(in.String()) 45 | case "name": 46 | out.Name = string(in.String()) 47 | case "ownerId": 48 | out.OwnerID = int64(in.Int64()) 49 | case "membershipCount": 50 | out.MembershipCount = int(in.Int()) 51 | case "metadata": 52 | if in.IsNull() { 53 | in.Skip() 54 | } else { 55 | in.Delim('{') 56 | out.Metadata = make(map[string]interface{}) 57 | for !in.IsDelim('}') { 58 | key := string(in.String()) 59 | in.WantColon() 60 | var v1 interface{} 61 | if m, ok := v1.(easyjson.Unmarshaler); ok { 62 | m.UnmarshalEasyJSON(in) 63 | } else if m, ok := v1.(json.Unmarshaler); ok { 64 | _ = m.UnmarshalJSON(in.Raw()) 65 | } else { 66 | v1 = in.Interface() 67 | } 68 | (out.Metadata)[key] = v1 69 | in.WantComma() 70 | } 71 | in.Delim('}') 72 | } 73 | case "allowApplication": 74 | out.AllowApplication = bool(in.Bool()) 75 | case "autoJoin": 76 | out.AutoJoin = bool(in.Bool()) 77 | case "createdAt": 78 | out.CreatedAt = int64(in.Int64()) 79 | case "updatedAt": 80 | out.UpdatedAt = int64(in.Int64()) 81 | case "deletedAt": 82 | out.DeletedAt = int64(in.Int64()) 83 | default: 84 | in.SkipRecursive() 85 | } 86 | in.WantComma() 87 | } 88 | in.Delim('}') 89 | if isTopLevel { 90 | in.Consumed() 91 | } 92 | } 93 | func easyjson91eb9988EncodeGithubComTopfreegamesKhanModels(out *jwriter.Writer, in Clan) { 94 | out.RawByte('{') 95 | first := true 96 | _ = first 97 | { 98 | const prefix string = ",\"id\":" 99 | out.RawString(prefix[1:]) 100 | out.Int64(int64(in.ID)) 101 | } 102 | { 103 | const prefix string = ",\"gameId\":" 104 | out.RawString(prefix) 105 | out.String(string(in.GameID)) 106 | } 107 | { 108 | const prefix string = ",\"publicId\":" 109 | out.RawString(prefix) 110 | out.String(string(in.PublicID)) 111 | } 112 | { 113 | const prefix string = ",\"name\":" 114 | out.RawString(prefix) 115 | out.String(string(in.Name)) 116 | } 117 | { 118 | const prefix string = ",\"ownerId\":" 119 | out.RawString(prefix) 120 | out.Int64(int64(in.OwnerID)) 121 | } 122 | { 123 | const prefix string = ",\"membershipCount\":" 124 | out.RawString(prefix) 125 | out.Int(int(in.MembershipCount)) 126 | } 127 | { 128 | const prefix string = ",\"metadata\":" 129 | out.RawString(prefix) 130 | if in.Metadata == nil && (out.Flags&jwriter.NilMapAsEmpty) == 0 { 131 | out.RawString(`null`) 132 | } else { 133 | out.RawByte('{') 134 | v2First := true 135 | for v2Name, v2Value := range in.Metadata { 136 | if v2First { 137 | v2First = false 138 | } else { 139 | out.RawByte(',') 140 | } 141 | out.String(string(v2Name)) 142 | out.RawByte(':') 143 | if m, ok := v2Value.(easyjson.Marshaler); ok { 144 | m.MarshalEasyJSON(out) 145 | } else if m, ok := v2Value.(json.Marshaler); ok { 146 | out.Raw(m.MarshalJSON()) 147 | } else { 148 | out.Raw(json.Marshal(v2Value)) 149 | } 150 | } 151 | out.RawByte('}') 152 | } 153 | } 154 | { 155 | const prefix string = ",\"allowApplication\":" 156 | out.RawString(prefix) 157 | out.Bool(bool(in.AllowApplication)) 158 | } 159 | { 160 | const prefix string = ",\"autoJoin\":" 161 | out.RawString(prefix) 162 | out.Bool(bool(in.AutoJoin)) 163 | } 164 | { 165 | const prefix string = ",\"createdAt\":" 166 | out.RawString(prefix) 167 | out.Int64(int64(in.CreatedAt)) 168 | } 169 | { 170 | const prefix string = ",\"updatedAt\":" 171 | out.RawString(prefix) 172 | out.Int64(int64(in.UpdatedAt)) 173 | } 174 | { 175 | const prefix string = ",\"deletedAt\":" 176 | out.RawString(prefix) 177 | out.Int64(int64(in.DeletedAt)) 178 | } 179 | out.RawByte('}') 180 | } 181 | 182 | // MarshalEasyJSON supports easyjson.Marshaler interface 183 | func (v Clan) MarshalEasyJSON(w *jwriter.Writer) { 184 | easyjson91eb9988EncodeGithubComTopfreegamesKhanModels(w, v) 185 | } 186 | 187 | // UnmarshalEasyJSON supports easyjson.Unmarshaler interface 188 | func (v *Clan) UnmarshalEasyJSON(l *jlexer.Lexer) { 189 | easyjson91eb9988DecodeGithubComTopfreegamesKhanModels(l, v) 190 | } 191 | -------------------------------------------------------------------------------- /cmd/migrate_mongo.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/uber-go/zap" 8 | 9 | "github.com/globalsign/mgo/bson" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | idb "github.com/topfreegames/extensions/v9/gorp/interfaces" 13 | mongoext "github.com/topfreegames/extensions/v9/mongo" 14 | imongo "github.com/topfreegames/extensions/v9/mongo/interfaces" 15 | "github.com/topfreegames/khan/log" 16 | "github.com/topfreegames/khan/models" 17 | "github.com/topfreegames/khan/mongo" 18 | ) 19 | 20 | var gameID string 21 | 22 | var migrateMongoCmd = &cobra.Command{ 23 | Use: "migrate-mongo", 24 | Short: "creates MongoDB indexes used by Khan for one game", 25 | Long: `Creates all indexes used by Khan for one game into a remote MongoDB instance. 26 | If the game do not exists in the main Postgres database, no actions take place.`, 27 | Run: func(cmd *cobra.Command, args []string) { 28 | // create logger 29 | logger := zap.New(zap.NewJSONEncoder(), zap.InfoLevel) 30 | logger = logger.With( 31 | zap.String("source", "cmd/migrate_mongo.go"), 32 | zap.String("operation", "migrateMongoCmd.Run"), 33 | zap.String("game", gameID), 34 | ) 35 | 36 | // read config 37 | config, err := newConfig() 38 | if err != nil { 39 | log.F(logger, "Error reading config.", func(cm log.CM) { 40 | cm.Write(zap.String("error", err.Error())) 41 | }) 42 | } 43 | 44 | // connect to main db and check if game exists 45 | db, err := newDatabase(config) 46 | if err != nil { 47 | log.F(logger, "Error connecting to postgres.", func(cm log.CM) { 48 | cm.Write(zap.String("error", err.Error())) 49 | }) 50 | } 51 | _, err = models.GetGameByPublicID(db, gameID) 52 | if err != nil { 53 | log.F(logger, "Error fetching game from postgres.", func(cm log.CM) { 54 | cm.Write(zap.String("error", err.Error())) 55 | }) 56 | } 57 | 58 | // connect to mongo and run migrations 59 | mongoDB, err := newMongo(config) 60 | if err != nil { 61 | log.F(logger, "Error connecting to mongo.", func(cm log.CM) { 62 | cm.Write(zap.String("error", err.Error())) 63 | }) 64 | } 65 | err = runMigrations(mongoDB, logger) 66 | if err != nil { 67 | log.F(logger, "Error running mongo migrations.", func(cm log.CM) { 68 | cm.Write(zap.String("error", err.Error())) 69 | }) 70 | } 71 | }, 72 | } 73 | 74 | func init() { 75 | RootCmd.AddCommand(migrateMongoCmd) 76 | 77 | migrateMongoCmd.Flags().StringVarP( 78 | &gameID, 79 | "game", 80 | "g", 81 | "", 82 | "game public ID in main database", 83 | ) 84 | } 85 | 86 | func newConfig() (*viper.Viper, error) { 87 | config := viper.New() 88 | config.SetConfigType("yaml") 89 | config.SetConfigFile(ConfigFile) 90 | config.AddConfigPath(".") 91 | config.SetEnvPrefix("khan") 92 | config.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) 93 | config.AutomaticEnv() 94 | return config, config.ReadInConfig() 95 | } 96 | 97 | func newDatabase(config *viper.Viper) (idb.Database, error) { 98 | host := config.GetString("postgres.host") 99 | user := config.GetString("postgres.user") 100 | dbName := config.GetString("postgres.dbname") 101 | password := config.GetString("postgres.password") 102 | port := config.GetInt("postgres.port") 103 | sslMode := config.GetString("postgres.sslMode") 104 | return models.InitDb(host, user, port, sslMode, dbName, password) 105 | } 106 | 107 | func newMongo(config *viper.Viper) (imongo.MongoDB, error) { 108 | config.Set("mongodb.database", config.GetString("mongodb.databaseName")) 109 | mongoDB, err := mongoext.NewClient("mongodb" /* conf keys prefix */, config) 110 | if err != nil { 111 | return nil, err 112 | } 113 | return mongoDB.MongoDB, nil 114 | } 115 | 116 | func runMigrations(mongoDB imongo.MongoDB, logger zap.Logger) error { 117 | logger = logger.With( 118 | zap.String("source", "cmd/migrate_mongo.go"), 119 | zap.String("operation", "runMigrations"), 120 | zap.String("game", gameID), 121 | ) 122 | 123 | log.I(logger, "Running mongo migrations for game...") 124 | 125 | // migrations 126 | type Migration func(imongo.MongoDB, zap.Logger) error 127 | migrations := []Migration{ 128 | createClanNameTextIndex, 129 | } 130 | for _, migration := range migrations { 131 | if err := migration(mongoDB, logger); err != nil { 132 | return err 133 | } 134 | } 135 | 136 | log.I(logger, "Migrated.") 137 | 138 | return nil 139 | } 140 | 141 | func createClanNameTextIndex(mongoDB imongo.MongoDB, logger zap.Logger) error { 142 | logger = logger.With( 143 | zap.String("source", "cmd/migrate_mongo.go"), 144 | zap.String("operation", "createClanNameTextIndex"), 145 | zap.String("game", gameID), 146 | ) 147 | 148 | cmd := mongo.GetClanNameTextIndexCommand(gameID, false) 149 | var res struct { 150 | OK int `bson:"ok"` 151 | NumIndexesBefore int `bson:"numIndexesBefore"` 152 | NumIndexesAfter int `bson:"numIndexesAfter"` 153 | } 154 | err := mongoDB.Run(cmd, &res) 155 | if err != nil { 156 | return err 157 | } 158 | if res.OK != 1 { 159 | return &MongoCommandError{cmd: cmd} 160 | } 161 | if res.NumIndexesAfter == res.NumIndexesBefore { 162 | log.W(logger, "Clan name text index already exists for this game.") 163 | } 164 | return nil 165 | } 166 | 167 | // MongoCommandError represents a MongoDB run command error. 168 | type MongoCommandError struct { 169 | cmd bson.D 170 | } 171 | 172 | func (e *MongoCommandError) Error() string { 173 | return fmt.Sprintf("Error in mongo command: %v.", e.cmd) 174 | } 175 | -------------------------------------------------------------------------------- /api/middleware.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package api 9 | 10 | import ( 11 | "bytes" 12 | "encoding/json" 13 | "fmt" 14 | "io" 15 | "runtime/debug" 16 | "time" 17 | 18 | "github.com/labstack/echo" 19 | "github.com/topfreegames/khan/log" 20 | "github.com/topfreegames/khan/util" 21 | "github.com/uber-go/zap" 22 | ) 23 | 24 | func getBodyFromNext(c echo.Context, next echo.HandlerFunc) (string, error) { 25 | res := c.Response() 26 | rw := res.Writer() 27 | buf := new(bytes.Buffer) 28 | mw := io.MultiWriter(rw, buf) 29 | res.SetWriter(mw) 30 | 31 | err := next(c) 32 | 33 | body := buf.String() 34 | return body, err 35 | } 36 | 37 | //NewBodyExtractionMiddleware with API version 38 | func NewBodyExtractionMiddleware() *BodyExtractionMiddleware { 39 | return &BodyExtractionMiddleware{} 40 | } 41 | 42 | //BodyExtractionMiddleware extracts the body 43 | type BodyExtractionMiddleware struct{} 44 | 45 | // Serve serves the middleware 46 | func (v *BodyExtractionMiddleware) Serve(next echo.HandlerFunc) echo.HandlerFunc { 47 | return func(c echo.Context) error { 48 | body, err := getBodyFromNext(c, next) 49 | c.Set("body", body) 50 | return err 51 | } 52 | } 53 | 54 | //NewVersionMiddleware with API version 55 | func NewVersionMiddleware() *VersionMiddleware { 56 | return &VersionMiddleware{ 57 | Version: util.VERSION, 58 | } 59 | } 60 | 61 | //VersionMiddleware inserts the current version in all requests 62 | type VersionMiddleware struct { 63 | Version string 64 | } 65 | 66 | // Serve serves the middleware 67 | func (v *VersionMiddleware) Serve(next echo.HandlerFunc) echo.HandlerFunc { 68 | return func(c echo.Context) error { 69 | c.Response().Header().Set(echo.HeaderServer, fmt.Sprintf("Khan/v%s", v.Version)) 70 | c.Response().Header().Set("Khan-Server", fmt.Sprintf("Khan/v%s", v.Version)) 71 | return next(c) 72 | } 73 | } 74 | 75 | func getHTTPParams(ctx echo.Context) (string, map[string]string, string) { 76 | qs := "" 77 | if len(ctx.QueryParams()) > 0 { 78 | qsBytes, _ := json.Marshal(ctx.QueryParams()) 79 | qs = string(qsBytes) 80 | } 81 | 82 | headers := map[string]string{} 83 | for _, headerKey := range ctx.Response().Header().Keys() { 84 | headers[string(headerKey)] = string(ctx.Response().Header().Get(headerKey)) 85 | } 86 | 87 | cookies := string(ctx.Response().Header().Get("Cookie")) 88 | return qs, headers, cookies 89 | } 90 | 91 | //NewRecoveryMiddleware returns a configured middleware 92 | func NewRecoveryMiddleware(onError func(error, []byte)) *RecoveryMiddleware { 93 | return &RecoveryMiddleware{ 94 | OnError: onError, 95 | } 96 | } 97 | 98 | //RecoveryMiddleware recovers from errors 99 | type RecoveryMiddleware struct { 100 | OnError func(error, []byte) 101 | } 102 | 103 | //Serve executes on error handler when errors happen 104 | func (r *RecoveryMiddleware) Serve(next echo.HandlerFunc) echo.HandlerFunc { 105 | return func(c echo.Context) error { 106 | defer func() { 107 | if err := recover(); err != nil { 108 | eError, ok := err.(error) 109 | if !ok { 110 | eError = fmt.Errorf(fmt.Sprintf("%v", err)) 111 | } 112 | if r.OnError != nil { 113 | r.OnError(eError, debug.Stack()) 114 | } 115 | c.Error(eError) 116 | } 117 | }() 118 | return next(c) 119 | } 120 | } 121 | 122 | // NewLoggerMiddleware returns the logger middleware 123 | func NewLoggerMiddleware(theLogger zap.Logger) *LoggerMiddleware { 124 | l := &LoggerMiddleware{Logger: theLogger} 125 | return l 126 | } 127 | 128 | //LoggerMiddleware is responsible for logging to Zap all requests 129 | type LoggerMiddleware struct { 130 | Logger zap.Logger 131 | } 132 | 133 | // Serve serves the middleware 134 | func (l *LoggerMiddleware) Serve(next echo.HandlerFunc) echo.HandlerFunc { 135 | return func(c echo.Context) error { 136 | logger := l.Logger.With( 137 | zap.String("source", "request"), 138 | ) 139 | 140 | //all except latency to string 141 | var ip, method, path string 142 | var status int 143 | var latency time.Duration 144 | var startTime, endTime time.Time 145 | 146 | path = c.Path() 147 | method = c.Request().Method() 148 | 149 | startTime = time.Now() 150 | 151 | err := next(c) 152 | 153 | //no time.Since in order to format it well after 154 | endTime = time.Now() 155 | latency = endTime.Sub(startTime) 156 | 157 | status = c.Response().Status() 158 | ip = c.Request().RemoteAddress() 159 | 160 | route := c.Get("route") 161 | if route == nil { 162 | log.D(logger, "Route does not have route set in ctx") 163 | return err 164 | } 165 | 166 | reqLog := logger.With( 167 | zap.String("route", route.(string)), 168 | zap.Time("endTime", endTime), 169 | zap.Int("statusCode", status), 170 | zap.Duration("latency", latency), 171 | zap.String("ip", ip), 172 | zap.String("method", method), 173 | zap.String("path", path), 174 | ) 175 | 176 | //request failed 177 | if status > 399 && status < 500 { 178 | log.D(reqLog, "Request failed.") 179 | return err 180 | } 181 | 182 | //request is ok, but server failed 183 | if status > 499 { 184 | log.D(reqLog, "Response failed.") 185 | return err 186 | } 187 | 188 | //Everything went ok 189 | if cm := reqLog.Check(zap.DebugLevel, "Request successful."); cm.OK() { 190 | cm.Write() 191 | } 192 | 193 | return err 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /models/hook_test.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package models_test 9 | 10 | import ( 11 | "time" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | uuid "github.com/satori/go.uuid" 16 | . "github.com/topfreegames/khan/models" 17 | "github.com/topfreegames/khan/models/fixtures" 18 | ) 19 | 20 | var _ = Describe("Hook Model", func() { 21 | var testDb DB 22 | 23 | BeforeEach(func() { 24 | var err error 25 | testDb, err = GetTestDB() 26 | Expect(err).NotTo(HaveOccurred()) 27 | }) 28 | 29 | Describe("Hook Model", func() { 30 | 31 | Describe("Model Basic Tests", func() { 32 | It("Should create a new Hook", func() { 33 | hook, err := fixtures.CreateHookFactory(testDb, "", GameUpdatedHook, "http://test/created") 34 | Expect(err).NotTo(HaveOccurred()) 35 | Expect(hook.ID).NotTo(BeEquivalentTo(0)) 36 | 37 | dbHook, err := GetHookByID(testDb, hook.ID) 38 | Expect(err).NotTo(HaveOccurred()) 39 | 40 | Expect(dbHook.GameID).To(Equal(hook.GameID)) 41 | Expect(dbHook.URL).To(Equal(hook.URL)) 42 | Expect(dbHook.EventType).To(Equal(hook.EventType)) 43 | }) 44 | 45 | It("Should update a Hook", func() { 46 | hook, err := fixtures.CreateHookFactory(testDb, "", GameUpdatedHook, "http://test/updated") 47 | Expect(err).NotTo(HaveOccurred()) 48 | dt := hook.UpdatedAt 49 | hook.URL = "http://test/updated2" 50 | 51 | time.Sleep(time.Millisecond) 52 | 53 | count, err := testDb.Update(hook) 54 | Expect(err).NotTo(HaveOccurred()) 55 | Expect(count).To(BeEquivalentTo(1)) 56 | Expect(hook.UpdatedAt).To(BeNumerically(">", dt)) 57 | Expect(hook.URL).To(Equal("http://test/updated2")) 58 | }) 59 | }) 60 | 61 | Describe("Get Hook By ID", func() { 62 | It("Should get existing Hook", func() { 63 | hook, err := fixtures.CreateHookFactory(testDb, "", GameUpdatedHook, "http://test/getbyid") 64 | Expect(err).NotTo(HaveOccurred()) 65 | 66 | dbHook, err := GetHookByID(testDb, hook.ID) 67 | Expect(err).NotTo(HaveOccurred()) 68 | Expect(dbHook.ID).To(Equal(hook.ID)) 69 | }) 70 | 71 | It("Should not get non-existing Hook", func() { 72 | _, err := GetHookByID(testDb, -1) 73 | Expect(err).To(HaveOccurred()) 74 | Expect(err.Error()).To(Equal("Hook was not found with id: -1")) 75 | }) 76 | }) 77 | 78 | Describe("Get Hook By Public ID", func() { 79 | It("Should get existing Hook", func() { 80 | hook, err := fixtures.CreateHookFactory(testDb, "", GameUpdatedHook, "http://test/getbyid") 81 | Expect(err).NotTo(HaveOccurred()) 82 | 83 | dbHook, err := GetHookByPublicID(testDb, hook.GameID, hook.PublicID) 84 | Expect(err).NotTo(HaveOccurred()) 85 | Expect(dbHook.ID).To(Equal(hook.ID)) 86 | }) 87 | 88 | It("Should not get non-existing Hook", func() { 89 | _, err := GetHookByPublicID(testDb, "invalid", "key") 90 | Expect(err).To(HaveOccurred()) 91 | Expect(err.Error()).To(Equal("Hook was not found with id: key")) 92 | }) 93 | }) 94 | 95 | Describe("Create Hook", func() { 96 | It("Should create a new Hook with CreateHook", func() { 97 | game := fixtures.GameFactory.MustCreate().(*Game) 98 | err := testDb.Insert(game) 99 | Expect(err).NotTo(HaveOccurred()) 100 | 101 | hook, err := CreateHook( 102 | testDb, 103 | game.PublicID, 104 | GameUpdatedHook, 105 | "http://test/created", 106 | ) 107 | Expect(err).NotTo(HaveOccurred()) 108 | Expect(hook.ID).NotTo(BeEquivalentTo(0)) 109 | 110 | dbHook, err := GetHookByID(testDb, hook.ID) 111 | Expect(err).NotTo(HaveOccurred()) 112 | 113 | Expect(dbHook.GameID).To(Equal(hook.GameID)) 114 | Expect(dbHook.EventType).To(Equal(hook.EventType)) 115 | Expect(dbHook.URL).To(Equal(hook.URL)) 116 | }) 117 | 118 | It("Create same Hook works fine", func() { 119 | gameID := uuid.NewV4().String() 120 | hook, err := fixtures.CreateHookFactory(testDb, gameID, GameUpdatedHook, "http://test/created") 121 | 122 | hook2, err := CreateHook( 123 | testDb, 124 | gameID, 125 | GameUpdatedHook, 126 | "http://test/created", 127 | ) 128 | Expect(err).NotTo(HaveOccurred()) 129 | Expect(hook2.ID == hook.ID).To(BeTrue()) 130 | 131 | dbHook, err := GetHookByID(testDb, hook.ID) 132 | Expect(err).NotTo(HaveOccurred()) 133 | 134 | Expect(dbHook.GameID).To(Equal(hook.GameID)) 135 | Expect(dbHook.EventType).To(Equal(hook.EventType)) 136 | Expect(dbHook.URL).To(Equal(hook.URL)) 137 | }) 138 | 139 | }) 140 | 141 | Describe("Remove Hook", func() { 142 | It("Should remove a Hook with RemoveHook", func() { 143 | hook, err := fixtures.CreateHookFactory(testDb, "", GameUpdatedHook, "http://test/update") 144 | Expect(err).NotTo(HaveOccurred()) 145 | 146 | err = RemoveHook( 147 | testDb, 148 | hook.GameID, 149 | hook.PublicID, 150 | ) 151 | 152 | Expect(err).NotTo(HaveOccurred()) 153 | 154 | number, err := testDb.SelectInt("select count(*) from hooks where id=$1", hook.ID) 155 | Expect(err).NotTo(HaveOccurred()) 156 | Expect(number == 0).To(BeTrue()) 157 | }) 158 | }) 159 | 160 | Describe("Get All Hooks", func() { 161 | It("Should get all hooks", func() { 162 | gameID := uuid.NewV4().String() 163 | _, err := fixtures.GetTestHooks(testDb, gameID, 5) 164 | Expect(err).NotTo(HaveOccurred()) 165 | 166 | hooks, err := GetAllHooks(testDb) 167 | 168 | Expect(err).NotTo(HaveOccurred()) 169 | Expect(len(hooks)).To(BeNumerically(">", 10)) 170 | }) 171 | }) 172 | 173 | }) 174 | }) 175 | -------------------------------------------------------------------------------- /models/prune.go: -------------------------------------------------------------------------------- 1 | // khan 2 | // https://github.com/topfreegames/khan 3 | // 4 | // Licensed under the MIT license: 5 | // http://www.opensource.org/licenses/mit-license 6 | // Copyright © 2016 Top Free Games 7 | 8 | package models 9 | 10 | import ( 11 | "fmt" 12 | 13 | "github.com/topfreegames/khan/log" 14 | "github.com/topfreegames/khan/util" 15 | "github.com/uber-go/zap" 16 | ) 17 | 18 | // PruneStats show stats about what has been pruned 19 | type PruneStats struct { 20 | PendingApplicationsPruned int 21 | PendingInvitesPruned int 22 | DeniedMembershipsPruned int 23 | DeletedMembershipsPruned int 24 | } 25 | 26 | //GetStats returns a formatted message 27 | func (ps *PruneStats) GetStats() string { 28 | return fmt.Sprintf( 29 | "-Pending Applications: %d\n-Pending Invites: %d\n-Denied Memberships: %d\n-Deleted Memberships: %d\n", 30 | ps.PendingApplicationsPruned, 31 | ps.PendingInvitesPruned, 32 | ps.DeniedMembershipsPruned, 33 | ps.DeletedMembershipsPruned, 34 | ) 35 | } 36 | 37 | // PruneOptions has all the prunable memberships TTL 38 | type PruneOptions struct { 39 | GameID string 40 | PendingApplicationsExpiration int 41 | PendingInvitesExpiration int 42 | DeniedMembershipsExpiration int 43 | DeletedMembershipsExpiration int 44 | } 45 | 46 | func runAndReturnRowsAffected(query string, db DB, args ...interface{}) (int, error) { 47 | res, err := db.Exec(query, args...) 48 | if err != nil { 49 | return 0, err 50 | } 51 | rows, err := res.RowsAffected() 52 | return int(rows), err 53 | } 54 | 55 | func prunePendingApplications(options *PruneOptions, db DB, logger zap.Logger) (int, error) { 56 | query := `DELETE FROM memberships m WHERE 57 | m.game_id=$1 AND 58 | m.deleted_at=0 AND 59 | m.approved=FALSE AND 60 | m.denied=FALSE AND 61 | m.requestor_id=m.player_id AND 62 | m.updated_at < $2` 63 | 64 | updatedAt := util.NowMilli() - int64(options.PendingApplicationsExpiration*1000) 65 | return runAndReturnRowsAffected(query, db, options.GameID, updatedAt) 66 | } 67 | 68 | func prunePendingInvites(options *PruneOptions, db DB, logger zap.Logger) (int, error) { 69 | query := `DELETE FROM memberships m WHERE 70 | m.game_id=$1 AND 71 | m.deleted_at=0 AND 72 | m.approved=FALSE AND 73 | m.denied=FALSE AND 74 | m.requestor_id != m.player_id AND 75 | m.updated_at < $2` 76 | 77 | updatedAt := util.NowMilli() - int64(options.PendingInvitesExpiration*1000) 78 | return runAndReturnRowsAffected(query, db, options.GameID, updatedAt) 79 | } 80 | 81 | func pruneDeniedMemberships(options *PruneOptions, db DB, logger zap.Logger) (int, error) { 82 | query := `DELETE FROM memberships m WHERE 83 | m.game_id=$1 AND 84 | m.denied=TRUE AND 85 | m.updated_at < $2` 86 | 87 | updatedAt := util.NowMilli() - int64(options.DeniedMembershipsExpiration*1000) 88 | return runAndReturnRowsAffected(query, db, options.GameID, updatedAt) 89 | } 90 | 91 | func pruneDeletedMemberships(options *PruneOptions, db DB, logger zap.Logger) (int, error) { 92 | query := `DELETE FROM memberships m WHERE 93 | m.game_id=$1 AND 94 | m.deleted_at > 0 AND 95 | m.updated_at < $2` 96 | 97 | updatedAt := util.NowMilli() - int64(options.DeletedMembershipsExpiration*1000) 98 | return runAndReturnRowsAffected(query, db, options.GameID, updatedAt) 99 | } 100 | 101 | // PruneStaleData off of Khan's database 102 | func PruneStaleData(options *PruneOptions, db DB, logger zap.Logger) (*PruneStats, error) { 103 | log.I(logger, "Pruning stale data...", func(cm log.CM) { 104 | cm.Write( 105 | zap.String("GameID", options.GameID), 106 | zap.Int("PendingApplicationsExpiration", options.PendingApplicationsExpiration), 107 | zap.Int("PendingInvitesExpiration", options.PendingInvitesExpiration), 108 | zap.Int("DeniedMembershipsExpiration", options.DeniedMembershipsExpiration), 109 | zap.Int("DeletedMembershipsExpiration", options.DeletedMembershipsExpiration), 110 | ) 111 | }) 112 | 113 | pendingApplicationsPruned, err := prunePendingApplications(options, db, logger) 114 | if err != nil { 115 | log.E(logger, "Failed to prune stale pending applications.", func(cm log.CM) { 116 | cm.Write(zap.Error(err)) 117 | }) 118 | return nil, err 119 | } 120 | 121 | pendingInvitesPruned, err := prunePendingInvites(options, db, logger) 122 | if err != nil { 123 | log.E(logger, "Failed to prune stale pending invites.", func(cm log.CM) { 124 | cm.Write(zap.Error(err)) 125 | }) 126 | return nil, err 127 | } 128 | 129 | deniedMembershipsPruned, err := pruneDeniedMemberships(options, db, logger) 130 | if err != nil { 131 | log.E(logger, "Failed to prune stale denied memberships.", func(cm log.CM) { 132 | cm.Write(zap.Error(err)) 133 | }) 134 | return nil, err 135 | } 136 | 137 | deletedMembershipsPruned, err := pruneDeletedMemberships(options, db, logger) 138 | if err != nil { 139 | log.E(logger, "Failed to prune stale deleted memberships.", func(cm log.CM) { 140 | cm.Write(zap.Error(err)) 141 | }) 142 | return nil, err 143 | } 144 | 145 | stats := &PruneStats{ 146 | PendingApplicationsPruned: pendingApplicationsPruned, 147 | PendingInvitesPruned: pendingInvitesPruned, 148 | DeniedMembershipsPruned: deniedMembershipsPruned, 149 | DeletedMembershipsPruned: deletedMembershipsPruned, 150 | } 151 | 152 | log.I(logger, "Pruned stale data succesfully.", func(cm log.CM) { 153 | cm.Write( 154 | zap.String("GameID", options.GameID), 155 | zap.Int("PendingApplicationsPruned", stats.PendingApplicationsPruned), 156 | zap.Int("PendingInvitesPruned", stats.PendingInvitesPruned), 157 | zap.Int("DeniedMembershipsPruned", stats.DeniedMembershipsPruned), 158 | zap.Int("DeletedMembershipsPruned", stats.DeletedMembershipsPruned), 159 | ) 160 | }) 161 | return stats, nil 162 | } 163 | --------------------------------------------------------------------------------