diff --git a/.gitignore b/.gitignore index 3728ecb..81ad378 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ db.sqlite secrets.go tripwire skins/ +capes/ diff --git a/auth.go b/auth.go index 7a973b6..318a9f4 100644 --- a/auth.go +++ b/auth.go @@ -1,10 +1,7 @@ package main import ( - "errors" - "io/ioutil" "net/http" - "os" "github.com/gorilla/mux" ) @@ -43,12 +40,12 @@ func authenticateEndpoint(w http.ResponseWriter, r *http.Request) { handleError(w, err) return } - playeruuid, err := getPlayerUUID(authPayload.Username) + player, err := getPlayerByUsername(authPayload.Username) if err != nil { handleError(w, err) return } - profile := MCProfile{authPayload.Username, playeruuid} + profile := MCProfile{authPayload.Username, player.UUID} authResponse := AuthResponse{ ClientToken: clientToken, AccessToken: authToken, @@ -155,81 +152,13 @@ func invalidateEndpoint(w http.ResponseWriter, r *http.Request) { sendEmpty(w) } -func getSkinEndpoint(w http.ResponseWriter, r *http.Request) { - uuid := mux.Vars(r)["uuid"] - skin, err := getPlayerSkinByUUID(uuid) - if err != nil { - if !errors.Is(err, &NotFoundError{}) { - handleError(w, err) - return - } - - skin, err = os.ReadFile("default.png") - if err != nil { - handleError(w, err) - return - } - } - w.Header().Set("Content-Type", "image/png") - w.Write(skin) -} - -func setSkinEndpoint(w http.ResponseWriter, r *http.Request) { - uuid := mux.Vars(r)["uuid"] - - r.ParseMultipartForm(int64(config.MaxSkinSize)) - - pass := r.FormValue("password") - - correct, err := checkPlayerPassByUUID(uuid, pass) - if err != nil { - handleError(w, err) - return - } - - if !correct { - sendError(w, YggError{Code: 400, Error: "Bad Request", ErrorMessage: "Invalid credentials."}) - return - } - - file, _, err := r.FormFile("skinfile") - if err != nil { - handleError(w, err) - return - } - - skin, err := ioutil.ReadAll(file) - if err != nil { - handleError(w, err) - return - } - - if len(skin) < 4 || - skin[0] != 0x89 || - skin[1] != 0x50 || - skin[2] != 0x4e || - skin[3] != 0x47 { - sendError(w, YggError{Code: 400, Error: "Bad Request", ErrorMessage: "The request data is malformed."}) - return - } - - err = setPlayerSkinByUUID(uuid, skin) - if err != nil { - handleError(w, err) - return - } - - sendEmpty(w) -} - func registerAuthEndpoints(r *mux.Router) { - r.HandleFunc("/", rootEndpoint) - r.HandleFunc("/authenticate", authenticateEndpoint).Methods("POST") - r.HandleFunc("/refresh", refreshTokenEndpoint).Methods("POST") - r.HandleFunc("/signout", signoutEndpoint).Methods("POST") - r.HandleFunc("/invalidate", invalidateEndpoint).Methods("POST") - r.HandleFunc("/validate", validateEndpoint).Methods("POST") - r.HandleFunc("/admin/addUser", addUserEndpoint).Methods("POST") - r.HandleFunc("/getSkin/{uuid}", getSkinEndpoint).Methods("GET") - r.HandleFunc("/setSkin/{uuid}", setSkinEndpoint).Methods("POST") + prefix := "/authserver" + r.HandleFunc(prefix+"/", rootEndpoint) + r.HandleFunc(prefix+"/authenticate", authenticateEndpoint).Methods("POST") + r.HandleFunc(prefix+"/refresh", refreshTokenEndpoint).Methods("POST") + r.HandleFunc(prefix+"/signout", signoutEndpoint).Methods("POST") + r.HandleFunc(prefix+"/invalidate", invalidateEndpoint).Methods("POST") + r.HandleFunc(prefix+"/validate", validateEndpoint).Methods("POST") + r.HandleFunc(prefix+"/admin/addUser", addUserEndpoint).Methods("POST") } diff --git a/config.go b/config.go index 6007867..9c4674b 100644 --- a/config.go +++ b/config.go @@ -7,9 +7,9 @@ import ( ) type Configuration struct { - BaseUrl string `yaml:"baseUrl"` - DebugMode bool `yaml:"debugMode"` - MaxSkinSize int `yaml:"maxSkinSize"` + BaseUrl string `yaml:"baseUrl"` + DebugMode bool `yaml:"debugMode"` + MaxTextureSize int `yaml:"maxTextureSize"` } var config Configuration diff --git a/config.yaml b/config.yaml index 2b267a3..f792acb 100644 --- a/config.yaml +++ b/config.yaml @@ -1,3 +1,8 @@ -baseUrl: "http://localhost:1000" +# The URL the Tripwire instance is hosted on. +baseUrl: "http://localhost:10000" + +# Show debug output (if unsure, set to false) debugMode: true -maxSkinSize: 8096 \ No newline at end of file + +# The maximum size (in bytes) of a skin/cape that can be uploaded. +maxTextureSize: 8096 \ No newline at end of file diff --git a/db.go b/db.go index 2f5afab..81b614b 100644 --- a/db.go +++ b/db.go @@ -31,6 +31,7 @@ func createDatabase() error { uuid VARCHAR(255) NOT NULL, client_token VARCHAR(255) NOT NULL, auth_token VARCHAR(255) NOT NULL, + web_token VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); ` @@ -44,15 +45,16 @@ func createDatabase() error { func insertUser(username string, password string) error { playeruuid := uuid.New().String() sqlStatement := ` - INSERT INTO users (username, password, uuid, client_token, auth_token) VALUES (?, ?, ?, ? ,?); + INSERT INTO users (username, password, uuid, client_token, auth_token, web_token) VALUES (?, ?, ?, ?, ?, ?); ` - _, err := DB.Exec(sqlStatement, username, password, playeruuid, "", "") + _, err := DB.Exec(sqlStatement, username, password, playeruuid, "", "", "") if err != nil { return err } return nil } +// todo: deduplicate these functions func getAuthToken(username string, password string) (string, error) { sqlStatement := ` SELECT auth_token FROM users WHERE username = ? AND password = ?; @@ -84,6 +86,37 @@ func getAuthToken(username string, password string) (string, error) { return authToken, nil } +func getWebToken(username string, password string) (string, error) { + sqlStatement := ` + SELECT web_token FROM users WHERE username = ? AND password = ?; + ` + row := DB.QueryRow(sqlStatement, username, password) + + // get web token + var webToken string + err := row.Scan(&webToken) + if err != nil { + if err != sql.ErrNoRows { + return "", err + } else { + return "", &NotFoundError{} + } + } + if webToken == "" { + // generate new webToken + webToken = uuid.New().String() + // update authToken + sqlStatement := ` + UPDATE users SET web_token = ? WHERE username = ? AND password = ?; + ` + _, err := DB.Exec(sqlStatement, webToken, username, password) + if err != nil { + return "", err + } + } + return webToken, nil +} + // func checkClientToken(clientToken string, userName string) (string, error) { // // assumes user is already logged in // sqlStatement := ` @@ -147,6 +180,17 @@ func checkClientToken(clientToken string, userName string) (string, error) { // return nil // } +func resetTokens(username string, password string) error { + sqlStatement := ` + UPDATE users SET web_token = "", auth_token = "" WHERE username = ? AND password = ?; + ` + _, err := DB.Exec(sqlStatement, username, password) + if err != nil { + return err + } + return nil +} + func createUser(username string, adminToken string) (string, error) { // check if adminToken is valid if validateAdminToken(adminToken) { diff --git a/main.go b/main.go index 10358f8..826d584 100644 --- a/main.go +++ b/main.go @@ -13,14 +13,41 @@ import ( // sendError(w, err) // } +func logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if config.DebugMode { + log.Printf("Got hit at %s", r.URL.Path) + } + next.ServeHTTP(w, r) + }) +} + +func notfoundlogger(w http.ResponseWriter, r *http.Request) { + if config.DebugMode { + log.Printf("Got hit at %s (NOT FOUND)", r.URL.Path) + } + sendError(w, YggError{ + Code: 404, + Error: "Not Found", + ErrorMessage: "The server has not found anything matching the request URI", + }) +} + func handleRequests() { r := mux.NewRouter().StrictSlash(true) + r.Use(logger) + r.NotFoundHandler = http.HandlerFunc(notfoundlogger) + err := loadConfig() if err != nil { log.Fatalln("Failed to load config.yaml:", err) } + + // todo: make this cleaner if possible registerAuthEndpoints(r) registerSessionEndpoints(r) + registerWebEndpoints(r) + log.Println("Tripwire started.") log.Fatal(http.ListenAndServe(":10000", r)) } @@ -28,6 +55,7 @@ func handleRequests() { func main() { log.Println("Tripwire initializing...") os.Mkdir("skins", 0755) + os.Mkdir("capes", 0755) initDB() handleRequests() diff --git a/player.go b/player.go index deae929..f02bc89 100644 --- a/player.go +++ b/player.go @@ -1,20 +1,22 @@ package main // todo: make a consistent "player" type and have these functions return it +// todo: dont manually patch column name into various internal funcs (would make the extra funcs unnecessary) import ( "database/sql" "errors" "io/fs" "os" + "log" ) -func playerExistsByUsername(username string) (bool, error) { +func _playerExistsBy(query string, value any) (bool, error) { sqlStatement := ` - SELECT username FROM users WHERE username = ?; + SELECT username FROM users WHERE ` + query + ` = ?; ` var x string - err := DB.QueryRow(sqlStatement, username).Scan(&x) + err := DB.QueryRow(sqlStatement, value).Scan(&x) if err != nil { if err == sql.ErrNoRows { return false, nil @@ -23,52 +25,55 @@ func playerExistsByUsername(username string) (bool, error) { } return true, nil } - func playerExistsByUUID(uuid string) (bool, error) { + return _playerExistsBy("uuid", uuid) +} +func playerExistsByUsername(username string) (bool, error) { + return _playerExistsBy("username", username) +} + +func _getPlayerBy(query string, value any) (PlayerData, error) { + // todo: make this function less repetitive if possible + sqlStatement := ` - SELECT uuid FROM users WHERE uuid = ?; + SELECT id, username, password, uuid, client_token, auth_token, web_token, created_at FROM users WHERE ` + query + ` = ?; ` - var x string - err := DB.QueryRow(sqlStatement, uuid).Scan(&x) + var player PlayerData + err := DB.QueryRow(sqlStatement, value).Scan( + &player.Id, + &player.Username, + &player.Password, + &player.UUID, + &player.ClientToken, + &player.AuthToken, + &player.WebToken, + &player.CreatedAt, + ) if err != nil { if err == sql.ErrNoRows { - return false, nil + return player, nil } - return false, err + return player, err } - return true, nil + return player, nil +} +func getPlayerByUUID(uuid string) (PlayerData, error) { + return _getPlayerBy("uuid", uuid) +} +func getPlayerByUsername(username string) (PlayerData, error) { + return _getPlayerBy("username", username) +} +func getPlayerByAuthToken(auth string) (PlayerData, error) { + log.Println(auth) + return _getPlayerBy("auth_token", auth) } -func getPlayerUUID(username string) (string, error) { - sqlStatement := ` - SELECT uuid FROM users WHERE username = ?; - ` - row := DB.QueryRow(sqlStatement, username) - - var uuid string - err := row.Scan(&uuid) - if err != nil { - return "", err +// todo: dedupe skin and cape functions if possible +func getPlayerTexture(textureId string, uuid string) ([]byte, error) { + if !(textureId == "skin" || textureId == "cape") { + return []byte{}, &InvalidDataError{} } - return uuid, nil -} - -func getPlayerUsername(uuid string) (string, error) { - sqlStatement := ` - SELECT username FROM users WHERE uuid = ?; - ` - row := DB.QueryRow(sqlStatement, uuid) - - var username string - err := row.Scan(&username) - if err != nil { - return "", err - } - return username, nil -} - -func getPlayerSkinByUUID(uuid string) ([]byte, error) { - skin, err := os.ReadFile("skins/" + uuid + ".png") + tex, err := os.ReadFile(textureId + "s/" + uuid + ".png") if err != nil { if errors.Is(err, fs.ErrNotExist) { return []byte{}, &NotFoundError{} @@ -76,13 +81,17 @@ func getPlayerSkinByUUID(uuid string) ([]byte, error) { return []byte{}, err } - return skin, nil + return tex, nil } -func setPlayerSkinByUUID(uuid string, skin []byte) error { - if len(skin) > config.MaxSkinSize { +func setPlayerTexture(textureId string, uuid string, tex []byte) error { + if !(textureId == "skin" || textureId == "cape") { + return &InvalidDataError{} + } + if len(tex) > config.MaxTextureSize { return &TooLargeError{} } + exists, err := playerExistsByUUID(uuid) if err != nil { return err @@ -91,7 +100,7 @@ func setPlayerSkinByUUID(uuid string, skin []byte) error { return &NotFoundError{} } - err = os.WriteFile("skins/"+uuid+".png", skin, 0644) + err = os.WriteFile(textureId+"s/"+uuid+".png", tex, 0644) if err != nil { return err } @@ -99,25 +108,26 @@ func setPlayerSkinByUUID(uuid string, skin []byte) error { return nil } -func findPlayerUUIDByAuthToken(token string) (string, error) { - sqlStatement := ` - SELECT uuid FROM users WHERE auth_token = ?; - ` - row := DB.QueryRow(sqlStatement, token) - - var uuid string - err := row.Scan(&uuid) - if err != nil { - return "", err +func playerHasTexture(textureId string, uuid string) (bool, error) { + if !(textureId == "skin" || textureId == "cape") { + return false, &InvalidDataError{} } - return uuid, nil + + _, err := os.Stat(textureId + "s/" + uuid + ".png") + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, err + } + return true, nil } -func checkPlayerPassByUUID(uuid string, password string) (bool, error) { +func _checkPlayerPassBy(query string, value any, password string) (bool, error) { sqlStatement := ` - SELECT password FROM users WHERE auth_token = ?; + SELECT password FROM users WHERE ` + query + ` = ?; ` - row := DB.QueryRow(sqlStatement, token) + row := DB.QueryRow(sqlStatement, value) var pass string err := row.Scan(&pass) @@ -129,3 +139,9 @@ func checkPlayerPassByUUID(uuid string, password string) (bool, error) { } return true, nil } +func checkPlayerPassByUUID(uuid string, password string) (bool, error) { + return _checkPlayerPassBy("uuid", uuid, password) +} +func checkPlayerPassByUsername(username string, password string) (bool, error) { + return _checkPlayerPassBy("username", username, password) +} diff --git a/session.go b/session.go index 6a4f510..725c1f3 100644 --- a/session.go +++ b/session.go @@ -4,7 +4,9 @@ import ( "encoding/base64" "encoding/json" "net/http" + "os" "strings" + "log" "github.com/gorilla/mux" ) @@ -26,13 +28,13 @@ func profileEndpoint(w http.ResponseWriter, r *http.Request) { return } - username, err := getPlayerUsername(uuid) + player, err := getPlayerByUUID(uuid) if err != nil { handleError(w, err) return } - response, err := generateProfileResponse(uuid, username) + response, err := generateProfileResponse(uuid, player.Username) if err != nil { handleError(w, err) return @@ -48,13 +50,13 @@ func hasJoinedEndpoint(w http.ResponseWriter, r *http.Request) { return } - uuid, err := getPlayerUUID(params.Get("username")) + player, err := getPlayerByUsername(params.Get("username")) if err != nil { handleError(w, err) return } - response, err := generateProfileResponse(params.Get("username"), uuid) + response, err := generateProfileResponse(params.Get("username"), player.UUID) if err != nil { handleError(w, err) return @@ -64,8 +66,10 @@ func hasJoinedEndpoint(w http.ResponseWriter, r *http.Request) { } func joinEndpoint(w http.ResponseWriter, r *http.Request) { - params := r.URL.Query() - if !params.Has("accessToken") || !params.Has("selectedProfile") || !params.Has("serverId") { + var payload JoinPayload + unmarshalTo(r, &payload) + + if payload.AccessToken == "" || payload.SelectedProfile == "" || payload.ServerId == "" { sendError(w, YggError{ Code: 400, Error: "IllegalArgumentException", @@ -73,31 +77,35 @@ func joinEndpoint(w http.ResponseWriter, r *http.Request) { }) return } - uuid, err := findPlayerUUIDByAuthToken(params.Get("accessToken")) + player, err := getPlayerByAuthToken(payload.AccessToken) if err != nil { handleError(w, err) return } - if params.Get("selectedProfile") != shrinkUUID(uuid) { + log.Println(payload.SelectedProfile, ",", player.UUID) + if payload.SelectedProfile != shrinkUUID(player.UUID) { sendError(w, YggError{ Code: 400, Error: "Bad Request", ErrorMessage: "The request data is malformed.", }) + return } sendEmpty(w) } func registerSessionEndpoints(r *mux.Router) { - r.HandleFunc("/session/minecraft/profile/{uuid}", profileEndpoint).Methods("GET") - r.HandleFunc("/session/minecraft/hasJoined", hasJoinedEndpoint).Methods("GET") + prefix := "/sessionserver" + r.HandleFunc(prefix+"/session/minecraft/profile/{uuid}", profileEndpoint).Methods("GET") + r.HandleFunc(prefix+"/session/minecraft/join", joinEndpoint).Methods("POST") + r.HandleFunc(prefix+"/session/minecraft/hasJoined", hasJoinedEndpoint).Methods("GET") } func generateProfileResponse(uuid string, username string) (ProfileResponse, error) { // todo: make this more visually appealing if possible clearUUID := strings.Join(strings.Split(uuid, "-"), "") skin := SkinTexture{} - skin.Url = config.BaseUrl + "/getSkin/" + uuid + skin.Url = config.BaseUrl + "/getTexture/" + uuid + "?type=skin" skin.Metadata = SkinMetadata{} skin.Metadata.Model = "default" @@ -107,6 +115,13 @@ func generateProfileResponse(uuid string, username string) (ProfileResponse, err textures.Textures = ProfileTextures{} textures.Textures.Skin = skin + _, err := os.Stat("capes/" + uuid + ".png") + if err == nil { + cape := &Texture{} + cape.Url = config.BaseUrl + "/getTexture/" + uuid + "?type=cape" + textures.Textures.Cape = cape + } + marshalledTextures, err := json.Marshal(textures) if err != nil { return ProfileResponse{}, err diff --git a/types.go b/types.go index b72f9ba..1fd99ad 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,7 @@ package main +import "time" + type YggError struct { Code int `json:"-"` Error string `json:"error"` @@ -31,6 +33,18 @@ type AuthResponse struct { SelectedProfile MCProfile `json:"selectedProfile"` } +type WebLogInResponse struct { + Token string `json:"token"` + UUID string `json:"uuid"` + Username string `json:"username"` +} + +type JoinPayload struct { + AccessToken string `json:"accessToken"` + SelectedProfile string `json:"selectedProfile"` + ServerId string `json:"serverId"` +} + type RefreshPayload struct { AccessToken string `json:"accessToken"` ClientToken string `json:"clientToken"` @@ -41,6 +55,17 @@ type UserCredentials struct { Password string `json:"password"` } +type PlayerData struct { + Id int `json:"id"` + Username string `json:"username"` + Password string `json:"password"` + UUID string `json:"uuid"` + ClientToken string `json:"clientToken"` + AuthToken string `json:"authToken"` + WebToken string `json:"webToken"` + CreatedAt time.Time `json:"createdAt"` +} + type YggdrasilInfo struct { Status string `json:"Status"` RuntimeMode string `json:"Runtime-Mode"` @@ -75,12 +100,17 @@ type SkinTexture struct { Metadata SkinMetadata `json:"metadata"` } +type Texture struct { + Url string `json:"url"` +} + type SkinMetadata struct { Model string `json:"model"` } type ProfileTextures struct { Skin SkinTexture `json:"SKIN"` + Cape *Texture `json:"CAPE"` } type Empty struct{} @@ -108,3 +138,9 @@ type TooLargeError struct{} func (m *TooLargeError) Error() string { return "The sent payload is too large." } + +type InvalidDataError struct{} + +func (m *InvalidDataError) Error() string { + return "The request data is malformed." +} diff --git a/util.go b/util.go index 771cb6b..ce8f8ff 100644 --- a/util.go +++ b/util.go @@ -4,6 +4,7 @@ import ( "encoding/json" "io/ioutil" "log" + "mime/multipart" "net/http" "runtime/debug" ) @@ -14,6 +15,7 @@ func sendJSON(w http.ResponseWriter, jsonMessage interface{}) { } func sendError(w http.ResponseWriter, err YggError) { + log.Printf("Send error: %s\n", err.ErrorMessage) w.WriteHeader(err.Code) sendJSON(w, err) } @@ -41,3 +43,25 @@ func handleError(w http.ResponseWriter, err error) { func sendEmpty(w http.ResponseWriter) { w.WriteHeader(http.StatusNoContent) } + +func recievePNG(filename string, w http.ResponseWriter, r *http.Request) ([]byte, *multipart.FileHeader, error) { + file, header, err := r.FormFile(filename) + if err != nil { + return []byte{}, header, err + } + + bytes, err := ioutil.ReadAll(file) + if err != nil { + return bytes, header, err + } + + if len(bytes) < 4 || + bytes[0] != 0x89 || + bytes[1] != 0x50 || + bytes[2] != 0x4e || + bytes[3] != 0x47 { + return bytes, header, &InvalidDataError{} + } + + return bytes, header, nil +} diff --git a/web.go b/web.go new file mode 100644 index 0000000..df0d53e --- /dev/null +++ b/web.go @@ -0,0 +1,201 @@ +package main + +import ( + "bytes" + "errors" + "image" + _ "image/png" + "net/http" + "os" + "strings" + + "github.com/gorilla/mux" +) + +func logInEndpoint(w http.ResponseWriter, r *http.Request) { + var details UserCredentials + err := unmarshalTo(r, &details) + if err != nil { + handleError(w, err) + return + } + + invalidCreds := YggError{ + Code: 401, + Error: "Unauthorized", + ErrorMessage: "Invalid credentials.", + } + exists, err := playerExistsByUsername(details.Username) + if err != nil { + handleError(w, err) + return + } + if !exists { + sendError(w, invalidCreds) + return + } + + correct, err := checkPlayerPassByUsername(details.Username, details.Password) + if err != nil { + handleError(w, err) + return + } + + if correct { + response := WebLogInResponse{} + webToken, err := getWebToken(details.Username, details.Password) + if err != nil { + handleError(w, err) + return + } + + player, err := getPlayerByUsername(details.Username) + if err != nil { + handleError(w, err) + return + } + + response.Token = webToken + response.UUID = player.UUID + response.Username = player.Username + + sendJSON(w, response) + } else { + sendError(w, YggError{ + Code: 401, + Error: "Unauthorized", + ErrorMessage: "Invalid credentials.", + }) + } +} + +func getResourceEndpoint(w http.ResponseWriter, r *http.Request) { + uuid := mux.Vars(r)["uuid"] + query := r.URL.Query() + if !query.Has("type") { + sendError(w, YggError{ + Code: 400, + Error: "Bad Request", + ErrorMessage: "Must specify texture type. Add ?type=skin or ?type=cape to the URL.", + }) + return + } + textureId := query.Get("type") + if !(textureId == "skin" || textureId == "cape") { + sendError(w, YggError{ + Code: 400, + Error: "Bad Request", + ErrorMessage: "Invalid texture type.", + }) + return + } + + skin, err := getPlayerTexture(textureId, uuid) + if err != nil { + if !errors.Is(err, &NotFoundError{}) { + handleError(w, err) + return + } + + skin, err = os.ReadFile("default.png") + if err != nil { + handleError(w, err) + return + } + } + w.Header().Set("Content-Type", "image/png") + w.Write(skin) +} + +func setResourceEndpoint(w http.ResponseWriter, r *http.Request) { + uuid := mux.Vars(r)["uuid"] + query := r.URL.Query() + if !query.Has("type") { + sendError(w, YggError{Code: 400, Error: "Bad Request", ErrorMessage: "Must specify texture type. Add ?type=skin or ?type=cape to the URL."}) + return + } + textureId := query.Get("type") + if !(textureId == "skin" || textureId == "cape") { + sendError(w, YggError{ + Code: 400, + Error: "Bad Request", + ErrorMessage: "Invalid texture type.", + }) + return + } + + r.ParseMultipartForm(int64(config.MaxTextureSize)) + + token := r.FormValue("token") + + player, err := getPlayerByUUID(uuid) + if err != nil { + handleError(w, err) + return + } + + if token != player.WebToken { + sendError(w, YggError{Code: 400, Error: "Bad Request", ErrorMessage: "Invalid credentials."}) + return + } + + file, _, err := recievePNG("file", w, r) + if err != nil { + handleError(w, err) + return + } + + reader := bytes.NewReader(file) + img, _, err := image.Decode(reader) + if err != nil { + sendError(w, YggError{ + Code: 400, + Error: "Bad Request", + ErrorMessage: "The request data is malformed.", + }) + return + } + + bounds := img.Bounds() + targetX := 64 + targetY := 64 + if textureId == "cape" { + targetY = 32 + } + + if bounds.Dx() != targetX || bounds.Dy() != targetY { + sendError(w, YggError{ + Code: 400, + Error: "Bad Request", + ErrorMessage: "The request data is malformed.", + }) + return + } + + err = setPlayerTexture(textureId, uuid, file) + if err != nil { + handleError(w, err) + return + } + + sendEmpty(w) +} + +func rootRedirect(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.Header.Get("User-Agent"), "Java") { + rootEndpoint(w, r) + return + } + http.Redirect(w, r, "/web", http.StatusMovedPermanently) +} + +func registerWebEndpoints(r *mux.Router) { + webDir := "/web" + r.HandleFunc("/", rootRedirect) + r.HandleFunc("/webapi/logIn", logInEndpoint).Methods("POST") + r.PathPrefix(webDir). + Handler(http.StripPrefix(webDir, http.FileServer(http.Dir("."+webDir)))) + + r.HandleFunc("/getTexture/{uuid}", getResourceEndpoint).Methods("GET") + r.HandleFunc("/setTexture/{uuid}", setResourceEndpoint).Methods("POST") +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..3e8e565 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + Tripwire Web Panel + + + + + + + + + + \ No newline at end of file diff --git a/web/main.css b/web/main.css new file mode 100644 index 0000000..d151edc --- /dev/null +++ b/web/main.css @@ -0,0 +1,90 @@ +:root { + background-color: #222; + color: #CCC; + font-family: Arial, sans-serif; + + --border-radius: 6px; + --header-height: 50px; + --accent-color: #5f00aa; + --accent-color-secondary: #4f0099; + --error-color: #cc4444; +} +body { + margin: 0; +} + +header { + position: absolute; + width: 100%; + height: var(--header-height); + line-height: var(--header-height); + background-color: #141414; + align-items: center; + vertical-align: middle; + padding: 0px 15px; + box-sizing: border-box; +} +#header-spacer { + padding-bottom: var(--header-height); +} + +#header-title { + font-size: 1.3em; +} + +a { + color: inherit; + text-decoration: inherit; +} + +.align-right { + float: right; +} + +button, a.button { + background-color: var(--accent-color); + transition: background-color 0.3s; + color: inherit; + border: 0; + border-radius: var(--border-radius); + padding: 8px 10px; + cursor: pointer; + color: white; +} +button:hover, a.button:hover { + background-color: var(--accent-color-secondary); +} + +input { + display: block; + background-color: #141414; + color: inherit; + border: 0; + border-radius: var(--border-radius); + padding: 8px 10px; + color: white; + margin-bottom: 5px; +} + +.center { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%,-50%); + text-align: center; +} + +.errorText { + color: var(--error-color); + display: block; +} + +.errorInput { + outline: 1px solid var(--error-color); +} + +#playerHead { + vertical-align: middle; + margin-top: -4px; + margin-right: 12px; +} \ No newline at end of file diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..71bf324 --- /dev/null +++ b/web/main.js @@ -0,0 +1,200 @@ +import htm from 'https://unpkg.com/htm?module' +const html = htm.bind(m); +var root = document.body + +var currentUser = null; +try { + currentUser = JSON.parse(Cookies.get("currentUser")) +} catch {} +var loggingIn = false; +var logInError = "" + +var PlayerHead = { + oncreate: function (vnode) { + const ctx = vnode.dom.getContext('2d'); + ctx.imageSmoothingEnabled = false + + let img = new Image(); + img.src = `/getTexture/${currentUser.uuid}?type=skin` + + img.onload = function(){ + ctx.drawImage(img, 8, 8, 8, 8, 0, 0, 32, 32); + } + }, + view: function(vnode) { + return html ` + + ` + } +} + +var Header = { + view: function() { + return html` +
+ Tripwire + +
+
+ ` + } +} + +var ErrorText = { + view: function(vnode) { + return html` + ${vnode.attrs.error.errorMessage} + ` + } +} + +m.route(root, "/", { + "/": { + view: function() { + return html` + <${Header} /> + ${currentUser ? + html` +
+ Change skin +
+ ` : + html`Please log in to continue.` + } + ` + } + }, + "/skinchanger": function SkinChanger() { + var pageError = null; + var success = false; + return { + view: function() { + if (!currentUser) { + m.route.set("/login") + } + return html` + <${Header} /> + ${currentUser ? + success ? + html ` +
+ Texture successfully uploaded!

+ Go back +
+ ` : + html` +
+ Select Skin/Cape + (only .PNG supported)

+ + + + + { + pageError = null + }} + /> + ${pageError && html`<${ErrorText} error=${pageError}/>`} + +
+ ` : + html`Please log in to continue.` + } + ` + } + } + }, + "/login": { + view: function() { + if (currentUser) { + m.route.set("/") + } + let oninput = () => { + logInError = null + } + return html` + <${Header} /> +
+ + + ${logInError && html` + <${ErrorText} error=${logInError} /> + `} + +
+ ` + } + } +}) \ No newline at end of file