pushing unfinished work for the night

This commit is contained in:
TaiAurori 2022-06-24 23:25:49 -04:00
parent 27f4047cf7
commit 7e089795c7
14 changed files with 760 additions and 156 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ db.sqlite
secrets.go secrets.go
tripwire tripwire
skins/ skins/
capes/

91
auth.go
View file

@ -1,10 +1,7 @@
package main package main
import ( import (
"errors"
"io/ioutil"
"net/http" "net/http"
"os"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -43,12 +40,12 @@ func authenticateEndpoint(w http.ResponseWriter, r *http.Request) {
handleError(w, err) handleError(w, err)
return return
} }
playeruuid, err := getPlayerUUID(authPayload.Username) player, err := getPlayerByUsername(authPayload.Username)
if err != nil { if err != nil {
handleError(w, err) handleError(w, err)
return return
} }
profile := MCProfile{authPayload.Username, playeruuid} profile := MCProfile{authPayload.Username, player.UUID}
authResponse := AuthResponse{ authResponse := AuthResponse{
ClientToken: clientToken, ClientToken: clientToken,
AccessToken: authToken, AccessToken: authToken,
@ -155,81 +152,13 @@ func invalidateEndpoint(w http.ResponseWriter, r *http.Request) {
sendEmpty(w) 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) { func registerAuthEndpoints(r *mux.Router) {
r.HandleFunc("/", rootEndpoint) prefix := "/authserver"
r.HandleFunc("/authenticate", authenticateEndpoint).Methods("POST") r.HandleFunc(prefix+"/", rootEndpoint)
r.HandleFunc("/refresh", refreshTokenEndpoint).Methods("POST") r.HandleFunc(prefix+"/authenticate", authenticateEndpoint).Methods("POST")
r.HandleFunc("/signout", signoutEndpoint).Methods("POST") r.HandleFunc(prefix+"/refresh", refreshTokenEndpoint).Methods("POST")
r.HandleFunc("/invalidate", invalidateEndpoint).Methods("POST") r.HandleFunc(prefix+"/signout", signoutEndpoint).Methods("POST")
r.HandleFunc("/validate", validateEndpoint).Methods("POST") r.HandleFunc(prefix+"/invalidate", invalidateEndpoint).Methods("POST")
r.HandleFunc("/admin/addUser", addUserEndpoint).Methods("POST") r.HandleFunc(prefix+"/validate", validateEndpoint).Methods("POST")
r.HandleFunc("/getSkin/{uuid}", getSkinEndpoint).Methods("GET") r.HandleFunc(prefix+"/admin/addUser", addUserEndpoint).Methods("POST")
r.HandleFunc("/setSkin/{uuid}", setSkinEndpoint).Methods("POST")
} }

View file

@ -9,7 +9,7 @@ import (
type Configuration struct { type Configuration struct {
BaseUrl string `yaml:"baseUrl"` BaseUrl string `yaml:"baseUrl"`
DebugMode bool `yaml:"debugMode"` DebugMode bool `yaml:"debugMode"`
MaxSkinSize int `yaml:"maxSkinSize"` MaxTextureSize int `yaml:"maxTextureSize"`
} }
var config Configuration var config Configuration

View file

@ -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 debugMode: true
maxSkinSize: 8096
# The maximum size (in bytes) of a skin/cape that can be uploaded.
maxTextureSize: 8096

48
db.go
View file

@ -31,6 +31,7 @@ func createDatabase() error {
uuid VARCHAR(255) NOT NULL, uuid VARCHAR(255) NOT NULL,
client_token VARCHAR(255) NOT NULL, client_token VARCHAR(255) NOT NULL,
auth_token VARCHAR(255) NOT NULL, auth_token VARCHAR(255) NOT NULL,
web_token VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
); );
` `
@ -44,15 +45,16 @@ func createDatabase() error {
func insertUser(username string, password string) error { func insertUser(username string, password string) error {
playeruuid := uuid.New().String() playeruuid := uuid.New().String()
sqlStatement := ` 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 { if err != nil {
return err return err
} }
return nil return nil
} }
// todo: deduplicate these functions
func getAuthToken(username string, password string) (string, error) { func getAuthToken(username string, password string) (string, error) {
sqlStatement := ` sqlStatement := `
SELECT auth_token FROM users WHERE username = ? AND password = ?; SELECT auth_token FROM users WHERE username = ? AND password = ?;
@ -84,6 +86,37 @@ func getAuthToken(username string, password string) (string, error) {
return authToken, nil 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) { // func checkClientToken(clientToken string, userName string) (string, error) {
// // assumes user is already logged in // // assumes user is already logged in
// sqlStatement := ` // sqlStatement := `
@ -147,6 +180,17 @@ func checkClientToken(clientToken string, userName string) (string, error) {
// return nil // 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) { func createUser(username string, adminToken string) (string, error) {
// check if adminToken is valid // check if adminToken is valid
if validateAdminToken(adminToken) { if validateAdminToken(adminToken) {

28
main.go
View file

@ -13,14 +13,41 @@ import (
// sendError(w, err) // 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() { func handleRequests() {
r := mux.NewRouter().StrictSlash(true) r := mux.NewRouter().StrictSlash(true)
r.Use(logger)
r.NotFoundHandler = http.HandlerFunc(notfoundlogger)
err := loadConfig() err := loadConfig()
if err != nil { if err != nil {
log.Fatalln("Failed to load config.yaml:", err) log.Fatalln("Failed to load config.yaml:", err)
} }
// todo: make this cleaner if possible
registerAuthEndpoints(r) registerAuthEndpoints(r)
registerSessionEndpoints(r) registerSessionEndpoints(r)
registerWebEndpoints(r)
log.Println("Tripwire started.") log.Println("Tripwire started.")
log.Fatal(http.ListenAndServe(":10000", r)) log.Fatal(http.ListenAndServe(":10000", r))
} }
@ -28,6 +55,7 @@ func handleRequests() {
func main() { func main() {
log.Println("Tripwire initializing...") log.Println("Tripwire initializing...")
os.Mkdir("skins", 0755) os.Mkdir("skins", 0755)
os.Mkdir("capes", 0755)
initDB() initDB()
handleRequests() handleRequests()

130
player.go
View file

@ -1,20 +1,22 @@
package main package main
// todo: make a consistent "player" type and have these functions return it // 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 ( import (
"database/sql" "database/sql"
"errors" "errors"
"io/fs" "io/fs"
"os" "os"
"log"
) )
func playerExistsByUsername(username string) (bool, error) { func _playerExistsBy(query string, value any) (bool, error) {
sqlStatement := ` sqlStatement := `
SELECT username FROM users WHERE username = ?; SELECT username FROM users WHERE ` + query + ` = ?;
` `
var x string var x string
err := DB.QueryRow(sqlStatement, username).Scan(&x) err := DB.QueryRow(sqlStatement, value).Scan(&x)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return false, nil return false, nil
@ -23,52 +25,55 @@ func playerExistsByUsername(username string) (bool, error) {
} }
return true, nil return true, nil
} }
func playerExistsByUUID(uuid string) (bool, error) { 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 := ` 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 var player PlayerData
err := DB.QueryRow(sqlStatement, uuid).Scan(&x) 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 != nil {
if err == sql.ErrNoRows { 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) { // todo: dedupe skin and cape functions if possible
sqlStatement := ` func getPlayerTexture(textureId string, uuid string) ([]byte, error) {
SELECT uuid FROM users WHERE username = ?; if !(textureId == "skin" || textureId == "cape") {
` return []byte{}, &InvalidDataError{}
row := DB.QueryRow(sqlStatement, username)
var uuid string
err := row.Scan(&uuid)
if err != nil {
return "", err
} }
return uuid, nil tex, err := os.ReadFile(textureId + "s/" + uuid + ".png")
}
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")
if err != nil { if err != nil {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return []byte{}, &NotFoundError{} return []byte{}, &NotFoundError{}
@ -76,13 +81,17 @@ func getPlayerSkinByUUID(uuid string) ([]byte, error) {
return []byte{}, err return []byte{}, err
} }
return skin, nil return tex, nil
} }
func setPlayerSkinByUUID(uuid string, skin []byte) error { func setPlayerTexture(textureId string, uuid string, tex []byte) error {
if len(skin) > config.MaxSkinSize { if !(textureId == "skin" || textureId == "cape") {
return &InvalidDataError{}
}
if len(tex) > config.MaxTextureSize {
return &TooLargeError{} return &TooLargeError{}
} }
exists, err := playerExistsByUUID(uuid) exists, err := playerExistsByUUID(uuid)
if err != nil { if err != nil {
return err return err
@ -91,7 +100,7 @@ func setPlayerSkinByUUID(uuid string, skin []byte) error {
return &NotFoundError{} return &NotFoundError{}
} }
err = os.WriteFile("skins/"+uuid+".png", skin, 0644) err = os.WriteFile(textureId+"s/"+uuid+".png", tex, 0644)
if err != nil { if err != nil {
return err return err
} }
@ -99,25 +108,26 @@ func setPlayerSkinByUUID(uuid string, skin []byte) error {
return nil return nil
} }
func findPlayerUUIDByAuthToken(token string) (string, error) { func playerHasTexture(textureId string, uuid string) (bool, error) {
sqlStatement := ` if !(textureId == "skin" || textureId == "cape") {
SELECT uuid FROM users WHERE auth_token = ?; return false, &InvalidDataError{}
`
row := DB.QueryRow(sqlStatement, token)
var uuid string
err := row.Scan(&uuid)
if err != nil {
return "", err
} }
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 := ` 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 var pass string
err := row.Scan(&pass) err := row.Scan(&pass)
@ -129,3 +139,9 @@ func checkPlayerPassByUUID(uuid string, password string) (bool, error) {
} }
return true, nil 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)
}

View file

@ -4,7 +4,9 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
"strings" "strings"
"log"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -26,13 +28,13 @@ func profileEndpoint(w http.ResponseWriter, r *http.Request) {
return return
} }
username, err := getPlayerUsername(uuid) player, err := getPlayerByUUID(uuid)
if err != nil { if err != nil {
handleError(w, err) handleError(w, err)
return return
} }
response, err := generateProfileResponse(uuid, username) response, err := generateProfileResponse(uuid, player.Username)
if err != nil { if err != nil {
handleError(w, err) handleError(w, err)
return return
@ -48,13 +50,13 @@ func hasJoinedEndpoint(w http.ResponseWriter, r *http.Request) {
return return
} }
uuid, err := getPlayerUUID(params.Get("username")) player, err := getPlayerByUsername(params.Get("username"))
if err != nil { if err != nil {
handleError(w, err) handleError(w, err)
return return
} }
response, err := generateProfileResponse(params.Get("username"), uuid) response, err := generateProfileResponse(params.Get("username"), player.UUID)
if err != nil { if err != nil {
handleError(w, err) handleError(w, err)
return return
@ -64,8 +66,10 @@ func hasJoinedEndpoint(w http.ResponseWriter, r *http.Request) {
} }
func joinEndpoint(w http.ResponseWriter, r *http.Request) { func joinEndpoint(w http.ResponseWriter, r *http.Request) {
params := r.URL.Query() var payload JoinPayload
if !params.Has("accessToken") || !params.Has("selectedProfile") || !params.Has("serverId") { unmarshalTo(r, &payload)
if payload.AccessToken == "" || payload.SelectedProfile == "" || payload.ServerId == "" {
sendError(w, YggError{ sendError(w, YggError{
Code: 400, Code: 400,
Error: "IllegalArgumentException", Error: "IllegalArgumentException",
@ -73,31 +77,35 @@ func joinEndpoint(w http.ResponseWriter, r *http.Request) {
}) })
return return
} }
uuid, err := findPlayerUUIDByAuthToken(params.Get("accessToken")) player, err := getPlayerByAuthToken(payload.AccessToken)
if err != nil { if err != nil {
handleError(w, err) handleError(w, err)
return return
} }
if params.Get("selectedProfile") != shrinkUUID(uuid) { log.Println(payload.SelectedProfile, ",", player.UUID)
if payload.SelectedProfile != shrinkUUID(player.UUID) {
sendError(w, YggError{ sendError(w, YggError{
Code: 400, Code: 400,
Error: "Bad Request", Error: "Bad Request",
ErrorMessage: "The request data is malformed.", ErrorMessage: "The request data is malformed.",
}) })
return
} }
sendEmpty(w) sendEmpty(w)
} }
func registerSessionEndpoints(r *mux.Router) { func registerSessionEndpoints(r *mux.Router) {
r.HandleFunc("/session/minecraft/profile/{uuid}", profileEndpoint).Methods("GET") prefix := "/sessionserver"
r.HandleFunc("/session/minecraft/hasJoined", hasJoinedEndpoint).Methods("GET") 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) { func generateProfileResponse(uuid string, username string) (ProfileResponse, error) {
// todo: make this more visually appealing if possible // todo: make this more visually appealing if possible
clearUUID := strings.Join(strings.Split(uuid, "-"), "") clearUUID := strings.Join(strings.Split(uuid, "-"), "")
skin := SkinTexture{} skin := SkinTexture{}
skin.Url = config.BaseUrl + "/getSkin/" + uuid skin.Url = config.BaseUrl + "/getTexture/" + uuid + "?type=skin"
skin.Metadata = SkinMetadata{} skin.Metadata = SkinMetadata{}
skin.Metadata.Model = "default" skin.Metadata.Model = "default"
@ -107,6 +115,13 @@ func generateProfileResponse(uuid string, username string) (ProfileResponse, err
textures.Textures = ProfileTextures{} textures.Textures = ProfileTextures{}
textures.Textures.Skin = skin 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) marshalledTextures, err := json.Marshal(textures)
if err != nil { if err != nil {
return ProfileResponse{}, err return ProfileResponse{}, err

View file

@ -1,5 +1,7 @@
package main package main
import "time"
type YggError struct { type YggError struct {
Code int `json:"-"` Code int `json:"-"`
Error string `json:"error"` Error string `json:"error"`
@ -31,6 +33,18 @@ type AuthResponse struct {
SelectedProfile MCProfile `json:"selectedProfile"` 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 { type RefreshPayload struct {
AccessToken string `json:"accessToken"` AccessToken string `json:"accessToken"`
ClientToken string `json:"clientToken"` ClientToken string `json:"clientToken"`
@ -41,6 +55,17 @@ type UserCredentials struct {
Password string `json:"password"` 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 { type YggdrasilInfo struct {
Status string `json:"Status"` Status string `json:"Status"`
RuntimeMode string `json:"Runtime-Mode"` RuntimeMode string `json:"Runtime-Mode"`
@ -75,12 +100,17 @@ type SkinTexture struct {
Metadata SkinMetadata `json:"metadata"` Metadata SkinMetadata `json:"metadata"`
} }
type Texture struct {
Url string `json:"url"`
}
type SkinMetadata struct { type SkinMetadata struct {
Model string `json:"model"` Model string `json:"model"`
} }
type ProfileTextures struct { type ProfileTextures struct {
Skin SkinTexture `json:"SKIN"` Skin SkinTexture `json:"SKIN"`
Cape *Texture `json:"CAPE"`
} }
type Empty struct{} type Empty struct{}
@ -108,3 +138,9 @@ type TooLargeError struct{}
func (m *TooLargeError) Error() string { func (m *TooLargeError) Error() string {
return "The sent payload is too large." return "The sent payload is too large."
} }
type InvalidDataError struct{}
func (m *InvalidDataError) Error() string {
return "The request data is malformed."
}

24
util.go
View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"io/ioutil" "io/ioutil"
"log" "log"
"mime/multipart"
"net/http" "net/http"
"runtime/debug" "runtime/debug"
) )
@ -14,6 +15,7 @@ func sendJSON(w http.ResponseWriter, jsonMessage interface{}) {
} }
func sendError(w http.ResponseWriter, err YggError) { func sendError(w http.ResponseWriter, err YggError) {
log.Printf("Send error: %s\n", err.ErrorMessage)
w.WriteHeader(err.Code) w.WriteHeader(err.Code)
sendJSON(w, err) sendJSON(w, err)
} }
@ -41,3 +43,25 @@ func handleError(w http.ResponseWriter, err error) {
func sendEmpty(w http.ResponseWriter) { func sendEmpty(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent) 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
View 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
View 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
View 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
View 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>
`
}
}
})