├── Procfile ├── .idea ├── .gitignore ├── misc.xml ├── dictionaries │ └── project.xml ├── modules.xml ├── ImpactServer.iml ├── runConfigurations │ └── Run.xml └── vcs.xml ├── static ├── google0dac0c1f21963dd8.html ├── google5b4336da37970ce2.html ├── favicon.ico ├── img │ ├── header.png │ ├── header.xcf │ ├── project1.jpg │ ├── project3.png │ ├── project4.jpg │ ├── project5.png │ ├── welcome.png │ ├── java-icon.png │ ├── macmoment.png │ ├── parallax1.png │ ├── project2.jpeg │ ├── project6.jpeg │ ├── avatar_flash.png │ ├── avatar_robin.png │ ├── windows-icon.png │ ├── windowsmoment.png │ ├── avatar_cat_woman.png │ ├── javamoment_linux.png │ ├── javamoment_mac.png │ ├── javamoment_win.png │ ├── pepsi_background.png │ └── avatar_captain_america.png ├── favicon-128x128.png ├── favicon-16x16.png ├── favicon-196x196.png ├── favicon-256x256.png ├── favicon-32x32.png ├── favicon-96x96.png ├── fonts │ ├── FontAwesome.otf │ ├── fontawesome-webfont.eot │ ├── fontawesome-webfont.ttf │ ├── roboto │ │ ├── Roboto-Bold.eot │ │ ├── Roboto-Bold.ttf │ │ ├── Roboto-Bold.woff │ │ ├── Roboto-Light.eot │ │ ├── Roboto-Light.ttf │ │ ├── Roboto-Thin.eot │ │ ├── Roboto-Thin.ttf │ │ ├── Roboto-Thin.woff │ │ ├── Roboto-Bold.woff2 │ │ ├── Roboto-Light.woff │ │ ├── Roboto-Light.woff2 │ │ ├── Roboto-Medium.eot │ │ ├── Roboto-Medium.ttf │ │ ├── Roboto-Medium.woff │ │ ├── Roboto-Regular.eot │ │ ├── Roboto-Regular.ttf │ │ ├── Roboto-Thin.woff2 │ │ ├── Roboto-Medium.woff2 │ │ ├── Roboto-Regular.woff │ │ └── Roboto-Regular.woff2 │ ├── fontawesome-webfont.woff │ └── fontawesome-webfont.woff2 ├── apple-touch-icon-57x57.png ├── apple-touch-icon-60x60.png ├── apple-touch-icon-72x72.png ├── apple-touch-icon-76x76.png ├── apple-touch-icon-114x114.png ├── apple-touch-icon-120x120.png ├── apple-touch-icon-144x144.png ├── apple-touch-icon-152x152.png ├── recaptcha.html ├── min │ ├── auth.jquery-min.js │ ├── init-min.js │ └── style-min.css ├── discord_oauth.html ├── account_explanation.html ├── alternatives.html ├── css │ └── style.css ├── js │ └── init.js └── discord.html ├── .travis.yml ├── src ├── mailgun │ └── main.go ├── middleware │ ├── log.go │ ├── redirect.go │ ├── cache_test.go │ ├── rewrite.go │ ├── cache.go │ ├── limit.go │ ├── index.go │ ├── index_test.go │ └── auth.go ├── web │ ├── applepay.go │ ├── stripe.go │ ├── changelog.go │ ├── server.go │ ├── changelog_test.go │ ├── recaptcha.go │ └── releases.go ├── util │ ├── mediatype │ │ └── index.go │ ├── email.go │ ├── runners.go │ ├── proxy.go │ ├── ip.go │ ├── rsa.go │ ├── urls_test.go │ ├── proxy_test.go │ ├── txt_import.go │ ├── rsa_test.go │ ├── urls.go │ ├── http_test.go │ └── http.go ├── api │ ├── v1 │ │ ├── shared_test.go │ │ ├── dbtest.go │ │ ├── themes_test.go │ │ ├── premiumcheck.go │ │ ├── futureclientintegration.go │ │ ├── motd.go │ │ ├── thealtening.go │ │ ├── impactbotintegration.go │ │ ├── emailtest.go │ │ ├── server.go │ │ ├── minecraft.go │ │ ├── themes.go │ │ └── password.go │ └── server.go ├── database │ ├── null_uuid.go │ ├── conn.go │ ├── listener.go │ ├── user.go │ └── schema.go ├── minecraft │ ├── auth.go │ └── profile.go ├── jwt │ ├── discord.go │ ├── minecraft.go │ ├── password.go │ └── jwt.go ├── users │ ├── features.go │ ├── user.go │ ├── editions.go │ ├── user_info.go │ └── role.go ├── newWeb │ └── server.go ├── heroku │ └── heroku.go ├── s3proxy │ └── server.go ├── recaptcha │ └── recaptcha.go ├── cloudflare │ └── main.go ├── server.go └── discord │ └── discord.go ├── .gitignore ├── README.md └── go.mod /Procfile: -------------------------------------------------------------------------------- 1 | web: bin/src 2 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Default ignored files 3 | /workspace.xml 4 | /shelf/ 5 | -------------------------------------------------------------------------------- /static/google0dac0c1f21963dd8.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google0dac0c1f21963dd8.html -------------------------------------------------------------------------------- /static/google5b4336da37970ce2.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google5b4336da37970ce2.html -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/img/header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/header.png -------------------------------------------------------------------------------- /static/img/header.xcf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/header.xcf -------------------------------------------------------------------------------- /static/img/project1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/project1.jpg -------------------------------------------------------------------------------- /static/img/project3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/project3.png -------------------------------------------------------------------------------- /static/img/project4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/project4.jpg -------------------------------------------------------------------------------- /static/img/project5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/project5.png -------------------------------------------------------------------------------- /static/img/welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/welcome.png -------------------------------------------------------------------------------- /static/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/favicon-128x128.png -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/favicon-196x196.png -------------------------------------------------------------------------------- /static/favicon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/favicon-256x256.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/favicon-96x96.png -------------------------------------------------------------------------------- /static/img/java-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/java-icon.png -------------------------------------------------------------------------------- /static/img/macmoment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/macmoment.png -------------------------------------------------------------------------------- /static/img/parallax1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/parallax1.png -------------------------------------------------------------------------------- /static/img/project2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/project2.jpeg -------------------------------------------------------------------------------- /static/img/project6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/project6.jpeg -------------------------------------------------------------------------------- /static/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /static/img/avatar_flash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/avatar_flash.png -------------------------------------------------------------------------------- /static/img/avatar_robin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/avatar_robin.png -------------------------------------------------------------------------------- /static/img/windows-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/windows-icon.png -------------------------------------------------------------------------------- /static/img/windowsmoment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/windowsmoment.png -------------------------------------------------------------------------------- /static/img/avatar_cat_woman.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/avatar_cat_woman.png -------------------------------------------------------------------------------- /static/img/javamoment_linux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/javamoment_linux.png -------------------------------------------------------------------------------- /static/img/javamoment_mac.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/javamoment_mac.png -------------------------------------------------------------------------------- /static/img/javamoment_win.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/javamoment_win.png -------------------------------------------------------------------------------- /static/img/pepsi_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/pepsi_background.png -------------------------------------------------------------------------------- /static/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /static/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /static/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /static/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /static/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /static/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /static/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /static/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Bold.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Bold.eot -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Bold.ttf -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Bold.woff -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Light.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Light.eot -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Light.ttf -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Thin.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Thin.eot -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Thin.ttf -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Thin.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Thin.woff -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.13.x 5 | 6 | script: 7 | - diff -u <(echo -n) <(gofmt -d ./) 8 | - go test -v ./... 9 | -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /static/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Bold.woff2 -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Light.woff -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Light.woff2 -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Medium.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Medium.eot -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Medium.ttf -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Medium.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Medium.woff -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Regular.eot -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Regular.ttf -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Thin.woff2 -------------------------------------------------------------------------------- /static/img/avatar_captain_america.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/img/avatar_captain_america.png -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Medium.woff2 -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Regular.woff -------------------------------------------------------------------------------- /static/fonts/roboto/Roboto-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ImpactDevelopment/ImpactServer/HEAD/static/fonts/roboto/Roboto-Regular.woff2 -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /src/mailgun/main.go: -------------------------------------------------------------------------------- 1 | package mailgun 2 | 3 | import ( 4 | "os" 5 | 6 | mailgun "github.com/mailgun/mailgun-go/v3" 7 | ) 8 | 9 | var MG = mailgun.NewMailgun(os.Getenv("MAILGUN_DOMAIN_IDENTIFIER"), os.Getenv("MAILGUN_API_KEY")) 10 | -------------------------------------------------------------------------------- /.idea/dictionaries/project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | hwid 5 | minecraft 6 | mojang 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/middleware/log.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import "github.com/labstack/echo/v4/middleware" 4 | 5 | var Log = middleware.LoggerWithConfig(middleware.LoggerConfig{ 6 | Format: "#${header:X-Request-ID} ${status} ${method} ${host}${uri} latency=${latency} [${latency_human}] error=${error}\n", 7 | }) 8 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, build with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | /ImpactServer 15 | 16 | .DS_Store 17 | 18 | /bin 19 | 20 | /src/src 21 | -------------------------------------------------------------------------------- /.idea/ImpactServer.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/web/applepay.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "net/http" 6 | "os" 7 | ) 8 | 9 | var applePayVerification string 10 | 11 | func init() { 12 | applePayVerification = os.Getenv("APPLE_PAY_VERIFICATION") 13 | } 14 | 15 | func applePayVerify(c echo.Context) error { 16 | return c.String(http.StatusOK, applePayVerification) 17 | } 18 | -------------------------------------------------------------------------------- /src/util/mediatype/index.go: -------------------------------------------------------------------------------- 1 | package mediatype 2 | 3 | // MediaType is a type as used by Accept, Content-Type and similar HTTP headers 4 | type MediaType string 5 | 6 | // Default MediaType constants 7 | const ( 8 | JSON MediaType = "application/json" 9 | XML MediaType = "application/xml" 10 | Form MediaType = "application/x-www-form-urlencoded" 11 | ) 12 | 13 | func (t MediaType) String() string { 14 | return string(t) 15 | } 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ImpactServer 2 | 3 | [![Build Status](https://travis-ci.com/ImpactDevelopment/ImpactServer.svg?branch=master)](https://travis-ci.com/ImpactDevelopment/ImpactServer) 4 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/f9b0bdc0a81841a282a87167cfab9d56)](https://www.codacy.com/manual/LeafHacker/ImpactServer?utm_source=github.com&utm_medium=referral&utm_content=ImpactDevelopment/ImpactServer&utm_campaign=Badge_Grade) 5 | -------------------------------------------------------------------------------- /static/recaptcha.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | reCAPTCHA demo: Simple page 4 | 5 | 6 | 7 |
8 |
9 |
10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /src/api/v1/shared_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | func getServer() (e *echo.Echo) { 11 | e = echo.New() 12 | API(e.Group("/v1")) 13 | return 14 | } 15 | 16 | func test(s *echo.Echo, url string) *httptest.ResponseRecorder { 17 | req := httptest.NewRequest(http.MethodGet, url, nil) 18 | rec := httptest.NewRecorder() 19 | s.ServeHTTP(rec, req) 20 | return rec 21 | } 22 | -------------------------------------------------------------------------------- /src/util/email.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "regexp" 4 | 5 | var emailPattern = regexp.MustCompile(`^[a-zA-Z0-9.!#$%&'*+\\/=?^_{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`) 6 | 7 | // IsValidEmail checks if the email provided passes the required structure and length. 8 | func IsValidEmail(email string) bool { 9 | if len(email) < 3 || len(email) > 254 { 10 | return false 11 | } 12 | return emailPattern.MatchString(email) 13 | } 14 | -------------------------------------------------------------------------------- /src/api/v1/dbtest.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/ImpactDevelopment/ImpactServer/src/database" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | func dbTest(c echo.Context) error { 11 | var value int 12 | err := database.DB.QueryRow("SELECT test FROM test").Scan(&value) 13 | if err != nil { 14 | return err 15 | } 16 | _, err = database.DB.Exec("UPDATE test SET test=test+1") 17 | if err != nil { 18 | return err 19 | } 20 | return c.JSON(http.StatusOK, value) 21 | } 22 | -------------------------------------------------------------------------------- /.idea/runConfigurations/Run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/web/stripe.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "net/http" 6 | ) 7 | 8 | // Redirects the old testing URI /stripe from PR#53 to /donate 9 | func stripe(c echo.Context) error { 10 | address := c.Request().URL 11 | 12 | // Echo tends to set the Request URL to just the path+query 13 | if address.Host == "" { 14 | address.Host = c.Request().Host 15 | } 16 | if address.Scheme == "" { 17 | address.Scheme = c.Scheme() 18 | } 19 | 20 | // 301 /stripe → /donate 21 | address.Path = "/donate" 22 | return c.Redirect(http.StatusMovedPermanently, address.String()) 23 | } 24 | -------------------------------------------------------------------------------- /src/api/server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | v1 "github.com/ImpactDevelopment/ImpactServer/src/api/v1" 5 | mid "github.com/ImpactDevelopment/ImpactServer/src/middleware" 6 | "github.com/labstack/echo/v4" 7 | "github.com/labstack/echo/v4/middleware" 8 | ) 9 | 10 | // Server returns an echo server that handles api requests for each version 11 | func Server() (e *echo.Echo) { 12 | e = echo.New() 13 | // Allow browser clients to use the API 14 | e.Use(middleware.CORS()) 15 | e.Use(mid.Log) 16 | 17 | // Setup GetUser(c) for all API routes 18 | e.Use(mid.Auth) 19 | 20 | v1.API(e.Group("/v1")) 21 | 22 | return 23 | } 24 | -------------------------------------------------------------------------------- /src/util/runners.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // DoLater runs a callback function once, after the specified delay 8 | func DoLater(delay time.Duration, f func()) { 9 | go func() { 10 | time.Sleep(delay) 11 | f() 12 | }() 13 | } 14 | 15 | // DoRepeatedly runs a callback function after each interval of delay 16 | // it continues until quit is closed 17 | func DoRepeatedly(interval time.Duration, f func()) (quit chan struct{}) { 18 | quit = make(chan struct{}) 19 | go func() { 20 | ticker := time.NewTicker(interval) 21 | for range ticker.C { 22 | select { 23 | case <-quit: 24 | return 25 | default: 26 | f() 27 | } 28 | } 29 | }() 30 | return 31 | } 32 | -------------------------------------------------------------------------------- /src/database/null_uuid.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql/driver" 5 | 6 | "github.com/google/uuid" 7 | ) 8 | 9 | // NullUUID represents a uuid.UUID that may be null. It is 10 | // similar to sql.NullString and other sql Null* types. 11 | type NullUUID struct { 12 | UUID uuid.UUID 13 | Valid bool // Valid is true if UUID is not NULL 14 | } 15 | 16 | // Scan implements sql.Scanner and wraps uuid.Scan 17 | func (n *NullUUID) Scan(value interface{}) error { 18 | n.Valid = value != nil 19 | if n.Valid { 20 | return n.UUID.Scan(value) 21 | } else { 22 | return nil 23 | } 24 | } 25 | 26 | // Value implements sql.Valuer and wraps uuid.Value 27 | func (n NullUUID) Value() (driver.Value, error) { 28 | if !n.Valid { 29 | return nil, nil 30 | } 31 | return n.UUID.Value() 32 | } 33 | -------------------------------------------------------------------------------- /src/util/proxy.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "net/url" 7 | 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | // var func to allow overriding in tests 12 | var serveProxy = func(proxy *httputil.ReverseProxy, req *http.Request, res http.ResponseWriter) { 13 | proxy.ServeHTTP(res, req) 14 | } 15 | 16 | func Proxy(c echo.Context, target *url.URL) { 17 | proxy := &httputil.ReverseProxy{ 18 | Director: func(req *http.Request) { 19 | // Change the URL 20 | req.URL = target 21 | req.Header.Set("X-Forwarded-Host", req.Host) 22 | req.Host = target.Host 23 | 24 | // Don't send our cookies to github 25 | req.Header.Del(echo.HeaderCookie) 26 | req.Header.Del(echo.HeaderAuthorization) 27 | }, 28 | } 29 | 30 | serveProxy(proxy, c.Request(), c.Response()) 31 | } 32 | -------------------------------------------------------------------------------- /src/api/v1/themes_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "testing" 7 | 8 | "github.com/aws/aws-sdk-go/private/util" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestGetThemes(t *testing.T) { 13 | // Override the returned themes 14 | themes = map[string]theme{ 15 | "theme-one": { 16 | DefaultFont: &font{Color: 0xff00cc}, 17 | }, 18 | "theme-two": { 19 | Background: &background{URL: "hello, world"}, 20 | }, 21 | } 22 | expected := `{"theme-one":{"default_font":{"color":16711884}},"theme-two":{"background":{"url":"hello, world"}}}` 23 | 24 | e := getServer() 25 | res := test(e, "/v1/themes") 26 | 27 | assert.Equal(t, http.StatusOK, res.Code) 28 | body, err := ioutil.ReadAll(res.Body) 29 | if assert.NoError(t, err) { 30 | assert.Equal(t, expected, util.Trim(string(body))) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/api/v1/premiumcheck.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | 7 | "github.com/ImpactDevelopment/ImpactServer/src/database" 8 | "github.com/google/uuid" 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | func premiumCheck(c echo.Context) error { 13 | if c.QueryParam("auth")+"0" != os.Getenv("API_AUTH_SECRET") { 14 | return c.JSON(http.StatusForbidden, "auth wrong im sowwy") 15 | } 16 | uuidStr := c.QueryParam("uuid") 17 | minecraftID, err := uuid.Parse(uuidStr) 18 | if err != nil { 19 | return c.JSON(http.StatusForbidden, "uuid is bad?") 20 | } 21 | user := database.LookupUserByMinecraftID(minecraftID) 22 | if user == nil || len(user.Roles) <= 0 { 23 | return echo.NewHTTPError(http.StatusForbidden, "no premium user found for uuid "+minecraftID.String()) 24 | } 25 | return c.JSON(http.StatusOK, user.RoleIDs(false)) // ALL role IDs 26 | } 27 | -------------------------------------------------------------------------------- /src/minecraft/auth.go: -------------------------------------------------------------------------------- 1 | package minecraft 2 | 3 | import ( 4 | "errors" 5 | "github.com/ImpactDevelopment/ImpactServer/src/util" 6 | "strings" 7 | ) 8 | 9 | const urlHasJoined = "https://sessionserver.mojang.com/session/minecraft/hasJoined" 10 | 11 | func HasJoinedServer(username, hash string) (*Profile, error) { 12 | req, err := util.GetRequest(urlHasJoined) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | req.SetQuery("username", username) 18 | req.SetQuery("serverId", "0"+hash) 19 | 20 | resp, err := req.Do() 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | var profile Profile 26 | err = resp.JSON(&profile) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | match := strings.EqualFold(username, profile.Name) 32 | if !match { 33 | return nil, errors.New("invalid username") 34 | } 35 | 36 | return &profile, nil 37 | } 38 | -------------------------------------------------------------------------------- /src/database/conn.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | _ "github.com/lib/pq" 10 | ) 11 | 12 | var DB *sql.DB 13 | 14 | func init() { 15 | url := os.Getenv("DATABASE_URL") 16 | if url == "" { 17 | fmt.Println("WARNING: No database url specified, not connecting to postgres!") 18 | return 19 | } 20 | var err error 21 | DB, err = sql.Open("postgres", url) 22 | if err != nil { 23 | // ok if there IS a url then we're expected to be able to connect 24 | // so if THAT fails, then that's a real error 25 | panic(err) 26 | } 27 | log.Println("Postgres opened") 28 | err = DB.Ping() 29 | if err != nil { 30 | // apparently this DOUBLE CHECKS that it's up? 31 | panic(err) 32 | } 33 | log.Println("Postgres pinged") 34 | initialSetup() 35 | log.Println("Postgres schema created") 36 | setupListener(url) 37 | } 38 | -------------------------------------------------------------------------------- /src/middleware/redirect.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "strings" 6 | ) 7 | 8 | // StripExt redirects any request ending with a provided extension to one without it, using the supplied http status code 9 | func StripExt(code int, extension ...string) echo.MiddlewareFunc { 10 | return func(next echo.HandlerFunc) echo.HandlerFunc { 11 | return func(c echo.Context) error { 12 | req := c.Request() 13 | path := req.URL.Path 14 | 15 | // Redirect 16 | for _, ext := range extension { 17 | if strings.HasSuffix(path, "."+ext) { 18 | url := *req.URL 19 | if url.Host == "" { 20 | url.Host = req.Host 21 | } 22 | if url.Scheme == "" { 23 | url.Scheme = c.Scheme() 24 | } 25 | url.Path = path[:len(path)-len(ext)-1] 26 | return c.Redirect(code, url.String()) 27 | } 28 | } 29 | return next(c) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/util/ip.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "strings" 6 | ) 7 | 8 | func RealIPIfUnambiguous(c echo.Context) string { 9 | xForwardedFor := getSplitSlice(c) 10 | if len(xForwardedFor) == 2 { 11 | // We are behind exactly two proxies (heroku and cloudflare) 12 | return strings.TrimSpace(xForwardedFor[0]) 13 | } 14 | return "" 15 | } 16 | 17 | func RealIPBestGuess(c echo.Context) string { 18 | xForwardedFor := getSplitSlice(c) 19 | if l := len(xForwardedFor); l >= 2 { 20 | // We are behind two proxies (heroku and cloudflare) and the user has either proxied or lied in their header 21 | // Return the ip that cloudflare got 22 | return strings.TrimSpace(xForwardedFor[l-2]) 23 | } 24 | // We probably aren't behind any proxies 25 | return c.Request().RemoteAddr 26 | } 27 | 28 | func getSplitSlice(c echo.Context) []string { 29 | return strings.Split(c.Request().Header.Get(echo.HeaderXForwardedFor), ",") 30 | } 31 | -------------------------------------------------------------------------------- /src/jwt/discord.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/ImpactDevelopment/ImpactServer/src/database" 7 | "github.com/ImpactDevelopment/ImpactServer/src/discord" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | type discordRequest struct { 12 | Token string `json:"access_token" form:"access_token" query:"access_token"` 13 | } 14 | 15 | func DiscordLoginHandler(c echo.Context) error { 16 | var body discordRequest 17 | if err := c.Bind(&body); err != nil { 18 | return err 19 | } 20 | if body.Token == "" { 21 | return echo.NewHTTPError(http.StatusBadRequest, "access_token must be provided") 22 | } 23 | 24 | // Get the user's identity 25 | discordId, err := discord.GetUserId(body.Token) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | user := database.LookupUserByDiscordID(discordId) 31 | if user == nil { 32 | return echo.NewHTTPError(http.StatusUnauthorized, "no user found") 33 | } 34 | 35 | return respondWithToken(user, c) 36 | } 37 | -------------------------------------------------------------------------------- /src/util/rsa.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/base64" 8 | ) 9 | 10 | func GenerateRsa() *rsa.PrivateKey { 11 | // Should never error 12 | key, err := rsa.GenerateKey(rand.Reader, 4096) 13 | if err != nil { 14 | panic(err) 15 | } 16 | return key 17 | } 18 | 19 | func RsaPubToStr(key *rsa.PublicKey) string { 20 | bytes, err := x509.MarshalPKIXPublicKey(key) 21 | if err != nil { 22 | panic(err) 23 | } 24 | return base64.StdEncoding.EncodeToString(bytes) 25 | } 26 | 27 | func RsaToStr(key *rsa.PrivateKey) string { 28 | bytes := x509.MarshalPKCS1PrivateKey(key) 29 | return base64.StdEncoding.EncodeToString(bytes) 30 | } 31 | 32 | func StrToRsa(keyString string) (key *rsa.PrivateKey, err error) { 33 | bytes, err := base64.StdEncoding.DecodeString(keyString) 34 | if err != nil { 35 | return 36 | } 37 | key, err = x509.ParsePKCS1PrivateKey(bytes) 38 | if err != nil { 39 | return 40 | } 41 | 42 | return 43 | } 44 | -------------------------------------------------------------------------------- /static/min/auth.jquery-min.js: -------------------------------------------------------------------------------- 1 | /* http://www.minifier.org */ 2 | (function($){function getToken(){return window.localStorage.getItem("access_token")} 3 | $.extend({withAuth:function(url,options){if(typeof url==="object"){options=url 4 | url=undefined} 5 | options=options||{} 6 | options.url=url||options.url 7 | options.headers=options.headers||{} 8 | if(getToken()){options.headers.Authorization="Bearer "+getToken()}else{} 9 | return $.ajax(options)}}) 10 | $.each(["get","post"],function(i,method){$.withAuth[method]=function(url,data,callback,type){if(jQuery.isFunction(data)){type=type||callback 11 | callback=data 12 | data=undefined} 13 | return $.withAuth({url:url,type:method,dataType:type,data:data,success:callback})}}) 14 | $.withAuth.ajax=function(url,options){return $.withAuth(url,options)} 15 | $.withAuth.setToken=function setToken(token){window.localStorage.setItem("access_token",token)}})(jQuery) -------------------------------------------------------------------------------- /src/middleware/cache_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/labstack/echo/v4" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestCache(t *testing.T) { 13 | // Helper function 14 | test := func(age int) *httptest.ResponseRecorder { 15 | e := echo.New() 16 | e.Use(Cache(age)) 17 | e.Any("/*", func(c echo.Context) error { 18 | return c.String(http.StatusOK, "Ok cowboy") 19 | }) 20 | req := httptest.NewRequest(http.MethodGet, "http://foobar.net/cached.meme/", nil) 21 | rec := httptest.NewRecorder() 22 | e.ServeHTTP(rec, req) 23 | return rec 24 | } 25 | 26 | // Cache-Control header should equal max-age=[age] 27 | rec := test(200) 28 | assert.Equal(t, http.StatusOK, rec.Code) 29 | assert.Equal(t, "public, max-age=200", rec.Header().Get("Cache-Control")) 30 | 31 | rec = test(150) 32 | assert.Equal(t, http.StatusOK, rec.Code) 33 | assert.Equal(t, "public, max-age=150", rec.Header().Get("Cache-Control")) 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ImpactDevelopment/ImpactServer 2 | 3 | go 1.13 4 | 5 | // +heroku goVersion go1.13 6 | 7 | require ( 8 | github.com/aws/aws-sdk-go v1.25.9 9 | github.com/bwmarrin/discordgo v0.22.0 10 | github.com/gbrlsnchs/jwt/v3 v3.0.0-rc.0 11 | github.com/google/uuid v1.1.1 12 | github.com/heroku/heroku-go/v5 v5.1.0 13 | github.com/kr/pretty v0.1.0 // indirect 14 | github.com/labstack/echo/v4 v4.1.11 15 | github.com/lib/pq v1.2.0 16 | github.com/mailgun/mailgun-go/v3 v3.6.0 17 | github.com/mattn/go-colorable v0.1.4 // indirect 18 | github.com/mattn/go-isatty v0.0.10 // indirect 19 | github.com/stretchr/testify v1.5.1 20 | github.com/stripe/stripe-go/v71 v71.48.0 21 | github.com/valyala/fasttemplate v1.1.0 // indirect 22 | golang.org/x/crypto v0.0.0-20200210222208-86ce3cb69678 23 | golang.org/x/text v0.3.2 // indirect 24 | golang.org/x/time v0.0.0-20191024005414-555d28b269f0 25 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect 26 | gopkg.in/ezzarghili/recaptcha-go.v4 v4.0.0 27 | gopkg.in/yaml.v2 v2.2.4 // indirect 28 | ) 29 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 23 | 24 | -------------------------------------------------------------------------------- /src/jwt/minecraft.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "github.com/ImpactDevelopment/ImpactServer/src/minecraft" 5 | "net/http" 6 | 7 | "github.com/ImpactDevelopment/ImpactServer/src/database" 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | func MinecraftLoginHandler(c echo.Context) error { 12 | var body struct { 13 | Username string `json:"username" form:"username" query:"username"` 14 | Hash string `json:"hash" form:"hash" query:"hash"` 15 | } 16 | if err := c.Bind(&body); err != nil { 17 | return err 18 | } 19 | if body.Username == "" || body.Hash == "" { 20 | return echo.NewHTTPError(http.StatusBadRequest, "both username and hash must be provided") 21 | } 22 | profile, err := minecraft.HasJoinedServer(body.Username, body.Hash) 23 | if err != nil { 24 | return echo.NewHTTPError(http.StatusInternalServerError, "failed authentication with mojang").SetInternal(err) 25 | } 26 | user := database.LookupUserByMinecraftID(profile.ID) 27 | if user == nil || len(user.Roles) <= 0 { 28 | return echo.NewHTTPError(http.StatusUnauthorized, "no premium user found") 29 | } 30 | 31 | return respondWithToken(user, c) 32 | } 33 | -------------------------------------------------------------------------------- /src/jwt/password.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "github.com/ImpactDevelopment/ImpactServer/src/database" 5 | "github.com/labstack/echo/v4" 6 | "golang.org/x/crypto/bcrypt" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type passwordRequest struct { 12 | Email string `json:"email" form:"email" query:"email"` 13 | Password string `json:"password" form:"password" query:"password"` 14 | } 15 | 16 | func PasswordLoginHandler(c echo.Context) error { 17 | var body passwordRequest 18 | if err := c.Bind(&body); err != nil { 19 | return err 20 | } 21 | if body.Email == "" || body.Password == "" { 22 | return echo.NewHTTPError(http.StatusBadRequest, "email and password must both be provided") 23 | } 24 | 25 | // Get the user 26 | user := database.LookupUserByEmail(strings.TrimSpace(body.Email)) 27 | if user == nil { 28 | return echo.NewHTTPError(http.StatusUnauthorized, "no user found") 29 | } 30 | 31 | // Check the password 32 | err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)) 33 | if err != nil { 34 | return echo.NewHTTPError(http.StatusUnauthorized, "incorrect password") 35 | } 36 | 37 | return respondWithToken(user, c) 38 | } 39 | -------------------------------------------------------------------------------- /src/users/features.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | type Features struct { 4 | // Public features are listed on the public /users/info endpoint unless the user is incognito 5 | Public []string `json:"public,omitempty"` 6 | 7 | // Private features are only exposed to the user 8 | Private []string `json:"private,omitempty"` 9 | } 10 | 11 | func (user *User) Features() *Features { 12 | private := user.privateFeatures() 13 | public := user.publicFeatures() 14 | 15 | if len(private) > 0 || len(public) > 0 { 16 | return &Features{ 17 | Public: public, 18 | Private: private, 19 | } 20 | } 21 | return nil 22 | } 23 | 24 | func (user *User) privateFeatures() (features []string) { 25 | edition := user.Edition() 26 | if edition != nil { 27 | features = append(features, "edition") 28 | } 29 | return 30 | } 31 | 32 | func (user *User) publicFeatures() (features []string) { 33 | info := user.UserInfo 34 | if info != nil { 35 | if info.BackgroundColor != "" || info.BorderColor != "" || info.TextColor != "" { 36 | features = append(features, "nametag") 37 | } 38 | if info.Cape != "" { 39 | features = append(features, "cape") 40 | } 41 | if info.Icon != "" { 42 | features = append(features, "icon") 43 | } 44 | } 45 | return 46 | } 47 | -------------------------------------------------------------------------------- /src/api/v1/futureclientintegration.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "strings" 7 | 8 | "github.com/ImpactDevelopment/ImpactServer/src/database" 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | func futureIntegrationMasonList(c echo.Context) error { 13 | auth := c.QueryParam("auth") + "0" 14 | if auth != os.Getenv("API_AUTH_SECRET") && auth != os.Getenv("FUTURE_AUTH_SECRET") { 15 | return c.JSON(http.StatusForbidden, "auth wrong im sowwy") 16 | } 17 | 18 | rows, err := database.DB.Query("SELECT mc_uuid FROM users WHERE spawnmason") 19 | if err != nil { 20 | return err 21 | } 22 | defer rows.Close() 23 | 24 | var b strings.Builder 25 | for rows.Next() { 26 | var uuidStr string 27 | err = rows.Scan(&uuidStr) 28 | if err != nil { 29 | return err 30 | } 31 | b.WriteString(uuidStr + "\n") 32 | } 33 | err = rows.Err() 34 | if err != nil { 35 | return err 36 | } 37 | 38 | return c.String(http.StatusOK, b.String()) 39 | } 40 | 41 | func futureIntegrationOverallData(c echo.Context) error { 42 | auth := c.QueryParam("auth") + "0" 43 | if auth != os.Getenv("API_AUTH_SECRET") && auth != os.Getenv("FUTURE_AUTH_SECRET") { 44 | return c.JSON(http.StatusForbidden, "auth wrong im sowwy") 45 | } 46 | return c.JSON(http.StatusOK, userDataNonHashed) 47 | } 48 | -------------------------------------------------------------------------------- /src/web/changelog.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/ImpactDevelopment/ImpactServer/src/util" 8 | 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | const github = "https://impactdevelopment.github.io" 13 | 14 | func changelog(c echo.Context) error { 15 | // Forward to the changelog hosted by github 16 | 17 | target, err := url.Parse(github + "/Impact/changelog") 18 | if err != nil { 19 | return err //wtf 20 | } 21 | util.Proxy(c, target) 22 | return nil 23 | } 24 | 25 | func impactRedirect(c echo.Context) error { 26 | address := c.Request().URL 27 | 28 | // Echo tends to set the Request URL to just the path+query 29 | if address.Host == "" { 30 | address.Host = c.Request().Host 31 | } 32 | if address.Scheme == "" { 33 | address.Scheme = c.Scheme() 34 | } 35 | 36 | // Special case: 301 /Impact/changelog → /changelog 37 | if address.Path == "/Impact/changelog" { 38 | address.Path = "/changelog" 39 | return c.Redirect(http.StatusMovedPermanently, address.String()) 40 | } 41 | 42 | // Pull the bits we need from the github url 43 | ghAddr, err := url.Parse(github) 44 | if err != nil { 45 | return err 46 | } 47 | address.Scheme = ghAddr.Scheme 48 | address.Host = ghAddr.Host 49 | 50 | // 302 to github.io 51 | return c.Redirect(http.StatusFound, address.String()) 52 | } 53 | -------------------------------------------------------------------------------- /src/api/v1/motd.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/ImpactDevelopment/ImpactServer/src/util" 10 | 11 | "github.com/ImpactDevelopment/ImpactServer/src/cloudflare" 12 | "github.com/labstack/echo/v4" 13 | ) 14 | 15 | const motdURL = "https://impactdevelopment.github.io/Resources/data/motd.txt" 16 | 17 | var motd string 18 | 19 | func init() { 20 | var err error 21 | motd, err = fetchMotd() 22 | if err != nil { 23 | log.Println("MOTD ERROR", err) 24 | motd = "Ok, so our MOTD service may or may not be semi-broken right now..." 25 | } 26 | util.DoRepeatedly(3*time.Minute, func() { 27 | newer, err := fetchMotd() 28 | if err != nil { 29 | log.Println("MOTD ERROR", err) 30 | return 31 | } 32 | newMotd(newer) 33 | }) 34 | } 35 | 36 | func newMotd(newer string) { 37 | if newer != motd { 38 | log.Println("MOTD UPDATE from", motd, "to", newer) 39 | motd = newer 40 | cloudflare.PurgeURLs([]string{"https://api.impactclient.net/v1/motd"}) 41 | } 42 | } 43 | 44 | func fetchMotd() (string, error) { 45 | resp, err := http.Get(motdURL) 46 | if err != nil { 47 | return "", err 48 | } 49 | defer resp.Body.Close() 50 | data, err := ioutil.ReadAll(resp.Body) 51 | if err != nil { 52 | return "", err 53 | } 54 | return string(data), nil 55 | } 56 | 57 | func getMotd(c echo.Context) error { 58 | return c.String(http.StatusOK, motd) 59 | } 60 | -------------------------------------------------------------------------------- /src/newWeb/server.go: -------------------------------------------------------------------------------- 1 | package newWeb 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/ImpactDevelopment/ImpactServer/src/util" 8 | "github.com/labstack/echo/v4" 9 | 10 | mid "github.com/ImpactDevelopment/ImpactServer/src/middleware" 11 | ) 12 | 13 | func Server() (e *echo.Echo) { 14 | e = echo.New() 15 | 16 | e.Use(mid.Log) 17 | 18 | e.GET("/ImpactInstaller.*", redirect(http.StatusFound, "https://impactclient.net/"), mid.NoCache()) 19 | e.Any("/*", proxy("https://impact-web.herokuapp.com/")) 20 | 21 | return 22 | } 23 | 24 | func proxy(address string) func(echo.Context) error { 25 | return func(c echo.Context) error { 26 | addr := c.Request().URL 27 | 28 | // Pull the bits we need from the heroku addr 29 | newAddr, err := url.Parse(address) 30 | if err != nil { 31 | return err 32 | } 33 | addr.Scheme = newAddr.Scheme 34 | addr.Host = newAddr.Host 35 | 36 | // Proxy to heroku 37 | util.Proxy(c, addr) 38 | return nil 39 | } 40 | } 41 | 42 | func redirect(code int, address string) func(echo.Context) error { 43 | return func(c echo.Context) error { 44 | addr := c.Request().URL 45 | 46 | // Pull the bits we need from the address 47 | newAddr, err := url.Parse(address) 48 | if err != nil { 49 | return err 50 | } 51 | addr.Scheme = newAddr.Scheme 52 | addr.Host = newAddr.Host 53 | 54 | // 302 to the current location 55 | return c.Redirect(code, addr.String()) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/web/server.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | mid "github.com/ImpactDevelopment/ImpactServer/src/middleware" 7 | "github.com/labstack/echo/v4" 8 | ) 9 | 10 | func Server() (e *echo.Echo) { 11 | e = echo.New() 12 | 13 | e.Use(mid.Log) 14 | 15 | e.Match([]string{http.MethodHead, http.MethodGet}, "/changelog", changelog) 16 | e.Any("/Impact/*", impactRedirect) 17 | e.GET("/releases.json", releases, mid.Cache(86400)) // 1 day, since HTTP can be cached even beyond cloudflare 18 | e.Match([]string{http.MethodHead, http.MethodGet}, "/stripe", stripe) 19 | 20 | e.GET("/ImpactInstaller.jar", installerForJar, mid.NoCache()) 21 | e.GET("/ImpactInstaller.exe", installerForExe, mid.NoCache()) 22 | 23 | e.POST("/discordverify", discordVerify) 24 | e.POST("/recaptchaverify", simpleRecaptchaCheck) 25 | e.Any("/.well-known/apple-developer-merchantid-domain-association", applePayVerify) 26 | 27 | staticEcho := echo.New() 28 | // Redirect any request ending in .html 29 | staticEcho.Pre(mid.StripExt(http.StatusMovedPermanently, "html")) 30 | // Append .html to any path with no dot in the filename 31 | staticEcho.Pre(mid.RegexRewrite(map[string]string{ 32 | `.*/[^/.]+$`: "$0.html", 33 | })) 34 | staticEcho.Use(mid.CacheUntilRestart(3600)) // 1 hour 35 | staticEcho.Static("/", "static") 36 | e.Any("/*", func(c echo.Context) error { 37 | staticEcho.ServeHTTP(c.Response(), c.Request()) 38 | return nil 39 | }) 40 | 41 | return 42 | } 43 | -------------------------------------------------------------------------------- /src/middleware/rewrite.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | // RegexRewrite is based on echo's Rewrite but expects a regex rather than trying to create one from a glob 11 | func RegexRewrite(rules map[string]string) echo.MiddlewareFunc { 12 | // Convert the string:string map to a regex:string map 13 | rulesRegex := map[*regexp.Regexp]string{} 14 | for k, v := range rules { 15 | rulesRegex[regexp.MustCompile(k)] = v 16 | } 17 | 18 | return func(next echo.HandlerFunc) echo.HandlerFunc { 19 | return func(c echo.Context) error { 20 | url := c.Request().URL 21 | 22 | // Rewrite 23 | for k, v := range rulesRegex { 24 | replacer := captureTokens(k, url.Path) 25 | if replacer != nil { 26 | url.Path = replacer.Replace(v) 27 | break 28 | } 29 | } 30 | 31 | return next(c) 32 | } 33 | } 34 | } 35 | 36 | // captureTokens is based on echo/v4@v4.1.11/middleware/middleware.go but supports replacing $0 37 | func captureTokens(pattern *regexp.Regexp, input string) *strings.Replacer { 38 | groups := pattern.FindAllStringSubmatch(input, -1) 39 | if groups == nil { 40 | return nil 41 | } 42 | matches := groups[0] 43 | replace := make([]string, 2*len(matches)) 44 | for i, match := range matches { 45 | j := 2 * i 46 | replace[j] = "$" + strconv.Itoa(i) 47 | replace[j+1] = match 48 | } 49 | return strings.NewReplacer(replace...) 50 | } 51 | -------------------------------------------------------------------------------- /src/heroku/heroku.go: -------------------------------------------------------------------------------- 1 | package heroku 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | heroku "github.com/heroku/heroku-go/v5" 10 | ) 11 | 12 | const RELEASE_TIME = 24 * time.Hour 13 | 14 | var token string 15 | var app_id string 16 | 17 | func init() { 18 | token = os.Getenv("HEROKU_API_TOKEN") 19 | app_id = os.Getenv("HEROKU_APP_ID") 20 | if token == "" || app_id == "" { 21 | fmt.Println("WARNING: Not checking Heroku I don't have an API key!") 22 | } 23 | } 24 | 25 | func NoRecentReleases() bool { 26 | if token == "" || app_id == "" { 27 | fmt.Println("Assuming potentially recent releases, since no Heroku api access!") 28 | return false 29 | } 30 | heroku.DefaultTransport.BearerToken = token 31 | 32 | h := heroku.NewService(heroku.DefaultClient) 33 | apps, err := h.AppList(context.TODO(), nil) 34 | if err != nil { 35 | fmt.Println(err) 36 | fmt.Println("Invalid Heroku API token? Assuming potentially recent releases!") 37 | return false 38 | } 39 | for _, app := range apps { 40 | if app.ID == app_id { 41 | fmt.Println("Found myself on Heroku, app ID is", app.ID, "and app name is", app.Name) 42 | fmt.Println("Most recent release is", app.ReleasedAt.Format(time.RFC3339)) 43 | dur := time.Since(*app.ReleasedAt) 44 | fmt.Println("Duration since last release is", dur) 45 | return dur > RELEASE_TIME 46 | } 47 | } 48 | panic("I was provided with a heroku api token and heroku app id, but the api token did not grant me access to that app?") 49 | } 50 | -------------------------------------------------------------------------------- /src/web/changelog_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/labstack/echo/v4" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestConst(t *testing.T) { 13 | assert.Equal(t, "https://impactdevelopment.github.io", github) 14 | } 15 | 16 | func TestImpactRedirect(t *testing.T) { 17 | const route = "/Impact/" 18 | const path = "assets/css/style.css?v=foobar" 19 | 20 | e := echo.New() 21 | req := httptest.NewRequest(http.MethodGet, "http://foobar.cool"+route+path, nil) 22 | rec := httptest.NewRecorder() 23 | c := e.NewContext(req, rec) 24 | c.SetPath(route + "*") 25 | err := impactRedirect(c) 26 | 27 | if assert.NoError(t, err) { 28 | // Expect 302 29 | assert.Equal(t, http.StatusFound, rec.Code) 30 | // Expect the correct target 31 | assert.Equal(t, github+route+path, rec.Header().Get(echo.HeaderLocation)) 32 | } 33 | } 34 | 35 | func TestChangelogRedirect(t *testing.T) { 36 | const route = "/Impact/" 37 | const path = "changelog" 38 | 39 | e := echo.New() 40 | req := httptest.NewRequest(http.MethodGet, "http://foobar.cool"+route+path, nil) 41 | rec := httptest.NewRecorder() 42 | c := e.NewContext(req, rec) 43 | c.SetPath(route + "*") 44 | err := impactRedirect(c) 45 | 46 | if assert.NoError(t, err) { 47 | // Expect 301 48 | assert.Equal(t, http.StatusMovedPermanently, rec.Code) 49 | // Expect the correct target 50 | assert.Equal(t, "http://foobar.cool/changelog", rec.Header().Get(echo.HeaderLocation)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/users/user.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | ) 6 | 7 | type User struct { 8 | ID uuid.UUID `json:"-"` 9 | Email string `json:"email"` 10 | MinecraftID *uuid.UUID `json:"minecraft"` 11 | DiscordID string `json:"discord"` 12 | PasswordHash string `json:"-"` 13 | StripeID string `json:"-"` 14 | LegacyEnabled bool `json:"legacy_enabled"` 15 | Incognito bool `json:"incognito"` 16 | Legacy bool `json:"legacy"` 17 | Roles []Role `json:"roles"` 18 | UserInfo *UserInfo `json:"user_info"` 19 | } 20 | 21 | func (user User) RoleIDs(legacyOnly bool) []string { 22 | roles := user.Roles 23 | arr := make([]string, 0) 24 | for _, role := range roles { 25 | if !role.LegacyList && legacyOnly { 26 | continue 27 | } 28 | arr = append(arr, role.ID) 29 | } 30 | return arr 31 | } 32 | 33 | func (user User) HasRoleWithID(roleID string) bool { 34 | for _, role := range user.Roles { 35 | if role.ID == roleID { 36 | return true 37 | } 38 | } 39 | return false 40 | } 41 | 42 | // IsFullAccount returns true if the user is a full Impact Account 43 | func (user User) IsFullAccount() bool { 44 | return user.Email != "" 45 | } 46 | 47 | // CheckPassword returns true if the password is correct 48 | func (user User) CheckPassword(password string) bool { 49 | if !user.IsFullAccount() { 50 | return false 51 | } 52 | hash := password // TODO actually hash passwords 53 | return user.PasswordHash == hash 54 | } 55 | -------------------------------------------------------------------------------- /src/s3proxy/server.go: -------------------------------------------------------------------------------- 1 | package s3proxy 2 | 3 | import ( 4 | mid "github.com/ImpactDevelopment/ImpactServer/src/middleware" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | "time" 10 | 11 | "github.com/ImpactDevelopment/ImpactServer/src/util" 12 | 13 | "github.com/aws/aws-sdk-go/aws" 14 | "github.com/aws/aws-sdk-go/aws/session" 15 | "github.com/aws/aws-sdk-go/service/s3" 16 | "github.com/labstack/echo/v4" 17 | ) 18 | 19 | var AWSSession = session.Must(session.NewSession(&aws.Config{Region: aws.String("us-east-1")})) 20 | 21 | func Server() (e *echo.Echo) { 22 | e = echo.New() 23 | 24 | e.Use(mid.Log) 25 | 26 | e.Match([]string{http.MethodHead, http.MethodGet}, "/*", proxyHandler("", "impactclient-files")) 27 | e.Match([]string{http.MethodHead, http.MethodGet}, "/test_alternate/*", proxyHandler("/test_alternate", os.Getenv("ALT_BUCKET")), mid.AuthGetParam(), mid.NoCache()) 28 | 29 | return 30 | } 31 | 32 | func proxyHandler(base string, bucket string) func(c echo.Context) error { 33 | return func(c echo.Context) error { 34 | file := c.Request().URL.Path[len(base):] 35 | 36 | s3Req, _ := s3.New(AWSSession).GetObjectRequest(&s3.GetObjectInput{ 37 | Bucket: aws.String(bucket), 38 | Key: aws.String(file), 39 | }) 40 | 41 | s3PresignedURL, err := s3Req.Presign(1 * time.Minute) 42 | if err != nil { 43 | return err 44 | } 45 | 46 | log.Println(s3PresignedURL) 47 | 48 | target, err := url.Parse(s3PresignedURL) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | util.Proxy(c, target) 54 | return nil 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/recaptcha/recaptcha.go: -------------------------------------------------------------------------------- 1 | package recaptcha 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ImpactDevelopment/ImpactServer/src/util" 6 | "github.com/labstack/echo/v4" 7 | "gopkg.in/ezzarghili/recaptcha-go.v4" 8 | "net/http" 9 | "os" 10 | "time" 11 | ) 12 | 13 | var captcha recaptcha.ReCAPTCHA 14 | 15 | func init() { 16 | secret := os.Getenv("RECAPTCHA_SECRET_KEY") 17 | if secret == "" { 18 | fmt.Println("WARNING: No recaptcha secret; discord verification is disabled!") 19 | return 20 | } 21 | var err error 22 | captcha, err = recaptcha.NewReCAPTCHA(secret, recaptcha.V2, 10*time.Second) 23 | if err != nil { 24 | panic(err) 25 | } 26 | } 27 | 28 | type request struct { 29 | Recaptcha string `json:"g-recaptcha-response" form:"g-recaptcha-response" query:"g-recaptcha-response"` 30 | } 31 | 32 | // Verify returns a HTTP error if the provided g-recaptcha-response is invalid 33 | func Verify(c echo.Context) error { 34 | body := &request{} 35 | err := c.Bind(body) 36 | if err != nil { 37 | return err 38 | } 39 | if body.Recaptcha == "" { 40 | return echo.NewHTTPError(http.StatusBadRequest, "recaptcha must be provided") 41 | } 42 | 43 | // remoteIP is empty string if not present, which is exactly what this library expects 44 | err = captcha.VerifyWithOptions(body.Recaptcha, recaptcha.VerifyOption{ 45 | RemoteIP: util.RealIPIfUnambiguous(c), 46 | Hostname: util.GetServerURL().Hostname(), 47 | }) 48 | if err != nil { 49 | fmt.Println(err) 50 | return echo.NewHTTPError(http.StatusBadRequest, "Recaptcha failed").SetInternal(err) 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /src/util/urls_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/url" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSetQuery(t *testing.T) { 11 | url1, err := url.Parse("http://example.com/foo?that=thing") 12 | assert.NoError(t, err) 13 | 14 | assert.Equal(t, "thing", url1.Query().Get("that")) 15 | SetQuery(url1, "that", "random thing") 16 | assert.Equal(t, "random thing", url1.Query().Get("that")) 17 | assert.Equal(t, "http://example.com/foo?that=random+thing", url1.String()) 18 | } 19 | 20 | func TestGetSubdomain(t *testing.T) { 21 | // Get an empty string if no sub-domain 22 | assert.Equal(t, "", GetSubdomains("example.com")) 23 | assert.Equal(t, "", GetSubdomains("example.co.uk")) 24 | assert.Equal(t, "", GetSubdomains("localhost")) 25 | 26 | // Get a sub-domain if present 27 | assert.Equal(t, "foo", GetSubdomains("foo.bar.com")) 28 | assert.Equal(t, "foo", GetSubdomains("foo.bar.co.uk")) 29 | assert.Equal(t, "foo", GetSubdomains("foo.localhost")) 30 | 31 | // Support sub-sub domains 32 | assert.Equal(t, "foo.bar", GetSubdomains("foo.bar.example.com")) 33 | assert.Equal(t, "foo.bar", GetSubdomains("foo.bar.example.co.uk")) 34 | assert.Equal(t, "foo.bar", GetSubdomains("foo.bar.localhost")) 35 | 36 | // Don't break if a port is included 37 | assert.Equal(t, "", GetSubdomains("example.com:3000")) 38 | assert.Equal(t, "", GetSubdomains("localhost:321")) 39 | assert.Equal(t, "abc", GetSubdomains("abc.example.com:3000")) 40 | assert.Equal(t, "abc", GetSubdomains("abc.localhost:321")) 41 | assert.Equal(t, "abc.def", GetSubdomains("abc.def.example.com:3000")) 42 | assert.Equal(t, "abc.def", GetSubdomains("abc.def.localhost:321")) 43 | } 44 | -------------------------------------------------------------------------------- /src/database/listener.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | "time" 7 | 8 | "github.com/lib/pq" 9 | ) 10 | 11 | var callbacks = make([]func(), 0) 12 | 13 | // this is just paranoia. this is only modified in init and only used after init, but might as well be safe and mutex it :) 14 | var callbacksLock sync.Mutex 15 | 16 | func CallbackOnUsersTableUpdate(callback func()) { 17 | callbacksLock.Lock() 18 | defer callbacksLock.Unlock() 19 | callbacks = append(callbacks, callback) 20 | } 21 | 22 | func fireCallbacks() { 23 | callbacksLock.Lock() 24 | defer callbacksLock.Unlock() 25 | for _, callback := range callbacks { 26 | callback() 27 | } 28 | } 29 | 30 | func setupListener(url string) { 31 | minReconn := 10 * time.Second 32 | maxReconn := time.Minute 33 | listener := pq.NewListener(url, minReconn, maxReconn, func(ev pq.ListenerEventType, err error) { 34 | if err != nil { 35 | log.Println("WARNING: Postgres listener hit some kind of error!") 36 | log.Println(err) 37 | } 38 | }) 39 | err := listener.Listen("users_updated") 40 | if err != nil { 41 | panic(err) 42 | } 43 | log.Println("Postgres listener created") 44 | go func() { 45 | for { 46 | select { 47 | case <-listener.Notify: 48 | log.Println("Postgres trigger 'users_updated' got pinged!") 49 | fireCallbacks() 50 | 51 | // ping the listener every 30 mins even if no notify 52 | // this is the suggested pattern, given that sometimes connections can drop 53 | // source: https://github.com/lib/pq/blob/master/example/listen/doc.go 54 | case <-time.After(30 * time.Minute): 55 | go listener.Ping() 56 | fireCallbacks() // failsafe 57 | } 58 | } 59 | }() 60 | } 61 | -------------------------------------------------------------------------------- /src/api/v1/thealtening.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | const alteningReferral = "impact" 10 | 11 | var alteningInfoStruct = TheAlteningInfo{ 12 | Dashboard: &Dashboard{ 13 | GenerateUrl: "https://panel.thealtening.com/?ref=" + alteningReferral + "#generator", 14 | AccountUrl: "https://panel.thealtening.com/?ref=" + alteningReferral + "#account", 15 | }, 16 | Generator: &Generator{ 17 | FreeUrl: "https://thealtening.com/?ref=" + alteningReferral, 18 | PaidUrl: "https://panel.thealtening.com/?ref=" + alteningReferral + "#generator", 19 | }, 20 | Shop: "https://shop.thealtening.com/?r=" + alteningReferral, 21 | // TODO load a list of `Promo`s at runtime 22 | Promos: &[]Promo{ 23 | { 24 | Code: alteningReferral, 25 | Discount: "20%", 26 | }, 27 | }, 28 | Enabled: true, 29 | } 30 | 31 | type TheAlteningInfo struct { 32 | Dashboard *Dashboard `json:"dashboard,omitempty"` 33 | Generator *Generator `json:"generate,omitempty"` 34 | Shop string `json:"shop,omitempty"` 35 | Promos *[]Promo `json:"promotions,omitempty"` 36 | Enabled bool `json:"enabled"` 37 | } 38 | 39 | type Dashboard struct { 40 | GenerateUrl string `json:"generate,omitempty"` 41 | AccountUrl string `json:"account,omitempty"` 42 | } 43 | 44 | type Generator struct { 45 | FreeUrl string `json:"free,omitempty"` 46 | PaidUrl string `json:"premium,omitempty"` 47 | } 48 | 49 | type Promo struct { 50 | Code string `json:"promo_code,omitempty"` 51 | Discount string `json:"discount,omitempty"` 52 | Expiry *time.Time `json:"expiry,omitempty"` 53 | } 54 | 55 | func getTheAlteningInfo(c echo.Context) error { 56 | return c.JSON(200, alteningInfoStruct) 57 | } 58 | -------------------------------------------------------------------------------- /src/users/editions.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import "strings" 4 | 5 | type Edition struct { 6 | // The edition icon, will be drawn left of the text 7 | Icon string `json:"icon,omitempty"` 8 | // The edition text, User.Edition() concatenates these into a list and appends "Edition" 9 | // e.g. "Pepsi Premium Edition" 10 | Text string `json:"text,omitempty"` 11 | // Colour of the edition text 12 | TextColor string `json:"text_color,omitempty"` 13 | } 14 | 15 | func (user User) Edition() *Edition { 16 | // Start by building a list of editions 17 | var editions []Edition 18 | if user.MinecraftID != nil { 19 | if special, ok := specialCases[*user.MinecraftID]; ok { 20 | if e := special.edition; e != nil { 21 | editions = append(editions, *e) 22 | } 23 | } 24 | } 25 | for _, role := range getRolesSorted(user.Roles) { 26 | if template, ok := defaultRoleTemplates[role.ID]; ok { 27 | e := template.edition 28 | if e != nil { 29 | editions = append(editions, *e) 30 | } 31 | } 32 | } 33 | 34 | // Now we've built the slice, we can reduce it 35 | if len(editions) > 0 { 36 | var ret Edition 37 | 38 | // Set first icon 39 | for _, e := range editions { 40 | if e.Icon != "" { 41 | ret.Icon = e.Icon 42 | break 43 | } 44 | } 45 | 46 | // Set first text_color 47 | for _, e := range editions { 48 | if e.TextColor != "" { 49 | ret.TextColor = e.TextColor 50 | break 51 | } 52 | } 53 | 54 | // Concatenate the text 55 | var text strings.Builder 56 | for _, e := range editions { 57 | if text.Len() > 0 && e.Text != "" { 58 | text.WriteString(" ") 59 | } 60 | text.WriteString(e.Text) 61 | } 62 | if text.Len() > 0 { 63 | text.WriteString(" Edition") 64 | } 65 | ret.Text = text.String() 66 | 67 | return &ret 68 | } 69 | 70 | return nil 71 | } 72 | -------------------------------------------------------------------------------- /src/web/recaptcha.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "github.com/ImpactDevelopment/ImpactServer/src/discord" 5 | "github.com/ImpactDevelopment/ImpactServer/src/recaptcha" 6 | "github.com/labstack/echo/v4" 7 | "net/http" 8 | ) 9 | 10 | func simpleRecaptchaCheck(c echo.Context) error { 11 | err := recaptcha.Verify(c) 12 | if err != nil { 13 | return err 14 | } 15 | return c.String(200, "Success") 16 | } 17 | 18 | type verification struct { 19 | Token string `json:"token" form:"token" query:"token"` 20 | Id string `json:"discord" form:"discord" query:"discord"` 21 | } 22 | 23 | func discordVerify(c echo.Context) error { 24 | body := &verification{} 25 | err := c.Bind(body) 26 | if err != nil { 27 | return err 28 | } 29 | err = recaptcha.Verify(c) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | if body.Id == "" && body.Token == "" { 35 | return echo.NewHTTPError(http.StatusBadRequest, " discord id (or access token) must be provided") 36 | } 37 | 38 | // Get the user's identity 39 | var discordId string 40 | if body.Id != "" { 41 | discordId = body.Id 42 | } else { 43 | discordId, err = discord.GetUserId(body.Token) 44 | if err != nil { 45 | return err 46 | } 47 | } 48 | 49 | if discord.CheckServerMembership(discordId) { 50 | err = discord.GiveVerified(discordId) 51 | if err != nil { 52 | return err 53 | } 54 | } else { 55 | if body.Token == "" { 56 | // they arent in, and we cant join since no token :( 57 | return echo.NewHTTPError(http.StatusBadRequest, discordId+" doesn't appear to be a member of our discord?") 58 | } else { 59 | err = discord.JoinOurServer(body.Token, discordId, false) 60 | if err != nil { 61 | return err 62 | } 63 | } 64 | } 65 | 66 | return c.Redirect(http.StatusFound, "https://discordapp.com/channels/208753003996512258/222120655594848256") 67 | } 68 | -------------------------------------------------------------------------------- /src/cloudflare/main.go: -------------------------------------------------------------------------------- 1 | package cloudflare 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/ImpactDevelopment/ImpactServer/src/heroku" 8 | "github.com/ImpactDevelopment/ImpactServer/src/util" 9 | ) 10 | 11 | var zone string 12 | var key string 13 | 14 | func init() { 15 | zone = os.Getenv("CLOUDFLARE_ZONE_IDENTIFIER") 16 | key = os.Getenv("CLOUDFLARE_API_KEY") 17 | if zone == "" || key == "" { 18 | fmt.Println("WARNING: Not purging cloudflare cache since I don't have an API key!") 19 | } 20 | } 21 | 22 | func PurgeIfNeeded() { 23 | if heroku.NoRecentReleases() { 24 | fmt.Println("Heroku API says NO RECENT RELEASES, therefore this is simply the daily scheduled restart. NOT purging cloudflare!") 25 | return 26 | } 27 | fmt.Println("Purging cloudflare cache of everything") 28 | purgeWithData(struct { 29 | PurgeEverything bool `json:"purge_everything"` 30 | }{true}) 31 | } 32 | 33 | func PurgeURLs(urls []string) { 34 | fmt.Println("Purging cloudflare cache of URLs", urls) 35 | purgeWithData(struct { 36 | Files []string `json:"files"` 37 | }{urls}) 38 | } 39 | 40 | func purgeWithData(jsonData interface{}) { 41 | if zone == "" { 42 | fmt.Println("WARNING: Not purging cloudflare cache since a zone is not specified!") 43 | return 44 | } 45 | if key == "" { 46 | fmt.Println("WARNING: Not purging cloudflare cache since a key is not specified!") 47 | return 48 | } 49 | 50 | url := "https://api.cloudflare.com/client/v4/zones/" + zone + "/purge_cache" 51 | req, err := util.JSONRequest(url, jsonData) 52 | if err != nil { 53 | fmt.Println("Cloudflare error building request", err) 54 | return 55 | } 56 | req.Authorization("Bearer", key) 57 | 58 | resp, err := req.Do() 59 | if err != nil { 60 | fmt.Println("Cloudflare purge error", err) 61 | return 62 | } 63 | 64 | fmt.Println("Cloudflare: code: "+resp.Status()+", body: ", resp.String()) 65 | } 66 | -------------------------------------------------------------------------------- /src/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/ImpactDevelopment/ImpactServer/src/api" 9 | 10 | "github.com/ImpactDevelopment/ImpactServer/src/cloudflare" 11 | mid "github.com/ImpactDevelopment/ImpactServer/src/middleware" 12 | "github.com/ImpactDevelopment/ImpactServer/src/newWeb" 13 | "github.com/ImpactDevelopment/ImpactServer/src/s3proxy" 14 | "github.com/ImpactDevelopment/ImpactServer/src/util" 15 | "github.com/ImpactDevelopment/ImpactServer/src/web" 16 | "github.com/labstack/echo/v4" 17 | "github.com/labstack/echo/v4/middleware" 18 | ) 19 | 20 | var port = 3000 21 | 22 | func init() { 23 | // Check if $PORT has been set to an int 24 | if p, err := strconv.Atoi(os.Getenv("PORT")); err == nil { 25 | port = p 26 | } 27 | } 28 | 29 | func main() { 30 | hosts := map[string]*echo.Echo{ 31 | "": web.Server(), 32 | "new": newWeb.Server(), 33 | "files": s3proxy.Server(), 34 | "api": api.Server(), 35 | } 36 | 37 | e := echo.New() 38 | 39 | // Enforce URL style 40 | e.Pre(middleware.NonWWWRedirect()) 41 | e.Pre(middleware.RemoveTrailingSlash()) 42 | e.Pre(mid.RemoveIndexHTML(http.StatusMovedPermanently)) 43 | e.Pre(mid.EnforceHTTPS(http.StatusMovedPermanently)) 44 | 45 | e.Use(middleware.BodyLimit("1M")) 46 | 47 | e.Any("/*", func(c echo.Context) error { 48 | req := c.Request() 49 | res := c.Response() 50 | 51 | server := hosts[util.GetSubdomains(req.Host)] 52 | 53 | if server == nil { 54 | return echo.ErrNotFound 55 | } 56 | 57 | server.ServeHTTP(res, req) 58 | return nil 59 | }) 60 | 61 | e.Use(middleware.Recover()) 62 | 63 | go cloudflare.PurgeIfNeeded() // "go" as a vague halfhearted attempt to make this occur only after we start listening and serving, to prevent long blocking requests 64 | // Start the server 65 | e.Logger.Fatal(StartServer(e, port)) 66 | } 67 | 68 | func StartServer(e *echo.Echo, port int) error { 69 | return e.Start(":" + strconv.Itoa(port)) 70 | } 71 | -------------------------------------------------------------------------------- /src/middleware/cache.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | // instruct the browser and cloudflare to cache for this amount of time 10 | func Cache(maxAge int) echo.MiddlewareFunc { 11 | return func(next echo.HandlerFunc) echo.HandlerFunc { 12 | return func(c echo.Context) error { 13 | c.Response().Header().Set("Cache-Control", "public, max-age="+strconv.Itoa(maxAge)) 14 | return next(c) 15 | } 16 | } 17 | } 18 | 19 | // override an existing max-age header (e.g. from github) with a s-maxage that only cloudflare will respect instead 20 | func CacheCloudflare(cloudflareMaxAge int) echo.MiddlewareFunc { 21 | return func(next echo.HandlerFunc) echo.HandlerFunc { 22 | return func(c echo.Context) error { 23 | c.Response().Header().Set("Cache-Control", "public, s-maxage="+strconv.Itoa(cloudflareMaxAge)+", max-age="+strconv.Itoa(cloudflareMaxAge)) 24 | return next(c) 25 | } 26 | } 27 | } 28 | 29 | // cache indefinitely in cloudflare (until this server restarts and purges cloudflare), and for the defined amount of time in the browser 30 | func CacheUntilRestart(browserMaxAge int) echo.MiddlewareFunc { 31 | return func(next echo.HandlerFunc) echo.HandlerFunc { 32 | return func(c echo.Context) error { 33 | // https://stackoverflow.com/questions/7071763/max-value-for-cache-control-header-in-http/25201898 34 | c.Response().Header().Set("Cache-Control", "public, s-maxage=31536000, max-age="+strconv.Itoa(browserMaxAge)) 35 | // not a typo! s-maxage really has no hyphen between max and age, while max-age does! 36 | return next(c) 37 | } 38 | } 39 | } 40 | 41 | // cache indefinitely (until a cache purge) 42 | func CacheUntilPurge() echo.MiddlewareFunc { 43 | return CacheUntilRestart(31536000) 44 | } 45 | 46 | // do not cache anywhere 47 | func NoCache() echo.MiddlewareFunc { 48 | return func(next echo.HandlerFunc) echo.HandlerFunc { 49 | return func(c echo.Context) error { 50 | c.Response().Header().Set("Cache-Control", "private, max-age=0") 51 | return next(c) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/util/proxy_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "net/http/httputil" 7 | "net/url" 8 | "testing" 9 | 10 | "github.com/labstack/echo/v4" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestProxy(t *testing.T) { 15 | // Override serveProxy and store what's passed into it 16 | var ( 17 | servedCount = 0 18 | servedProxy *httputil.ReverseProxy 19 | servedReq *http.Request 20 | servedRes http.ResponseWriter 21 | ) 22 | serveProxy = func(proxy *httputil.ReverseProxy, req *http.Request, res http.ResponseWriter) { 23 | servedCount++ 24 | servedProxy = proxy 25 | servedReq = req 26 | servedRes = res 27 | } 28 | 29 | // Setup the request 30 | e := echo.New() 31 | req := httptest.NewRequest(http.MethodGet, "http://foobar.host/changelog", nil) 32 | rec := httptest.NewRecorder() 33 | c := e.NewContext(req, rec) 34 | c.SetPath("/changelog") 35 | 36 | target, _ := url.Parse("https://impactdevelopment.github.io/Impact/changelog") 37 | 38 | // Run the handler 39 | Proxy(c, target) 40 | // Basic checks 41 | assert.Equal(t, 1, servedCount) 42 | assert.NotNil(t, servedProxy) 43 | assert.NotNil(t, servedReq) 44 | assert.NotNil(t, servedRes) 45 | 46 | // Request should be unchanged 47 | assert.Equal(t, "", servedReq.Header.Get("X-Forwarded-Host")) 48 | assert.Equal(t, "foobar.host", servedReq.Host) 49 | assert.Equal(t, "foobar.host", servedReq.URL.Host) 50 | assert.Equal(t, "/changelog", servedReq.URL.Path) 51 | assert.Equal(t, "", servedReq.URL.RawQuery) 52 | assert.Equal(t, "http://foobar.host/changelog", servedReq.URL.String()) 53 | 54 | // The Director function should mutate the request 55 | servedProxy.Director(servedReq) 56 | assert.Equal(t, "foobar.host", servedReq.Header.Get("X-Forwarded-Host")) 57 | assert.Equal(t, "impactdevelopment.github.io", servedReq.Host) 58 | assert.Equal(t, "https", servedReq.URL.Scheme) 59 | assert.Equal(t, "impactdevelopment.github.io", servedReq.URL.Host) 60 | assert.Equal(t, "/Impact/changelog", servedReq.URL.Path) 61 | assert.Equal(t, "", servedReq.URL.RawQuery) 62 | assert.Equal(t, target.String(), servedReq.URL.String()) 63 | } 64 | -------------------------------------------------------------------------------- /src/util/txt_import.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/ImpactDevelopment/ImpactServer/src/database" 10 | "github.com/google/uuid" 11 | ) 12 | 13 | // cd Resources/data/users 14 | // git blame --line-porcelain *.txt | grep -e "\t" -e "committer-time" | tail -r | tr "\n" " " | tr "\t" "\n" | sed -e '$a\' | tail -n +2 | tail -r | sed "s/ committer-time//g" > blame.txt 15 | 16 | func ImportFromBlameAge() { 17 | data, err := ioutil.ReadFile("blame.txt") 18 | if err != nil { 19 | panic(err) 20 | } 21 | dates := make(map[uuid.UUID]int64) // there are duplicates (elmo) 22 | for _, line := range strings.Split(string(data), "\n") { 23 | if line == "" { 24 | continue // oh you silly last line 25 | } 26 | uuidStr := strings.Split(line, " ")[0] 27 | epochStr := strings.Split(line, " ")[1] 28 | uuidVal := uuid.MustParse(uuidStr) 29 | epochVal, err := strconv.ParseInt(epochStr, 10, 64) // base 10, 64 bit. rofl 30 | if err != nil { 31 | panic(err) 32 | } 33 | if dates[uuidVal] == 0 || epochVal < dates[uuidVal] { 34 | dates[uuidVal] = epochVal 35 | } 36 | } 37 | for uuidVal, epochVal := range dates { 38 | fmt.Println(uuidVal) 39 | _, err := database.DB.Exec("INSERT INTO users (mc_uuid, created_at) VALUES ($1, $2)", uuidVal, epochVal) 40 | if err != nil { 41 | panic(err) 42 | } 43 | } 44 | } 45 | 46 | func ImportFromRoles() { 47 | //importFromRole("pepsi") 48 | //importFromRole("developer") 49 | //importFromRole("staff") 50 | //importFromRole("premium") 51 | importFromRole("spawnmason") 52 | } 53 | 54 | func importFromRole(role string) { 55 | data, err := ioutil.ReadFile(role + ".txt") 56 | if err != nil { 57 | panic(err) 58 | } 59 | for _, line := range strings.Split(string(data), "\n") { 60 | if line == "" { 61 | continue // oh you silly last line 62 | } 63 | fmt.Println(line) 64 | // delibrately ignore duplicate errors lol 65 | database.DB.Exec("INSERT INTO users(mc_uuid) VALUES ($1)", line) 66 | _, err = database.DB.Exec("UPDATE users SET "+role+" = TRUE WHERE mc_uuid = $1", line) 67 | if err != nil { 68 | panic(err) 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/middleware/limit.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/ImpactDevelopment/ImpactServer/src/util" 5 | "github.com/labstack/echo/v4" 6 | "golang.org/x/time/rate" 7 | "net/http" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | func Limit(duration time.Duration, bursts int) echo.MiddlewareFunc { 13 | limiter := &rateLimiter{ 14 | keys: make(map[string]*rate.Limiter), 15 | mutex: &sync.RWMutex{}, 16 | rate: rate.Limit(float64(time.Second) / float64(duration)), 17 | bursts: bursts, 18 | } 19 | 20 | return func(next echo.HandlerFunc) echo.HandlerFunc { 21 | return func(c echo.Context) error { 22 | // key is either user id or user ip depending on if we are logged in 23 | var key string 24 | if user := GetUser(c); user != nil { 25 | key = user.ID.String() 26 | } else { 27 | // Get the user's ip; can't just use the request address since we are behind proxies 28 | key = util.RealIPBestGuess(c) 29 | } 30 | 31 | // Check we aren't limited 32 | if !limiter.get(key).Allow() { 33 | return echo.NewHTTPError(http.StatusTooManyRequests) 34 | } 35 | 36 | return next(c) 37 | } 38 | } 39 | } 40 | 41 | type rateLimiter struct { 42 | keys map[string]*rate.Limiter 43 | mutex *sync.RWMutex 44 | rate rate.Limit 45 | bursts int 46 | } 47 | 48 | // add creates a new rate limiter if it does not exist already and adds it to the keys map 49 | // calling get() will be cheaper in most cases since it only needs a read lock 50 | func (lim *rateLimiter) add(key string) *rate.Limiter { 51 | // shit getting real, let's get a full read-write lock! 52 | lim.mutex.Lock() 53 | defer lim.mutex.Unlock() 54 | 55 | // Double check nothing was added since last calling RUnlock() 56 | limiter, exists := lim.keys[key] 57 | if !exists { 58 | limiter := rate.NewLimiter(lim.rate, lim.bursts) 59 | lim.keys[key] = limiter 60 | } 61 | 62 | return limiter 63 | } 64 | 65 | // get returns the rate limiter for the provided key if it exists. 66 | // Otherwise calls add to add key to the map 67 | func (lim *rateLimiter) get(key string) *rate.Limiter { 68 | // we only need a read lock, for now 69 | lim.mutex.RLock() 70 | limiter, exists := lim.keys[key] 71 | lim.mutex.RUnlock() 72 | 73 | if !exists { 74 | limiter = lim.add(key) 75 | } 76 | 77 | return limiter 78 | } 79 | -------------------------------------------------------------------------------- /src/api/v1/impactbotintegration.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | "github.com/ImpactDevelopment/ImpactServer/src/database" 10 | "github.com/labstack/echo/v4" 11 | ) 12 | 13 | func checkDonator(c echo.Context) error { 14 | auth := c.QueryParam("auth") + "0" 15 | if auth != os.Getenv("IMPACTBOT_AUTH_SECRET") { 16 | return c.JSON(http.StatusForbidden, "auth wrong im sowwy") 17 | } 18 | var premium bool 19 | err := database.DB.QueryRow("SELECT premium FROM users WHERE discord_id = $1", c.Param("discordid")).Scan(&premium) 20 | if err != nil { 21 | log.Println(err) 22 | } 23 | if premium { 24 | return c.String(http.StatusOK, "yes") 25 | } else { 26 | return c.String(http.StatusOK, "no") 27 | } 28 | } 29 | 30 | func genkey(c echo.Context) error { 31 | var body struct { 32 | Auth string `json:"auth" form:"auth" query:"auth"` 33 | Roles []string `json:"roles" form:"role" query:"role"` 34 | } 35 | err := c.Bind(&body) 36 | if err != nil { 37 | return err 38 | } 39 | if body.Auth+"0" != os.Getenv("IMPACTBOT_AUTH_SECRET") { 40 | return c.JSON(http.StatusForbidden, "auth wrong im sowwy") 41 | } 42 | 43 | // Extract bools from role list 44 | var premium, pepsi, spawnmason, staff bool 45 | if len(body.Roles) > 0 { 46 | var invalid []string 47 | for _, role := range body.Roles { 48 | switch strings.ToLower(strings.TrimSpace(role)) { 49 | case "premium": 50 | premium = true 51 | case "pepsi": 52 | pepsi = true 53 | case "spawnmason": 54 | spawnmason = true 55 | case "staff": 56 | staff = true 57 | default: 58 | invalid = append(invalid, role) 59 | } 60 | } 61 | if len(invalid) > 0 { 62 | var msg strings.Builder 63 | msg.WriteString("Invalid role") 64 | if len(invalid) > 1 { 65 | msg.WriteString("s") 66 | } 67 | msg.WriteString(" `") 68 | msg.WriteString(strings.Join(invalid, ", ")) 69 | msg.WriteString("`") 70 | return c.String(http.StatusBadRequest, msg.String()) 71 | } 72 | } 73 | 74 | var token string 75 | err = database.DB.QueryRow("INSERT INTO pending_donations(amount, premium, pepsi, spawnmason, staff) VALUES(0, $1, $2, $3, $4) RETURNING token", premium, pepsi, spawnmason, staff).Scan(&token) 76 | if err != nil { 77 | return err 78 | } 79 | return c.String(http.StatusOK, token) 80 | } 81 | -------------------------------------------------------------------------------- /src/api/v1/emailtest.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "os" 9 | "time" 10 | 11 | "github.com/ImpactDevelopment/ImpactServer/src/mailgun" 12 | "github.com/labstack/echo/v4" 13 | ) 14 | 15 | func emailTest(c echo.Context) error { 16 | if c.QueryParam("auth")+"0" != os.Getenv("API_AUTH_SECRET") { 17 | return errors.New("no u") 18 | } 19 | 20 | message := mailgun.MG.NewMessage("Impcat Verification Idk Lmao ", "I am, basically, emailing you", "text only version of the rich text: image of speckles (just imagine a good kitty)", c.QueryParam("dest")) 21 | message.SetHtml(` 22 | 23 | 24 | 25 | 28 | 29 |
26 | 27 |
30 | wow
31 |
32 |

