forked from TripwireTeam/tripwire
pushing unfinished work for the night
This commit is contained in:
parent
27f4047cf7
commit
7e089795c7
14 changed files with 760 additions and 156 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@ db.sqlite
|
|||
secrets.go
|
||||
tripwire
|
||||
skins/
|
||||
capes/
|
||||
|
|
91
auth.go
91
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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
# The maximum size (in bytes) of a skin/cape that can be uploaded.
|
||||
maxTextureSize: 8096
|
48
db.go
48
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) {
|
||||
|
|
28
main.go
28
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()
|
||||
|
|
130
player.go
130
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)
|
||||
}
|
||||
|
|
37
session.go
37
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
|
||||
|
|
36
types.go
36
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."
|
||||
}
|
||||
|
|
24
util.go
24
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
|
||||
}
|
||||
|
|
201
web.go
Normal file
201
web.go
Normal file
|
@ -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")
|
||||
}
|
15
web/index.html
Normal file
15
web/index.html
Normal file
|
@ -0,0 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<title>Tripwire Web Panel</title>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1'>
|
||||
<link rel='stylesheet' type='text/css' media='screen' href='./web/main.css'>
|
||||
<script src="https://unpkg.com/mithril/mithril.js"></script>
|
||||
<script src="https://unpkg.com/js-cookie"></script>
|
||||
<script src='./web/main.js' type="module"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>JavaScript must be enabled to use this application.</noscript>
|
||||
</body>
|
||||
</html>
|
90
web/main.css
Normal file
90
web/main.css
Normal file
|
@ -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;
|
||||
}
|
200
web/main.js
Normal file
200
web/main.js
Normal file
|
@ -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 `
|
||||
<canvas id="playerHead" width="32" height="32" />
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
var Header = {
|
||||
view: function() {
|
||||
return html`
|
||||
<header>
|
||||
<a href="#!/" id="header-title">Tripwire</span>
|
||||
<div class="align-right">
|
||||
${currentUser ?
|
||||
html`
|
||||
<${PlayerHead} />
|
||||
<span>Hello, ${currentUser.username}</span>
|
||||
` :
|
||||
html`<a class="button" href="#!/login">Log In</a>`
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
<div id="header-spacer"></div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
var ErrorText = {
|
||||
view: function(vnode) {
|
||||
return html`
|
||||
<span class="errorText">${vnode.attrs.error.errorMessage}</span>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
m.route(root, "/", {
|
||||
"/": {
|
||||
view: function() {
|
||||
return html`
|
||||
<${Header} />
|
||||
${currentUser ?
|
||||
html`
|
||||
<div class="center">
|
||||
<a class="button" href="#!/skinchanger">Change skin</a>
|
||||
</div>
|
||||
` :
|
||||
html`<span>Please log in to continue.</span>`
|
||||
}
|
||||
`
|
||||
}
|
||||
},
|
||||
"/skinchanger": function SkinChanger() {
|
||||
var pageError = null;
|
||||
var success = false;
|
||||
return {
|
||||
view: function() {
|
||||
if (!currentUser) {
|
||||
m.route.set("/login")
|
||||
}
|
||||
return html`
|
||||
<${Header} />
|
||||
${currentUser ?
|
||||
success ?
|
||||
html `
|
||||
<div class="center">
|
||||
<span style="font-size: 1.5em;">Texture successfully uploaded!</span><br/><br/>
|
||||
<a class="button" href="#!/">Go back</a>
|
||||
</div>
|
||||
` :
|
||||
html`
|
||||
<div class="center">
|
||||
<span style="font-size: 1.5em;">Select Skin/Cape </span>
|
||||
<span>(only .PNG supported)</span><br/><br/>
|
||||
<input style="display: inline;"
|
||||
type="radio"
|
||||
id="skinRadio"
|
||||
name="textureId"
|
||||
value="skin"
|
||||
checked
|
||||
/>
|
||||
<label for="skinRadio">Skin</label>
|
||||
<input style="display: inline;"
|
||||
type="radio"
|
||||
id="capeRadio"
|
||||
name="textureId"
|
||||
value="cape"
|
||||
/>
|
||||
<label for="capeRadio">Cape</label>
|
||||
<input id="textureFile"
|
||||
style="width: 100%"
|
||||
type="file"
|
||||
accept="image/png"
|
||||
onchange=${() => {
|
||||
pageError = null
|
||||
}}
|
||||
/>
|
||||
${pageError && html`<${ErrorText} error=${pageError}/>`}
|
||||
<button onclick=${() => {
|
||||
let input = document.getElementById("textureFile")
|
||||
if (input.files.length == 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let file = input.files[0]
|
||||
|
||||
let body = new FormData()
|
||||
body.append("file", file)
|
||||
body.append("token", currentUser.token)
|
||||
m.request({
|
||||
method: "POST",
|
||||
url: "/setTexture/"+currentUser.uuid+"?type=" +
|
||||
(document.getElementById("skinRadio").checked ? "skin" : "cape"),
|
||||
body: body,
|
||||
}).then((res) => {
|
||||
success = true
|
||||
}, (res) => {
|
||||
pageError = res.response
|
||||
})
|
||||
}}>Let it rip</button>
|
||||
</div>
|
||||
` :
|
||||
html`<span>Please log in to continue.</span>`
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
},
|
||||
"/login": {
|
||||
view: function() {
|
||||
if (currentUser) {
|
||||
m.route.set("/")
|
||||
}
|
||||
let oninput = () => {
|
||||
logInError = null
|
||||
}
|
||||
return html`
|
||||
<${Header} />
|
||||
<div class="center">
|
||||
<input id="usernameLogin"
|
||||
placeholder="Username"
|
||||
type="text"
|
||||
class=${logInError && "errorInput"}
|
||||
oninput=${oninput}
|
||||
/>
|
||||
<input id="passwordLogin"
|
||||
placeholder="Password"
|
||||
type="text"
|
||||
class=${logInError && "errorInput"}
|
||||
oninput=${oninput}
|
||||
/>
|
||||
${logInError && html`
|
||||
<${ErrorText} error=${logInError} />
|
||||
`}
|
||||
<button disabled=${loggingIn} onclick=${function() {
|
||||
loggingIn = true;
|
||||
m.request({
|
||||
method: "POST",
|
||||
url: "/webapi/logIn",
|
||||
body: {
|
||||
username: document.getElementById("usernameLogin").value,
|
||||
password: document.getElementById("passwordLogin").value,
|
||||
}
|
||||
})
|
||||
.then(function(result) {
|
||||
currentUser = result
|
||||
Cookies.set("currentUser", JSON.stringify(result))
|
||||
loggingIn = false
|
||||
}, function(result) {
|
||||
logInError = result.response
|
||||
loggingIn = false
|
||||
})
|
||||
}}>Log In</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
})
|
Loading…
Reference in a new issue