package api import ( "crypto/x509" "encoding/json" "encoding/pem" "errors" "io" "log" "net/http" "slices" "strconv" "strings" authdiscord "example.com/auth/discord" configserver "example.com/config/server" character "example.com/database/character" customization "example.com/database/customization" function "example.com/database/function" functionset "example.com/database/functionset" functiontag "example.com/database/functiontag" group "example.com/database/group" inventoryslot "example.com/database/inventoryslot" item "example.com/database/item" itemtag "example.com/database/itemtag" person "example.com/database/person" role "example.com/database/role" schematic "example.com/database/schematic" tier "example.com/database/tier" user "example.com/database/user" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" "github.com/xyproto/randomstring" "golang.org/x/oauth2" "gorm.io/gorm" ) var GlobalDatabase *gorm.DB var GlobalConfig configserver.AppConfig var GlobalOAuth *oauth2.Config var filteredModelNames []string // Private Functions func updateUser(context *gin.Context, discordUser authdiscord.DiscordUser, oauthTokenJSON string) { err := user.Update( GlobalDatabase, discordUser.Id, discordUser.Global_Name, discordUser.Username, discordUser.Avatar, discordUser.Avatar_Decoration_Data.Asset, oauthTokenJSON, true, ) if err != nil { log.Println(err) context.AbortWithStatus(http.StatusInternalServerError) } } func createUser(context *gin.Context, discordUser authdiscord.DiscordUser, oauthTokenJSON string) { err := user.Create( GlobalDatabase, discordUser.Id, discordUser.Global_Name, discordUser.Username, discordUser.Avatar, discordUser.Avatar_Decoration_Data.Asset, oauthTokenJSON, true, ) if err != nil { log.Println(err) context.AbortWithStatus(http.StatusInternalServerError) } } func getPublicKey(token *jwt.Token) (any, error) { userId, err := token.Claims.GetIssuer() if err != nil { return []byte(""), nil } apiKeyName, err := token.Claims.GetSubject() if err != nil { return []byte(""), nil } var user user.User user.Get(GlobalDatabase, userId) key := user.GetAPIKeySecret(GlobalDatabase, apiKeyName) keyBlock, _ := pem.Decode([]byte(key)) privateKey, _ := x509.ParseECPrivateKey(keyBlock.Bytes) return &privateKey.PublicKey, nil } func checkAuthentication(context *gin.Context) *oauth2.Token { oauthTokenJSON, err := context.Cookie("discord-oauthtoken") if err != nil { authHeader := context.Request.Header.Get("Authorization") if authHeader == "" { return nil } signedString := strings.Split(authHeader, " ")[1] token, err := jwt.ParseWithClaims(signedString, &jwt.RegisteredClaims{}, getPublicKey) if err != nil || !token.Valid { log.Println(err) context.AbortWithStatus(http.StatusBadRequest) return nil } var oauthToken *oauth2.Token userId, _ := token.Claims.GetIssuer() json.Unmarshal([]byte((*user.Get(GlobalDatabase, []string{userId}))[0].LoginToken), &oauthToken) return oauthToken } var oauthToken *oauth2.Token err = json.Unmarshal([]byte(oauthTokenJSON), &oauthToken) if err == nil { if !oauthToken.Valid() || !(*user.Get(GlobalDatabase, []string{oauthTokenJSON}))[0].LoggedIn { return nil } return oauthToken } return nil } func validateBasicParams(context *gin.Context) (*[]uint, *[]byte) { IDArray, IDOk := context.GetQueryArray("id") body := []byte{} var err error if slices.Contains([]string{"POST", "PUT"}, context.Request.Method) { if context.Request.Method == "PUT" && IDOk && len(IDArray) != 1 { err = errors.New("invalid number of IDs were included") log.Println(err) context.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return nil, nil } body, err = io.ReadAll(context.Request.Body) if err != nil { log.Println(err) context.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return nil, nil } if !json.Valid(body) { err := errors.New("invalid JSON Body") log.Println(err) context.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return nil, nil } } var IDUintArray []uint for _, ID := range IDArray { IDUint, err := strconv.Atoi(ID) if err != nil { log.Println(err) context.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return nil, nil } IDUintArray = append(IDUintArray, uint(IDUint)) } return &IDUintArray, &body } func validateRequest(context *gin.Context) (string, *[]uint, *[]byte) { // Check that the method is valid if !slices.Contains([]string{"GET", "POST", "PUT", "DELETE"}, context.Request.Method) { err := errors.New("request must be GET, POST, PUT, or DELETE") log.Println(err) context.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return "", nil, nil } // Check authentication if not a GET request if (context.Request.Method != "GET") && (checkAuthentication(context) == nil) { err := errors.New("must be authenticated to make this kind of request") log.Println(err) context.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": err.Error(), }) return "", nil, nil } // Get the object type from the request parameters objectType := context.Param("object") // Check that the object type is valid if !slices.Contains(filteredModelNames, objectType) { err := errors.New("requested object type does not exist") log.Println(err) context.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return "", nil, nil } // Validate and parse out required parameters from request IDUintArray, body := validateBasicParams(context) return objectType, IDUintArray, body } // Authentication Workflow func AuthCallback(context *gin.Context) { // Discord OAuth library requires this. oauthState := randomstring.CookieFriendlyString(32) context.Set("state", oauthState) // Get the OAuth token. oauthToken, err := GlobalOAuth.Exchange(context.Request.Context(), context.Query("code")) if err != nil { context.Redirect(http.StatusInternalServerError, GlobalConfig.GetFrontendRootDomain()) return } // Get the current discord user info. var currentDiscordUser authdiscord.DiscordUser result := currentDiscordUser.Get(context, oauthToken, GlobalOAuth) if result != "" { context.Redirect(http.StatusInternalServerError, GlobalConfig.GetFrontendRootDomain()) return } else { var currentUser user.User // Parse object to string so we can save as a cookie. oauthTokenJSON, _ := json.Marshal(oauthToken) // Either create or update the user in the database if currentUser.Get(GlobalDatabase, currentDiscordUser.Id) == nil { if currentUser.LoggedIn { oauthTokenJSON = []byte(currentUser.LoginToken) } updateUser(context, currentDiscordUser, string(oauthTokenJSON)) } else { createUser(context, currentDiscordUser, string(oauthTokenJSON)) } // Save the resulting oauthTokenJSON as a cookie. context.SetCookie("discord-oauthtoken", string(oauthTokenJSON), 0, "", GlobalConfig.API.Domain, false, false) } // Redirect to the dashboard once logged in. context.Redirect(http.StatusTemporaryRedirect, GlobalConfig.GetFrontendRootDomain()+"/dashboard") } func AuthLoginRedirect(context *gin.Context) { context.Redirect(http.StatusTemporaryRedirect, GlobalOAuth.AuthCodeURL(context.GetString("state"))) } func AuthLogoutRedirect(context *gin.Context) { oauthTokenJSON, err := context.Cookie("discord-oauthtoken") if err == nil { user.Logout(GlobalDatabase, oauthTokenJSON) context.SetCookie("discord-oauthtoken", "", -1, "", GlobalConfig.API.Domain, false, true) } else { log.Println(err) } context.Redirect(http.StatusTemporaryRedirect, GlobalConfig.GetFrontendRootDomain()) } // Public Functions func SetFilteredModels() { var modelNames []string filteredModelNames = []string{} result := GlobalDatabase.Table("sqlite_master").Where("type = ?", "table").Pluck("name", &modelNames) if result.Error != nil { log.Fatal(errors.New("unable to access model names")) } for _, model := range modelNames { if slices.Contains([]string{"api_keys", "sqlite_sequence"}, model) || slices.Contains(strings.Split(model, "_"), "associations") { continue } model = strings.Replace(model, "people", "persons", 1) model = strings.Replace(model, "_", "-", -1) filteredModelNames = append(filteredModelNames, model[:len(model)-1]) } } func ObjectRequest(context *gin.Context) { // Make sure request has valid parameters objectType, IDUintArray, body := validateRequest(context) // This shouldn't happen, but just in case... if objectType == "" { return } // Determine how to handle the request var err error method := context.Request.Method resultJSON := gin.H{} switch objectType { case "user": user.GetAll(GlobalDatabase) case "person": result, resultError := person.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "group": result, resultError := group.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "character": result, resultError := character.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "role": result, resultError := role.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "tier": result, resultError := tier.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "function-set": result, resultError := functionset.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "function": result, resultError := function.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "function-tag": result, resultError := functiontag.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "inventory-slot": result, resultError := inventoryslot.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "item": result, resultError := item.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "item-tag": result, resultError := itemtag.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "customization": result, resultError := customization.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } case "schematic": result, resultError := schematic.HandleRequest(method, GlobalDatabase, IDUintArray, body) err = resultError resultJSON = gin.H{ "result": result, } default: err = errors.New("request made for object that exists, but is not implemented") context.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } if err != nil { log.Println(err) context.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ "error": err.Error(), }) return } context.JSON(http.StatusOK, resultJSON) } func CreateAPIToken(context *gin.Context) { name, nameOK := context.GetQuery("name") if nameOK { oauthToken := checkAuthentication(context) if oauthToken != nil { oauthTokenJSON, err := json.Marshal(&oauthToken) if err != nil { log.Println("This should never happen, how did this happen???\n", err) context.AbortWithStatus(http.StatusInternalServerError) return } var userId string GlobalDatabase.Model(&user.User{}).Select("id").Where("login_token = ?", string(oauthTokenJSON)).Take(&userId) currentUser := (*user.Get(GlobalDatabase, []string{userId}))[0] result := currentUser.GenerateAPIKey(GlobalDatabase, name) if result != nil { log.Println("This should also never happen, how did this happen???\n", err) context.AbortWithStatus(http.StatusInternalServerError) return } token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ "iss": currentUser.Id, "sub": name, }) context.JSON(http.StatusOK, gin.H{ "token": token, "secret": currentUser.GetAPIKeySecret(GlobalDatabase, name), "claims": gin.H{ "iss": currentUser.Id, "sub": name, }, }) } } else { context.AbortWithStatus(http.StatusBadRequest) } } func GetDiscordUser(context *gin.Context) { oauthToken := checkAuthentication(context) if oauthToken != nil { var discordUser authdiscord.DiscordUser result := discordUser.Get(context, oauthToken, GlobalOAuth) if result != "" { log.Println("Assuming the Discord OAuth Key has expired.") context.Redirect(http.StatusUnauthorized, GlobalConfig.GetFrontendRootDomain()+"/logout") } else { if (*user.Get(GlobalDatabase, []string{discordUser.Id}))[0].LoggedIn { context.JSON(http.StatusOK, discordUser) } else { context.Redirect(http.StatusTemporaryRedirect, GlobalConfig.GetFrontendRootDomain()+"/logout") } } } } func GetUserLoggedIn(context *gin.Context) { context.JSON(http.StatusOK, gin.H{ "message": (checkAuthentication(context) != nil), }) } func InitializeRouter() *gin.Engine { router := gin.Default() router.Use(cors.New(cors.Config{ AllowOrigins: []string{GlobalConfig.GetFrontendRootDomain()}, AllowMethods: []string{"PUT", "POST", "DELETE", "GET"}, AllowHeaders: []string{"Origin"}, ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, })) // Testing router.GET("/ping", func(context *gin.Context) { context.Status(http.StatusOK) }) // Authentication Workflow router.GET("/auth/callback", AuthCallback) router.GET("/auth/login", AuthLoginRedirect) router.GET("/auth/logout", AuthLogoutRedirect) // User methods router.GET("/user/token/generate", CreateAPIToken) router.GET("/user/info", GetDiscordUser) router.GET("/user/authorized", GetUserLoggedIn) // Object Requests router.POST("/:object", ObjectRequest) router.PUT("/:object", ObjectRequest) router.GET("/:object", ObjectRequest) router.DELETE("/:object", ObjectRequest) return router }