spekl (large text)

33 |
small text
34 | 35 | 36 | You can donate to help support Brady with Impact Development. 37 | 38 | If you donate $5 USD or more (and enter your Minecraft username), you will recieve a few small rewards such as early access to upcoming releases through nightly builds (now including 1.14.4 builds), premium mods (currently Ignite), a cape visible to other Impact users, and a gold colored name in the Impact Discord Server. 39 | 40 | Before making a payment, ensure that your Minecraft Username or UUID is specified in the payment note and Discord Username#XXXX if you would like the roles in our server. Payments may take up to 72 hours to process. 41 | 42 | In order to access nightly builds you must join the Discord server and provide proof of payment, or when you make the payment specify your Discord account. 43 | PayPal. The safer, easier way to pay online. 44 | Impact is not a hack client, a cheat client, or a hacked client, it is a utility mod (like OptiFine). Please bear in mind that utility mods like Impact can be against the rules on some servers. 😉 45 | Contact us on discord 46 | More links 47 | 48 | 49 | `) 50 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 51 | defer cancel() 52 | resp, id, err := mailgun.MG.Send(ctx, message) 53 | if err != nil { 54 | return c.JSON(http.StatusInternalServerError, err.Error()) 55 | } 56 | fmt.Printf("ID: %s Resp: %s\n", id, resp) 57 | return c.JSON(http.StatusOK, resp) 58 | } 59 | -------------------------------------------------------------------------------- /src/middleware/index.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/labstack/echo/v4" 9 | ) 10 | 11 | // Redirect trailing index.html's. 12 | // code must be in the 300 range 13 | func RemoveIndexHTML(code int) echo.MiddlewareFunc { 14 | return func(next echo.HandlerFunc) echo.HandlerFunc { 15 | return func(c echo.Context) error { 16 | // Copy URL struct 17 | address := c.Request().URL 18 | slice := deleteEmpty(strings.Split(address.Path, "/")) 19 | 20 | // If last path element is index.html 21 | if i := len(slice) - 1; i >= 0 && strings.ToLower(slice[i]) == "index.html" { 22 | // re-build the path without the last element 23 | address.Path = strings.Join(slice[:i], "/") 24 | 25 | // Echo tends to set the Request URL to just the path+query 26 | if address.Host == "" { 27 | address.Host = c.Request().Host 28 | } 29 | if address.Scheme == "" { 30 | address.Scheme = c.Scheme() 31 | } 32 | 33 | // Redirect 34 | return c.Redirect(code, address.String()) 35 | } 36 | return next(c) 37 | } 38 | } 39 | } 40 | 41 | // Remove empty elements from a slice 42 | func deleteEmpty(s []string) []string { 43 | var r []string 44 | for _, str := range s { 45 | if str != "" { 46 | r = append(r, str) 47 | } 48 | } 49 | return r 50 | } 51 | 52 | func EnforceHTTPS(code int) echo.MiddlewareFunc { 53 | return func(next echo.HandlerFunc) echo.HandlerFunc { 54 | return func(c echo.Context) error { 55 | // we CANNOT use X-Forwarded-Proto 56 | // reason: while user -> cloudflare results in cloudflare -> heroku having the correct X-Forwarded-Proto 57 | // heroku overwrites this header to the scheme used in the cloudflare -> heroku hop, which is http 58 | visitorStr := c.Request().Header.Get("Cf-Visitor") 59 | if visitorStr == "" { 60 | // perhaps this is local dev on localhost 61 | // regardless of how it happened, this ain't cloudflare 62 | return next(c) 63 | } 64 | var visitor struct { 65 | Scheme string `json:"scheme"` 66 | } 67 | err := json.Unmarshal([]byte(visitorStr), &visitor) 68 | if err != nil { 69 | fmt.Println("Cloudflare sent unparseable Cf-Visitor header??", visitorStr) 70 | return next(c) 71 | } 72 | if visitor.Scheme != "http" { 73 | return next(c) 74 | } 75 | // it is http 76 | addr := c.Request().URL 77 | if addr.Path == "/releases.json" { 78 | // don't break 4.7 and 4.8 update checker 79 | return next(c) 80 | } 81 | addr.Scheme = "https" 82 | addr.Host = c.Request().Host 83 | c.Response().Header().Set("Cache-Control", "private, max-age=0") 84 | return c.Redirect(code, addr.String()) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/api/v1/server.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/ImpactDevelopment/ImpactServer/src/jwt" 7 | 8 | "github.com/ImpactDevelopment/ImpactServer/src/middleware" 9 | "github.com/labstack/echo/v4" 10 | ) 11 | 12 | // API configures the Group to implement v1 of the API 13 | func API(api *echo.Group) { 14 | // TODO API Doc 15 | 16 | api.GET("/thealtening/info", getTheAlteningInfo, middleware.CacheUntilPurge()) 17 | api.GET("/motd", getMotd, middleware.CacheUntilPurge()) 18 | api.GET("/themes", getThemes, middleware.CacheUntilPurge()) 19 | api.GET("/minecraft/user/info", getUserInfo, middleware.CacheUntilPurge()) 20 | api.GET("/minecraft/user/:role/list", getRoleMembers, middleware.CacheUntilPurge()) 21 | api.GET("/dbtest", dbTest, middleware.NoCache()) 22 | api.GET("/user/me", getUser, middleware.NoCache(), middleware.RequireAuth) 23 | api.PATCH("/user/me", patchUser, middleware.NoCache(), middleware.RequireAuth) 24 | api.PUT("/password/me", putPassword, middleware.NoCache(), middleware.RequireAuth) 25 | api.PUT("/password/:token", putPassword, middleware.NoCache()) 26 | api.Match([]string{http.MethodGet, http.MethodPost}, "/password/reset", resetPassword, middleware.NoCache()) // TODO ratelimit resets 27 | api.Match([]string{http.MethodGet, http.MethodPost}, "/login/password", jwt.PasswordLoginHandler, middleware.NoCache()) 28 | api.Match([]string{http.MethodGet, http.MethodPost}, "/login/minecraft", jwt.MinecraftLoginHandler, middleware.NoCache()) 29 | api.Match([]string{http.MethodGet, http.MethodPost}, "/login/discord", jwt.DiscordLoginHandler, middleware.NoCache()) 30 | api.Any("/stripe/info", getStripeInfo, middleware.CacheUntilPurge()) 31 | api.Any("/stripe/webhook", handleStripeWebhook, middleware.NoCache()) 32 | api.Match([]string{http.MethodGet, http.MethodPost}, "/stripe/createpayment", createStripePayment, middleware.NoCache()) 33 | api.Match([]string{http.MethodGet, http.MethodPost}, "/stripe/redeem", redeemStripePayment, middleware.NoCache()) 34 | api.GET("/stripe/connect/login", getStripeLogin, middleware.NoCache(), middleware.RequireAuth) 35 | api.Match([]string{http.MethodGet, http.MethodPost}, "/checktoken", checkToken, middleware.NoCache()) 36 | api.Match([]string{http.MethodGet, http.MethodPost}, "/register/token", registerWithToken, middleware.NoCache()) 37 | api.GET("/emailtest", emailTest, middleware.NoCache()) 38 | api.GET("/premiumcheck", premiumCheck, middleware.NoCache()) 39 | api.GET("/integration/futureclient/masonlist", futureIntegrationMasonList, middleware.NoCache()) 40 | api.GET("/integration/futureclient/overalldata", futureIntegrationOverallData, middleware.NoCache()) 41 | api.GET("/integration/impactbot/checkdonator/:discordid", checkDonator, middleware.NoCache()) 42 | api.GET("/integration/impactbot/genkey", genkey, middleware.NoCache()) 43 | } 44 | -------------------------------------------------------------------------------- /static/min/init-min.js: -------------------------------------------------------------------------------- 1 | /* https://javascript-minifier.com/ */ 2 | !function(s){s(function(){s(".scrollspy").scrollSpy();var e,a,n=2500,t=3800,d=t-3e3,l=50,r=150,o=500,c=o+800,h=600,p=1500;function u(i){var e=m(i);if(i.parents(".cd-headline").hasClass("type")){var a=i.parent(".cd-words-wrapper");a.addClass("selected").removeClass("waiting"),setTimeout(function(){a.removeClass("selected"),i.removeClass("is-visible").addClass("is-hidden").children("i").removeClass("in").addClass("out")},o),setTimeout(function(){f(e,r)},c)}else if(i.parents(".cd-headline").hasClass("letters")){var p=i.children("i").length>=e.children("i").length;!function i(e,a,t,d){e.removeClass("in").addClass("out");e.is(":last-child")?t&&setTimeout(function(){u(m(a))},n):setTimeout(function(){i(e.next(),a,t,d)},d);if(e.is(":last-child")&&s("html").hasClass("no-csstransitions")){var l=m(a);w(a,l)}}(i.find("i").eq(0),i,p,l),C(e.find("i").eq(0),e,p,l)}else i.parents(".cd-headline").hasClass("clip")?i.parents(".cd-words-wrapper").animate({width:"2px"},h,function(){w(i,e),f(e)}):i.parents(".cd-headline").hasClass("loading-bar")?(i.parents(".cd-words-wrapper").removeClass("is-loading"),w(i,e),setTimeout(function(){u(e)},t),setTimeout(function(){i.parents(".cd-words-wrapper").addClass("is-loading")},d)):(w(i,e),setTimeout(function(){u(e)},n))}function f(s,i){s.parents(".cd-headline").hasClass("type")?(C(s.find("i").eq(0),s,!1,i),s.addClass("is-visible").removeClass("is-hidden")):s.parents(".cd-headline").hasClass("clip")&&s.parents(".cd-words-wrapper").animate({width:s.width()+10},h,function(){setTimeout(function(){u(s)},p)})}function C(s,i,e,a){s.addClass("in").removeClass("out"),s.is(":last-child")?(i.parents(".cd-headline").hasClass("type")&&setTimeout(function(){i.parents(".cd-words-wrapper").addClass("waiting")},200),e||setTimeout(function(){u(i)},n)):setTimeout(function(){C(s.next(),i,e,a)},a)}function m(s){return s.is(":last-child")?s.parent().children().eq(0):s.next()}function w(s,i){s.removeClass("is-visible").addClass("is-hidden"),i.removeClass("is-hidden").addClass("is-visible")}s(".cd-headline.letters").find("b").each(function(){var e=s(this),a=e.text().split(""),n=e.hasClass("is-visible");for(i in a)e.parents(".rotate-2").length>0&&(a[i]=""+a[i]+""),a[i]=n?''+a[i]+"":""+a[i]+"";var t=a.join("");e.html(t).css("opacity",1)}),e=s(".cd-headline"),a=n,e.each(function(){var i=s(this);if(i.hasClass("loading-bar"))a=t,setTimeout(function(){i.find(".cd-words-wrapper").addClass("is-loading")},d);else if(i.hasClass("clip")){var e=i.find(".cd-words-wrapper"),n=e.width()+10;e.css("width",n)}else if(!i.hasClass("type")){var l=i.find(".cd-words-wrapper b"),r=0;l.each(function(){var i=s(this).width();i>r&&(r=i)}),i.find(".cd-words-wrapper").css("width",r)}setTimeout(function(){u(i.find(".is-visible").eq(0))},a)}),s(".button-collapse").sideNav({menuWidth:240,draggable:!0,closeOnClick:!0}),s(".parallax").parallax()})}(jQuery); -------------------------------------------------------------------------------- /src/minecraft/profile.go: -------------------------------------------------------------------------------- 1 | package minecraft 2 | 3 | import ( 4 | "github.com/ImpactDevelopment/ImpactServer/src/util" 5 | "github.com/google/uuid" 6 | "github.com/labstack/echo/v4" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | ) 11 | 12 | const urlNames = "https://api.mojang.com/user/profiles//names" 13 | const urlProfile = "https://api.mojang.com/users/profiles/minecraft/" 14 | 15 | // Profile includes both the minecraft user's ID and their name 16 | type Profile struct { 17 | ID uuid.UUID `json:"id"` 18 | Name string `json:"name"` 19 | } 20 | 21 | // GetProfile returns the full Profile from either the name or ID 22 | func GetProfile(minecraft string) (*Profile, error) { 23 | var ret = &Profile{} 24 | 25 | // Try parsing minecraft as a UUID, if that fails use it as a name to lookup the UUID 26 | minecraftID, err := uuid.Parse(strings.TrimSpace(minecraft)) 27 | if err == nil && minecraftID.String() != "" { 28 | // minecraft is an id, verify it 29 | var bad = echo.NewHTTPError(http.StatusBadRequest, "bad minecraft uuid") 30 | 31 | // Lookup minecraft name 32 | reqUrl := strings.Replace(urlNames, "", url.PathEscape(strings.Replace(minecraftID.String(), "-", "", -1)), 1) 33 | req, err := util.GetRequest(reqUrl) 34 | if err != nil { 35 | return nil, bad 36 | } 37 | resp, err := req.Do() 38 | if err != nil { 39 | return nil, bad 40 | } 41 | if !resp.Ok() { 42 | return nil, bad 43 | } 44 | 45 | // Parse response 46 | type name struct { 47 | Name string `json:"name"` 48 | At int64 `json:"changedToAt"` 49 | } 50 | var body = make([]name, 5) 51 | err = resp.JSON(&body) 52 | if err != nil { 53 | return nil, err 54 | } 55 | if len(body) < 1 { 56 | return nil, err 57 | } 58 | 59 | // Find the most recent name, this is probably body[len(body)-1] but let's explicitly check changedToAt 60 | newest := body[0] 61 | for _, it := range body { 62 | if it.At > newest.At { 63 | newest = it 64 | } 65 | } 66 | 67 | ret = &Profile{ 68 | ID: minecraftID, 69 | Name: newest.Name, 70 | } 71 | } else { 72 | // minecraft must be a name, look up the id 73 | var bad = echo.NewHTTPError(http.StatusBadRequest, "bad minecraft username") 74 | 75 | reqUrl := strings.Replace(urlProfile, "", url.PathEscape(strings.TrimSpace(minecraft)), 1) 76 | req, err := util.GetRequest(reqUrl) 77 | if err != nil { 78 | return nil, bad 79 | } 80 | resp, err := req.Do() 81 | if err != nil { 82 | return nil, bad 83 | } 84 | if !resp.Ok() { 85 | return nil, bad 86 | } 87 | 88 | // Parse the response 89 | // https://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time 90 | // Response happens to use the same format as our Profile struct 91 | err = resp.JSON(&ret) 92 | if err != nil || ret.ID.String() == "" { 93 | return nil, bad 94 | } 95 | } 96 | 97 | return ret, nil 98 | } 99 | -------------------------------------------------------------------------------- /static/discord_oauth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Discord authentication 5 | 77 | 84 | 85 | 86 | 87 |

You can now close this window.

88 | 91 | 92 | -------------------------------------------------------------------------------- /src/middleware/index_test.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/labstack/echo/v4" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestIndexToSlash(t *testing.T) { 13 | // Helper func to setup the test 14 | setup := func(code int) (e *echo.Echo) { 15 | e = echo.New() 16 | e.Pre(RemoveIndexHTML(code)) 17 | e.Any("/*", func(c echo.Context) error { 18 | return c.String(http.StatusOK, "Ok cowboy") 19 | }) 20 | return 21 | } 22 | 23 | // Helper function to run a request and return the response 24 | test := func(s *echo.Echo, url string) *httptest.ResponseRecorder { 25 | req := httptest.NewRequest(http.MethodGet, url, nil) 26 | rec := httptest.NewRecorder() 27 | s.ServeHTTP(rec, req) 28 | return rec 29 | } 30 | 31 | e := setup(http.StatusMovedPermanently) 32 | 33 | // index.html/ should produce 301 34 | rec := test(e, "http://foobar.net/foo/bar/index.html/") 35 | assert.Equal(t, http.StatusMovedPermanently, rec.Code) 36 | assert.Equal(t, "http://foobar.net/foo/bar", rec.Header().Get(echo.HeaderLocation)) 37 | assert.Equal(t, "", rec.Body.String()) 38 | 39 | // index.html should produce 301 40 | rec = test(e, "http://foobar.net/foo/bar/index.html") 41 | assert.Equal(t, http.StatusMovedPermanently, rec.Code) 42 | assert.Equal(t, "http://foobar.net/foo/bar", rec.Header().Get(echo.HeaderLocation)) 43 | assert.Equal(t, "", rec.Body.String()) 44 | 45 | // / should not redirect 46 | rec = test(e, "http://foobar.net/foo/bar/") 47 | assert.Equal(t, http.StatusOK, rec.Code) 48 | assert.Equal(t, "", rec.Header().Get(echo.HeaderLocation)) 49 | assert.Equal(t, "Ok cowboy", rec.Body.String()) 50 | 51 | // neither should bare path 52 | rec = test(e, "http://foobar.net/foo/bar") 53 | assert.Equal(t, http.StatusOK, rec.Code) 54 | assert.Equal(t, "", rec.Header().Get(echo.HeaderLocation)) 55 | assert.Equal(t, "Ok cowboy", rec.Body.String()) 56 | 57 | // root/ 58 | rec = test(e, "http://foobar.net/") 59 | assert.Equal(t, http.StatusOK, rec.Code) 60 | assert.Equal(t, "", rec.Header().Get(echo.HeaderLocation)) 61 | assert.Equal(t, "Ok cowboy", rec.Body.String()) 62 | 63 | // root 64 | rec = test(e, "http://foobar.net") 65 | assert.Equal(t, http.StatusOK, rec.Code) 66 | assert.Equal(t, "", rec.Header().Get(echo.HeaderLocation)) 67 | assert.Equal(t, "Ok cowboy", rec.Body.String()) 68 | 69 | // index.html/ should produce 301 70 | rec = test(e, "http://foobar.net/index.html/") 71 | assert.Equal(t, http.StatusMovedPermanently, rec.Code) 72 | assert.Equal(t, "http://foobar.net", rec.Header().Get(echo.HeaderLocation)) 73 | assert.Equal(t, "", rec.Body.String()) 74 | 75 | // index.html should produce 301 76 | rec = test(e, "http://foobar.net/index.html") 77 | assert.Equal(t, http.StatusMovedPermanently, rec.Code) 78 | assert.Equal(t, "http://foobar.net", rec.Header().Get(echo.HeaderLocation)) 79 | assert.Equal(t, "", rec.Body.String()) 80 | 81 | // Try with a different status code 82 | e = setup(http.StatusFound) 83 | rec = test(e, "http://foobar.net/index.html") 84 | assert.Equal(t, http.StatusFound, rec.Code) 85 | assert.Equal(t, "http://foobar.net", rec.Header().Get(echo.HeaderLocation)) 86 | assert.Equal(t, "", rec.Body.String()) 87 | } 88 | -------------------------------------------------------------------------------- /src/users/user_info.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import "github.com/google/uuid" 4 | 5 | // Public information about the user, can be hidden using `incognito` 6 | // Be sure to update src/users/features.go:publicFeatures() if adding/removing features 7 | type UserInfo struct { 8 | // Icon to display next to this user 9 | Icon string `json:"icon,omitempty"` 10 | // Cape this user should wear 11 | Cape string `json:"cape,omitempty"` 12 | // Color code of the text for nametags. e.g. LIGHT_PURPLE or BLUE 13 | TextColor string `json:"text_color,omitempty"` 14 | // Numeric ARGB color of the nametag background. Empty string for default. e.g. 1358954495 for pepsi's light gray 15 | BackgroundColor string `json:"bg_color,omitempty"` 16 | // Numeric ARGB color of the nametag border. Empty string for default. e.g. -1761673216 for pepsi's red 17 | BorderColor string `json:"border_color,omitempty"` 18 | } 19 | 20 | // NewUserInfo creates a UserInfo based on a User's roles and any special cases that apply to them 21 | func NewUserInfo(user User) *UserInfo { 22 | var info UserInfo 23 | 24 | if user.MinecraftID != nil { 25 | if special, ok := specialCases[*user.MinecraftID]; ok { 26 | if special.info != nil { 27 | info = *special.info 28 | } 29 | } 30 | } 31 | 32 | for _, role := range getRolesSorted(user.Roles) { // go in order from highest priority to least (aka numerically lowest to highest) 33 | role.applyDefaults(&info) 34 | } 35 | 36 | return &info 37 | } 38 | 39 | var specialCases = map[uuid.UUID]roleTemplate{ // TODO this should basically just be a SELECT * FROM customizations; 40 | // catgorl 41 | uuid.MustParse("2c3174fc-0c6b-4cfb-bb2b-0069bf7294d1"): { 42 | info: &UserInfo{ 43 | TextColor: "LIGHT_PURPLE", 44 | }, 45 | }, 46 | // leijurv 47 | uuid.MustParse("51dcd870-d33b-40e9-9fc1-aecdcff96081"): { 48 | info: &UserInfo{ 49 | TextColor: "RED", 50 | Icon: "https://files.impactclient.net/img/texture/speckles128.png", 51 | }, 52 | edition: &Edition{ 53 | Icon: "https://files.impactclient.net/img/texture/speckles128.png", 54 | }, 55 | }, 56 | // liejurv since leijurv is disabled 57 | uuid.MustParse("7b9c005b-011e-42de-bfb4-c0003f5c3a77"): { 58 | info: &UserInfo{ 59 | TextColor: "RED", 60 | Icon: "https://files.impactclient.net/img/texture/speckles128.png", 61 | }, 62 | edition: &Edition{ 63 | Icon: "https://files.impactclient.net/img/texture/speckles128.png", 64 | }, 65 | }, 66 | // triibu popstonia 67 | uuid.MustParse("8e563236-c7f5-4c82-aa27-c95bf3f4c322"): { 68 | info: &UserInfo{ 69 | Icon: "https://files.impactclient.net/img/texture/popstonia.png", 70 | }, 71 | }, 72 | // popstonia (rebane) 73 | uuid.MustParse("342fc44b-1fd1-4272-a4c3-a98a2df98abc"): { 74 | info: &UserInfo{ 75 | Icon: "https://files.impactclient.net/img/texture/popstonia.png", 76 | }, 77 | }, 78 | // HermeticLock 79 | uuid.MustParse("e97ff4c0-48bf-4c98-be34-248fdde2ffd3"): { 80 | info: &UserInfo{ 81 | TextColor: "RED", 82 | Icon: "https://i.imgur.com/aKt1g4H.jpg", 83 | Cape: "https://i.imgur.com/bvhC1Xk.png", 84 | }, 85 | edition: &Edition{ 86 | Icon: "https://i.imgur.com/aKt1g4H.jpg", 87 | }, 88 | }, 89 | // peanut 90 | uuid.MustParse("9d913c0a-3d57-4ce9-8b7d-689973312856"): { 91 | info: &UserInfo{ 92 | TextColor: "ORANGE", 93 | }, 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /src/middleware/auth.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "fmt" 5 | "github.com/ImpactDevelopment/ImpactServer/src/jwt" 6 | "github.com/ImpactDevelopment/ImpactServer/src/users" 7 | "github.com/labstack/echo/v4" 8 | "net/http" 9 | "os" 10 | "regexp" 11 | ) 12 | 13 | const userCtxKey = "user" 14 | 15 | var authBearerRegx = regexp.MustCompile(`^Bearer\s+(\S+)`) 16 | 17 | // GetUser returns the User object attached to the context, presumably by the RequireAuth middleware. 18 | // Otherwise it returns nil. 19 | func GetUser(c echo.Context) (user *users.User) { 20 | // Try to cast to *user.User, ignore if it failed, user probably just isn't set 21 | user, _ = c.Get(userCtxKey).(*users.User) 22 | return 23 | } 24 | 25 | var Auth = func(next echo.HandlerFunc) echo.HandlerFunc { 26 | return func(c echo.Context) error { 27 | if header := c.Request().Header.Get(echo.HeaderAuthorization); header != "" { 28 | if m := authBearerRegx.FindStringSubmatch(header); len(m) == 2 && m[1] != "" { 29 | token := m[1] 30 | 31 | // Verify the JWT 32 | user, err := jwt.Verify(token) 33 | if err != nil { 34 | return echo.NewHTTPError(http.StatusUnauthorized, "invalid token").SetInternal(err) 35 | } 36 | // Set the user context userCtxKey 37 | c.Set(userCtxKey, user) 38 | } 39 | } 40 | return next(c) 41 | } 42 | } 43 | 44 | // RequireAuth requires that the Authorization header be set, correctly formatted, and the token to be valid 45 | // For invalid token, it sends “401 - Unauthorized” response. 46 | // For missing or invalid Authorization header, it sends “400 - Bad Request”. 47 | var RequireAuth = func(next echo.HandlerFunc) echo.HandlerFunc { 48 | return func(c echo.Context) error { 49 | header := c.Request().Header.Get(echo.HeaderAuthorization) 50 | if header == "" { 51 | return echo.NewHTTPError(http.StatusBadRequest, "authentication is required") 52 | } 53 | if m := authBearerRegx.FindStringSubmatch(header); len(m) != 2 || m[1] == "" { 54 | return echo.NewHTTPError(http.StatusBadRequest, "invalid "+echo.HeaderAuthorization+" header") 55 | } 56 | return Auth(func(c echo.Context) error { 57 | // Require Auth to have succeeded 58 | if user := GetUser(c); user == nil { 59 | return echo.NewHTTPError(http.StatusUnauthorized, "no user found") 60 | } 61 | return next(c) 62 | })(c) 63 | } 64 | } 65 | 66 | // RequireRoles returns a middleware that requires the user to have at least one of the provided role IDs. 67 | // This middleware automatically calls RequireAuth 68 | func RequireRole(roles ...string) echo.MiddlewareFunc { 69 | // Figure out how best to print the roles in http errors 70 | var rolesString string 71 | if len(roles) == 1 { 72 | rolesString = fmt.Sprintf("%v", roles[0]) 73 | } else { 74 | rolesString = fmt.Sprintf("%v", roles) 75 | } 76 | 77 | return func(next echo.HandlerFunc) echo.HandlerFunc { 78 | return RequireAuth(func(c echo.Context) error { 79 | user := GetUser(c) 80 | if user == nil { 81 | return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Roles required but no user: %v", rolesString)) 82 | } 83 | 84 | // If any role matches, continue with request 85 | for _, role := range user.Roles { 86 | for _, required := range roles { 87 | if role.ID == required { 88 | return next(c) 89 | } 90 | } 91 | } 92 | 93 | // The user doesn't have any matching roles 94 | return echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("Required at least one role from %v", rolesString)) 95 | }) 96 | } 97 | } 98 | 99 | func AuthGetParam() echo.MiddlewareFunc { 100 | return func(next echo.HandlerFunc) echo.HandlerFunc { 101 | return func(c echo.Context) error { 102 | auth := c.QueryParam("auth") + "0" 103 | if auth != os.Getenv("API_AUTH_SECRET") { 104 | return c.JSON(http.StatusForbidden, "auth wrong im sowwy") 105 | } 106 | return next(c) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/api/v1/minecraft.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "log" 7 | "net/http" 8 | "reflect" 9 | "strings" 10 | 11 | "github.com/ImpactDevelopment/ImpactServer/src/database" 12 | 13 | "github.com/ImpactDevelopment/ImpactServer/src/cloudflare" 14 | "github.com/ImpactDevelopment/ImpactServer/src/users" 15 | "github.com/google/uuid" 16 | 17 | "github.com/labstack/echo/v4" 18 | ) 19 | 20 | var userData map[string]users.UserInfo 21 | var userDataNonHashed map[string]users.UserInfo 22 | 23 | var legacyRoles map[string]string 24 | 25 | // API Handler /minecraft/user/info 26 | func getUserInfo(c echo.Context) error { 27 | return c.JSON(http.StatusOK, userData) 28 | } 29 | 30 | // Legacy API handler /minecraft/user/:role/list 31 | func getRoleMembers(c echo.Context) error { 32 | ret := legacyRoles[c.Param("role")] 33 | if ret == "" { 34 | return c.NoContent(http.StatusNotFound) 35 | } 36 | return c.String(http.StatusOK, ret) 37 | } 38 | 39 | func init() { 40 | usersList := database.GetAllUsers() 41 | updatedData(usersList) 42 | updatedLegacyRoles(usersList) 43 | database.CallbackOnUsersTableUpdate(checkDatabaseForUpdatedUsers) 44 | } 45 | 46 | func checkDatabaseForUpdatedUsers() { 47 | usersList := database.GetAllUsers() 48 | if updatedData(usersList) { 49 | log.Println("MC UPDATE: Updated user info") 50 | cloudflare.PurgeURLs([]string{ 51 | "https://api.impactclient.net/v1/minecraft/user/info", 52 | }) 53 | } 54 | if updatedLegacyRoles(usersList) { 55 | log.Println("MC UPDATE: Updated user legacy data") 56 | cloudflare.PurgeURLs([]string{ 57 | "https://api.impactclient.net/v1/minecraft/user/staff/list", 58 | "https://api.impactclient.net/v1/minecraft/user/developer/list", 59 | "https://api.impactclient.net/v1/minecraft/user/pepsi/list", 60 | "https://api.impactclient.net/v1/minecraft/user/premium/list", 61 | }) 62 | } 63 | } 64 | 65 | func updatedData(usersList []users.User) bool { 66 | newUserData, newUnhashed := generateMap(usersList) 67 | // reflect.DeepEqual is slow, especially since this map is big 68 | if userData == nil || !reflect.DeepEqual(newUserData, userData) { 69 | userData = newUserData 70 | userDataNonHashed = newUnhashed 71 | return true 72 | } 73 | return false 74 | } 75 | 76 | func updatedLegacyRoles(usersList []users.User) bool { 77 | newLegacyRoles := generateLegacy(usersList) 78 | // reflect.DeepEqual is slow, especially since this map is big 79 | if legacyRoles == nil || !reflect.DeepEqual(newLegacyRoles, legacyRoles) { 80 | legacyRoles = newLegacyRoles 81 | return true 82 | } 83 | return false 84 | } 85 | 86 | func generateLegacy(usersList []users.User) map[string]string { 87 | m := make(map[string]string) 88 | for role, roleVal := range users.Roles { 89 | if !roleVal.LegacyList { 90 | continue 91 | } 92 | var list strings.Builder 93 | for _, user := range usersList { 94 | if !user.HasRoleWithID(role) { 95 | continue 96 | } 97 | if !user.LegacyEnabled { 98 | continue 99 | } 100 | if minecraftID := user.MinecraftID; minecraftID != nil { 101 | list.WriteString(minecraftID.String() + "\n") 102 | } 103 | } 104 | m[role] = list.String() 105 | } 106 | return m 107 | } 108 | 109 | func generateMap(usersList []users.User) (map[string]users.UserInfo, map[string]users.UserInfo) { 110 | data := make(map[string]users.UserInfo) 111 | unhashed := make(map[string]users.UserInfo) 112 | for _, user := range usersList { 113 | if !user.Incognito && user.MinecraftID != nil && user.UserInfo != nil { 114 | // if a user has cape disabled, they are trying to be incognito. we should send no entry at all. not good enough to send "HASH123":{}. 115 | data[hashUUID(*user.MinecraftID)] = *user.UserInfo 116 | unhashed[user.MinecraftID.String()] = *user.UserInfo 117 | } 118 | } 119 | return data, unhashed 120 | } 121 | 122 | func hashUUID(uuid uuid.UUID) string { 123 | hash := sha256.Sum256([]byte(uuid.String())) 124 | return hex.EncodeToString(hash[:]) 125 | } 126 | -------------------------------------------------------------------------------- /src/database/user.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | "github.com/lib/pq" 7 | 8 | "github.com/ImpactDevelopment/ImpactServer/src/users" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type userRow struct { 13 | id uuid.UUID 14 | email sql.NullString 15 | minecraft NullUUID 16 | discord sql.NullString 17 | passwdHash sql.NullString 18 | stripe sql.NullString 19 | legacyEnabled bool 20 | capeEnabled bool 21 | legacy bool 22 | roleList pq.StringArray 23 | } 24 | 25 | // rowScanner is implemented by sql.Row and sql.Rows 26 | type rowScanner interface { 27 | Scan(dest ...interface{}) error 28 | } 29 | 30 | // scanUsersView takes a sql.Row or sql.Rows and scans it into the user. 31 | // It is assumed the row is has the same column order as `users_view` 32 | func (user *userRow) scanUsersView(row rowScanner) error { 33 | return row.Scan(&user.id, &user.email, &user.minecraft, &user.discord, &user.passwdHash, &user.stripe, &user.capeEnabled, &user.legacyEnabled, &user.legacy, &user.roleList) 34 | } 35 | 36 | // makeUser converts a userRow into a users.User 37 | func (user *userRow) makeUser() users.User { 38 | ret := users.User{ 39 | LegacyEnabled: user.legacyEnabled, 40 | Incognito: !user.capeEnabled, 41 | Legacy: user.legacy, 42 | Roles: user.roles(), 43 | } 44 | if user.email.Valid { 45 | ret.Email = user.email.String 46 | } 47 | if user.minecraft.Valid { 48 | ret.MinecraftID = &user.minecraft.UUID 49 | } 50 | if user.discord.Valid { 51 | ret.DiscordID = user.discord.String 52 | } 53 | if user.passwdHash.Valid { 54 | ret.PasswordHash = user.passwdHash.String 55 | } 56 | if user.stripe.Valid { 57 | ret.StripeID = user.stripe.String 58 | } 59 | ret.UserInfo = users.NewUserInfo(ret) 60 | ret.ID = user.id 61 | return ret 62 | } 63 | 64 | func (user userRow) roles() []users.Role { 65 | var roles []users.Role 66 | for _, roleID := range user.roleList { 67 | if role, ok := users.Roles[roleID]; ok { 68 | roles = append(roles, role) 69 | } else { 70 | fmt.Printf("User %s has unknown role %s\n", user.id, roleID) 71 | } 72 | } 73 | return roles 74 | } 75 | 76 | // GetAllUsers returns... all the users 77 | func GetAllUsers() []users.User { 78 | if DB == nil { 79 | fmt.Println("Database not connected!") 80 | return nil 81 | } 82 | 83 | rows, err := DB.Query(`SELECT * FROM users_view`) 84 | if err != nil { 85 | panic(err) 86 | } 87 | defer rows.Close() 88 | 89 | ret := make([]users.User, 0) 90 | for rows.Next() { 91 | var r userRow 92 | err = r.scanUsersView(rows) 93 | if err != nil { 94 | panic(err) 95 | } 96 | ret = append(ret, r.makeUser()) 97 | } 98 | 99 | err = rows.Err() 100 | if err != nil { 101 | panic(err) 102 | } 103 | 104 | return ret 105 | } 106 | 107 | // LookupUserByMinecraftID returns the matching user, or nil if not found 108 | func LookupUserByID(id uuid.UUID) *users.User { 109 | return lookupUserByField("user_id", id) 110 | } 111 | 112 | func LookupUserByEmail(email string) *users.User { 113 | return lookupUserByField("email", email) 114 | } 115 | 116 | // LookupUserByMinecraftID returns the matching user, or nil if not found 117 | func LookupUserByMinecraftID(minecraftID uuid.UUID) *users.User { 118 | return lookupUserByField("mc_uuid", minecraftID) 119 | } 120 | 121 | // LookupUserByDiscordID returns the matching user, or nil if not found 122 | func LookupUserByDiscordID(discordID string) *users.User { 123 | return lookupUserByField("discord_id", discordID) 124 | } 125 | 126 | func lookupUserByField(field string, value interface{}) *users.User { 127 | if DB == nil { 128 | fmt.Println("Database not connected!") 129 | return nil 130 | } 131 | 132 | var r userRow 133 | err := r.scanUsersView(DB.QueryRow(`SELECT * FROM users_view WHERE `+field+` = $1`, value)) 134 | if err != nil { 135 | if err == sql.ErrNoRows { 136 | return nil // no match 137 | } 138 | panic(err) // any other error 139 | } 140 | 141 | user := r.makeUser() 142 | return &user 143 | } 144 | -------------------------------------------------------------------------------- /static/account_explanation.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Impact Account 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 20 |
21 | 22 |
23 |
24 |

How Impact Accounts work

25 |
26 |
The current system
27 |

28 | The newer (4.8+) Impact Account system separates enabling premium and listing info (such as capes). It also adds a layer of privacy to showing info. 29 |

30 | Our API shows a SHA hashed version of your Minecraft ID, which prevents somebody from easily getting a list of Impact Account holders. 31 | However, it is still possible for them to check if individual Minecraft accounts have Impact Account features. 32 | They can do this by hashing the Minecraft ID and then looking through the API's list to see if the resultant hash is in the list. 33 | If you don't want this to be possible, you can enable Incognito Mode and you won't be included in the list. 34 | Not being in the list means no one can tell if you have an Impact Account or not, including other Impact users. 35 |

36 | Because listing info and enabling premium now separated, if you enable Incognito Mode you will still get access to other Impact Account features, such as donation perks like Ignite and nightly builds. 37 | These features use Minecraft's authentication system (the same one used by Multiplayer Servers). This means you must launch Impact while online and logged in to the Minecraft account that is linked to your Impact Account. 38 |

39 |
The legacy system
40 |

41 | The legacy (pre 4.8) Impact Account system uses plain text lists that list all Minecraft IDs with a given "role" (e.g. the premium role for donators). 42 |

43 |

44 | The problem with this is that anyone can easily get a list of Impact Accounts holders. 45 | For example an overzealous moderator might set up a system that bans anyone with an Impact Account automatically. 46 |

47 |

48 | An additional issue is that the same list is used for enabling all features, from capes to Ignite, so it's not possible to have an "Incognito Mode" that doesn't disable everything. 49 | Luckily there's little reason to use an Impact version before 4.8, since 4.8 supports 1.12.2, 1.13.2 and 1.14.4. 50 | Therefore we don't recommend having "Legacy mode" enabled unless you understand the privacy concerns and want Impact Account features on Impact versions prior to 4.8. 51 |

52 |
Open Source
53 |

54 | This website and all the code that makes it run, including the Impact Account code, is open source. 55 | If you know how to read code why not browse our GitHub repo? 56 |

57 | Even better if you spot something that could be improved, you can report an Issue or even contribute directly by proposing a Pull Request! 58 |

59 |
60 |
61 |
62 | 63 | -------------------------------------------------------------------------------- /static/min/style-min.css: -------------------------------------------------------------------------------- 1 | /* http://www.minifier.org */ 2 | .hidden{display:none}.default_color{background-color:#2196F3!important}.default_color_text{color:#2196F3!important}.large_text{font-size:2.4rem;font-weight:100}.med_text{font-size:1.8rem;font-weight:100}h2,h3{font-weight:300;margin-bottom:4%;line-height:4.5rem}h3{font-weight:240}i{font-style:normal}b,em,strong{font-style:normal;font-weight:400;color:#2196F3}strong{font-weight:700}code{font-family:"Courier New",Courier,monospace;color:#181818;background-color:#f0f0f0;padding:2px}h2 i,h3 i,h4 i,.large_text i,h2 b,h3 b,h4 b,.large_text b,h2 em,h3 em,h4 em .large_text em{font-weight:100}h2 strong,h3 strong,h4 strong,.large_text strong{font-weight:300}nav,footer,.page-footer{background-color:#2196F3;margin-top:0}.icon-block{padding:0 15px}#intro,#work,#team{padding-top:4rem}#index-banner{min-height:632px;max-height:864px;position:relative;background-color:#2196F3}.brand-logo{position:absolute;color:#fff;display:inline-block;font-size:2.1rem;font-style:normal;font-weight:100;padding:0;letter-spacing:7px}.brand-logo h1{display:inline;margin:0;padding:0;font-size:2.1rem;font-weight:100}.in{font-weight:400!important;font-style:normal!important}.promo i{color:#2196F3;font-size:7rem;display:block}.card-content a{color:#2196F3}.card-content a:hover{color:#2196F3}#work,#team{background:rgb(247,247,247)}.text_pink{color:#EF9A9A}nav ul a{font-size:1.2rem;color:#FFF;letter-spacing:2px;display:block;font-weight:300;padding:0 15px}.cd-headline.type .cd-words-wrapper{vertical-align:top;overflow:hidden}.cd-headline.type .cd-words-wrapper::after{content:'';position:absolute;right:0;top:50%;bottom:auto;-webkit-transform:translateY(-50%);-moz-transform:translateY(-50%);-ms-transform:translateY(-50%);-o-transform:translateY(-50%);transform:translateY(-50%);height:90%;width:1px;background-color:#aebcb9}.cd-headline.type .cd-words-wrapper.waiting::after{-webkit-animation:cd-pulse 1s infinite;-moz-animation:cd-pulse 1s infinite;animation:cd-pulse 1s infinite}.cd-headline.type .cd-words-wrapper.selected{background-color:#FFF}.cd-headline.type .cd-words-wrapper.selected::after{visibility:hidden}.cd-headline.type .cd-words-wrapper.selected b{color:#2196F3}.cd-headline.type b{visibility:hidden}.cd-headline.type b.is-visible{visibility:visible}.cd-headline.type i{position:absolute;visibility:hidden}.cd-headline.type i.in{position:relative;visibility:visible}@-webkit-keyframes cd-pulse{0%{-webkit-transform:translateY(-50%) scale(1);opacity:1}40%{-webkit-transform:translateY(-50%) scale(.9);opacity:0}100%{-webkit-transform:translateY(-50%) scale(0);opacity:0}}@-moz-keyframes cd-pulse{0%{-moz-transform:translateY(-50%) scale(1);opacity:1}40%{-moz-transform:translateY(-50%) scale(.9);opacity:0}100%{-moz-transform:translateY(-50%) scale(0);opacity:0}}@keyframes cd-pulse{0%{-webkit-transform:translateY(-50%) scale(1);-moz-transform:translateY(-50%) scale(1);-ms-transform:translateY(-50%) scale(1);-o-transform:translateY(-50%) scale(1);transform:translateY(-50%) scale(1);opacity:1}40%{-webkit-transform:translateY(-50%) scale(.9);-moz-transform:translateY(-50%) scale(.9);-ms-transform:translateY(-50%) scale(.9);-o-transform:translateY(-50%) scale(.9);transform:translateY(-50%) scale(.9);opacity:0}100%{-webkit-transform:translateY(-50%) scale(0);-moz-transform:translateY(-50%) scale(0);-ms-transform:translateY(-50%) scale(0);-o-transform:translateY(-50%) scale(0);transform:translateY(-50%) scale(0);opacity:0}}#preloader{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#fff;z-index:1200}#status{width:200px;height:200px;position:absolute;left:50%;top:50%;background-image:url(../img/status.gif);background-repeat:no-repeat;background-position:center;margin:-100px 0 0 -100px}input,textarea{border-bottom:1px solid #fff}nav a.button-collapse{left:-25px}.card-avatar .waves-effect{text-align:center;margin-top:20px}.card-avatar img{height:150px;width:150px;border-radius:75px}.card-avatar .card-content{text-align:center}.card .card-content p{margin:15px 0}.card-avatar .card-content i{font-size:1.5rem}.card-avatar .card-content .card-title{line-height:30px!important}.parallax-container{max-height:400px}.grey-text.text-lighten-3,.card-avatar{transition:all 150ms}.grey-text.text-lighten-3:hover,.card-avatar:hover{transform:translateY(-1px)}[type="checkbox"].filled-in:checked+span:not(.lever):after{border:2px solid #2196F3;background-color:#2196F3}.btn{background-color:#2196F3}.btn:hover{background-color:#1e86da} -------------------------------------------------------------------------------- /src/jwt/jwt.go: -------------------------------------------------------------------------------- 1 | package jwt 2 | 3 | import ( 4 | "crypto/rsa" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/ImpactDevelopment/ImpactServer/src/database" 11 | "github.com/google/uuid" 12 | 13 | "github.com/labstack/echo/v4" 14 | 15 | "github.com/ImpactDevelopment/ImpactServer/src/users" 16 | "github.com/ImpactDevelopment/ImpactServer/src/util" 17 | "github.com/gbrlsnchs/jwt/v3" 18 | ) 19 | 20 | type impactUserJWT struct { 21 | jwt.Payload 22 | Roles []string `json:"roles"` 23 | Legacy bool `json:"legacy"` 24 | MinecraftID *uuid.UUID `json:"mcuuid,omitempty"` 25 | DiscordID string `json:"discordid,omitempty"` 26 | } 27 | type donationJWT struct { 28 | jwt.Payload 29 | OrderID string `json:"order"` 30 | Amount int `json:"amount"` 31 | } 32 | 33 | var rs512 jwt.Algorithm 34 | 35 | var jwtIssuerURL string 36 | 37 | func init() { 38 | var key *rsa.PrivateKey 39 | 40 | if env := os.Getenv("JWT_KEY"); env != "" { 41 | var err error 42 | key, err = util.StrToRsa(env) 43 | if err != nil { 44 | fmt.Println("WARNING: Unable to load JWT_KEY from the environment", err) 45 | } 46 | } 47 | 48 | addr := util.GetServerURL() 49 | jwtIssuerURL = addr.Scheme + "://api." + addr.Host + "/v1" 50 | fmt.Println("JWT Issuer URL is", jwtIssuerURL) 51 | 52 | if key == nil { 53 | fmt.Println("WARNING: JWT_KEY not specified, generating a temporary one") 54 | key = util.GenerateRsa() 55 | fmt.Println("Printing private key since this is temporary") 56 | fmt.Println("Private key is", util.RsaToStr(key)) 57 | } 58 | 59 | fmt.Println("Public key is", util.RsaPubToStr(&key.PublicKey)) 60 | 61 | // rs512 can be used to sign and verify tokens, e.g. jtw.Sign(payload []byte, rs512 Algorithm) 62 | rs512 = jwt.NewRS512(jwt.RSAPrivateKey(key), jwt.RSAPublicKey(&key.PublicKey)) 63 | } 64 | 65 | func Verify(token string) (*users.User, error) { 66 | var ( 67 | now = time.Now() 68 | 69 | // Validate "iss", "iat" and "exp" claims 70 | issValidator = jwt.IssuerValidator(jwtIssuerURL) 71 | iatValidator = jwt.IssuedAtValidator(now) 72 | expValidator = jwt.ExpirationTimeValidator(now) 73 | 74 | // Use jwt.ValidatePayload to build a jwt.VerifyOption. 75 | // Validators are run in the order informed. 76 | userPayload impactUserJWT 77 | validator = jwt.ValidatePayload(&userPayload.Payload, issValidator, iatValidator, expValidator) 78 | ) 79 | 80 | // Verify the token is signed with our key and is valid 81 | // populate userPayload with the token's fields 82 | _, err := jwt.Verify([]byte(token), rs512, &userPayload, validator) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | // The subject should be the user's id 88 | id, err := uuid.Parse(userPayload.Subject) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | user := database.LookupUserByID(id) 94 | if user == nil { 95 | return nil, fmt.Errorf("unable to find user with id %s", id.String()) 96 | } 97 | 98 | return user, nil 99 | } 100 | 101 | // CreateUserJWT returns a jwt token for the user with the subject (if not empty). 102 | // The client can then use this to verify that the user has authenticated 103 | // with a valid Impact server by checking the signature and issuer. 104 | // If the client chooses, it could cache the token and reuse it until its 105 | // expiration time. 106 | func CreateUserJWT(user *users.User) string { 107 | now := time.Now() 108 | 109 | return createJWT(impactUserJWT{ 110 | Payload: jwt.Payload{ 111 | Issuer: jwtIssuerURL, 112 | Subject: user.ID.String(), 113 | Audience: jwt.Audience{"impact_client", "impact_account"}, 114 | ExpirationTime: jwt.NumericDate(now.Add(24 * time.Hour)), 115 | IssuedAt: jwt.NumericDate(now), 116 | }, 117 | MinecraftID: user.MinecraftID, 118 | DiscordID: user.DiscordID, 119 | Roles: user.RoleIDs(true), // 4.8.3 120 | Legacy: user.Legacy, 121 | }) 122 | } 123 | 124 | func createJWT(payload interface{}) string { 125 | token, err := jwt.Sign(payload, rs512) 126 | if err != nil { 127 | return "" 128 | } 129 | return string(token) 130 | } 131 | 132 | // respondWithToken responds to a http request with the token or returns a HTTPError 133 | func respondWithToken(user *users.User, c echo.Context) error { 134 | token := CreateUserJWT(user) 135 | if token == "" { 136 | return echo.NewHTTPError(http.StatusInternalServerError, "error creating jwt token") 137 | } 138 | 139 | // TODO respect Accept header 140 | return c.String(http.StatusOK, token) 141 | } 142 | -------------------------------------------------------------------------------- /src/users/role.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "sort" 7 | ) 8 | 9 | type Role struct { 10 | // Role id, e.g. "developer" 11 | ID string `json:"id"` 12 | // Role rank, lower is better 13 | rank int 14 | // Is there a legacy list of pure UUIDs that old clients rely on? 15 | LegacyList bool 16 | } 17 | 18 | type roleTemplate struct { 19 | info *UserInfo 20 | edition *Edition 21 | } 22 | 23 | var Roles = map[string]Role{ 24 | "pepsi": {ID: "pepsi", rank: 1, LegacyList: true}, 25 | "spawnmason": {ID: "spawnmason", rank: 0, LegacyList: false}, 26 | "developer": {ID: "developer", rank: 2, LegacyList: true}, 27 | "staff": {ID: "staff", rank: 3, LegacyList: true}, 28 | "premium": {ID: "premium", rank: 4, LegacyList: true}, 29 | } 30 | 31 | var defaultRoleTemplates = map[string]roleTemplate{ 32 | "developer": { 33 | info: &UserInfo{ 34 | Cape: "https://files.impactclient.net/img/texture/developer_cape_elytra.png", 35 | }, 36 | }, 37 | "staff": { 38 | info: &UserInfo{ 39 | Cape: "https://files.impactclient.net/img/texture/staff_cape_elytra.png", 40 | }, 41 | edition: &Edition{ 42 | Text: "Staff", 43 | TextColor: "#FF7734EB", 44 | }, 45 | }, 46 | "pepsi": { 47 | info: &UserInfo{ 48 | Icon: "https://files.impactclient.net/img/texture/pepsi_v2_128.png", 49 | Cape: "https://files.impactclient.net/img/texture/pepsi_cape_elytra.png", 50 | TextColor: "BLUE", // #004B93 is the official logo blue 51 | BackgroundColor: "#50FFFFFF", // #005CB4 is the official "background" blue, #0063a7 is also used 52 | BorderColor: "#FFC9002B", // #C9002B is the official logo red 53 | }, 54 | edition: &Edition{ 55 | Icon: "https://files.impactclient.net/img/texture/pepsi_v2_128.png", 56 | Text: "Pepsi", 57 | TextColor: "#FFC9002B", 58 | }, 59 | }, 60 | "spawnmason": { 61 | info: &UserInfo{ 62 | Icon: "https://files.impactclient.net/img/texture/spawnmason128.png", 63 | Cape: "https://files.impactclient.net/img/texture/spawnmason_cape_elytra.png", 64 | TextColor: "GOLD", 65 | BackgroundColor: "#90404040", 66 | BorderColor: "RED", 67 | }, 68 | }, 69 | "premium": { 70 | info: &UserInfo{ 71 | Cape: "https://files.impactclient.net/img/texture/premium_cape_elytra.png", 72 | }, 73 | edition: &Edition{ 74 | Text: "Premium", 75 | TextColor: "GOLD", 76 | }, 77 | }, 78 | } 79 | 80 | func (role Role) applyDefaults(info *UserInfo) { 81 | t, ok := defaultRoleTemplates[role.ID] 82 | if !ok { 83 | fmt.Println("ERROR idk how to apply", role.ID) 84 | // No default template to apply 85 | return 86 | } 87 | if t.info == nil { 88 | return 89 | } 90 | 91 | template := t.info 92 | if template.Icon != "" && info.Icon == "" { 93 | info.Icon = template.Icon 94 | } 95 | if template.Cape != "" && info.Cape == "" { 96 | info.Cape = template.Cape 97 | } 98 | if template.TextColor != "" && info.TextColor == "" { 99 | info.TextColor = template.TextColor 100 | } 101 | if template.BackgroundColor != "" && info.BackgroundColor == "" { 102 | info.BackgroundColor = template.BackgroundColor 103 | } 104 | if template.BorderColor != "" && info.BorderColor == "" { 105 | info.BorderColor = template.BorderColor 106 | } 107 | } 108 | 109 | func getRolesSorted(roles []Role) (sorted []Role) { 110 | // needed so that higher priority roles set cape and icon instead of lower priority ones 111 | // copying slices via = is by reference, so use append instead 112 | // https://github.com/go101/go101/wiki/How-to-perfectly-clone-a-slice%3F 113 | sorted = append(roles[:0:0], roles...) 114 | sort.Slice(sorted, func(i, j int) bool { 115 | return sorted[i].rank < sorted[j].rank 116 | }) 117 | return 118 | } 119 | 120 | // MarshalJSON implements the json.Marshaler interface 121 | // it marshals the role to just the role id as a json string 122 | func (role Role) MarshalJSON() ([]byte, error) { 123 | return []byte(fmt.Sprintf(`"%s"`, role.ID)), nil 124 | } 125 | 126 | // UnmarshalJSON implements the json.Unmarshaler interface 127 | // it allows unmarshalling a full role from just the role id json string 128 | func (role *Role) UnmarshalJSON(bytes []byte) error { 129 | var id string 130 | err := json.Unmarshal(bytes, &id) 131 | if err != nil { 132 | return err 133 | } 134 | if r, ok := Roles[id]; ok { 135 | role.ID = r.ID 136 | role.rank = r.rank 137 | role.LegacyList = r.LegacyList 138 | return nil 139 | } 140 | return fmt.Errorf("unable to find role with id %s", string(id)) 141 | } 142 | -------------------------------------------------------------------------------- /src/util/rsa_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/asn1" 5 | "encoding/base64" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // Integration test 12 | func TestRSA(t *testing.T) { 13 | // Create the keys 14 | prv := GenerateRsa() 15 | assert.NotNil(t, prv, "GenerateRsa should not return nil, maybe an error was ignored") 16 | pub := &prv.PublicKey 17 | 18 | // Export the keys to pem string 19 | prvStr := RsaToStr(prv) 20 | 21 | // Import the keys from pem string 22 | prvParsed, err := StrToRsa(prvStr) 23 | if assert.NoError(t, err) { 24 | pubParsed := &prvParsed.PublicKey 25 | 26 | // Export the newly imported keys 27 | prvParsedStr := RsaToStr(prvParsed) 28 | 29 | assert.Equal(t, prvStr, prvParsedStr, "Export and Import should result in the same private key") 30 | assert.Equal(t, prv, prvParsed, "Export and Import should result in the same private key") 31 | assert.Equal(t, pub, pubParsed, "Export and Import should result in the same public key") 32 | } 33 | } 34 | 35 | func TestStrToRsa(t *testing.T) { 36 | validKey := "MIIJJwIBAAKCAgEAsObrU3FdAJnEs/kIhzkgvwssH/jz3ZZRloTlAde3HMt2gbyR9u7Gcb3wY7jwnjv9sBEe0qv3otqvCD0LYO6wFk3KCOO8aF3aHPdxzybUShxtxHtmXECzyIFHFX8O1MQ317JBYUUXiTflAEOcH3gABJWvOKA2gbMG/W3I9CuO7EWtA3fv9fHqrHszrybTw8MXmEY/nt+YthcyICoRgU3fK6GyqEJ5yI0/PtRdnqGJerFV+cL972MlwQQ3yPUgDkueJNei7Jh3yIidHDUP+spckrRV+UoqvZBSFJ47NAPxhRu8sVO+Ww2RzeBOMX0CKniOXXPII0PQiR7Hy6qIKFOY46YLGXeEyhZnZrXWy9mO8Q2m5PHD2q9f/bwcaXjoxikTygX77M5xwZERAH0bE9/1Bz2AKrSpDHi3fFU6IM7TScaqL3tIv/j9QFKbZxvZf4+0hKA6aAXvrBYNkdk6fhgAprYgeIP2jis5twXG2Y3qsX+NxykwJUc/iTnwK5EIYwCJMZAP3hrHSe5xxgeOZmMP/dLVNL7vUHPzkwLeAAzFtQ+x5HINzkx8qnZM6XWQ700O3ic4XFCgA5xm5DTYq+kLJ5jJSA6DYadvIINZXsZd9ypBzNJ7fiqKrmxbufUXzmw9x2ndk1X86acKIHOz/AEYVv7fk1Yxi9ik1CJE5zZDcX8CAwEAAQKCAgAVCF+SXDgiiiXJACLzcOdjz4A/jOnxvp2Ut9hCj9NFqSs94Z25LkqJ23tpX+O77IYNGPwBMFERG88Tu65OqBJnlHgg9nLANeho6UKuzn8PELI8Wi+haE/31ucMtz6cLXg2PQto9T4HIo4nqeI2G55k7ScYJHRWl2KNXzA1V7h2fxJDB0+QfmLYfw12Fbe33so/YJrP2OXfQILFMDtElG2kUmVbfAvevGx4m+dFpQ8jd1Ixj+2BONiUSlwXmI1nJbZ3yuukFbyoKxYC9Iwh1U2MY8SVDyxlvXME4ItJc+6TVOjqbHqFeOeNAs5JNAO96PeERO/WwYlZxD8dB/mIUegrdiN5613XOxQpy8oldp0rQIky5q2792OodFGaj0/TdtPILwUfL8PgmQfpUhmKlxWOi++Faa7ofwlhSj+kpx62U9soiH8vXvOB53BQe1c/MmygiytfAzzh08+PFqBRnMQi7onQrBX7XypHuGA7cdxoWmIYuS0gCu8r80od8aiI4VUzJj9kP5QitQYJmJeM/wIYDHtONBqNdxteHJY8U+o99VQI0zMQK2Gsf3fBaI8dlQzNCagAIw8OoJTY5673rokLTffe7yyXSUpilWvINAIU57ZZbUb7ccXCWIfN6akwtUyu+mNhheddpnLUkDfGSWyFhnupsB9wxgBZm8WnDfKGgQKCAQEA5VyDDjowDW0G9Ug0K7bJ9a2ye2DNHolV4txKdmGAg+GeDVan2wl0pYdB/aRVtq41HRFHT2QMCHodKmTcZiw0rSTrwGoeI/BTZi1iP5vYD1AuQTxAEiVPW7+INq3g4QY58s97hvAwDGQ/4hGIYCTvrNe+aPz7LgwFcpgymSEySzM9/v8wpNccuJhPNKiD3lGqtwDkA6RWwWYQDejMMruA7E/v42kdf4qXH6uR+l4+Z/xBVajeemclYkGX9IiKbKNFTY7InTRAXs5qYGgVfQ7uIBovE9cy4FbrciyYSQaYcqDfbUMpNzCU+P78YJfBgW/8jmCqmghEN+e1EjXj0hWiKwKCAQEAxXKo2QDsRZTClEV7yzH7A1N/vVTF6ByTFeW+Qcr975pJUQvVWUlGMB701qFuPT9GxpuGk2LkpUU++34K44xbdGmF2A2vCP8642un9b6fTHzyPPVpLkZA2DjqatkXQ+5YLI6Eaqu1Nk2d85x8Nvn9ABnPFSpzbVV0+qjigOOwpaD5f6J34F+LAP7U9psOdYdsYzW9Cdr06XUoznD3l5cFXjEhAFKTag9BCLEVPKBajGthu1EkHCmRfde2jM1Cr18mZnV5bcbFGUNHOiAmEtO8fwJBoj4HMO48/QEv0Sf/4YSVwBLtO2r3LtD0qhjcXWWgx7es8ofe118KKdczG9kH/QKCAQAY4g59zqZD7p4gojK2w1/pvWxtojTeqTueHxQc/7r3k9SX0dzoEICNLL1mDRwXc5LjkmpQHKSJjuX3IXYfx4/3cNf6ygh3Ea2amjXcfMXV83bxMN4qmc2gQIlAlWCeSRSkWQonu4sa7Q1ZM1m+RIOUFtvbfAasGjXFFun2Xvmb2vVQ4tKeL5A4Hp4JMncL+YQx0nDqTDv1Q2NefvEYV+tGt+1omJDQs3JtxylRJkRS97UG3Ak28lXF8SPRLbcGzjfIkEMHexG4t2AnEWOza5k99llBJ8mnOQbWHixvT73eQcG7ktu31xdyZAdxW0VtC3802xvnFhqAjizAywPqWNp9AoIBAA4MbXUbOrRstDeGhhtcEAcZjtIy0O4F8nUxZosZ3V2J9cN9ew2iSAsueK84xzY2ZVvGPxoHhEs6FRQh0LaGCw/KXkqUFqsmNdNumoHCsWTo0veBYp13RC/eRNebYKtlrwJklYlddERL23w02yWyPc0fCPvxjErwNKWNFKilCrGONZJeRfdB9Qr6Fr8BI1M7cnvQnAWyfZCK1H9zzDoN9cTQ7A8w0OpP8Ymjx+YLZsXs8gQ47r/OOVrh2UxFYoRF2d6aZyxnYyi7/7pkBTF7vUKwL2lSzoItwUsjJXrVRMCQBXOoJRcAMlwzY+UiZbODgqATMowDHNjoGzoE5M8LbyUCggEAcIH1FQNS0FmwmXP8mhrEsTy5x/xh2C+JtBCuoYd58TddSLPfmI33KUiy4oxqkP4++VZ7i4AQYzrDckslPIbGeXlDEsGMC/dhn0asUryWjlmeVmSnKcqtjnNxQx3qr+6FPe5kI+r2J2y81XB/gYipZXAeBjJyDR3J0okPf3fjQ3g37VAZzLPoUQRmcIiYvvH08R9ZU2CqcYNCnV0vsCVX6rB+wxpGu14akn3flgJ1hFiT5pcbSaicwUwKbHbOj8Od0uFKN40nV+ME+Iat0WuvQQs7vH0iYvT/DmPmIsQSmy9Xz99dz934gKZYUXF2aV1ERxm1XtUXPHofkgwNtmxdAg==" 37 | invalidKey := "invalid/key=" 38 | invalidBase64 := "invalid_base64" 39 | 40 | parsed, err := StrToRsa(validKey) 41 | if assert.NoError(t, err, "A valid key should not error") { 42 | assert.NotNil(t, parsed) 43 | assert.Equal(t, 512, parsed.Size(), "Expect size to be %d bytes (%d bits)", 4096/8, 4096) 44 | } 45 | 46 | parsed, err = StrToRsa(invalidKey) 47 | assert.Nil(t, parsed) 48 | assert.Error(t, err, "Should error out when an invalid key is passed") 49 | assert.IsType(t, asn1.StructuralError{}, err, "Should return StructuralError when passed an invalid key") 50 | 51 | err = nil 52 | parsed, err = StrToRsa(invalidBase64) 53 | assert.Nil(t, parsed) 54 | assert.Error(t, err, "Should error out when keyString is invalid base64") 55 | assert.IsType(t, base64.CorruptInputError(0), err, "Should return CorruptInputError when passed invalid base64") 56 | } 57 | -------------------------------------------------------------------------------- /static/alternatives.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Impact Alternatives 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 31 | 32 | 33 | 35 | 37 | 38 | 39 | 47 | 48 | 49 |
50 | 57 |
58 |
59 |
60 |
61 |
62 |

Impact supports Minecraft 1.16.5, 1.15.2, 1.14.4, 1.13.2, 1.12.2, 1.12.1, 1.12, and 1.11.2.

63 |

Impact does NOT support cracked/non-premium launchers.

64 |

Download/Setup FAQ

65 |
66 |
67 |
68 |
69 |

Alternatives

70 |

71 | Please note, this list is manually compiled. We cannot garentee its acuracy nor can we promise to keep it up to date. 72 |
73 | The projects listed here are third-party projects, not afiliated with Impact in any way. We do not endorse them and cannot garentee they are safe to use. 74 |

75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 |
NameCostNotesVersions
FutureOne-time purchaseFuture is a premium client with support for the latest versions, popular with players on various anarchy servers. Impact, sponsered by Future™1.14.4, 1.13.2, 1.12.2, 1.8.9
AristoisFree (optional donation perks)Aristois is a free (closed-source) mod that aims to be always up-to-date with the latest minecraft versions. It can be installed with support for Fabric, Forge, MultiMC, and more.1.8.9 through 1.18.2
98 |
99 |
100 |
101 |
102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /src/api/v1/themes.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/labstack/echo/v4" 7 | ) 8 | 9 | type ( 10 | theme struct { 11 | Background *background `json:"background,omitempty"` 12 | DefaultFont *font `json:"default_font,omitempty"` 13 | TitleFont *font `json:"title_font,omitempty"` 14 | MOTDFont *font `json:"motd_font,omitempty"` 15 | // TODO add useful things to themes 16 | } 17 | background struct { 18 | // Epic meme make the client support data:image/png;base64 URIs 19 | URL string `json:"url,omitempty"` 20 | } 21 | font struct { 22 | Color uint32 `json:"color,omitempty"` 23 | } 24 | ) 25 | 26 | // API Handler 27 | func getThemes(c echo.Context) error { 28 | return c.JSON(http.StatusOK, themes) 29 | } 30 | 31 | var themes = map[string]theme{ 32 | "Impact": { 33 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/Pink_sunset_at_Visevnik.jpg"}, 34 | }, 35 | "alexandre-godreau": { 36 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/alexandre-godreau-203580-unsplash.jpg"}, 37 | }, 38 | "andrew-ruiz": { 39 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/andrew-ruiz-406374-unsplash.jpg"}, 40 | }, 41 | "aniket-deole": { 42 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/aniket-deole-294646-unsplash.jpg"}, 43 | }, 44 | "bailey-zindel": { 45 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/bailey-zindel-396398-unsplash.jpg"}, 46 | }, 47 | "benjamin-voros": { 48 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/benjamin-voros-575800-unsplash.jpg"}, 49 | }, 50 | "casey-horner": { 51 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/casey-horner-1265505-unsplash.jpg"}, 52 | }, 53 | "daniel-leone": { 54 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/daniel-leone-185834-unsplash.jpg"}, 55 | }, 56 | "eberhard-grossgasteiger": { 57 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/eberhard-grossgasteiger-299348-unsplash.jpg"}, 58 | }, 59 | "gabriele-garanzelli": { 60 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/gabriele-garanzelli-529492-unsplash.jpg"}, 61 | }, 62 | "james-donovan": { 63 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/james-donovan-180375-unsplash.jpg"}, 64 | }, 65 | "john-westrock": { 66 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/john-westrock-638048-unsplash.jpg"}, 67 | }, 68 | "julian-zett": { 69 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/julian-zett-643140-unsplash.jpg"}, 70 | }, 71 | "juskteez-vu": { 72 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/juskteez-vu-3824-unsplash.jpg"}, 73 | }, 74 | "martin-jernberg": { 75 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/martin-jernberg-197949-unsplash.jpg"}, 76 | }, 77 | "nasa": { 78 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/nasa-53884-unsplash.jpg"}, 79 | }, 80 | "olivier-miche": { 81 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/olivier-miche-508901-unsplash.jpg"}, 82 | }, 83 | "pascal-debrunner": { 84 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/pascal-debrunner-634122-unsplash.jpg"}, 85 | }, 86 | "patrick-fore": { 87 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/patrick-fore-562304-unsplash.jpg"}, 88 | }, 89 | "stephan-seeber": { 90 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/stephan-seeber-507791-unsplash.jpg"}, 91 | }, 92 | "stephen-wheeler": { 93 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/stephen-wheeler-732168-unsplash.jpg"}, 94 | }, 95 | "tanya-nevidoma": { 96 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/tanya-nevidoma-1085291-unsplash.jpg"}, 97 | }, 98 | "vashishtha-jogi": { 99 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/vashishtha-jogi-101218-unsplash.jpg"}, 100 | }, 101 | "wolfgang-hasselmann": { 102 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/wolfgang-hasselmann-1403514-unsplash.jpg"}, 103 | }, 104 | "yuriy-garnaev": { 105 | Background: &background{URL: "https://impactdevelopment.github.io/Resources/textures/backgrounds/yuriy-garnaev-395879-unsplash.jpg"}, 106 | }, 107 | } 108 | -------------------------------------------------------------------------------- /src/util/urls.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | func init() { 11 | // Run on init to panic early if we typo our hardcoded urls 12 | addr := GetServerURL() 13 | fmt.Println("Server URL is", addr) 14 | } 15 | 16 | func GetServerURL() *url.URL { 17 | var ( 18 | addr *url.URL 19 | err error 20 | ) 21 | 22 | if env := os.Getenv("SERVER_URL"); env != "" { 23 | addr, err = url.Parse(env) 24 | } else { 25 | port := strings.TrimSpace(os.Getenv("PORT")) 26 | switch port { 27 | case "80": 28 | port = "" 29 | case "": 30 | port = ":3000" 31 | default: 32 | port = ":" + port 33 | } 34 | 35 | addr, err = url.Parse("http://localhost" + port) 36 | } 37 | 38 | if err != nil { 39 | panic("Failed to parse server url") 40 | } 41 | 42 | return addr 43 | } 44 | 45 | // SetQuery changes the query parameters on the given url 46 | func SetQuery(address *url.URL, key, value string) { 47 | query := address.Query() 48 | query.Set(key, value) 49 | address.RawQuery = query.Encode() 50 | } 51 | 52 | // GetSubdomains tries to return the subdomain part from a host or hostname using ugly hacks. 53 | func GetSubdomains(host string) string { 54 | domains := []string{"localhost"} 55 | ogTLD := []string{"com", "org", "net", "int", "edu", "gov", "mil", "biz"} 56 | ccTLD := []string{"ac", "ad", "ae", "af", "ag", "ai", "al", "am", "ao", "aq", "ar", "as", "at", "au", "aw", "ax", "az", "ba", "bb", "bd", "be", "bf", "bg", "bh", "bi", "bj", "bm", "bn", "bo", "br", "bs", "bt", "bw", "by", "bz", "ca", "cc", "cd", "cf", "cg", "ch", "ci", "ck", "cl", "cm", "cn", "co", "cr", "cu", "cv", "cw", "cx", "cy", "cz", "de", "dj", "dk", "dm", "do", "dz", "ec", "ee", "eg", "er", "es", "et", "eu", "fi", "fj", "fk", "fm", "fo", "fr", "ga", "gd", "ge", "gf", "gg", "gh", "gi", "gl", "gm", "gn", "gp", "gq", "gr", "gs", "gt", "gu", "gw", "gy", "hk", "hm", "hn", "hr", "ht", "hu", "id", "ie", "il", "im", "in", "io", "iq", "ir", "is", "it", "je", "jm", "jo", "jp", "ke", "kg", "kh", "ki", "km", "kn", "kp", "kr", "kw", "ky", "kz", "la", "lb", "lc", "li", "lk", "lr", "ls", "lt", "lu", "lv", "ly", "ma", "mc", "md", "me", "mg", "mh", "mk", "ml", "mm", "mn", "mo", "mp", "mq", "mr", "ms", "mt", "mu", "mv", "mw", "mx", "my", "mz", "na", "nc", "ne", "nf", "ng", "ni", "nl", "no", "np", "nr", "nu", "nz", "om", "pa", "pe", "pf", "pg", "ph", "pk", "pl", "pm", "pn", "pr", "ps", "pt", "pw", "py", "qa", "re", "ro", "rs", "ru", "rw", "sa", "sb", "sc", "sd", "se", "sg", "sh", "si", "sk", "sl", "sm", "sn", "so", "sr", "ss", "st", "su", "sv", "sx", "sy", "sz", "tc", "td", "tf", "tg", "th", "tj", "tk", "tl", "tm", "tn", "to", "tr", "tt", "tv", "tw", "tz", "ua", "ug", "uk", "us", "uy", "uz", "va", "vc", "ve", "vg", "vi", "vn", "vu", "wf", "ws", "ye", "yt", "za", "zm", "zw", "xn--lgbbat1ad8j", "xn--y9a3aq", "xn--mgbcpq6gpa1a", "xn--54b7fta0cc", "xn--90ais", "xn--90ae", "xn--fiqs8s", "xn--fiqz9s", "xn--wgbh1c", "xn--e1a4c", "xn--qxa6a", "xn--node", "xn--qxam", "xn--j6w193g", "xn--h2brj9c", "xn--mgbbh1a71e", "xn--fpcrj9c3d", "xn--gecrj9c", "xn--s9brj9c", "xn--xkc2dl3a5ee0h", "xn--45brj9c", "xn--2scrj9c", "xn--rvc1e0am3e", "xn--45br5cyl", "xn--3hcrj9c", "xn--mgbbh1a", "xn--h2breg3eve", "xn--h2brj9c8c", "xn--mgbgu82a", "xn--mgba3a4f16a", "xn--mgbtx2b", "xn--mgbayh7gpa", "xn--80ao21a", "xn--q7ce6a", "xn--mix082f", "xn--mix891f", "xn--mgbx4cd0ab", "xn--mgbah1a3hjkrd", "xn--l1acc", "xn--mgbc0a9azcg", "xn--d1alf", "xn--mgb9awbf", "xn--mgbai9azgqp6j", "xn--ygbi2ammx", "xn--wgbl6a", "xn--p1ai", "xn--mgberp4a5d4ar", "xn--90a3ac", "xn--yfro4i67o", "xn--clchc0ea0b2g2a9gcd", "xn--3e0b707e", "xn--fzc2c9e2c", "xn--xkc2al3hye2a", "xn--mgbpl2fh", "xn--ogbpf8fl", "xn--kprw13d", "xn--kpry57d", "xn--o3cw4h", "xn--pgbs0dh", "xn--j1amh", "xn--mgbaam7a8h", "xn--mgb2ddes"} 57 | localTLD := []string{"example", "invalid", "local", "test"} 58 | sLD := []string{"com", "co", "me", "net", "org", "sch", "edu"} 59 | 60 | host = stripPort(host) 61 | 62 | tld := getTLD(host) 63 | host = stripTLD(host) 64 | 65 | if contains(domains, tld) { 66 | return host 67 | } 68 | 69 | if contains(localTLD, tld) { 70 | return stripTLD(host) 71 | } 72 | 73 | if contains(ogTLD, tld) { 74 | return stripTLD(host) 75 | } 76 | 77 | if contains(ccTLD, tld) { 78 | tld2 := getTLD(host) 79 | host2 := stripTLD(host) 80 | // check 2nd level domain on ccTLDs 81 | if contains(sLD, tld2) { 82 | return stripTLD(host2) 83 | } 84 | return host2 85 | } 86 | return "" 87 | } 88 | 89 | func stripPort(host string) string { 90 | i := strings.LastIndex(host, ":") 91 | if i < 0 { 92 | return host 93 | } 94 | return host[:i] 95 | } 96 | 97 | func getTLD(domain string) string { 98 | i := strings.LastIndex(domain, ".") 99 | if i < 0 { 100 | return domain 101 | } 102 | return domain[i+1:] 103 | } 104 | 105 | func stripTLD(domain string) string { 106 | i := strings.LastIndex(domain, ".") 107 | if i < 0 { 108 | return "" 109 | } 110 | return domain[:i] 111 | } 112 | 113 | func contains(slice []string, entry string) bool { 114 | for _, value := range slice { 115 | if value == entry { 116 | return true 117 | } 118 | } 119 | return false 120 | } 121 | -------------------------------------------------------------------------------- /src/util/http_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http" 7 | "strconv" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | // testRoundTripper implements http.RoundTripper 14 | type testRoundTripper func(req *http.Request, body string) *http.Response 15 | 16 | func (f testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { 17 | // Read body if it exists so we can pass it on as a string 18 | var body []byte 19 | if req.Body != nil { 20 | defer req.Body.Close() 21 | body, _ = ioutil.ReadAll(req.Body) 22 | } 23 | 24 | return f(req, string(body)), nil 25 | } 26 | 27 | //testClient returns *http.Client with Transport replaced to avoid making real calls 28 | func testClient(roundTripFunc testRoundTripper) *http.Client { 29 | return &http.Client{ 30 | Transport: roundTripFunc, 31 | } 32 | } 33 | 34 | // testResponse reduces some of the boilerplate needed in a testClient's testRoundTripper callback 35 | func testResponse(status int, body string, headers map[string]string) *http.Response { 36 | h := http.Header{} 37 | if headers != nil { 38 | for key, value := range headers { 39 | h.Set(key, value) 40 | } 41 | } 42 | 43 | return &http.Response{ 44 | StatusCode: status, 45 | Body: ioutil.NopCloser(bytes.NewBufferString(body)), 46 | Header: h, 47 | } 48 | } 49 | 50 | type ( 51 | testStruct1 struct { 52 | It string `json:"it" xml:"it" form:"it"` 53 | } 54 | testStruct2 struct { 55 | That string `json:"that" xml:"that" form:"that"` 56 | } 57 | ) 58 | 59 | func TestGet(t *testing.T) { 60 | request, err := GetRequest("http://example.com/some/path") 61 | assert.NoError(t, err) 62 | 63 | // Override client to avoid actually sending request 64 | request.client = testClient(func(req *http.Request, body string) *http.Response { 65 | // Test request parameters 66 | assert.Equal(t, req.URL.String(), "http://example.com/some/path") 67 | assert.Equal(t, req.Method, http.MethodGet) 68 | assert.Empty(t, body) 69 | 70 | assert.Equal(t, "ImpactServer", req.UserAgent()) 71 | assert.Equal(t, "ImpactServer", req.Header.Get("User-Agent")) 72 | 73 | return testResponse(http.StatusOK, "OK", nil) 74 | }) 75 | 76 | response, err := request.Do() 77 | assert.NoError(t, err) 78 | 79 | assert.True(t, response.Ok()) 80 | assert.Equal(t, 200, response.Code()) 81 | assert.Equal(t, "200 OK", response.Status()) 82 | 83 | body := response.String() 84 | assert.Equal(t, "OK", body) 85 | 86 | assert.NoError(t, err) 87 | assert.Equal(t, "OK", body) 88 | } 89 | 90 | func TestJSON(t *testing.T) { 91 | reqBody := testStruct1{"Hello, world"} 92 | request, err := JSONRequest("http://example.com/some/other/path", reqBody) 93 | assert.NoError(t, err) 94 | 95 | // Override client to avoid actually sending request 96 | request.client = testClient(func(req *http.Request, body string) *http.Response { 97 | // Test request parameters 98 | assert.Equal(t, req.URL.String(), "http://example.com/some/other/path") 99 | assert.Equal(t, req.Method, http.MethodPost) 100 | assert.Equal(t, `{"it":"Hello, world"}`, body) 101 | 102 | assert.Equal(t, "ImpactServer", req.UserAgent()) 103 | assert.Equal(t, "application/json", req.Header.Get("Content-Type")) 104 | length, err := strconv.Atoi(req.Header.Get("Content-Length")) 105 | assert.NoError(t, err) 106 | assert.Equal(t, len(body), length) 107 | 108 | return testResponse(http.StatusOK, `{"that":"thing"}`, nil) 109 | }) 110 | 111 | response, err := request.Do() 112 | assert.NoError(t, err) 113 | 114 | assert.True(t, response.Ok()) 115 | assert.Equal(t, 200, response.Code()) 116 | assert.Equal(t, "200 OK", response.Status()) 117 | 118 | body := testStruct2{} 119 | err = response.JSON(&body) 120 | assert.NoError(t, err) 121 | assert.Equal(t, testStruct2{"thing"}, body) 122 | assert.Equal(t, "thing", body.That) 123 | } 124 | 125 | func TestXML(t *testing.T) { 126 | reqBody := testStruct1{"Hello, world"} 127 | request, err := XMLRequest("http://example.com/some/other/path", reqBody) 128 | assert.NoError(t, err) 129 | 130 | // Override client to avoid actually sending request 131 | request.client = testClient(func(req *http.Request, body string) *http.Response { 132 | // Test request parameters 133 | assert.Equal(t, req.URL.String(), "http://example.com/some/other/path") 134 | assert.Equal(t, req.Method, http.MethodPost) 135 | assert.Equal(t, ``+"\n"+`Hello, world`, body) 136 | 137 | assert.Equal(t, "ImpactServer", req.UserAgent()) 138 | assert.Equal(t, "application/xml", req.Header.Get("Content-Type")) 139 | length, err := strconv.Atoi(req.Header.Get("Content-Length")) 140 | assert.NoError(t, err) 141 | assert.Equal(t, len(body), length) 142 | 143 | return testResponse(http.StatusOK, ``+"\n"+`thing`, nil) 144 | }) 145 | 146 | response, err := request.Do() 147 | assert.NoError(t, err) 148 | 149 | assert.True(t, response.Ok()) 150 | assert.Equal(t, 200, response.Code()) 151 | assert.Equal(t, "200 OK", response.Status()) 152 | 153 | body := testStruct2{} 154 | err = response.XML(&body) 155 | assert.NoError(t, err) 156 | assert.Equal(t, testStruct2{"thing"}, body) 157 | assert.Equal(t, "thing", body.That) 158 | } 159 | -------------------------------------------------------------------------------- /src/database/schema.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | func initialSetup() { 8 | err := createTables() 9 | if err != nil { 10 | panic(err) 11 | } 12 | } 13 | 14 | func createTables() error { 15 | _, err := DB.Exec(` 16 | CREATE EXTENSION IF NOT EXISTS "pgcrypto"; 17 | `) 18 | if err != nil { 19 | log.Println("Unable to load pgcrypto extension") 20 | return err 21 | } 22 | 23 | _, err = DB.Exec(` 24 | CREATE TABLE IF NOT EXISTS pending_donations ( 25 | token UUID PRIMARY KEY DEFAULT gen_random_uuid(), 26 | created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT, -- UNIX seconds 27 | amount INTEGER, -- Can be null since this might be a _free_ giftcard or staff token 28 | currency TEXT, 29 | 30 | -- Either paypal, stripe or both can be null. If both are null it is essentially a "gift card" 31 | paypal_order_id TEXT UNIQUE, 32 | paypal_payer_id TEXT, 33 | paypal_payer_email TEXT, 34 | stripe_payment_id TEXT UNIQUE, 35 | stripe_payer_email TEXT, 36 | 37 | -- Roles to be granted 38 | premium BOOL NOT NULL DEFAULT FALSE, 39 | pepsi BOOL NOT NULL DEFAULT FALSE, 40 | spawnmason BOOL NOT NULL DEFAULT FALSE, 41 | staff BOOL NOT NULL DEFAULT FALSE, 42 | 43 | used BOOL NOT NULL DEFAULT FALSE, 44 | used_by UUID REFERENCES users(user_id), 45 | log_msg_id TEXT 46 | ); 47 | `) 48 | if err != nil { 49 | log.Println("Unable to create pending_donations table") 50 | return err 51 | } 52 | 53 | // Scuff city, PQ doesn't support INET/CIDR postgres types 54 | _, err = DB.Exec(` 55 | CREATE TABLE IF NOT EXISTS payment_intents ( 56 | stripe_payment_id TEXT PRIMARY KEY, 57 | ip_address TEXT NOT NULL 58 | ); 59 | `) 60 | if err != nil { 61 | log.Println("Unable to create payment_intents table") 62 | return err 63 | } 64 | 65 | _, err = DB.Exec(` 66 | CREATE TABLE IF NOT EXISTS failed_charges ( 67 | ip_address TEXT PRIMARY KEY, 68 | failures INTEGER NOT NULL DEFAULT 1, 69 | rejections INTEGER NOT NULL DEFAULT 0, 70 | high_risk INTEGER NOT NULL DEFAULT 0 71 | ); 72 | `) 73 | if err != nil { 74 | log.Println("Unable to create failed_charges table") 75 | return err 76 | } 77 | 78 | _, err = DB.Exec(` 79 | CREATE TABLE IF NOT EXISTS password_resets ( 80 | token UUID PRIMARY KEY DEFAULT gen_random_uuid(), 81 | user_id UUID NOT NULL REFERENCES users(user_id) , 82 | created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT -- unix seconds 83 | ); 84 | `) 85 | if err != nil { 86 | log.Println("Unable to create password_resets table") 87 | return err 88 | } 89 | 90 | _, err = DB.Exec(` 91 | CREATE TABLE IF NOT EXISTS users ( 92 | user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 93 | email TEXT UNIQUE, 94 | password_hash TEXT, 95 | created_at BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW())::BIGINT, -- unix seconds 96 | 97 | mc_uuid UUID UNIQUE, 98 | discord_id TEXT UNIQUE, 99 | 100 | stripe_connect TEXT, -- the associated stripe connect account if present, used by devs to login to their stripe dashboard 101 | 102 | legacy_enabled BOOL NOT NULL DEFAULT FALSE, -- list this mc uuid in the premium list for 4.7 and below. this determines if you get a cape shown to other users who are using 4.7- 103 | cape_enabled BOOL NOT NULL DEFAULT TRUE, -- show a cape to others on 4.8+ 104 | 105 | legacy BOOL NOT NULL DEFAULT TRUE, 106 | premium BOOL NOT NULL DEFAULT FALSE, 107 | pepsi BOOL NOT NULL DEFAULT FALSE, 108 | spawnmason BOOL NOT NULL DEFAULT FALSE, 109 | staff BOOL NOT NULL DEFAULT FALSE, 110 | developer BOOL NOT NULL DEFAULT FALSE 111 | ); 112 | `) 113 | if err != nil { 114 | log.Println("Unable to create users table") 115 | return err 116 | } 117 | 118 | // A view allows us to control logical column order 119 | _, err = DB.Exec(` 120 | DROP VIEW IF EXISTS users_view; 121 | 122 | CREATE VIEW users_view AS SELECT 123 | user_id, 124 | email, 125 | mc_uuid, 126 | discord_id, 127 | password_hash, 128 | stripe_connect, 129 | cape_enabled, --TODO invert this to "incognito" 130 | legacy_enabled, 131 | legacy, 132 | STRING_TO_ARRAY( 133 | CONCAT_WS(',', 134 | CASE WHEN premium THEN 'premium' END, 135 | CASE WHEN pepsi THEN 'pepsi' END, 136 | CASE WHEN spawnmason THEN 'spawnmason' END, 137 | CASE WHEN staff THEN 'staff' END, 138 | CASE WHEN developer THEN 'developer' END 139 | ), 140 | ',' 141 | ) AS roles 142 | FROM users; 143 | `) 144 | if err != nil { 145 | log.Println("Unable to create users_view view") 146 | return err 147 | } 148 | 149 | _, err = DB.Exec(` 150 | CREATE OR REPLACE FUNCTION notify_users_updated() 151 | RETURNS trigger AS $$ 152 | DECLARE 153 | BEGIN 154 | PERFORM pg_notify('users_updated', ''); 155 | RETURN NEW; 156 | END; 157 | $$ LANGUAGE plpgsql; 158 | `) 159 | if err != nil { 160 | log.Println("Unable to create notify_users_updated trigger function") 161 | } 162 | 163 | _, err = DB.Exec(` 164 | DROP TRIGGER IF EXISTS users_update_trigger ON users; 165 | 166 | CREATE TRIGGER users_update_trigger 167 | AFTER INSERT OR UPDATE OR DELETE ON users 168 | FOR EACH STATEMENT 169 | EXECUTE PROCEDURE notify_users_updated(); 170 | `) 171 | if err != nil { 172 | log.Println("Unable to create users_update_trigger trigger") 173 | } 174 | 175 | return nil 176 | } 177 | -------------------------------------------------------------------------------- /src/api/v1/password.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "github.com/ImpactDevelopment/ImpactServer/src/database" 8 | "github.com/ImpactDevelopment/ImpactServer/src/mailgun" 9 | "github.com/ImpactDevelopment/ImpactServer/src/middleware" 10 | "github.com/ImpactDevelopment/ImpactServer/src/recaptcha" 11 | "github.com/ImpactDevelopment/ImpactServer/src/util" 12 | "github.com/google/uuid" 13 | "github.com/labstack/echo/v4" 14 | "golang.org/x/crypto/bcrypt" 15 | "net/http" 16 | "net/url" 17 | "strings" 18 | "time" 19 | ) 20 | 21 | const ( 22 | // Let's be real, I should be using text/template and html/template here instead of format strings 23 | text = `Here's your password reset link: %s` 24 | html = `

25 | Click here to reset your password or copy the following link if that doesn't work: 26 |

27 |
 28 | %s
 29 | 
` 30 | ) 31 | 32 | func resetPassword(c echo.Context) error { 33 | var body struct { 34 | Email string `json:"email" form:"email" query:"email"` 35 | } 36 | err := c.Bind(&body) 37 | if err != nil { 38 | return err 39 | } 40 | if body.Email == "" { 41 | return echo.NewHTTPError(http.StatusBadRequest, "email is required") 42 | } 43 | 44 | err = recaptcha.Verify(c) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | user := database.LookupUserByEmail(strings.TrimSpace(body.Email)) 50 | if user == nil { 51 | return echo.NewHTTPError(http.StatusBadRequest, "user not found") 52 | } 53 | 54 | token, err := genToken(user.ID) 55 | if err != nil { 56 | return err 57 | } 58 | resetURL := util.GetServerURL() 59 | resetURL.Path = "/forgotpassword.html" 60 | resetURL.RawQuery = url.Values{"token": {token.String()}}.Encode() 61 | 62 | // Send user an email, don't just give anyone a token lol 63 | message := mailgun.MG.NewMessage("Impact ", "Password reset", fmt.Sprintf(text, resetURL.String()), user.Email) 64 | message.SetHtml(fmt.Sprintf(html, resetURL.String(), resetURL.String())) 65 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 66 | defer cancel() 67 | _, _, err = mailgun.MG.Send(ctx, message) 68 | if err != nil { 69 | return echo.NewHTTPError(http.StatusInternalServerError, "failed to send reset email") 70 | } 71 | return c.JSON(http.StatusOK, struct { 72 | Message string `json:"message"` 73 | }{"success"}) 74 | } 75 | 76 | func putPassword(c echo.Context) error { 77 | var body struct { 78 | Password string `json:"password" form:"password" query:"password"` 79 | } 80 | err := c.Bind(&body) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | if user := middleware.GetUser(c); user != nil { 86 | // We are authenticated so trust the user 87 | err = setPassword(user.ID, body.Password) 88 | if err != nil { 89 | return err 90 | } 91 | } else { 92 | // We are not authenticated... They should provide a reset token 93 | token := strings.TrimSpace(c.Param("token")) 94 | var userID uuid.UUID 95 | var createdAt int64 96 | err = database.DB.QueryRow(`DELETE FROM password_resets WHERE token = $1 RETURNING user_id, created_at`, token).Scan(&userID, &createdAt) 97 | 98 | // Check the token actually exists 99 | if err == sql.ErrNoRows { 100 | return echo.NewHTTPError(http.StatusNotFound, "invalid reset token").SetInternal(err) 101 | } 102 | 103 | // If the error is some other error, that probably means we failed to delete the token 104 | if err != nil { 105 | return echo.NewHTTPError(http.StatusInternalServerError, "unable to delete reset token").SetInternal(err) 106 | } 107 | 108 | // Also check when the token was created: should be in the past, but not too far in the past... 109 | if now, then := time.Now(), time.Unix(createdAt, 0); now.Before(then) || now.After(then.Add(24*time.Hour)) { 110 | return echo.NewHTTPError(http.StatusUnauthorized, "expired reset token") 111 | } 112 | 113 | // OK, valid token so we can trust them now, I guess 114 | err = setPassword(userID, body.Password) 115 | if err != nil { 116 | return err 117 | } 118 | } 119 | return c.JSON(http.StatusOK, struct { 120 | Message string `json:"message"` 121 | }{"success"}) 122 | } 123 | 124 | func genToken(userID uuid.UUID) (token uuid.UUID, err error) { 125 | err = database.DB.QueryRow(`INSERT INTO password_resets (user_id) VALUES ($1) RETURNING token`, userID).Scan(&token) 126 | if err != nil { 127 | err = echo.NewHTTPError(http.StatusInternalServerError, "failed to create reset token").SetInternal(err) 128 | } 129 | return 130 | } 131 | 132 | func setPassword(userID uuid.UUID, password string) error { 133 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 134 | if err != nil { 135 | return echo.NewHTTPError(http.StatusInternalServerError, "failed to hash password").SetInternal(err) 136 | } 137 | 138 | // Set the new hash 139 | result, err := database.DB.Exec(`UPDATE users SET password_hash = $2 WHERE user_id = $1`, userID, hashedPassword) 140 | if err != nil { 141 | return echo.NewHTTPError(http.StatusInternalServerError, "unable to update password hash") 142 | } 143 | 144 | // Check it set correctly 145 | rows, err := result.RowsAffected() 146 | if err != nil { 147 | return echo.NewHTTPError(http.StatusInternalServerError, "DB driver doesn't support RowsAffected()") 148 | } 149 | if rows != 1 { 150 | return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Incorrect number of users affected: %d", rows)) 151 | } 152 | 153 | return nil 154 | } 155 | -------------------------------------------------------------------------------- /src/web/releases.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "reflect" 9 | "strings" 10 | "time" 11 | 12 | "github.com/ImpactDevelopment/ImpactServer/src/util/mediatype" 13 | 14 | "github.com/ImpactDevelopment/ImpactServer/src/cloudflare" 15 | "github.com/ImpactDevelopment/ImpactServer/src/s3proxy" 16 | "github.com/ImpactDevelopment/ImpactServer/src/util" 17 | "github.com/aws/aws-sdk-go/aws" 18 | "github.com/aws/aws-sdk-go/service/s3" 19 | "github.com/labstack/echo/v4" 20 | ) 21 | 22 | var rels map[string]Release 23 | 24 | var githubToken string 25 | 26 | type Asset struct { 27 | Name string `json:"name"` 28 | URL string `json:"browser_download_url"` 29 | } 30 | 31 | type Release struct { 32 | TagName string `json:"tag_name"` 33 | Draft bool `json:"draft"` 34 | Prerelease bool `json:"prerelease"` 35 | Assets []Asset `json:"assets"` 36 | } 37 | 38 | func init() { 39 | githubToken = os.Getenv("GITHUB_ACCESS_TOKEN") 40 | if githubToken == "" { 41 | fmt.Println("WARNING: No GitHub access token to bypass ratelimiting!") 42 | } 43 | var err error 44 | rels, err = allReleases() 45 | if err != nil { 46 | panic(err) 47 | } 48 | util.DoRepeatedly(15*time.Minute, func() { 49 | newRel, err := allReleases() 50 | if err != nil { 51 | log.Println("RELEASES ERROR", err) 52 | return 53 | } 54 | if !reflect.DeepEqual(rels, newRel) { 55 | rels = newRel 56 | 57 | cloudflare.PurgeURLs([]string{"http://impactclient.net/releases.json"}) 58 | } 59 | }) 60 | } 61 | 62 | func releases(c echo.Context) error { 63 | relsCopy := rels // vague multithreading protection idk lmao 64 | resp := make([]Release, 0, len(rels)) 65 | for _, v := range relsCopy { 66 | resp = append(resp, v) 67 | } 68 | return c.JSON(http.StatusOK, resp) 69 | } 70 | 71 | func allReleases() (map[string]Release, error) { 72 | resp := make(map[string]Release) 73 | err := githubReleases(resp) 74 | if err != nil { 75 | return nil, err 76 | } 77 | err = s3Releases(resp) 78 | if err != nil { 79 | return nil, err 80 | } 81 | return resp, nil 82 | } 83 | 84 | func githubReleases(rels map[string]Release) error { 85 | // not strictly necessary given that we won't be querying all that often 86 | // but we have no idea who else is on this IP (shared host from heroku) 87 | // so to guard against posssible "noisy neighbors" who are spamming github's api 88 | // we provoide an authorization token so that we get our own rate limit regardless of IP 89 | req, err := util.GetRequest("https://api.github.com/repos/ImpactDevelopment/ImpactReleases/releases") 90 | if err != nil { 91 | fmt.Println("Github error building request", err) 92 | return err 93 | } 94 | req.SetQuery("per_page", "100") 95 | req.Accept(mediatype.JSON) 96 | if githubToken != "" { 97 | req.Authorization("Bearer", githubToken) 98 | } 99 | 100 | resp, err := req.Do() 101 | if err != nil { 102 | fmt.Println("Github error", err) 103 | return err 104 | } 105 | 106 | var releasesData []Release 107 | err = resp.JSON(&releasesData) 108 | if err != nil || len(releasesData) == 0 { 109 | fmt.Println("Github returned invalid json reply!!") 110 | fmt.Println(err) 111 | fmt.Println(resp.String()) 112 | return err 113 | } 114 | 115 | for _, rel := range releasesData { 116 | rels[rel.TagName] = rel 117 | } 118 | return nil 119 | } 120 | 121 | func s3Releases(resp map[string]Release) error { 122 | objs := make([]*s3.Object, 0) 123 | err := s3.New(s3proxy.AWSSession).ListObjectsV2Pages(&s3.ListObjectsV2Input{ 124 | Bucket: aws.String("impactclient-files"), 125 | Prefix: aws.String("artifacts/Impact/"), 126 | }, func(page *s3.ListObjectsV2Output, lastPage bool) (shouldContinue bool) { 127 | for _, obj := range page.Contents { 128 | if obj.Key == nil { 129 | continue 130 | } 131 | objs = append(objs, obj) 132 | } 133 | return true 134 | }) 135 | if err != nil { 136 | fmt.Println("s3 error but let's not break the client for everyone since this only affects premium") 137 | fmt.Println(err) 138 | return nil 139 | } 140 | 141 | keys := make(map[string]bool) 142 | 143 | for _, item := range objs { 144 | if *item.StorageClass != "STANDARD" { 145 | continue 146 | } 147 | keys[*item.Key] = true 148 | } 149 | 150 | for k := range keys { 151 | // e.g. artifacts/Impact/dev/dev-856f3ad-1.13.2/Impact-dev-856f3ad-1.13.2.jar 152 | parts := strings.Split(k, "/") 153 | fileName := parts[len(parts)-1] // Impact-dev-856f3ad-1.13.2.jar 154 | if !strings.HasPrefix(fileName, "Impact-") || !strings.HasSuffix(fileName, ".jar") { 155 | continue 156 | } 157 | 158 | tagName := parts[len(parts)-2] // dev-856f3ad-1.13.2 159 | fullPath := k[:len(k)-3] // artifacts/Impact/dev/dev-856f3ad-1.13.2/Impact-dev-856f3ad-1.13.2. 160 | internalName := fileName[:len(fileName)-3] // Impact-dev-856f3ad-1.13.2. 161 | 162 | if _, ok := keys[fullPath+"json"]; !ok { 163 | continue 164 | } 165 | 166 | rel := Release{ 167 | TagName: tagName, 168 | Draft: strings.Contains(tagName, "dev"), 169 | Prerelease: !strings.Contains(tagName, "release"), 170 | Assets: []Asset{ 171 | { 172 | Name: fileName, 173 | URL: "https://files.impactclient.net/" + k, 174 | }, 175 | { 176 | Name: internalName + "json", 177 | URL: "https://files.impactclient.net/" + fullPath + "json", 178 | }, 179 | }, 180 | } 181 | 182 | if _, ok := keys[fullPath+"json.asc"]; ok { 183 | rel.Assets = append(rel.Assets, Asset{ 184 | Name: internalName + "json.asc", 185 | URL: "https://files.impactclient.net/" + fullPath + "json.asc", 186 | }) 187 | } 188 | resp[tagName] = rel 189 | } 190 | return nil 191 | } 192 | -------------------------------------------------------------------------------- /src/util/http.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "encoding/xml" 7 | "io" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "strconv" 12 | "strings" 13 | 14 | "github.com/ImpactDevelopment/ImpactServer/src/util/mediatype" 15 | ) 16 | 17 | const userAgent = "ImpactServer" 18 | 19 | // HTTPRequest wraps http.Request so that we can provide custom methods 20 | type HTTPRequest struct { 21 | Req *http.Request 22 | 23 | // client is the http.Client which will do the request 24 | // 25 | // it is set here so it can be overridden by tests 26 | client *http.Client 27 | } 28 | 29 | // HTTPResponse wraps http.Response so that we can provide custom methods 30 | type HTTPResponse struct { 31 | Resp *http.Response 32 | Body []byte 33 | } 34 | 35 | // NewRequest wraps http.NewRequest but returns HTTPRequest instead of http.Request. The ImpactServer User Agent is automatically added 36 | // 37 | // You probably want to use one of its wrappers like GetRequest or JSONRequest instead 38 | func NewRequest(method, url string, body io.Reader) (*HTTPRequest, error) { 39 | request, err := http.NewRequest(method, url, body) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | request.Header.Set("User-Agent", userAgent) 45 | 46 | return &HTTPRequest{ 47 | Req: request, 48 | client: http.DefaultClient, 49 | }, nil 50 | } 51 | 52 | // GetRequest returns a HTTPRequest using method GET with no body 53 | func GetRequest(address string) (*HTTPRequest, error) { 54 | return NewRequest(http.MethodGet, address, nil) 55 | } 56 | 57 | // JSONRequest returns a HTTPRequest using method POST with a JSON marshalled body 58 | func JSONRequest(address string, body interface{}) (*HTTPRequest, error) { 59 | post, err := json.Marshal(body) 60 | if err != nil { 61 | return nil, err 62 | } 63 | 64 | request, err := NewRequest(http.MethodPost, address, bytes.NewReader(post)) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | request.setContentType(mediatype.JSON) 70 | request.setLength(len(post)) 71 | 72 | return request, nil 73 | } 74 | 75 | // XMLRequest returns a HTTPRequest using method POST with an XML marshalled body 76 | func XMLRequest(address string, body interface{}) (*HTTPRequest, error) { 77 | // Strip the trailing newline, since we add our own later. 78 | doctype := strings.Trim(xml.Header, "\n") 79 | return XMLRequestWithDoctype(address, doctype, body) 80 | } 81 | 82 | // XMLRequestWithDoctype returns a HTTPRequest using method POST with an XML marshalled body with the specified doctype 83 | func XMLRequestWithDoctype(address, doctype string, body interface{}) (*HTTPRequest, error) { 84 | postBody, err := xml.Marshal(body) 85 | if err != nil { 86 | return nil, err 87 | } 88 | var post []byte 89 | if doctype == "" { 90 | post = postBody 91 | } else { 92 | post = []byte(doctype + "\n" + string(postBody)) 93 | } 94 | 95 | request, err := NewRequest(http.MethodPost, address, bytes.NewReader(post)) 96 | if err != nil { 97 | return nil, err 98 | } 99 | 100 | request.setContentType(mediatype.XML) 101 | request.setLength(len(post)) 102 | 103 | return request, nil 104 | } 105 | 106 | // FormRequest returns a HTTPRequest using method POST with a x-www-form-urlencoded marshalled body 107 | func FormRequest(address string, form map[string]string) (*HTTPRequest, error) { 108 | post := urlValues(form).Encode() 109 | 110 | request, err := NewRequest(http.MethodPost, address, strings.NewReader(post)) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | request.setContentType(mediatype.Form) 116 | request.setLength(len(post)) 117 | 118 | return request, nil 119 | } 120 | 121 | // URL returns the url.URL associated with this HTTPRequest 122 | func (r HTTPRequest) URL() *url.URL { 123 | return r.Req.URL 124 | } 125 | 126 | // SetQuery sets a url query value on the HTTPRequest's URL 127 | func (r *HTTPRequest) SetQuery(key, value string) { 128 | SetQuery(r.URL(), key, value) 129 | } 130 | 131 | // SetHeader sets a header on the HTTPRequest 132 | func (r *HTTPRequest) SetHeader(key, value string) { 133 | r.Req.Header.Set(key, value) 134 | } 135 | 136 | func (r *HTTPRequest) setLength(length int) { 137 | r.SetHeader("Content-Length", strconv.Itoa(length)) 138 | } 139 | 140 | func (r *HTTPRequest) setContentType(mediaType mediatype.MediaType) { 141 | r.SetHeader("Content-Type", mediaType.String()) 142 | } 143 | 144 | // Accept sets the Accept header on the HTTPRequest to indicate what content-type we expect 145 | func (r *HTTPRequest) Accept(mediaType mediatype.MediaType) { 146 | r.SetHeader("Accept", mediaType.String()) 147 | } 148 | 149 | // Authorization sets the Authorization header on the HTTPRequest for token-based auth 150 | // e.g. request.Authorization("Bearer", token) 151 | func (r *HTTPRequest) Authorization(authType string, authKey string) { 152 | r.SetHeader("Authorization", authType+" "+authKey) 153 | } 154 | 155 | // Do does a request and returns the response, as a HTTPResponse 156 | func (r *HTTPRequest) Do() (*HTTPResponse, error) { 157 | resp, err := r.client.Do(r.Req) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | // Read body if it exists 163 | var body []byte 164 | if resp.Body != nil { 165 | defer resp.Body.Close() 166 | body, err = ioutil.ReadAll(resp.Body) 167 | if err != nil { 168 | return nil, err 169 | } 170 | } 171 | 172 | return &HTTPResponse{ 173 | Resp: resp, 174 | Body: body, 175 | }, nil 176 | } 177 | 178 | // Ok returns true if the status code is "200 OK" 179 | func (r HTTPResponse) Ok() bool { 180 | return r.Code() == http.StatusOK 181 | } 182 | 183 | // Code returns the status code as an int 184 | func (r HTTPResponse) Code() int { 185 | return r.Resp.StatusCode 186 | } 187 | 188 | // Status returns the full status string, e.g. "400 Bad Request" 189 | func (r HTTPResponse) Status() string { 190 | code := r.Code() 191 | return strconv.Itoa(code) + " " + http.StatusText(code) 192 | } 193 | 194 | // GetHeader returns the value of the given header key 195 | func (r HTTPResponse) GetHeader(key string) string { 196 | return r.Resp.Header.Get(key) 197 | } 198 | 199 | // ContentType returns the MediaType of the response body, according to the Content-Type header 200 | func (r HTTPResponse) ContentType() mediatype.MediaType { 201 | return mediatype.MediaType(r.GetHeader("Content-Type")) 202 | } 203 | 204 | // String returns the body as a string 205 | func (r *HTTPResponse) String() string { 206 | return string(r.Body) 207 | } 208 | 209 | // JSON decodes the body into the provided interface{} 210 | func (r *HTTPResponse) JSON(v interface{}) error { 211 | return json.Unmarshal(r.Body, v) 212 | } 213 | 214 | // XML decodes the body into the provided interface{} 215 | func (r *HTTPResponse) XML(v interface{}) error { 216 | return xml.Unmarshal(r.Body, v) 217 | } 218 | 219 | // urlValues converts a map of strings to url Values for use in forms or query strings 220 | func urlValues(values map[string]string) *url.Values { 221 | v := &url.Values{} 222 | for key, value := range values { 223 | v.Set(key, value) 224 | } 225 | return v 226 | } 227 | -------------------------------------------------------------------------------- /src/discord/discord.go: -------------------------------------------------------------------------------- 1 | package discord 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/ImpactDevelopment/ImpactServer/src/minecraft" 7 | "github.com/ImpactDevelopment/ImpactServer/src/stripe" 8 | "github.com/labstack/echo/v4" 9 | "net/http" 10 | "os" 11 | "regexp" 12 | 13 | "github.com/bwmarrin/discordgo" 14 | ) 15 | 16 | var discord *discordgo.Session 17 | 18 | var guildID string 19 | var donatorRole string 20 | var verifiedRole string 21 | 22 | // Discord's OAuth tokens are alphanumeric 23 | var discordOAuthToken = regexp.MustCompile(`^[A-Za-z0-9]+$`) 24 | 25 | // Where to notify donations 26 | const donationMsgChannel = "678230156091064330" 27 | 28 | // User is a wrapper around discordgo.User so that we can feel ok about exporting it, 29 | // we also set Avatar to a full url instead of just the id for json reasons 30 | type User struct { 31 | discordgo.User 32 | Avatar string `json:"avatar"` 33 | } 34 | 35 | func init() { 36 | token := os.Getenv("DISCORD_BOT_TOKEN") 37 | if token == "" { 38 | fmt.Println("WARNING: No discord bot token, will not be able to grant donator role!") 39 | return 40 | } 41 | guildID = os.Getenv("DISCORD_GUILD_ID") 42 | donatorRole = os.Getenv("DISCORD_DONATOR_ROLE_ID") 43 | verifiedRole = os.Getenv("DISCORD_VERIFIED_ROLE_ID") 44 | if guildID == "" || donatorRole == "" || verifiedRole == "" { 45 | fmt.Println("WARNING: Discord info is bad") 46 | return 47 | } 48 | var err error 49 | discord, err = discordgo.New("Bot " + token) 50 | if err != nil { 51 | panic(err) 52 | } 53 | user, err := discord.User("@me") 54 | if err != nil { 55 | panic(err) 56 | } 57 | 58 | myselfID := user.ID 59 | fmt.Println("I am", myselfID) 60 | } 61 | 62 | // GetUserId returns the discord user id using the user's access token 63 | func GetUserId(accessToken string) (userId string, err error) { 64 | // Validate the token, prevent trying to auth with discord using some completely invalid token 65 | if !discordOAuthToken.MatchString(accessToken) { 66 | return "", echo.NewHTTPError(http.StatusBadRequest, "invalid access_token "+accessToken) 67 | } 68 | 69 | // Create a discord session using the provided token. Does not verify the token is valid in any way. 70 | // Using discordgo here is massively overkill, but who cares 71 | // This won't use websockets unless we call session.Open(), so there's no need to call Close() either. 72 | session, err := discordgo.New("Bearer " + accessToken) 73 | if err != nil { 74 | return "", echo.NewHTTPError(http.StatusInternalServerError, "error setting up discord session").SetInternal(err) 75 | } 76 | 77 | // Get the user's identity 78 | discordUser, err := session.User("@me") 79 | if err != nil { 80 | var restErr *discordgo.RESTError 81 | if errors.As(err, &restErr) { 82 | return "", echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf(`error authenticating with discord "%s"`, restErr.Message.Message)) 83 | } 84 | return "", echo.NewHTTPError(http.StatusInternalServerError, "error authenticating with discord").SetInternal(err) 85 | } 86 | if discordUser.ID == "" { 87 | return "", echo.NewHTTPError(http.StatusUnauthorized, "no discord user found") 88 | } 89 | 90 | return discordUser.ID, nil 91 | } 92 | 93 | // GetUser returns the user object matching the given user id 94 | func GetUser(id string) (user *User, err error) { 95 | discordUser, err := discord.User(id) 96 | if err != nil { 97 | var restErr *discordgo.RESTError 98 | if errors.As(err, &restErr) { 99 | return nil, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf(`error authenticating with discord "%s"`, restErr.Message.Message)) 100 | } 101 | return nil, echo.NewHTTPError(http.StatusInternalServerError, "error authenticating with discord").SetInternal(err) 102 | } 103 | 104 | return &User{*discordUser, discordUser.AvatarURL("")}, nil 105 | 106 | } 107 | 108 | // JoinOurServer adds the user matching discordId to our discord server. The user's access token must be provided and it 109 | // must have the guilds.join scope 110 | func JoinOurServer(accessToken string, discordID string, donator bool) error { 111 | roles := []string{verifiedRole} 112 | if donator { 113 | roles = append(roles, donatorRole) 114 | } 115 | return discord.GuildMemberAdd(accessToken, guildID, discordID, "", roles, false, false) 116 | } 117 | 118 | // SetDonator updates the roles for the given discord user without treating it like a new donation 119 | func SetDonator(discordID string, donator bool) error { 120 | if donator { 121 | return discord.GuildMemberRoleAdd(guildID, discordID, donatorRole) 122 | } else { 123 | return discord.GuildMemberRoleRemove(guildID, discordID, donatorRole) 124 | } 125 | } 126 | 127 | // GiveDonator grants the donator role to the user and verifies them 128 | func GiveDonator(discordID string) error { 129 | GiveVerified(discordID) 130 | // dont return early & fail to give donator role if we cant give verified 131 | return discord.GuildMemberRoleAdd(guildID, discordID, donatorRole) 132 | } 133 | 134 | // GiveVerified grants the user the verified role which allows them to see channels and talk in them. 135 | func GiveVerified(discordID string) error { 136 | return discord.GuildMemberRoleAdd(guildID, discordID, verifiedRole) 137 | } 138 | 139 | // CheckServerMembership is true if the user is a member of our guild 140 | func CheckServerMembership(discordID string) bool { 141 | member, err := discord.GuildMember(guildID, discordID) 142 | return err == nil && member != nil 143 | } 144 | 145 | func LogDonationEvent(editMsgID string, msg string, discordID string, minecraft *minecraft.Profile, currency string, amount int64) (logID string, err error) { 146 | m := discordgo.MessageSend{Content: msg} 147 | if discordID != "" || minecraft != nil || amount > 0 { 148 | m.Embed = &discordgo.MessageEmbed{ 149 | Type: "", 150 | } 151 | } 152 | 153 | if amount > 0 { 154 | m.Embed.Fields = append(m.Embed.Fields, &discordgo.MessageEmbedField{ 155 | Name: "Donation", 156 | Value: fmt.Sprintf("%s%01d.%02d", stripe.GetCurrencySymbol(currency), amount/100, amount%100), 157 | Inline: false, 158 | }) 159 | } 160 | 161 | if discordID != "" { 162 | if user, err := discord.User(discordID); err == nil { 163 | m.Embed.Author = &discordgo.MessageEmbedAuthor{ 164 | Name: user.Username, 165 | IconURL: user.AvatarURL(""), 166 | } 167 | m.Embed.Fields = append(m.Embed.Fields, &discordgo.MessageEmbedField{ 168 | Name: "Discord", 169 | Value: user.Mention(), 170 | Inline: true, 171 | }) 172 | } 173 | } 174 | 175 | if minecraft != nil { 176 | m.Embed.Fields = append(m.Embed.Fields, &discordgo.MessageEmbedField{ 177 | Name: "Minecraft", 178 | Value: fmt.Sprintf(`[%s](https://namemc.com/profile/%s)`, minecraft.Name, minecraft.ID.String()), 179 | Inline: true, 180 | }) 181 | m.Embed.Thumbnail = &discordgo.MessageEmbedThumbnail{ 182 | URL: "https://crafatar.com/avatars/" + minecraft.ID.String(), 183 | } 184 | } 185 | 186 | if editMsgID != "" { 187 | var edit *discordgo.Message 188 | edit, err = discord.ChannelMessageEditComplex(discordgo.NewMessageEdit(donationMsgChannel, editMsgID).SetContent(m.Content).SetEmbed(m.Embed)) 189 | if edit != nil { 190 | logID = edit.ID 191 | } 192 | return 193 | } 194 | 195 | var sent *discordgo.Message 196 | sent, err = discord.ChannelMessageSendComplex(donationMsgChannel, &m) 197 | if sent != nil { 198 | logID = sent.ID 199 | } 200 | return 201 | } 202 | -------------------------------------------------------------------------------- /static/css/style.css: -------------------------------------------------------------------------------- 1 | /* Custom Stylesheet */ 2 | /** 3 | * 4 | * Made By Joash Pereira 5 | * Thanks for MaterializeCSS.com 6 | * 7 | * Additional tweaks by the Impact Development team 8 | */ 9 | 10 | .hidden { 11 | display: none; 12 | } 13 | 14 | .default_color{ 15 | background-color: #2196F3 !important; 16 | } 17 | 18 | .default_color_text{ 19 | color: #2196F3 !important; 20 | } 21 | 22 | .large_text { 23 | font-size: 2.4rem; 24 | font-weight: 100; 25 | } 26 | 27 | .med_text { 28 | font-size: 1.8rem; 29 | font-weight: 100; 30 | } 31 | 32 | h2, h3 { 33 | font-weight: 300; 34 | margin-bottom: 4%; 35 | line-height: 4.5rem; 36 | } 37 | 38 | h3 { 39 | font-weight: 240; 40 | } 41 | 42 | i { 43 | font-style: normal; 44 | } 45 | 46 | b, em, strong { 47 | font-style: normal; 48 | font-weight: normal; 49 | color: #2196F3; 50 | } 51 | 52 | strong { 53 | font-weight: bold; 54 | } 55 | 56 | code { 57 | font-family: "Courier New", Courier, monospace; 58 | color: #181818; 59 | background-color: #f0f0f0; 60 | padding: 2px; 61 | } 62 | 63 | h2 i, h3 i, h4 i, .large_text i, 64 | h2 b, h3 b, h4 b, .large_text b, 65 | h2 em, h3 em, h4 em .large_text em { 66 | font-weight: 100; 67 | } 68 | 69 | h2 strong, h3 strong, h4 strong, .large_text strong { 70 | font-weight: 300; 71 | } 72 | 73 | nav, footer, .page-footer { 74 | background-color: #2196F3; 75 | margin-top: 0; 76 | } 77 | 78 | .icon-block { 79 | padding: 0 15px; 80 | } 81 | 82 | #intro, #work, #team {padding-top: 4rem;} 83 | 84 | 85 | #index-banner { 86 | min-height: 632px; 87 | max-height: 864px; 88 | position: relative; 89 | background-color: #2196F3; 90 | } 91 | 92 | .brand-logo { 93 | position: absolute; 94 | color: #fff; 95 | display: inline-block; 96 | font-size: 2.1rem; 97 | font-style: normal; 98 | font-weight: 100; 99 | padding: 0; 100 | letter-spacing: 7px; 101 | } 102 | 103 | .brand-logo h1 { 104 | display: inline; 105 | margin: 0; 106 | padding: 0; 107 | font-size: 2.1rem; 108 | font-weight: 100; 109 | } 110 | 111 | .in{ 112 | font-weight: 400 !important; 113 | font-style: normal !important; 114 | } 115 | 116 | .promo i { 117 | color: #2196F3; 118 | font-size: 7rem; 119 | display: block; 120 | } 121 | .card-content a {color: #2196F3;} 122 | 123 | .card-content a:hover {color: #2196F3;} 124 | 125 | #work, #team{background: rgb(247, 247, 247);} 126 | 127 | .text_pink{color:#EF9A9A;} 128 | 129 | nav ul a { 130 | font-size: 1.2rem; 131 | color: #FFF; 132 | letter-spacing: 2px; 133 | display: block; 134 | font-weight: 300; 135 | padding: 0px 15px; 136 | } 137 | 138 | .cd-headline.type .cd-words-wrapper { 139 | vertical-align: top; 140 | overflow: hidden; 141 | } 142 | 143 | .cd-headline.type .cd-words-wrapper::after { 144 | /* vertical bar */ 145 | content: ''; 146 | position: absolute; 147 | right: 0; 148 | top: 50%; 149 | bottom: auto; 150 | -webkit-transform: translateY(-50%); 151 | -moz-transform: translateY(-50%); 152 | -ms-transform: translateY(-50%); 153 | -o-transform: translateY(-50%); 154 | transform: translateY(-50%); 155 | height: 90%; 156 | width: 1px; 157 | background-color: #aebcb9; 158 | } 159 | 160 | .cd-headline.type .cd-words-wrapper.waiting::after { 161 | -webkit-animation: cd-pulse 1s infinite; 162 | -moz-animation: cd-pulse 1s infinite; 163 | animation: cd-pulse 1s infinite; 164 | } 165 | .cd-headline.type .cd-words-wrapper.selected { 166 | background-color: #FFF; 167 | } 168 | 169 | .cd-headline.type .cd-words-wrapper.selected::after { 170 | visibility: hidden; 171 | } 172 | 173 | .cd-headline.type .cd-words-wrapper.selected b { 174 | color: #2196F3; 175 | } 176 | 177 | .cd-headline.type b { 178 | visibility: hidden; 179 | } 180 | 181 | .cd-headline.type b.is-visible { 182 | visibility: visible; 183 | } 184 | 185 | .cd-headline.type i { 186 | position: absolute; 187 | visibility: hidden; 188 | } 189 | .cd-headline.type i.in { 190 | position: relative; 191 | visibility: visible; 192 | } 193 | 194 | @-webkit-keyframes cd-pulse { 195 | 0% { 196 | -webkit-transform: translateY(-50%) scale(1); 197 | opacity: 1; 198 | } 199 | 40% { 200 | -webkit-transform: translateY(-50%) scale(0.9); 201 | opacity: 0; 202 | } 203 | 100% { 204 | -webkit-transform: translateY(-50%) scale(0); 205 | opacity: 0; 206 | } 207 | } 208 | @-moz-keyframes cd-pulse { 209 | 0% { 210 | -moz-transform: translateY(-50%) scale(1); 211 | opacity: 1; 212 | } 213 | 40% { 214 | -moz-transform: translateY(-50%) scale(0.9); 215 | opacity: 0; 216 | } 217 | 100% { 218 | -moz-transform: translateY(-50%) scale(0); 219 | opacity: 0; 220 | } 221 | } 222 | 223 | @keyframes cd-pulse { 224 | 0% { 225 | -webkit-transform: translateY(-50%) scale(1); 226 | -moz-transform: translateY(-50%) scale(1); 227 | -ms-transform: translateY(-50%) scale(1); 228 | -o-transform: translateY(-50%) scale(1); 229 | transform: translateY(-50%) scale(1); 230 | opacity: 1; 231 | } 232 | 40% { 233 | -webkit-transform: translateY(-50%) scale(0.9); 234 | -moz-transform: translateY(-50%) scale(0.9); 235 | -ms-transform: translateY(-50%) scale(0.9); 236 | -o-transform: translateY(-50%) scale(0.9); 237 | transform: translateY(-50%) scale(0.9); 238 | opacity: 0; 239 | } 240 | 100% { 241 | -webkit-transform: translateY(-50%) scale(0); 242 | -moz-transform: translateY(-50%) scale(0); 243 | -ms-transform: translateY(-50%) scale(0); 244 | -o-transform: translateY(-50%) scale(0); 245 | transform: translateY(-50%) scale(0); 246 | opacity: 0; 247 | } 248 | } 249 | 250 | 251 | /* Preloader */ 252 | #preloader { 253 | position: fixed; 254 | top:0; 255 | left:0; 256 | right:0; 257 | bottom:0; 258 | background-color:#fff; /* change if the mask should have another color then white */ 259 | z-index:1200; /* makes sure it stays on top */ 260 | } 261 | 262 | #status { 263 | width:200px; 264 | height:200px; 265 | position:absolute; 266 | left:50%; /* centers the loading animation horizontally one the screen */ 267 | top:50%; /* centers the loading animation vertically one the screen */ 268 | background-image:url(../img/status.gif); /* path to your loading animation */ 269 | background-repeat:no-repeat; 270 | background-position:center; 271 | margin:-100px 0 0 -100px; /* is width and height divided by two */ 272 | } 273 | 274 | input, textarea { 275 | border-bottom: 1px solid #fff; 276 | } 277 | 278 | nav a.button-collapse { 279 | left: -25px; 280 | } 281 | 282 | .card-avatar .waves-effect { 283 | text-align: center; 284 | margin-top: 20px; 285 | } 286 | 287 | .card-avatar img { 288 | height: 150px; 289 | width: 150px; 290 | border-radius: 75px; 291 | } 292 | 293 | .card-avatar .card-content { 294 | text-align: center; 295 | } 296 | 297 | .card .card-content p { 298 | margin: 15px 0px; 299 | } 300 | 301 | .card-avatar .card-content i { 302 | font-size: 1.5rem; 303 | } 304 | 305 | .card-avatar .card-content .card-title { 306 | line-height: 30px !important; 307 | } 308 | 309 | .parallax-container { 310 | max-height: 400px; 311 | } 312 | 313 | .grey-text.text-lighten-3, .card-avatar { 314 | will-change: transform; 315 | transition: all 150ms; 316 | } 317 | 318 | .grey-text.text-lighten-3:hover, .card-avatar:hover { 319 | transform: translateY(-1px); 320 | } 321 | 322 | [type="checkbox"].filled-in:checked+span:not(.lever):after { 323 | border: 2px solid #2196F3; 324 | background-color: #2196F3; 325 | } 326 | 327 | .btn { 328 | background-color: #2196F3; 329 | } 330 | 331 | .btn:hover { 332 | background-color: #1e86da; 333 | } 334 | -------------------------------------------------------------------------------- /static/js/init.js: -------------------------------------------------------------------------------- 1 | (function ($) { 2 | $(function () { 3 | 4 | $('.scrollspy').scrollSpy(); 5 | 6 | /*** Animate word ***/ 7 | 8 | //set animation timing 9 | var animationDelay = 2500, 10 | //loading bar effect 11 | barAnimationDelay = 3800, 12 | barWaiting = barAnimationDelay - 3000, //3000 is the duration of the transition on the loading bar - set in the scss/css file 13 | //letters effect 14 | lettersDelay = 50, 15 | //type effect 16 | typeLettersDelay = 150, 17 | selectionDuration = 500, 18 | typeAnimationDelay = selectionDuration + 800, 19 | //clip effect 20 | revealDuration = 600, 21 | revealAnimationDelay = 1500; 22 | 23 | initHeadline(); 24 | 25 | function initHeadline() { 26 | singleLetters($('.cd-headline.letters').find('b')); 27 | animateHeadline($('.cd-headline')); 28 | } 29 | 30 | function singleLetters($words) { 31 | $words.each(function () { 32 | var word = $(this), 33 | letters = word.text().split(''), 34 | selected = word.hasClass('is-visible'); 35 | for (i in letters) { 36 | if (word.parents('.rotate-2').length > 0) letters[i] = '' + letters[i] + ''; 37 | letters[i] = (selected) ? '' + letters[i] + '' : '' + letters[i] + ''; 38 | } 39 | var newLetters = letters.join(''); 40 | word.html(newLetters).css('opacity', 1); 41 | }); 42 | } 43 | 44 | function animateHeadline($headlines) { 45 | var duration = animationDelay; 46 | $headlines.each(function () { 47 | var headline = $(this); 48 | 49 | if (headline.hasClass('loading-bar')) { 50 | duration = barAnimationDelay; 51 | setTimeout(function () { 52 | headline.find('.cd-words-wrapper').addClass('is-loading') 53 | }, barWaiting); 54 | } else if (headline.hasClass('clip')) { 55 | var spanWrapper = headline.find('.cd-words-wrapper'), 56 | newWidth = spanWrapper.width() + 10 57 | spanWrapper.css('width', newWidth); 58 | } else if (!headline.hasClass('type')) { 59 | //assign to .cd-words-wrapper the width of its longest word 60 | var words = headline.find('.cd-words-wrapper b'), 61 | width = 0; 62 | words.each(function () { 63 | var wordWidth = $(this).width(); 64 | if (wordWidth > width) width = wordWidth; 65 | }); 66 | headline.find('.cd-words-wrapper').css('width', width); 67 | } 68 | ; 69 | 70 | //trigger animation 71 | setTimeout(function () { 72 | hideWord(headline.find('.is-visible').eq(0)) 73 | }, duration); 74 | }); 75 | } 76 | 77 | function hideWord($word) { 78 | var nextWord = takeNext($word); 79 | 80 | if ($word.parents('.cd-headline').hasClass('type')) { 81 | var parentSpan = $word.parent('.cd-words-wrapper'); 82 | parentSpan.addClass('selected').removeClass('waiting'); 83 | setTimeout(function () { 84 | parentSpan.removeClass('selected'); 85 | $word.removeClass('is-visible').addClass('is-hidden').children('i').removeClass('in').addClass('out'); 86 | }, selectionDuration); 87 | setTimeout(function () { 88 | showWord(nextWord, typeLettersDelay) 89 | }, typeAnimationDelay); 90 | 91 | } else if ($word.parents('.cd-headline').hasClass('letters')) { 92 | var bool = ($word.children('i').length >= nextWord.children('i').length) ? true : false; 93 | hideLetter($word.find('i').eq(0), $word, bool, lettersDelay); 94 | showLetter(nextWord.find('i').eq(0), nextWord, bool, lettersDelay); 95 | 96 | } else if ($word.parents('.cd-headline').hasClass('clip')) { 97 | $word.parents('.cd-words-wrapper').animate({width: '2px'}, revealDuration, function () { 98 | switchWord($word, nextWord); 99 | showWord(nextWord); 100 | }); 101 | 102 | } else if ($word.parents('.cd-headline').hasClass('loading-bar')) { 103 | $word.parents('.cd-words-wrapper').removeClass('is-loading'); 104 | switchWord($word, nextWord); 105 | setTimeout(function () { 106 | hideWord(nextWord) 107 | }, barAnimationDelay); 108 | setTimeout(function () { 109 | $word.parents('.cd-words-wrapper').addClass('is-loading') 110 | }, barWaiting); 111 | 112 | } else { 113 | switchWord($word, nextWord); 114 | setTimeout(function () { 115 | hideWord(nextWord) 116 | }, animationDelay); 117 | } 118 | } 119 | 120 | function showWord($word, $duration) { 121 | if ($word.parents('.cd-headline').hasClass('type')) { 122 | showLetter($word.find('i').eq(0), $word, false, $duration); 123 | $word.addClass('is-visible').removeClass('is-hidden'); 124 | 125 | } else if ($word.parents('.cd-headline').hasClass('clip')) { 126 | $word.parents('.cd-words-wrapper').animate({'width': $word.width() + 10}, revealDuration, function () { 127 | setTimeout(function () { 128 | hideWord($word) 129 | }, revealAnimationDelay); 130 | }); 131 | } 132 | } 133 | 134 | function hideLetter($letter, $word, $bool, $duration) { 135 | $letter.removeClass('in').addClass('out'); 136 | 137 | if (!$letter.is(':last-child')) { 138 | setTimeout(function () { 139 | hideLetter($letter.next(), $word, $bool, $duration); 140 | }, $duration); 141 | } else if ($bool) { 142 | setTimeout(function () { 143 | hideWord(takeNext($word)) 144 | }, animationDelay); 145 | } 146 | 147 | if ($letter.is(':last-child') && $('html').hasClass('no-csstransitions')) { 148 | var nextWord = takeNext($word); 149 | switchWord($word, nextWord); 150 | } 151 | } 152 | 153 | function showLetter($letter, $word, $bool, $duration) { 154 | $letter.addClass('in').removeClass('out'); 155 | 156 | if (!$letter.is(':last-child')) { 157 | setTimeout(function () { 158 | showLetter($letter.next(), $word, $bool, $duration); 159 | }, $duration); 160 | } else { 161 | if ($word.parents('.cd-headline').hasClass('type')) { 162 | setTimeout(function () { 163 | $word.parents('.cd-words-wrapper').addClass('waiting'); 164 | }, 200); 165 | } 166 | if (!$bool) { 167 | setTimeout(function () { 168 | hideWord($word) 169 | }, animationDelay) 170 | } 171 | } 172 | } 173 | 174 | function takeNext($word) { 175 | return (!$word.is(':last-child')) ? $word.next() : $word.parent().children().eq(0); 176 | } 177 | 178 | function takePrev($word) { 179 | return (!$word.is(':first-child')) ? $word.prev() : $word.parent().children().last(); 180 | } 181 | 182 | function switchWord($oldWord, $newWord) { 183 | $oldWord.removeClass('is-visible').addClass('is-hidden'); 184 | $newWord.removeClass('is-hidden').addClass('is-visible'); 185 | } 186 | 187 | $('.button-collapse').sideNav({ 188 | menuWidth: 240, // Default is 240 189 | draggable: true, // Choose whether you can drag to open on touch screens 190 | closeOnClick: true // Closes side-nav on clicks, useful for Angular/Meteor 191 | }); 192 | 193 | $('.parallax').parallax(); 194 | 195 | }); // end of document ready 196 | })(jQuery); // end of jQuery name space 197 | -------------------------------------------------------------------------------- /static/discord.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Discord verification 5 | 6 | 7 | 8 | 9 | 78 | 79 | 91 | 92 | 93 | 94 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 |
115 | 122 |
123 | 124 | 127 | 128 |
129 |
130 |
The Discord has been terminated. Check back here later.
131 | 169 |
170 |
171 | 172 | 184 | 185 | 186 | 187 | 195 | 196 | 197 | 198 | --------------------------------------------------------------------------------