From f1b7589b2936335957a6a1da1eea3d66233ad0ce Mon Sep 17 00:00:00 2001 From: Arda Serdar Pektezol <1669855+pektezol@users.noreply.github.com> Date: Sat, 26 Aug 2023 08:53:24 +0300 Subject: refactor: reorganizing packages Former-commit-id: 99410223654c2a5ffc15fdab6ec3e921b5410cba --- backend/api/auth.go | 65 ++++++ backend/api/routes.go | 40 ++++ backend/controllers/homeController.go | 294 ------------------------ backend/controllers/loginController.go | 170 -------------- backend/controllers/mapController.go | 314 -------------------------- backend/controllers/modController.go | 334 ---------------------------- backend/controllers/recordController.go | 288 ------------------------ backend/controllers/userController.go | 383 -------------------------------- backend/handlers/home.go | 294 ++++++++++++++++++++++++ backend/handlers/login.go | 166 ++++++++++++++ backend/handlers/map.go | 314 ++++++++++++++++++++++++++ backend/handlers/mod.go | 334 ++++++++++++++++++++++++++++ backend/handlers/record.go | 292 ++++++++++++++++++++++++ backend/handlers/user.go | 383 ++++++++++++++++++++++++++++++++ backend/middleware/auth.go | 65 ------ backend/routes/routes.go | 41 ---- main.go | 8 +- 17 files changed, 1892 insertions(+), 1893 deletions(-) create mode 100644 backend/api/auth.go create mode 100644 backend/api/routes.go delete mode 100644 backend/controllers/homeController.go delete mode 100644 backend/controllers/loginController.go delete mode 100644 backend/controllers/mapController.go delete mode 100644 backend/controllers/modController.go delete mode 100644 backend/controllers/recordController.go delete mode 100644 backend/controllers/userController.go create mode 100644 backend/handlers/home.go create mode 100644 backend/handlers/login.go create mode 100644 backend/handlers/map.go create mode 100644 backend/handlers/mod.go create mode 100644 backend/handlers/record.go create mode 100644 backend/handlers/user.go delete mode 100644 backend/middleware/auth.go delete mode 100644 backend/routes/routes.go diff --git a/backend/api/auth.go b/backend/api/auth.go new file mode 100644 index 0000000..91ef80c --- /dev/null +++ b/backend/api/auth.go @@ -0,0 +1,65 @@ +package api + +import ( + "fmt" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" + "github.com/pektezol/leastportalshub/backend/database" + "github.com/pektezol/leastportalshub/backend/models" +) + +func CheckAuth(c *gin.Context) { + tokenString := c.GetHeader("Authorization") + // Validate token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(os.Getenv("SECRET_KEY")), nil + }) + if token == nil { + c.Next() + return + } + if err != nil { + c.Next() + return + } + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + // Check exp + if float64(time.Now().Unix()) > claims["exp"].(float64) { + c.Next() + return + } + // Get user from DB + var user models.User + database.DB.QueryRow(`SELECT u.steam_id, u.user_name, u.avatar_link, u.country_code, u.created_at, u.updated_at FROM users u WHERE steam_id = $1`, claims["sub"]).Scan( + &user.SteamID, &user.UserName, &user.AvatarLink, + &user.CountryCode, &user.CreatedAt, &user.UpdatedAt) + if user.SteamID == "" { + c.Next() + return + } + // Get user titles from DB + var moderator bool + user.Titles = []models.Title{} + rows, _ := database.DB.Query(`SELECT t.title_name, t.title_color FROM titles t INNER JOIN user_titles ut ON t.id=ut.title_id WHERE ut.user_id = $1`, user.SteamID) + for rows.Next() { + var title models.Title + rows.Scan(&title.Name, &title.Color) + if title.Name == "Moderator" { + moderator = true + } + user.Titles = append(user.Titles, title) + } + c.Set("user", user) + c.Set("mod", moderator) + c.Next() + } else { + c.Next() + return + } +} diff --git a/backend/api/routes.go b/backend/api/routes.go new file mode 100644 index 0000000..4dd8660 --- /dev/null +++ b/backend/api/routes.go @@ -0,0 +1,40 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "github.com/pektezol/leastportalshub/backend/handlers" + swaggerfiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" +) + +func InitRoutes(router *gin.Engine) { + api := router.Group("/api") + { + v1 := api.Group("/v1") + v1.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) + v1.GET("/", func(c *gin.Context) { + c.File("docs/index.html") + }) + v1.GET("/token", handlers.GetCookie) + v1.DELETE("/token", handlers.DeleteCookie) + v1.GET("/home", CheckAuth, handlers.Home) + v1.GET("/login", handlers.Login) + v1.GET("/profile", CheckAuth, handlers.Profile) + v1.PUT("/profile", CheckAuth, handlers.UpdateCountryCode) + v1.POST("/profile", CheckAuth, handlers.UpdateUser) + v1.GET("/users/:id", CheckAuth, handlers.FetchUser) + v1.GET("/demos", handlers.DownloadDemoWithID) + v1.GET("/maps/:id/summary", handlers.FetchMapSummary) + v1.POST("/maps/:id/summary", CheckAuth, handlers.CreateMapSummary) + v1.PUT("/maps/:id/summary", CheckAuth, handlers.EditMapSummary) + v1.DELETE("/maps/:id/summary", CheckAuth, handlers.DeleteMapSummary) + v1.PUT("/maps/:id/image", CheckAuth, handlers.EditMapImage) + v1.GET("/maps/:id/leaderboards", handlers.FetchMapLeaderboards) + v1.POST("/maps/:id/record", CheckAuth, handlers.CreateRecordWithDemo) + v1.GET("/rankings", handlers.Rankings) + v1.GET("/search", handlers.SearchWithQuery) + v1.GET("/games", handlers.FetchGames) + v1.GET("/games/:id", handlers.FetchChapters) + v1.GET("/chapters/:id", handlers.FetchChapterMaps) + } +} diff --git a/backend/controllers/homeController.go b/backend/controllers/homeController.go deleted file mode 100644 index d1b99cb..0000000 --- a/backend/controllers/homeController.go +++ /dev/null @@ -1,294 +0,0 @@ -package controllers - -import ( - "log" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/pektezol/leastportalshub/backend/database" - "github.com/pektezol/leastportalshub/backend/models" -) - -type SearchResponse struct { - Players []models.UserShort `json:"players"` - Maps []models.MapShort `json:"maps"` -} - -type RankingsResponse struct { - RankingsSP []models.UserRanking `json:"rankings_sp"` - RankingsMP []models.UserRanking `json:"rankings_mp"` -} - -func Home(c *gin.Context) { - user, exists := c.Get("user") - if !exists { - c.JSON(200, "no id, not auth") - } else { - c.JSON(200, gin.H{ - "output": user, - }) - } -} - -// GET Rankings -// -// @Description Get rankings of every player. -// @Tags rankings -// @Produce json -// @Success 200 {object} models.Response{data=RankingsResponse} -// @Failure 400 {object} models.Response -// @Router /rankings [get] -func Rankings(c *gin.Context) { - rows, err := database.DB.Query(`SELECT steam_id, user_name FROM users`) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var spRankings []models.UserRanking - var mpRankings []models.UserRanking - for rows.Next() { - var userID, username string - err := rows.Scan(&userID, &username) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Getting all sp records for each user - var uniqueSingleUserRecords, totalSingleMaps int - sql := `SELECT COUNT(DISTINCT map_id), (SELECT COUNT(map_name) FROM maps - WHERE is_coop = FALSE AND is_disabled = false) FROM records_sp WHERE user_id = $1` - err = database.DB.QueryRow(sql, userID).Scan(&uniqueSingleUserRecords, &totalSingleMaps) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Has all singleplayer records - if uniqueSingleUserRecords == totalSingleMaps { - var ranking models.UserRanking - ranking.UserID = userID - ranking.UserName = username - sql := `SELECT DISTINCT map_id, score_count FROM records_sp WHERE user_id = $1 ORDER BY map_id, score_count` - rows, err := database.DB.Query(sql, userID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - totalScore := 0 - var maps []int - for rows.Next() { - var mapID, scoreCount int - rows.Scan(&mapID, &scoreCount) - if len(maps) != 0 && maps[len(maps)-1] == mapID { - continue - } - totalScore += scoreCount - maps = append(maps, mapID) - } - ranking.TotalScore = totalScore - spRankings = append(spRankings, ranking) - } - // Getting all mp records for each user - var uniqueMultiUserRecords, totalMultiMaps int - sql = `SELECT COUNT(DISTINCT map_id), (SELECT COUNT(map_name) FROM maps - WHERE is_coop = TRUE AND is_disabled = false) FROM records_mp WHERE host_id = $1 OR partner_id = $2` - err = database.DB.QueryRow(sql, userID, userID).Scan(&uniqueMultiUserRecords, &totalMultiMaps) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Has all singleplayer records - if uniqueMultiUserRecords == totalMultiMaps { - var ranking models.UserRanking - ranking.UserID = userID - ranking.UserName = username - sql := `SELECT DISTINCT map_id, score_count FROM records_mp WHERE host_id = $1 OR partner_id = $2 ORDER BY map_id, score_count` - rows, err := database.DB.Query(sql, userID, userID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - totalScore := 0 - var maps []int - for rows.Next() { - var mapID, scoreCount int - rows.Scan(&mapID, &scoreCount) - if len(maps) != 0 && maps[len(maps)-1] == mapID { - continue - } - totalScore += scoreCount - maps = append(maps, mapID) - } - ranking.TotalScore = totalScore - mpRankings = append(mpRankings, ranking) - } - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully retrieved rankings.", - Data: RankingsResponse{ - RankingsSP: spRankings, - RankingsMP: mpRankings, - }, - }) -} - -// GET Search With Query -// -// @Description Get all user and map data matching to the query. -// @Tags search -// @Produce json -// @Param q query string false "Search user or map name." -// @Success 200 {object} models.Response{data=SearchResponse} -// @Failure 400 {object} models.Response -// @Router /search [get] -func SearchWithQuery(c *gin.Context) { - query := c.Query("q") - query = strings.ToLower(query) - log.Println(query) - var response SearchResponse - // Cache all maps for faster response - var maps = []models.MapShort{ - {ID: 1, Name: "Container Ride"}, - {ID: 2, Name: "Portal Carousel"}, - {ID: 3, Name: "Portal Gun"}, - {ID: 4, Name: "Smooth Jazz"}, - {ID: 5, Name: "Cube Momentum"}, - {ID: 6, Name: "Future Starter"}, - {ID: 7, Name: "Secret Panel"}, - {ID: 8, Name: "Wakeup"}, - {ID: 9, Name: "Incinerator"}, - {ID: 10, Name: "Laser Intro"}, - {ID: 11, Name: "Laser Stairs"}, - {ID: 12, Name: "Dual Lasers"}, - {ID: 13, Name: "Laser Over Goo"}, - {ID: 14, Name: "Catapult Intro"}, - {ID: 15, Name: "Trust Fling"}, - {ID: 16, Name: "Pit Flings"}, - {ID: 17, Name: "Fizzler Intro"}, - {ID: 18, Name: "Ceiling Catapult"}, - {ID: 19, Name: "Ricochet"}, - {ID: 20, Name: "Bridge Intro"}, - {ID: 21, Name: "Bridge The Gap"}, - {ID: 22, Name: "Turret Intro"}, - {ID: 23, Name: "Laser Relays"}, - {ID: 24, Name: "Turret Blocker"}, - {ID: 25, Name: "Laser vs Turret"}, - {ID: 26, Name: "Pull The Rug"}, - {ID: 27, Name: "Column Blocker"}, - {ID: 28, Name: "Laser Chaining"}, - {ID: 29, Name: "Triple Laser"}, - {ID: 30, Name: "Jail Break"}, - {ID: 31, Name: "Escape"}, - {ID: 32, Name: "Turret Factory"}, - {ID: 33, Name: "Turret Sabotage"}, - {ID: 34, Name: "Neurotoxin Sabotage"}, - {ID: 35, Name: "Core"}, - {ID: 36, Name: "Underground"}, - {ID: 37, Name: "Cave Johnson"}, - {ID: 38, Name: "Repulsion Intro"}, - {ID: 39, Name: "Bomb Flings"}, - {ID: 40, Name: "Crazy Box"}, - {ID: 41, Name: "PotatOS"}, - {ID: 42, Name: "Propulsion Intro"}, - {ID: 43, Name: "Propulsion Flings"}, - {ID: 44, Name: "Conversion Intro"}, - {ID: 45, Name: "Three Gels"}, - {ID: 46, Name: "Test"}, - {ID: 47, Name: "Funnel Intro"}, - {ID: 48, Name: "Ceiling Button"}, - {ID: 49, Name: "Wall Button"}, - {ID: 50, Name: "Polarity"}, - {ID: 51, Name: "Funnel Catch"}, - {ID: 52, Name: "Stop The Box"}, - {ID: 53, Name: "Laser Catapult"}, - {ID: 54, Name: "Laser Platform"}, - {ID: 55, Name: "Propulsion Catch"}, - {ID: 56, Name: "Repulsion Polarity"}, - {ID: 57, Name: "Finale 1"}, - {ID: 58, Name: "Finale 2"}, - {ID: 59, Name: "Finale 3"}, - {ID: 60, Name: "Finale 4"}, - {ID: 61, Name: "Calibration"}, - {ID: 62, Name: "Hub"}, - {ID: 63, Name: "Doors"}, - {ID: 64, Name: "Buttons"}, - {ID: 65, Name: "Lasers"}, - {ID: 66, Name: "Rat Maze"}, - {ID: 67, Name: "Laser Crusher"}, - {ID: 68, Name: "Behind The Scenes"}, - {ID: 69, Name: "Flings"}, - {ID: 70, Name: "Infinifling"}, - {ID: 71, Name: "Team Retrieval"}, - {ID: 72, Name: "Vertical Flings"}, - {ID: 73, Name: "Catapults"}, - {ID: 74, Name: "Multifling"}, - {ID: 75, Name: "Fling Crushers"}, - {ID: 76, Name: "Industrial Fan"}, - {ID: 77, Name: "Cooperative Bridges"}, - {ID: 78, Name: "Bridge Swap"}, - {ID: 79, Name: "Fling Block"}, - {ID: 80, Name: "Catapult Block"}, - {ID: 81, Name: "Bridge Fling"}, - {ID: 82, Name: "Turret Walls"}, - {ID: 83, Name: "Turret Assasin"}, - {ID: 84, Name: "Bridge Testing"}, - {ID: 85, Name: "Cooperative Funnels"}, - {ID: 86, Name: "Funnel Drill"}, - {ID: 87, Name: "Funnel Catch"}, - {ID: 88, Name: "Funnel Laser"}, - {ID: 89, Name: "Cooperative Polarity"}, - {ID: 90, Name: "Funnel Hop"}, - {ID: 91, Name: "Advanced Polarity"}, - {ID: 92, Name: "Funnel Maze"}, - {ID: 93, Name: "Turret Warehouse"}, - {ID: 94, Name: "Repulsion Jumps"}, - {ID: 95, Name: "Double Bounce"}, - {ID: 96, Name: "Bridge Repulsion"}, - {ID: 97, Name: "Wall Repulsion"}, - {ID: 98, Name: "Propulsion Crushers"}, - {ID: 99, Name: "Turret Ninja"}, - {ID: 100, Name: "Propulsion Retrieval"}, - {ID: 101, Name: "Vault Entrance"}, - {ID: 102, Name: "Seperation"}, - {ID: 103, Name: "Triple Axis"}, - {ID: 104, Name: "Catapult Catch"}, - {ID: 105, Name: "Bridge Gels"}, - {ID: 106, Name: "Maintenance"}, - {ID: 107, Name: "Bridge Catch"}, - {ID: 108, Name: "Double Lift"}, - {ID: 109, Name: "Gel Maze"}, - {ID: 110, Name: "Crazier Box"}, - } - var filteredMaps []models.MapShort - for _, m := range maps { - if strings.Contains(strings.ToLower(m.Name), strings.ToLower(query)) { - filteredMaps = append(filteredMaps, m) - } - } - response.Maps = filteredMaps - if len(response.Maps) == 0 { - response.Maps = []models.MapShort{} - } - rows, err := database.DB.Query("SELECT steam_id, user_name FROM users WHERE lower(user_name) LIKE $1", "%"+query+"%") - if err != nil { - log.Fatal(err) - } - defer rows.Close() - for rows.Next() { - var user models.UserShort - if err := rows.Scan(&user.SteamID, &user.UserName); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - response.Players = append(response.Players, user) - } - if len(response.Players) == 0 { - response.Players = []models.UserShort{} - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Search successfully retrieved.", - Data: response, - }) -} diff --git a/backend/controllers/loginController.go b/backend/controllers/loginController.go deleted file mode 100644 index 9d772a5..0000000 --- a/backend/controllers/loginController.go +++ /dev/null @@ -1,170 +0,0 @@ -package controllers - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "os" - "time" - - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v4" - "github.com/pektezol/leastportalshub/backend/database" - "github.com/pektezol/leastportalshub/backend/models" - "github.com/solovev/steam_go" -) - -type LoginResponse struct { - Token string `json:"token"` -} - -// Login -// -// @Description Get (redirect) login page for Steam auth. -// @Tags login -// @Accept json -// @Produce json -// @Success 200 {object} models.Response{data=LoginResponse} -// @Failure 400 {object} models.Response -// @Router /login [get] -func Login(c *gin.Context) { - openID := steam_go.NewOpenId(c.Request) - switch openID.Mode() { - case "": - c.Redirect(http.StatusMovedPermanently, openID.AuthUrl()) - case "cancel": - c.Redirect(http.StatusMovedPermanently, "/") - default: - steamID, err := openID.ValidateAndGetId() - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Create user if new - var checkSteamID int64 - err = database.DB.QueryRow("SELECT steam_id FROM users WHERE steam_id = $1", steamID).Scan(&checkSteamID) - // if err != nil { - // c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - // return - // } - // User does not exist - if checkSteamID == 0 { - user, err := GetPlayerSummaries(steamID, os.Getenv("API_KEY")) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Empty country code check - if user.LocCountryCode == "" { - user.LocCountryCode = "XX" - } - // Insert new user to database - database.DB.Exec(`INSERT INTO users (steam_id, user_name, avatar_link, country_code) - VALUES ($1, $2, $3, $4)`, steamID, user.PersonaName, user.AvatarFull, user.LocCountryCode) - } - moderator := false - rows, _ := database.DB.Query("SELECT title_name FROM titles t INNER JOIN user_titles ut ON t.id=ut.title_id WHERE ut.user_id = $1", steamID) - for rows.Next() { - var title string - rows.Scan(&title) - if title == "Moderator" { - moderator = true - } - } - // Generate JWT token - token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ - "sub": steamID, - "exp": time.Now().Add(time.Hour * 24 * 30).Unix(), - "mod": moderator, - }) - // Sign and get the complete encoded token as a string using the secret - tokenString, err := token.SignedString([]byte(os.Getenv("SECRET_KEY"))) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse("Failed to generate token.")) - return - } - c.SetCookie("token", tokenString, 3600*24*30, "/", "", true, true) - c.Redirect(http.StatusTemporaryRedirect, "/") - // c.JSON(http.StatusOK, models.Response{ - // Success: true, - // Message: "Successfully generated token.", - // Data: LoginResponse{ - // Token: tokenString, - // }, - // }) - return - } -} - -// GET Token -// -// @Description Gets the token cookie value from the user. -// @Tags auth -// @Produce json -// -// @Success 200 {object} models.Response{data=LoginResponse} -// @Failure 404 {object} models.Response -// @Router /token [get] -func GetCookie(c *gin.Context) { - cookie, err := c.Cookie("token") - if err != nil { - c.JSON(http.StatusNotFound, models.ErrorResponse("No token cookie found.")) - return - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Token cookie successfully retrieved.", - Data: LoginResponse{ - Token: cookie, - }, - }) -} - -// DELETE Token -// -// @Description Deletes the token cookie from the user. -// @Tags auth -// @Produce json -// -// @Success 200 {object} models.Response{data=LoginResponse} -// @Failure 404 {object} models.Response -// @Router /token [delete] -func DeleteCookie(c *gin.Context) { - cookie, err := c.Cookie("token") - if err != nil { - c.JSON(http.StatusNotFound, models.ErrorResponse("No token cookie found.")) - return - } - c.SetCookie("token", "", -1, "/", "", true, true) - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Token cookie successfully deleted.", - Data: LoginResponse{ - Token: cookie, - }, - }) -} - -func GetPlayerSummaries(steamId, apiKey string) (*models.PlayerSummaries, error) { - url := fmt.Sprintf("http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=%s&steamids=%s", apiKey, steamId) - resp, err := http.Get(url) - if err != nil { - return nil, err - } - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, err - } - - type Result struct { - Response struct { - Players []models.PlayerSummaries `json:"players"` - } `json:"response"` - } - var data Result - if err := json.Unmarshal(body, &data); err != nil { - return nil, err - } - return &data.Response.Players[0], err -} diff --git a/backend/controllers/mapController.go b/backend/controllers/mapController.go deleted file mode 100644 index 0a324d6..0000000 --- a/backend/controllers/mapController.go +++ /dev/null @@ -1,314 +0,0 @@ -package controllers - -import ( - "net/http" - "strconv" - - "github.com/gin-gonic/gin" - "github.com/pektezol/leastportalshub/backend/database" - "github.com/pektezol/leastportalshub/backend/models" -) - -type MapSummaryResponse struct { - Map models.Map `json:"map"` - Summary models.MapSummary `json:"summary"` -} - -type ChaptersResponse struct { - Game models.Game `json:"game"` - Chapters []models.Chapter `json:"chapters"` -} - -type ChapterMapsResponse struct { - Chapter models.Chapter `json:"chapter"` - Maps []models.MapShort `json:"maps"` -} - -// GET Map Summary -// -// @Description Get map summary with specified id. -// @Tags maps -// @Produce json -// @Param id path int true "Map ID" -// @Success 200 {object} models.Response{data=MapSummaryResponse} -// @Failure 400 {object} models.Response -// @Router /maps/{id}/summary [get] -func FetchMapSummary(c *gin.Context) { - id := c.Param("id") - response := MapSummaryResponse{Map: models.Map{}, Summary: models.MapSummary{Routes: []models.MapRoute{}}} - intID, err := strconv.Atoi(id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Get map data - response.Map.ID = intID - sql := `SELECT m.id, g.name, c.name, m.name, m.image, g.is_coop - FROM maps m - INNER JOIN games g ON m.game_id = g.id - INNER JOIN chapters c ON m.chapter_id = c.id - WHERE m.id = $1` - err = database.DB.QueryRow(sql, id).Scan(&response.Map.ID, &response.Map.GameName, &response.Map.ChapterName, &response.Map.MapName, &response.Map.Image, &response.Map.IsCoop) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Get map routes and histories - sql = `SELECT r.id, c.id, c.name, h.user_name, h.score_count, h.record_date, r.description, r.showcase, COALESCE(avg(rating), 0.0) FROM map_routes r - INNER JOIN categories c ON r.category_id = c.id - INNER JOIN map_history h ON r.map_id = h.map_id AND r.category_id = h.category_id - LEFT JOIN map_ratings rt ON r.map_id = rt.map_id AND r.category_id = rt.category_id - WHERE r.map_id = $1 AND h.score_count = r.score_count GROUP BY r.id, c.id, h.user_name, h.score_count, h.record_date, r.description, r.showcase - ORDER BY h.record_date ASC;` - rows, err := database.DB.Query(sql, id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - for rows.Next() { - route := models.MapRoute{Category: models.Category{}, History: models.MapHistory{}} - err = rows.Scan(&route.RouteID, &route.Category.ID, &route.Category.Name, &route.History.RunnerName, &route.History.ScoreCount, &route.History.Date, &route.Description, &route.Showcase, &route.Rating) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - response.Summary.Routes = append(response.Summary.Routes, route) - } - // Return response - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully retrieved map summary.", - Data: response, - }) -} - -// GET Map Leaderboards -// -// @Description Get map leaderboards with specified id. -// @Tags maps -// @Produce json -// @Param id path int true "Map ID" -// @Success 200 {object} models.Response{data=models.Map{data=models.MapRecords}} -// @Failure 400 {object} models.Response -// @Router /maps/{id}/leaderboards [get] -func FetchMapLeaderboards(c *gin.Context) { - // TODO: make new response type - id := c.Param("id") - // Get map data - var mapData models.Map - var mapRecordsData models.MapRecords - var isDisabled bool - intID, err := strconv.Atoi(id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - mapData.ID = intID - sql := `SELECT g.name, c.name, m.name, is_disabled, m.image - FROM maps m - INNER JOIN games g ON m.game_id = g.id - INNER JOIN chapters c ON m.chapter_id = c.id - WHERE m.id = $1` - err = database.DB.QueryRow(sql, id).Scan(&mapData.GameName, &mapData.ChapterName, &mapData.MapName, &isDisabled, &mapData.Image) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if isDisabled { - c.JSON(http.StatusBadRequest, models.ErrorResponse("Map is not available for competitive boards.")) - return - } - // TODO: avatar and names for host & partner - // Get records from the map - if mapData.GameName == "Portal 2 - Cooperative" { - var records []models.RecordMP - sql = `SELECT id, host_id, partner_id, score_count, score_time, host_demo_id, partner_demo_id, record_date - FROM ( - SELECT id, host_id, partner_id, score_count, score_time, host_demo_id, partner_demo_id, record_date, - ROW_NUMBER() OVER (PARTITION BY host_id, partner_id ORDER BY score_count, score_time) AS rn - FROM records_mp - WHERE map_id = $1 - ) sub - WHERE rn = 1` - rows, err := database.DB.Query(sql, id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - placement := 1 - ties := 0 - for rows.Next() { - var record models.RecordMP - err := rows.Scan(&record.RecordID, &record.HostID, &record.PartnerID, &record.ScoreCount, &record.ScoreTime, &record.HostDemoID, &record.PartnerDemoID, &record.RecordDate) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if len(records) != 0 && records[len(records)-1].ScoreTime == record.ScoreTime { - ties++ - record.Placement = placement - ties - } else { - record.Placement = placement - } - records = append(records, record) - placement++ - } - mapRecordsData.Records = records - } else { - var records []models.RecordSP - sql = `SELECT id, user_id, users.user_name, users.avatar_link, score_count, score_time, demo_id, record_date - FROM ( - SELECT id, user_id, score_count, score_time, demo_id, record_date, - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY score_count, score_time) AS rn - FROM records_sp - WHERE map_id = $1 - ) sub - INNER JOIN users ON user_id = users.steam_id - WHERE rn = 1` - rows, err := database.DB.Query(sql, id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - placement := 1 - ties := 0 - for rows.Next() { - var record models.RecordSP - err := rows.Scan(&record.RecordID, &record.UserID, &record.UserName, &record.UserAvatar, &record.ScoreCount, &record.ScoreTime, &record.DemoID, &record.RecordDate) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if len(records) != 0 && records[len(records)-1].ScoreTime == record.ScoreTime { - ties++ - record.Placement = placement - ties - } else { - record.Placement = placement - } - records = append(records, record) - placement++ - } - mapRecordsData.Records = records - } - // mapData.Data = mapRecordsData - // Return response - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully retrieved map leaderboards.", - Data: mapData, - }) -} - -// GET Games -// -// @Description Get games from the leaderboards. -// @Tags games & chapters -// @Produce json -// @Success 200 {object} models.Response{data=[]models.Game} -// @Failure 400 {object} models.Response -// @Router /games [get] -func FetchGames(c *gin.Context) { - rows, err := database.DB.Query(`SELECT id, name, is_coop FROM games`) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var games []models.Game - for rows.Next() { - var game models.Game - if err := rows.Scan(&game.ID, &game.Name, &game.IsCoop); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - games = append(games, game) - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully retrieved games.", - Data: games, - }) -} - -// GET Chapters of a Game -// -// @Description Get chapters from the specified game id. -// @Tags games & chapters -// @Produce json -// @Param id path int true "Game ID" -// @Success 200 {object} models.Response{data=ChaptersResponse} -// @Failure 400 {object} models.Response -// @Router /games/{id} [get] -func FetchChapters(c *gin.Context) { - gameID := c.Param("id") - intID, err := strconv.Atoi(gameID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var response ChaptersResponse - rows, err := database.DB.Query(`SELECT c.id, c.name, g.name FROM chapters c INNER JOIN games g ON c.game_id = g.id WHERE game_id = $1`, gameID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var chapters []models.Chapter - var gameName string - for rows.Next() { - var chapter models.Chapter - if err := rows.Scan(&chapter.ID, &chapter.Name, &gameName); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - chapters = append(chapters, chapter) - } - response.Game.ID = intID - response.Game.Name = gameName - response.Chapters = chapters - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully retrieved chapters.", - Data: response, - }) -} - -// GET Maps of a Chapter -// -// @Description Get maps from the specified chapter id. -// @Tags games & chapters -// @Produce json -// @Param id path int true "Chapter ID" -// @Success 200 {object} models.Response{data=ChapterMapsResponse} -// @Failure 400 {object} models.Response -// @Router /chapters/{id} [get] -func FetchChapterMaps(c *gin.Context) { - chapterID := c.Param("id") - intID, err := strconv.Atoi(chapterID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var response ChapterMapsResponse - rows, err := database.DB.Query(`SELECT m.id, m.name, c.name FROM maps m INNER JOIN chapters c ON m.chapter_id = c.id WHERE chapter_id = $1`, chapterID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var maps []models.MapShort - var chapterName string - for rows.Next() { - var mapShort models.MapShort - if err := rows.Scan(&mapShort.ID, &mapShort.Name, &chapterName); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - maps = append(maps, mapShort) - } - response.Chapter.ID = intID - response.Chapter.Name = chapterName - response.Maps = maps - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully retrieved maps.", - Data: response, - }) -} diff --git a/backend/controllers/modController.go b/backend/controllers/modController.go deleted file mode 100644 index 7acdb5d..0000000 --- a/backend/controllers/modController.go +++ /dev/null @@ -1,334 +0,0 @@ -package controllers - -import ( - "net/http" - "strconv" - "time" - - "github.com/gin-gonic/gin" - "github.com/pektezol/leastportalshub/backend/database" - "github.com/pektezol/leastportalshub/backend/models" -) - -type CreateMapSummaryRequest struct { - CategoryID int `json:"category_id" binding:"required"` - Description string `json:"description" binding:"required"` - Showcase string `json:"showcase"` - UserName string `json:"user_name" binding:"required"` - ScoreCount *int `json:"score_count" binding:"required"` - RecordDate time.Time `json:"record_date" binding:"required"` -} - -type EditMapSummaryRequest struct { - RouteID int `json:"route_id" binding:"required"` - Description string `json:"description" binding:"required"` - Showcase string `json:"showcase"` - UserName string `json:"user_name" binding:"required"` - ScoreCount *int `json:"score_count" binding:"required"` - RecordDate time.Time `json:"record_date" binding:"required"` -} - -type DeleteMapSummaryRequest struct { - RouteID int `json:"route_id" binding:"required"` -} - -type EditMapImageRequest struct { - Image string `json:"image" binding:"required"` -} - -// POST Map Summary -// -// @Description Create map summary with specified map id. -// @Tags maps -// @Produce json -// @Param Authorization header string true "JWT Token" -// @Param id path int true "Map ID" -// @Param request body CreateMapSummaryRequest true "Body" -// @Success 200 {object} models.Response{data=CreateMapSummaryRequest} -// @Failure 400 {object} models.Response -// @Router /maps/{id}/summary [post] -func CreateMapSummary(c *gin.Context) { - // Check if user exists - _, exists := c.Get("user") - if !exists { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) - return - } - mod, exists := c.Get("mod") - if !exists || !mod.(bool) { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions.")) - return - } - // Bind parameter and body - id := c.Param("id") - mapID, err := strconv.Atoi(id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var request CreateMapSummaryRequest - if err := c.BindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Start database transaction - tx, err := database.DB.Begin() - if err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) - return - } - defer tx.Rollback() - // Fetch route category and score count - var checkMapID int - sql := `SELECT m.id FROM maps m WHERE m.id = $1` - err = database.DB.QueryRow(sql, mapID).Scan(&checkMapID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if mapID != checkMapID { - c.JSON(http.StatusBadRequest, models.ErrorResponse("Map ID does not exist.")) - return - } - // Update database with new data - sql = `INSERT INTO map_routes (map_id,category_id,score_count,description,showcase) - VALUES ($1,$2,$3,$4,$5)` - _, err = tx.Exec(sql, mapID, request.CategoryID, request.ScoreCount, request.Description, request.Showcase) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - sql = `INSERT INTO map_history (map_id,category_id,user_name,score_count,record_date) - VALUES ($1,$2,$3,$4,$5)` - _, err = tx.Exec(sql, mapID, request.CategoryID, request.UserName, request.ScoreCount, request.RecordDate) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if err = tx.Commit(); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) - return - } - // Return response - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully created map summary.", - Data: request, - }) -} - -// PUT Map Summary -// -// @Description Edit map summary with specified map id. -// @Tags maps -// @Produce json -// @Param Authorization header string true "JWT Token" -// @Param id path int true "Map ID" -// @Param request body EditMapSummaryRequest true "Body" -// @Success 200 {object} models.Response{data=EditMapSummaryRequest} -// @Failure 400 {object} models.Response -// @Router /maps/{id}/summary [put] -func EditMapSummary(c *gin.Context) { - // Check if user exists - _, exists := c.Get("user") - if !exists { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) - return - } - mod, exists := c.Get("mod") - if !exists || !mod.(bool) { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions.")) - return - } - // Bind parameter and body - id := c.Param("id") - mapID, err := strconv.Atoi(id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var request EditMapSummaryRequest - if err := c.BindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Start database transaction - tx, err := database.DB.Begin() - if err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) - return - } - defer tx.Rollback() - // Fetch route category and score count - var categoryID, scoreCount, historyID int - sql := `SELECT mr.category_id, mr.score_count FROM map_routes mr INNER JOIN maps m ON m.id = mr.map_id WHERE m.id = $1 AND mr.id = $2` - err = database.DB.QueryRow(sql, mapID, request.RouteID).Scan(&categoryID, &scoreCount) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - sql = `SELECT mh.id FROM map_history mh WHERE mh.score_count = $1 AND mh.category_id = $2 AND mh.map_id = $3` - err = database.DB.QueryRow(sql, scoreCount, categoryID, mapID).Scan(&historyID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Update database with new data - sql = `UPDATE map_routes SET score_count = $2, description = $3, showcase = $4 WHERE id = $1` - _, err = tx.Exec(sql, request.RouteID, request.ScoreCount, request.Description, request.Showcase) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - sql = `UPDATE map_history SET user_name = $2, score_count = $3, record_date = $4 WHERE id = $1` - _, err = tx.Exec(sql, historyID, request.UserName, request.ScoreCount, request.RecordDate) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if err = tx.Commit(); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) - return - } - // Return response - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully updated map summary.", - Data: request, - }) -} - -// DELETE Map Summary -// -// @Description Delete map summary with specified map id. -// @Tags maps -// @Produce json -// @Param Authorization header string true "JWT Token" -// @Param id path int true "Map ID" -// @Param request body DeleteMapSummaryRequest true "Body" -// @Success 200 {object} models.Response{data=DeleteMapSummaryRequest} -// @Failure 400 {object} models.Response -// @Router /maps/{id}/summary [delete] -func DeleteMapSummary(c *gin.Context) { - // Check if user exists - _, exists := c.Get("user") - if !exists { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) - return - } - mod, exists := c.Get("mod") - if !exists || !mod.(bool) { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions.")) - return - } - // Bind parameter and body - id := c.Param("id") - mapID, err := strconv.Atoi(id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var request DeleteMapSummaryRequest - if err := c.BindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Start database transaction - tx, err := database.DB.Begin() - if err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) - return - } - defer tx.Rollback() - // Fetch route category and score count - var checkMapID, scoreCount, mapHistoryID int - sql := `SELECT m.id, mr.score_count FROM maps m INNER JOIN map_routes mr ON m.id=mr.map_id WHERE m.id = $1 AND mr.id = $2` - err = database.DB.QueryRow(sql, mapID, request.RouteID).Scan(&checkMapID, &scoreCount) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if mapID != checkMapID { - c.JSON(http.StatusBadRequest, models.ErrorResponse("Map ID does not exist.")) - return - } - sql = `SELECT mh.id FROM maps m INNER JOIN map_routes mr ON m.id=mr.map_id INNER JOIN map_history mh ON m.id=mh.map_id WHERE m.id = $1 AND mr.id = $2 AND mh.score_count = $3` - err = database.DB.QueryRow(sql, mapID, request.RouteID, scoreCount).Scan(&mapHistoryID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Update database with new data - sql = `DELETE FROM map_routes mr WHERE mr.id = $1 ` - _, err = tx.Exec(sql, request.RouteID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - sql = `DELETE FROM map_history mh WHERE mh.id = $1` - _, err = tx.Exec(sql, mapHistoryID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if err = tx.Commit(); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) - return - } - // Return response - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully delete map summary.", - Data: request, - }) -} - -// PUT Map Image -// -// @Description Edit map image with specified map id. -// @Tags maps -// @Produce json -// @Param Authorization header string true "JWT Token" -// @Param id path int true "Map ID" -// @Param request body EditMapImageRequest true "Body" -// @Success 200 {object} models.Response{data=EditMapImageRequest} -// @Failure 400 {object} models.Response -// @Router /maps/{id}/image [put] -func EditMapImage(c *gin.Context) { - // Check if user exists - _, exists := c.Get("user") - if !exists { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) - return - } - mod, exists := c.Get("mod") - if !exists || !mod.(bool) { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions.")) - return - } - // Bind parameter and body - id := c.Param("id") - mapID, err := strconv.Atoi(id) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - var request EditMapImageRequest - if err := c.BindJSON(&request); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Update database with new data - sql := `UPDATE maps SET image = $2 WHERE id = $1` - _, err = database.DB.Exec(sql, mapID, request.Image) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Return response - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully updated map image.", - Data: request, - }) -} diff --git a/backend/controllers/recordController.go b/backend/controllers/recordController.go deleted file mode 100644 index d141fc3..0000000 --- a/backend/controllers/recordController.go +++ /dev/null @@ -1,288 +0,0 @@ -package controllers - -import ( - "context" - "encoding/base64" - "io" - "log" - "mime/multipart" - "net/http" - "os" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/pektezol/leastportalshub/backend/database" - "github.com/pektezol/leastportalshub/backend/models" - "github.com/pektezol/leastportalshub/backend/parser" - "golang.org/x/oauth2/google" - "golang.org/x/oauth2/jwt" - "google.golang.org/api/drive/v3" -) - -type RecordRequest struct { - HostDemo *multipart.FileHeader `json:"host_demo" form:"host_demo" binding:"required" swaggerignore:"true"` - PartnerDemo *multipart.FileHeader `json:"partner_demo" form:"partner_demo" swaggerignore:"true"` - IsPartnerOrange bool `json:"is_partner_orange" form:"is_partner_orange"` - PartnerID string `json:"partner_id" form:"partner_id"` -} - -type RecordResponse struct { - ScoreCount int `json:"score_count"` - ScoreTime int `json:"score_time"` -} - -// POST Record -// -// @Description Post record with demo of a specific map. -// @Tags maps -// @Accept mpfd -// @Produce json -// @Param id path int true "Map ID" -// @Param Authorization header string true "JWT Token" -// @Param host_demo formData file true "Host Demo" -// @Param partner_demo formData file false "Partner Demo" -// @Param is_partner_orange formData boolean false "Is Partner Orange" -// @Param partner_id formData string false "Partner ID" -// @Success 200 {object} models.Response{data=RecordResponse} -// @Failure 400 {object} models.Response -// @Failure 401 {object} models.Response -// @Router /maps/{id}/record [post] -func CreateRecordWithDemo(c *gin.Context) { - mapId := c.Param("id") - // Check if user exists - user, exists := c.Get("user") - if !exists { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) - return - } - // Check if map is sp or mp - var gameName string - var isCoop bool - var isDisabled bool - sql := `SELECT g.name, m.is_disabled FROM maps m INNER JOIN games g ON m.game_id=g.id WHERE m.id = $1` - err := database.DB.QueryRow(sql, mapId).Scan(&gameName, &isDisabled) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if isDisabled { - c.JSON(http.StatusBadRequest, models.ErrorResponse("Map is not available for competitive boards.")) - return - } - if gameName == "Portal 2 - Cooperative" { - isCoop = true - } - // Get record request - var record RecordRequest - if err := c.ShouldBind(&record); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if isCoop && (record.PartnerDemo == nil || record.PartnerID == "") { - c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid entry for coop record submission.")) - return - } - // Demo files - demoFiles := []*multipart.FileHeader{record.HostDemo} - if isCoop { - demoFiles = append(demoFiles, record.PartnerDemo) - } - var hostDemoUUID, hostDemoFileID, partnerDemoUUID, partnerDemoFileID string - var hostDemoScoreCount, hostDemoScoreTime int - client := serviceAccount() - srv, err := drive.New(client) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Create database transaction for inserts - tx, err := database.DB.Begin() - if err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) - return - } - // Defer to a rollback in case anything fails - defer tx.Rollback() - for i, header := range demoFiles { - uuid := uuid.New().String() - // Upload & insert into demos - err = c.SaveUploadedFile(header, "backend/parser/"+uuid+".dem") - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - defer os.Remove("backend/parser/" + uuid + ".dem") - f, err := os.Open("backend/parser/" + uuid + ".dem") - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - defer f.Close() - file, err := createFile(srv, uuid+".dem", "application/octet-stream", f, os.Getenv("GOOGLE_FOLDER_ID")) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - hostDemoScoreCount, hostDemoScoreTime, err = parser.ProcessDemo("backend/parser/" + uuid + ".dem") - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if i == 0 { - hostDemoFileID = file.Id - hostDemoUUID = uuid - } else if i == 1 { - partnerDemoFileID = file.Id - partnerDemoUUID = uuid - } - _, err = tx.Exec(`INSERT INTO demos (id,location_id) VALUES ($1,$2)`, uuid, file.Id) - if err != nil { - deleteFile(srv, file.Id) - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - } - // Insert into records - if isCoop { - sql := `INSERT INTO records_mp(map_id,score_count,score_time,host_id,partner_id,host_demo_id,partner_demo_id) - VALUES($1, $2, $3, $4, $5, $6, $7)` - var hostID string - var partnerID string - if record.IsPartnerOrange { - hostID = user.(models.User).SteamID - partnerID = record.PartnerID - } else { - partnerID = user.(models.User).SteamID - hostID = record.PartnerID - } - _, err := tx.Exec(sql, mapId, hostDemoScoreCount, hostDemoScoreTime, hostID, partnerID, hostDemoUUID, partnerDemoUUID) - if err != nil { - deleteFile(srv, hostDemoFileID) - deleteFile(srv, partnerDemoFileID) - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // If a new world record based on portal count - // if record.ScoreCount < wrScore { - // _, err := tx.Exec(`UPDATE maps SET wr_score = $1, wr_time = $2 WHERE id = $3`, record.ScoreCount, record.ScoreTime, mapId) - // if err != nil { - // c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - // return - // } - // } - } else { - sql := `INSERT INTO records_sp(map_id,score_count,score_time,user_id,demo_id) - VALUES($1, $2, $3, $4, $5)` - _, err := tx.Exec(sql, mapId, hostDemoScoreCount, hostDemoScoreTime, user.(models.User).SteamID, hostDemoUUID) - if err != nil { - deleteFile(srv, hostDemoFileID) - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // If a new world record based on portal count - // if record.ScoreCount < wrScore { - // _, err := tx.Exec(`UPDATE maps SET wr_score = $1, wr_time = $2 WHERE id = $3`, record.ScoreCount, record.ScoreTime, mapId) - // if err != nil { - // c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - // return - // } - // } - } - if err = tx.Commit(); err != nil { - c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) - return - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully created record.", - Data: RecordResponse{ScoreCount: hostDemoScoreCount, ScoreTime: hostDemoScoreTime}, - }) -} - -// GET Demo -// -// @Description Get demo with specified demo uuid. -// @Tags demo -// @Accept json -// @Produce octet-stream -// @Param uuid query string true "Demo UUID" -// @Success 200 {file} binary "Demo File" -// @Failure 400 {object} models.Response -// @Router /demos [get] -func DownloadDemoWithID(c *gin.Context) { - uuid := c.Query("uuid") - var locationID string - if uuid == "" { - c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid id given.")) - return - } - err := database.DB.QueryRow(`SELECT location_id FROM demos WHERE id = $1`, uuid).Scan(&locationID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if locationID == "" { - c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid id given.")) - return - } - url := "https://drive.google.com/uc?export=download&id=" + locationID - fileName := uuid + ".dem" - output, err := os.Create(fileName) - defer os.Remove(fileName) - defer output.Close() - response, err := http.Get(url) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - defer response.Body.Close() - _, err = io.Copy(output, response.Body) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Downloaded file - c.Header("Content-Description", "File Transfer") - c.Header("Content-Transfer-Encoding", "binary") - c.Header("Content-Disposition", "attachment; filename="+fileName) - c.Header("Content-Type", "application/octet-stream") - c.File(fileName) - // c.FileAttachment() -} - -// Use Service account -func serviceAccount() *http.Client { - privateKey, _ := base64.StdEncoding.DecodeString(os.Getenv("GOOGLE_PRIVATE_KEY_BASE64")) - config := &jwt.Config{ - Email: os.Getenv("GOOGLE_CLIENT_EMAIL"), - PrivateKey: []byte(privateKey), - Scopes: []string{ - drive.DriveScope, - }, - TokenURL: google.JWTTokenURL, - } - client := config.Client(context.Background()) - return client -} - -// Create Gdrive file -func createFile(service *drive.Service, name string, mimeType string, content io.Reader, parentId string) (*drive.File, error) { - f := &drive.File{ - MimeType: mimeType, - Name: name, - Parents: []string{parentId}, - } - file, err := service.Files.Create(f).Media(content).Do() - - if err != nil { - log.Println("Could not create file: " + err.Error()) - return nil, err - } - - return file, nil -} - -// Delete Gdrive file -func deleteFile(service *drive.Service, fileId string) { - service.Files.Delete(fileId) -} diff --git a/backend/controllers/userController.go b/backend/controllers/userController.go deleted file mode 100644 index 84d589a..0000000 --- a/backend/controllers/userController.go +++ /dev/null @@ -1,383 +0,0 @@ -package controllers - -import ( - "net/http" - "os" - "regexp" - "time" - - "github.com/gin-gonic/gin" - "github.com/pektezol/leastportalshub/backend/database" - "github.com/pektezol/leastportalshub/backend/models" -) - -type ProfileResponse struct { - Profile bool `json:"profile"` - SteamID string `json:"steam_id"` - UserName string `json:"user_name"` - AvatarLink string `json:"avatar_link"` - CountryCode string `json:"country_code"` - Titles []models.Title `json:"titles"` - Links models.Links `json:"links"` - Rankings ProfileRankings `json:"rankings"` - Records ProfileRecords `json:"records"` -} - -type ProfileRankings struct { - Overall ProfileRankingsDetails `json:"overall"` - Singleplayer ProfileRankingsDetails `json:"singleplayer"` - Cooperative ProfileRankingsDetails `json:"cooperative"` -} - -type ProfileRankingsDetails struct { - Rank int `json:"rank"` - CompletionCount int `json:"completion_count"` - CompletionTotal int `json:"completion_total"` -} - -type ProfileRecords struct { - P2Singleplayer []ProfileRecordsDetails `json:"portal2_singleplayer"` - P2Cooperative []ProfileRecordsDetails `json:"portal2_cooperative"` -} - -type ProfileRecordsDetails struct { - MapID int `json:"map_id"` - MapName string `json:"map_name"` - Scores []ProfileScores `json:"scores"` -} - -type ProfileScores struct { - DemoID string `json:"demo_id"` - ScoreCount int `json:"score_count"` - ScoreTime int `json:"score_time"` - Date time.Time `json:"date"` -} - -type ScoreResponse struct { - MapID int `json:"map_id"` - Records any `json:"records"` -} - -// GET Profile -// -// @Description Get profile page of session user. -// @Tags users -// @Accept json -// @Produce json -// @Param Authorization header string true "JWT Token" -// @Success 200 {object} models.Response{data=ProfileResponse} -// @Failure 400 {object} models.Response -// @Failure 401 {object} models.Response -// @Router /profile [get] -func Profile(c *gin.Context) { - // Check if user exists - user, exists := c.Get("user") - if !exists { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) - return - } - // Get user links - links := models.Links{} - sql := `SELECT u.p2sr, u.steam, u.youtube, u.twitch FROM users u WHERE u.steam_id = $1` - err := database.DB.QueryRow(sql, user.(models.User).SteamID).Scan(&links.P2SR, &links.Steam, &links.YouTube, &links.Twitch) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // TODO: Get rankings (all maps done in one game) - records := ProfileRecords{ - P2Singleplayer: []ProfileRecordsDetails{}, - P2Cooperative: []ProfileRecordsDetails{}, - } - // Get singleplayer records - sql = `SELECT m.game_id, sp.map_id, m."name", sp.score_count, sp.score_time, sp.demo_id, sp.record_date - FROM records_sp sp INNER JOIN maps m ON sp.map_id = m.id WHERE sp.user_id = $1 ORDER BY sp.map_id, sp.score_count, sp.score_time;` - rows, err := database.DB.Query(sql, user.(models.User).SteamID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - for rows.Next() { - var mapID int - var mapName string - var gameID int - score := ProfileScores{} - rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date) - if gameID != 1 { - continue - } - // More than one record in one map - if len(records.P2Singleplayer) != 0 && mapID == records.P2Singleplayer[len(records.P2Singleplayer)-1].MapID { - records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score) - continue - } - // New map - records.P2Singleplayer = append(records.P2Singleplayer, ProfileRecordsDetails{ - MapID: mapID, - MapName: mapName, - Scores: []ProfileScores{}, - }) - records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score) - } - // Get multiplayer records - sql = `SELECT m.game_id, mp.map_id, m."name", mp.score_count, mp.score_time, CASE WHEN host_id = $1 THEN mp.host_demo_id WHEN partner_id = $1 THEN mp.partner_demo_id END demo_id, mp.record_date - FROM records_mp mp INNER JOIN maps m ON mp.map_id = m.id WHERE mp.host_id = $1 OR mp.partner_id = $1 ORDER BY mp.map_id, mp.score_count, mp.score_time;` - rows, err = database.DB.Query(sql, user.(models.User).SteamID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - for rows.Next() { - var mapID int - var mapName string - var gameID int - score := ProfileScores{} - rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date) - if gameID != 1 { - continue - } - // More than one record in one map - if len(records.P2Cooperative) != 0 && mapID == records.P2Cooperative[len(records.P2Cooperative)-1].MapID { - records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score) - continue - } - // New map - records.P2Cooperative = append(records.P2Cooperative, ProfileRecordsDetails{ - MapID: mapID, - MapName: mapName, - Scores: []ProfileScores{}, - }) - records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score) - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully retrieved user scores.", - Data: ProfileResponse{ - Profile: true, - SteamID: user.(models.User).SteamID, - UserName: user.(models.User).UserName, - AvatarLink: user.(models.User).AvatarLink, - CountryCode: user.(models.User).CountryCode, - Titles: user.(models.User).Titles, - Links: links, - Rankings: ProfileRankings{}, - Records: records, - }, - }) -} - -// GET User -// -// @Description Get profile page of another user. -// @Tags users -// @Accept json -// @Produce json -// @Param id path int true "User ID" -// @Success 200 {object} models.Response{data=ProfileResponse} -// @Failure 400 {object} models.Response -// @Failure 404 {object} models.Response -// @Router /users/{id} [get] -func FetchUser(c *gin.Context) { - id := c.Param("id") - // Check if id is all numbers and 17 length - match, _ := regexp.MatchString("^[0-9]{17}$", id) - if !match { - c.JSON(http.StatusNotFound, models.ErrorResponse("User not found.")) - return - } - // Check if user exists - var user models.User - links := models.Links{} - sql := `SELECT u.steam_id, u.user_name, u.avatar_link, u.country_code, u.created_at, u.updated_at, u.p2sr, u.steam, u.youtube, u.twitch FROM users u WHERE u.steam_id = $1` - err := database.DB.QueryRow(sql, id).Scan(&user.SteamID, &user.UserName, &user.AvatarLink, &user.CountryCode, &user.CreatedAt, &user.UpdatedAt, &links.P2SR, &links.Steam, &links.YouTube, &links.Twitch) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - if user.SteamID == "" { - // User does not exist - c.JSON(http.StatusNotFound, models.ErrorResponse("User not found.")) - return - } - // Get user titles - sql = `SELECT t.title_name, t.title_color FROM titles t - INNER JOIN user_titles ut ON t.id=ut.title_id WHERE ut.user_id = $1` - rows, err := database.DB.Query(sql, user.SteamID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - for rows.Next() { - var title models.Title - if err := rows.Scan(&title.Name, &title.Color); err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - user.Titles = append(user.Titles, title) - } - // TODO: Get rankings (all maps done in one game) - records := ProfileRecords{ - P2Singleplayer: []ProfileRecordsDetails{}, - P2Cooperative: []ProfileRecordsDetails{}, - } - // Get singleplayer records - sql = `SELECT m.game_id, sp.map_id, m."name", sp.score_count, sp.score_time, sp.demo_id, sp.record_date - FROM records_sp sp INNER JOIN maps m ON sp.map_id = m.id WHERE sp.user_id = $1 ORDER BY sp.map_id, sp.score_count, sp.score_time;` - rows, err = database.DB.Query(sql, user.SteamID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - for rows.Next() { - var mapID int - var mapName string - var gameID int - score := ProfileScores{} - rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date) - if gameID != 1 { - continue - } - // More than one record in one map - if len(records.P2Singleplayer) != 0 && mapID == records.P2Singleplayer[len(records.P2Singleplayer)-1].MapID { - records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score) - continue - } - // New map - records.P2Singleplayer = append(records.P2Singleplayer, ProfileRecordsDetails{ - MapID: mapID, - MapName: mapName, - Scores: []ProfileScores{}, - }) - records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score) - } - // Get multiplayer records - sql = `SELECT m.game_id, mp.map_id, m."name", mp.score_count, mp.score_time, CASE WHEN host_id = $1 THEN mp.host_demo_id WHEN partner_id = $1 THEN mp.partner_demo_id END demo_id, mp.record_date - FROM records_mp mp INNER JOIN maps m ON mp.map_id = m.id WHERE mp.host_id = $1 OR mp.partner_id = $1 ORDER BY mp.map_id, mp.score_count, mp.score_time;` - rows, err = database.DB.Query(sql, user.SteamID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - for rows.Next() { - var mapID int - var mapName string - var gameID int - score := ProfileScores{} - rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date) - if gameID != 1 { - continue - } - // More than one record in one map - if len(records.P2Cooperative) != 0 && mapID == records.P2Cooperative[len(records.P2Cooperative)-1].MapID { - records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score) - continue - } - // New map - records.P2Cooperative = append(records.P2Cooperative, ProfileRecordsDetails{ - MapID: mapID, - MapName: mapName, - Scores: []ProfileScores{}, - }) - records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score) - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully retrieved user scores.", - Data: ProfileResponse{ - Profile: true, - SteamID: user.SteamID, - UserName: user.UserName, - AvatarLink: user.AvatarLink, - CountryCode: user.CountryCode, - Titles: user.Titles, - Links: links, - Rankings: ProfileRankings{}, - Records: records, - }, - }) -} - -// PUT Profile -// -// @Description Update profile page of session user. -// @Tags users -// @Accept json -// @Produce json -// @Param Authorization header string true "JWT Token" -// @Success 200 {object} models.Response{data=ProfileResponse} -// @Failure 400 {object} models.Response -// @Failure 401 {object} models.Response -// @Router /profile [post] -func UpdateUser(c *gin.Context) { - // Check if user exists - user, exists := c.Get("user") - if !exists { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) - return - } - profile, err := GetPlayerSummaries(user.(models.User).SteamID, os.Getenv("API_KEY")) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - // Update profile - _, err = database.DB.Exec(`UPDATE users SET username = $1, avatar_link = $2, country_code = $3, updated_at = $4 - WHERE steam_id = $5`, profile.PersonaName, profile.AvatarFull, profile.LocCountryCode, time.Now().UTC(), user.(models.User).SteamID) - if err != nil { - c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) - return - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully updated user.", - Data: ProfileResponse{ - Profile: true, - SteamID: user.(models.User).SteamID, - UserName: profile.PersonaName, - AvatarLink: profile.AvatarFull, - CountryCode: profile.LocCountryCode, - }, - }) -} - -// PUT Profile/CountryCode -// -// @Description Update country code of session user. -// @Tags users -// @Accept json -// @Produce json -// @Param Authorization header string true "JWT Token" -// @Param country_code query string true "Country Code [XX]" -// @Success 200 {object} models.Response -// @Failure 400 {object} models.Response -// @Failure 401 {object} models.Response -// @Router /profile [put] -func UpdateCountryCode(c *gin.Context) { - // Check if user exists - user, exists := c.Get("user") - if !exists { - c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) - return - } - code := c.Query("country_code") - if code == "" { - c.JSON(http.StatusNotFound, models.ErrorResponse("Enter a valid country code.")) - return - } - var validCode string - err := database.DB.QueryRow(`SELECT country_code FROM countries WHERE country_code = $1`, code).Scan(&validCode) - if err != nil { - c.JSON(http.StatusNotFound, models.ErrorResponse(err.Error())) - return - } - // Valid code, update profile - _, err = database.DB.Exec(`UPDATE users SET country_code = $1 WHERE steam_id = $2`, validCode, user.(models.User).SteamID) - if err != nil { - c.JSON(http.StatusNotFound, models.ErrorResponse(err.Error())) - return - } - c.JSON(http.StatusOK, models.Response{ - Success: true, - Message: "Successfully updated country code.", - }) -} diff --git a/backend/handlers/home.go b/backend/handlers/home.go new file mode 100644 index 0000000..6e9a0df --- /dev/null +++ b/backend/handlers/home.go @@ -0,0 +1,294 @@ +package handlers + +import ( + "log" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/pektezol/leastportalshub/backend/database" + "github.com/pektezol/leastportalshub/backend/models" +) + +type SearchResponse struct { + Players []models.UserShort `json:"players"` + Maps []models.MapShort `json:"maps"` +} + +type RankingsResponse struct { + RankingsSP []models.UserRanking `json:"rankings_sp"` + RankingsMP []models.UserRanking `json:"rankings_mp"` +} + +func Home(c *gin.Context) { + user, exists := c.Get("user") + if !exists { + c.JSON(200, "no id, not auth") + } else { + c.JSON(200, gin.H{ + "output": user, + }) + } +} + +// GET Rankings +// +// @Description Get rankings of every player. +// @Tags rankings +// @Produce json +// @Success 200 {object} models.Response{data=RankingsResponse} +// @Failure 400 {object} models.Response +// @Router /rankings [get] +func Rankings(c *gin.Context) { + rows, err := database.DB.Query(`SELECT steam_id, user_name FROM users`) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var spRankings []models.UserRanking + var mpRankings []models.UserRanking + for rows.Next() { + var userID, username string + err := rows.Scan(&userID, &username) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Getting all sp records for each user + var uniqueSingleUserRecords, totalSingleMaps int + sql := `SELECT COUNT(DISTINCT map_id), (SELECT COUNT(map_name) FROM maps + WHERE is_coop = FALSE AND is_disabled = false) FROM records_sp WHERE user_id = $1` + err = database.DB.QueryRow(sql, userID).Scan(&uniqueSingleUserRecords, &totalSingleMaps) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Has all singleplayer records + if uniqueSingleUserRecords == totalSingleMaps { + var ranking models.UserRanking + ranking.UserID = userID + ranking.UserName = username + sql := `SELECT DISTINCT map_id, score_count FROM records_sp WHERE user_id = $1 ORDER BY map_id, score_count` + rows, err := database.DB.Query(sql, userID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + totalScore := 0 + var maps []int + for rows.Next() { + var mapID, scoreCount int + rows.Scan(&mapID, &scoreCount) + if len(maps) != 0 && maps[len(maps)-1] == mapID { + continue + } + totalScore += scoreCount + maps = append(maps, mapID) + } + ranking.TotalScore = totalScore + spRankings = append(spRankings, ranking) + } + // Getting all mp records for each user + var uniqueMultiUserRecords, totalMultiMaps int + sql = `SELECT COUNT(DISTINCT map_id), (SELECT COUNT(map_name) FROM maps + WHERE is_coop = TRUE AND is_disabled = false) FROM records_mp WHERE host_id = $1 OR partner_id = $2` + err = database.DB.QueryRow(sql, userID, userID).Scan(&uniqueMultiUserRecords, &totalMultiMaps) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Has all singleplayer records + if uniqueMultiUserRecords == totalMultiMaps { + var ranking models.UserRanking + ranking.UserID = userID + ranking.UserName = username + sql := `SELECT DISTINCT map_id, score_count FROM records_mp WHERE host_id = $1 OR partner_id = $2 ORDER BY map_id, score_count` + rows, err := database.DB.Query(sql, userID, userID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + totalScore := 0 + var maps []int + for rows.Next() { + var mapID, scoreCount int + rows.Scan(&mapID, &scoreCount) + if len(maps) != 0 && maps[len(maps)-1] == mapID { + continue + } + totalScore += scoreCount + maps = append(maps, mapID) + } + ranking.TotalScore = totalScore + mpRankings = append(mpRankings, ranking) + } + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully retrieved rankings.", + Data: RankingsResponse{ + RankingsSP: spRankings, + RankingsMP: mpRankings, + }, + }) +} + +// GET Search With Query +// +// @Description Get all user and map data matching to the query. +// @Tags search +// @Produce json +// @Param q query string false "Search user or map name." +// @Success 200 {object} models.Response{data=SearchResponse} +// @Failure 400 {object} models.Response +// @Router /search [get] +func SearchWithQuery(c *gin.Context) { + query := c.Query("q") + query = strings.ToLower(query) + log.Println(query) + var response SearchResponse + // Cache all maps for faster response + var maps = []models.MapShort{ + {ID: 1, Name: "Container Ride"}, + {ID: 2, Name: "Portal Carousel"}, + {ID: 3, Name: "Portal Gun"}, + {ID: 4, Name: "Smooth Jazz"}, + {ID: 5, Name: "Cube Momentum"}, + {ID: 6, Name: "Future Starter"}, + {ID: 7, Name: "Secret Panel"}, + {ID: 8, Name: "Wakeup"}, + {ID: 9, Name: "Incinerator"}, + {ID: 10, Name: "Laser Intro"}, + {ID: 11, Name: "Laser Stairs"}, + {ID: 12, Name: "Dual Lasers"}, + {ID: 13, Name: "Laser Over Goo"}, + {ID: 14, Name: "Catapult Intro"}, + {ID: 15, Name: "Trust Fling"}, + {ID: 16, Name: "Pit Flings"}, + {ID: 17, Name: "Fizzler Intro"}, + {ID: 18, Name: "Ceiling Catapult"}, + {ID: 19, Name: "Ricochet"}, + {ID: 20, Name: "Bridge Intro"}, + {ID: 21, Name: "Bridge The Gap"}, + {ID: 22, Name: "Turret Intro"}, + {ID: 23, Name: "Laser Relays"}, + {ID: 24, Name: "Turret Blocker"}, + {ID: 25, Name: "Laser vs Turret"}, + {ID: 26, Name: "Pull The Rug"}, + {ID: 27, Name: "Column Blocker"}, + {ID: 28, Name: "Laser Chaining"}, + {ID: 29, Name: "Triple Laser"}, + {ID: 30, Name: "Jail Break"}, + {ID: 31, Name: "Escape"}, + {ID: 32, Name: "Turret Factory"}, + {ID: 33, Name: "Turret Sabotage"}, + {ID: 34, Name: "Neurotoxin Sabotage"}, + {ID: 35, Name: "Core"}, + {ID: 36, Name: "Underground"}, + {ID: 37, Name: "Cave Johnson"}, + {ID: 38, Name: "Repulsion Intro"}, + {ID: 39, Name: "Bomb Flings"}, + {ID: 40, Name: "Crazy Box"}, + {ID: 41, Name: "PotatOS"}, + {ID: 42, Name: "Propulsion Intro"}, + {ID: 43, Name: "Propulsion Flings"}, + {ID: 44, Name: "Conversion Intro"}, + {ID: 45, Name: "Three Gels"}, + {ID: 46, Name: "Test"}, + {ID: 47, Name: "Funnel Intro"}, + {ID: 48, Name: "Ceiling Button"}, + {ID: 49, Name: "Wall Button"}, + {ID: 50, Name: "Polarity"}, + {ID: 51, Name: "Funnel Catch"}, + {ID: 52, Name: "Stop The Box"}, + {ID: 53, Name: "Laser Catapult"}, + {ID: 54, Name: "Laser Platform"}, + {ID: 55, Name: "Propulsion Catch"}, + {ID: 56, Name: "Repulsion Polarity"}, + {ID: 57, Name: "Finale 1"}, + {ID: 58, Name: "Finale 2"}, + {ID: 59, Name: "Finale 3"}, + {ID: 60, Name: "Finale 4"}, + {ID: 61, Name: "Calibration"}, + {ID: 62, Name: "Hub"}, + {ID: 63, Name: "Doors"}, + {ID: 64, Name: "Buttons"}, + {ID: 65, Name: "Lasers"}, + {ID: 66, Name: "Rat Maze"}, + {ID: 67, Name: "Laser Crusher"}, + {ID: 68, Name: "Behind The Scenes"}, + {ID: 69, Name: "Flings"}, + {ID: 70, Name: "Infinifling"}, + {ID: 71, Name: "Team Retrieval"}, + {ID: 72, Name: "Vertical Flings"}, + {ID: 73, Name: "Catapults"}, + {ID: 74, Name: "Multifling"}, + {ID: 75, Name: "Fling Crushers"}, + {ID: 76, Name: "Industrial Fan"}, + {ID: 77, Name: "Cooperative Bridges"}, + {ID: 78, Name: "Bridge Swap"}, + {ID: 79, Name: "Fling Block"}, + {ID: 80, Name: "Catapult Block"}, + {ID: 81, Name: "Bridge Fling"}, + {ID: 82, Name: "Turret Walls"}, + {ID: 83, Name: "Turret Assasin"}, + {ID: 84, Name: "Bridge Testing"}, + {ID: 85, Name: "Cooperative Funnels"}, + {ID: 86, Name: "Funnel Drill"}, + {ID: 87, Name: "Funnel Catch"}, + {ID: 88, Name: "Funnel Laser"}, + {ID: 89, Name: "Cooperative Polarity"}, + {ID: 90, Name: "Funnel Hop"}, + {ID: 91, Name: "Advanced Polarity"}, + {ID: 92, Name: "Funnel Maze"}, + {ID: 93, Name: "Turret Warehouse"}, + {ID: 94, Name: "Repulsion Jumps"}, + {ID: 95, Name: "Double Bounce"}, + {ID: 96, Name: "Bridge Repulsion"}, + {ID: 97, Name: "Wall Repulsion"}, + {ID: 98, Name: "Propulsion Crushers"}, + {ID: 99, Name: "Turret Ninja"}, + {ID: 100, Name: "Propulsion Retrieval"}, + {ID: 101, Name: "Vault Entrance"}, + {ID: 102, Name: "Seperation"}, + {ID: 103, Name: "Triple Axis"}, + {ID: 104, Name: "Catapult Catch"}, + {ID: 105, Name: "Bridge Gels"}, + {ID: 106, Name: "Maintenance"}, + {ID: 107, Name: "Bridge Catch"}, + {ID: 108, Name: "Double Lift"}, + {ID: 109, Name: "Gel Maze"}, + {ID: 110, Name: "Crazier Box"}, + } + var filteredMaps []models.MapShort + for _, m := range maps { + if strings.Contains(strings.ToLower(m.Name), strings.ToLower(query)) { + filteredMaps = append(filteredMaps, m) + } + } + response.Maps = filteredMaps + if len(response.Maps) == 0 { + response.Maps = []models.MapShort{} + } + rows, err := database.DB.Query("SELECT steam_id, user_name FROM users WHERE lower(user_name) LIKE $1", "%"+query+"%") + if err != nil { + log.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var user models.UserShort + if err := rows.Scan(&user.SteamID, &user.UserName); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + response.Players = append(response.Players, user) + } + if len(response.Players) == 0 { + response.Players = []models.UserShort{} + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Search successfully retrieved.", + Data: response, + }) +} diff --git a/backend/handlers/login.go b/backend/handlers/login.go new file mode 100644 index 0000000..4b151c2 --- /dev/null +++ b/backend/handlers/login.go @@ -0,0 +1,166 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v4" + "github.com/pektezol/leastportalshub/backend/database" + "github.com/pektezol/leastportalshub/backend/models" + "github.com/solovev/steam_go" +) + +type LoginResponse struct { + Token string `json:"token"` +} + +// Login +// +// @Description Get (redirect) login page for Steam auth. +// @Tags login +// @Accept json +// @Produce json +// @Success 200 {object} models.Response{data=LoginResponse} +// @Failure 400 {object} models.Response +// @Router /login [get] +func Login(c *gin.Context) { + openID := steam_go.NewOpenId(c.Request) + switch openID.Mode() { + case "": + c.Redirect(http.StatusMovedPermanently, openID.AuthUrl()) + case "cancel": + c.Redirect(http.StatusMovedPermanently, "/") + default: + steamID, err := openID.ValidateAndGetId() + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Create user if new + var checkSteamID int64 + database.DB.QueryRow("SELECT steam_id FROM users WHERE steam_id = $1", steamID).Scan(&checkSteamID) + // User does not exist + if checkSteamID == 0 { + user, err := GetPlayerSummaries(steamID, os.Getenv("API_KEY")) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Empty country code check + if user.LocCountryCode == "" { + user.LocCountryCode = "XX" + } + // Insert new user to database + database.DB.Exec(`INSERT INTO users (steam_id, user_name, avatar_link, country_code) + VALUES ($1, $2, $3, $4)`, steamID, user.PersonaName, user.AvatarFull, user.LocCountryCode) + } + moderator := false + rows, _ := database.DB.Query("SELECT title_name FROM titles t INNER JOIN user_titles ut ON t.id=ut.title_id WHERE ut.user_id = $1", steamID) + for rows.Next() { + var title string + rows.Scan(&title) + if title == "Moderator" { + moderator = true + } + } + // Generate JWT token + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": steamID, + "exp": time.Now().Add(time.Hour * 24 * 30).Unix(), + "mod": moderator, + }) + // Sign and get the complete encoded token as a string using the secret + tokenString, err := token.SignedString([]byte(os.Getenv("SECRET_KEY"))) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse("Failed to generate token.")) + return + } + c.SetCookie("token", tokenString, 3600*24*30, "/", "", true, true) + c.Redirect(http.StatusTemporaryRedirect, "/") + // c.JSON(http.StatusOK, models.Response{ + // Success: true, + // Message: "Successfully generated token.", + // Data: LoginResponse{ + // Token: tokenString, + // }, + // }) + return + } +} + +// GET Token +// +// @Description Gets the token cookie value from the user. +// @Tags auth +// @Produce json +// +// @Success 200 {object} models.Response{data=LoginResponse} +// @Failure 404 {object} models.Response +// @Router /token [get] +func GetCookie(c *gin.Context) { + cookie, err := c.Cookie("token") + if err != nil { + c.JSON(http.StatusNotFound, models.ErrorResponse("No token cookie found.")) + return + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Token cookie successfully retrieved.", + Data: LoginResponse{ + Token: cookie, + }, + }) +} + +// DELETE Token +// +// @Description Deletes the token cookie from the user. +// @Tags auth +// @Produce json +// +// @Success 200 {object} models.Response{data=LoginResponse} +// @Failure 404 {object} models.Response +// @Router /token [delete] +func DeleteCookie(c *gin.Context) { + cookie, err := c.Cookie("token") + if err != nil { + c.JSON(http.StatusNotFound, models.ErrorResponse("No token cookie found.")) + return + } + c.SetCookie("token", "", -1, "/", "", true, true) + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Token cookie successfully deleted.", + Data: LoginResponse{ + Token: cookie, + }, + }) +} + +func GetPlayerSummaries(steamId, apiKey string) (*models.PlayerSummaries, error) { + url := fmt.Sprintf("http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=%s&steamids=%s", apiKey, steamId) + resp, err := http.Get(url) + if err != nil { + return nil, err + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + type Result struct { + Response struct { + Players []models.PlayerSummaries `json:"players"` + } `json:"response"` + } + var data Result + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + return &data.Response.Players[0], err +} diff --git a/backend/handlers/map.go b/backend/handlers/map.go new file mode 100644 index 0000000..b47e793 --- /dev/null +++ b/backend/handlers/map.go @@ -0,0 +1,314 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/pektezol/leastportalshub/backend/database" + "github.com/pektezol/leastportalshub/backend/models" +) + +type MapSummaryResponse struct { + Map models.Map `json:"map"` + Summary models.MapSummary `json:"summary"` +} + +type ChaptersResponse struct { + Game models.Game `json:"game"` + Chapters []models.Chapter `json:"chapters"` +} + +type ChapterMapsResponse struct { + Chapter models.Chapter `json:"chapter"` + Maps []models.MapShort `json:"maps"` +} + +// GET Map Summary +// +// @Description Get map summary with specified id. +// @Tags maps +// @Produce json +// @Param id path int true "Map ID" +// @Success 200 {object} models.Response{data=MapSummaryResponse} +// @Failure 400 {object} models.Response +// @Router /maps/{id}/summary [get] +func FetchMapSummary(c *gin.Context) { + id := c.Param("id") + response := MapSummaryResponse{Map: models.Map{}, Summary: models.MapSummary{Routes: []models.MapRoute{}}} + intID, err := strconv.Atoi(id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Get map data + response.Map.ID = intID + sql := `SELECT m.id, g.name, c.name, m.name, m.image, g.is_coop + FROM maps m + INNER JOIN games g ON m.game_id = g.id + INNER JOIN chapters c ON m.chapter_id = c.id + WHERE m.id = $1` + err = database.DB.QueryRow(sql, id).Scan(&response.Map.ID, &response.Map.GameName, &response.Map.ChapterName, &response.Map.MapName, &response.Map.Image, &response.Map.IsCoop) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Get map routes and histories + sql = `SELECT r.id, c.id, c.name, h.user_name, h.score_count, h.record_date, r.description, r.showcase, COALESCE(avg(rating), 0.0) FROM map_routes r + INNER JOIN categories c ON r.category_id = c.id + INNER JOIN map_history h ON r.map_id = h.map_id AND r.category_id = h.category_id + LEFT JOIN map_ratings rt ON r.map_id = rt.map_id AND r.category_id = rt.category_id + WHERE r.map_id = $1 AND h.score_count = r.score_count GROUP BY r.id, c.id, h.user_name, h.score_count, h.record_date, r.description, r.showcase + ORDER BY h.record_date ASC;` + rows, err := database.DB.Query(sql, id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + for rows.Next() { + route := models.MapRoute{Category: models.Category{}, History: models.MapHistory{}} + err = rows.Scan(&route.RouteID, &route.Category.ID, &route.Category.Name, &route.History.RunnerName, &route.History.ScoreCount, &route.History.Date, &route.Description, &route.Showcase, &route.Rating) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + response.Summary.Routes = append(response.Summary.Routes, route) + } + // Return response + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully retrieved map summary.", + Data: response, + }) +} + +// GET Map Leaderboards +// +// @Description Get map leaderboards with specified id. +// @Tags maps +// @Produce json +// @Param id path int true "Map ID" +// @Success 200 {object} models.Response{data=models.Map{data=models.MapRecords}} +// @Failure 400 {object} models.Response +// @Router /maps/{id}/leaderboards [get] +func FetchMapLeaderboards(c *gin.Context) { + // TODO: make new response type + id := c.Param("id") + // Get map data + var mapData models.Map + var mapRecordsData models.MapRecords + var isDisabled bool + intID, err := strconv.Atoi(id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + mapData.ID = intID + sql := `SELECT g.name, c.name, m.name, is_disabled, m.image + FROM maps m + INNER JOIN games g ON m.game_id = g.id + INNER JOIN chapters c ON m.chapter_id = c.id + WHERE m.id = $1` + err = database.DB.QueryRow(sql, id).Scan(&mapData.GameName, &mapData.ChapterName, &mapData.MapName, &isDisabled, &mapData.Image) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if isDisabled { + c.JSON(http.StatusBadRequest, models.ErrorResponse("Map is not available for competitive boards.")) + return + } + // TODO: avatar and names for host & partner + // Get records from the map + if mapData.GameName == "Portal 2 - Cooperative" { + var records []models.RecordMP + sql = `SELECT id, host_id, partner_id, score_count, score_time, host_demo_id, partner_demo_id, record_date + FROM ( + SELECT id, host_id, partner_id, score_count, score_time, host_demo_id, partner_demo_id, record_date, + ROW_NUMBER() OVER (PARTITION BY host_id, partner_id ORDER BY score_count, score_time) AS rn + FROM records_mp + WHERE map_id = $1 + ) sub + WHERE rn = 1` + rows, err := database.DB.Query(sql, id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + placement := 1 + ties := 0 + for rows.Next() { + var record models.RecordMP + err := rows.Scan(&record.RecordID, &record.HostID, &record.PartnerID, &record.ScoreCount, &record.ScoreTime, &record.HostDemoID, &record.PartnerDemoID, &record.RecordDate) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if len(records) != 0 && records[len(records)-1].ScoreTime == record.ScoreTime { + ties++ + record.Placement = placement - ties + } else { + record.Placement = placement + } + records = append(records, record) + placement++ + } + mapRecordsData.Records = records + } else { + var records []models.RecordSP + sql = `SELECT id, user_id, users.user_name, users.avatar_link, score_count, score_time, demo_id, record_date + FROM ( + SELECT id, user_id, score_count, score_time, demo_id, record_date, + ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY score_count, score_time) AS rn + FROM records_sp + WHERE map_id = $1 + ) sub + INNER JOIN users ON user_id = users.steam_id + WHERE rn = 1` + rows, err := database.DB.Query(sql, id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + placement := 1 + ties := 0 + for rows.Next() { + var record models.RecordSP + err := rows.Scan(&record.RecordID, &record.UserID, &record.UserName, &record.UserAvatar, &record.ScoreCount, &record.ScoreTime, &record.DemoID, &record.RecordDate) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if len(records) != 0 && records[len(records)-1].ScoreTime == record.ScoreTime { + ties++ + record.Placement = placement - ties + } else { + record.Placement = placement + } + records = append(records, record) + placement++ + } + mapRecordsData.Records = records + } + // mapData.Data = mapRecordsData + // Return response + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully retrieved map leaderboards.", + Data: mapData, + }) +} + +// GET Games +// +// @Description Get games from the leaderboards. +// @Tags games & chapters +// @Produce json +// @Success 200 {object} models.Response{data=[]models.Game} +// @Failure 400 {object} models.Response +// @Router /games [get] +func FetchGames(c *gin.Context) { + rows, err := database.DB.Query(`SELECT id, name, is_coop FROM games`) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var games []models.Game + for rows.Next() { + var game models.Game + if err := rows.Scan(&game.ID, &game.Name, &game.IsCoop); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + games = append(games, game) + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully retrieved games.", + Data: games, + }) +} + +// GET Chapters of a Game +// +// @Description Get chapters from the specified game id. +// @Tags games & chapters +// @Produce json +// @Param id path int true "Game ID" +// @Success 200 {object} models.Response{data=ChaptersResponse} +// @Failure 400 {object} models.Response +// @Router /games/{id} [get] +func FetchChapters(c *gin.Context) { + gameID := c.Param("id") + intID, err := strconv.Atoi(gameID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var response ChaptersResponse + rows, err := database.DB.Query(`SELECT c.id, c.name, g.name FROM chapters c INNER JOIN games g ON c.game_id = g.id WHERE game_id = $1`, gameID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var chapters []models.Chapter + var gameName string + for rows.Next() { + var chapter models.Chapter + if err := rows.Scan(&chapter.ID, &chapter.Name, &gameName); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + chapters = append(chapters, chapter) + } + response.Game.ID = intID + response.Game.Name = gameName + response.Chapters = chapters + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully retrieved chapters.", + Data: response, + }) +} + +// GET Maps of a Chapter +// +// @Description Get maps from the specified chapter id. +// @Tags games & chapters +// @Produce json +// @Param id path int true "Chapter ID" +// @Success 200 {object} models.Response{data=ChapterMapsResponse} +// @Failure 400 {object} models.Response +// @Router /chapters/{id} [get] +func FetchChapterMaps(c *gin.Context) { + chapterID := c.Param("id") + intID, err := strconv.Atoi(chapterID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var response ChapterMapsResponse + rows, err := database.DB.Query(`SELECT m.id, m.name, c.name FROM maps m INNER JOIN chapters c ON m.chapter_id = c.id WHERE chapter_id = $1`, chapterID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var maps []models.MapShort + var chapterName string + for rows.Next() { + var mapShort models.MapShort + if err := rows.Scan(&mapShort.ID, &mapShort.Name, &chapterName); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + maps = append(maps, mapShort) + } + response.Chapter.ID = intID + response.Chapter.Name = chapterName + response.Maps = maps + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully retrieved maps.", + Data: response, + }) +} diff --git a/backend/handlers/mod.go b/backend/handlers/mod.go new file mode 100644 index 0000000..e47cb3f --- /dev/null +++ b/backend/handlers/mod.go @@ -0,0 +1,334 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/pektezol/leastportalshub/backend/database" + "github.com/pektezol/leastportalshub/backend/models" +) + +type CreateMapSummaryRequest struct { + CategoryID int `json:"category_id" binding:"required"` + Description string `json:"description" binding:"required"` + Showcase string `json:"showcase"` + UserName string `json:"user_name" binding:"required"` + ScoreCount *int `json:"score_count" binding:"required"` + RecordDate time.Time `json:"record_date" binding:"required"` +} + +type EditMapSummaryRequest struct { + RouteID int `json:"route_id" binding:"required"` + Description string `json:"description" binding:"required"` + Showcase string `json:"showcase"` + UserName string `json:"user_name" binding:"required"` + ScoreCount *int `json:"score_count" binding:"required"` + RecordDate time.Time `json:"record_date" binding:"required"` +} + +type DeleteMapSummaryRequest struct { + RouteID int `json:"route_id" binding:"required"` +} + +type EditMapImageRequest struct { + Image string `json:"image" binding:"required"` +} + +// POST Map Summary +// +// @Description Create map summary with specified map id. +// @Tags maps +// @Produce json +// @Param Authorization header string true "JWT Token" +// @Param id path int true "Map ID" +// @Param request body CreateMapSummaryRequest true "Body" +// @Success 200 {object} models.Response{data=CreateMapSummaryRequest} +// @Failure 400 {object} models.Response +// @Router /maps/{id}/summary [post] +func CreateMapSummary(c *gin.Context) { + // Check if user exists + _, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) + return + } + mod, exists := c.Get("mod") + if !exists || !mod.(bool) { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions.")) + return + } + // Bind parameter and body + id := c.Param("id") + mapID, err := strconv.Atoi(id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var request CreateMapSummaryRequest + if err := c.BindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Start database transaction + tx, err := database.DB.Begin() + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) + return + } + defer tx.Rollback() + // Fetch route category and score count + var checkMapID int + sql := `SELECT m.id FROM maps m WHERE m.id = $1` + err = database.DB.QueryRow(sql, mapID).Scan(&checkMapID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if mapID != checkMapID { + c.JSON(http.StatusBadRequest, models.ErrorResponse("Map ID does not exist.")) + return + } + // Update database with new data + sql = `INSERT INTO map_routes (map_id,category_id,score_count,description,showcase) + VALUES ($1,$2,$3,$4,$5)` + _, err = tx.Exec(sql, mapID, request.CategoryID, request.ScoreCount, request.Description, request.Showcase) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + sql = `INSERT INTO map_history (map_id,category_id,user_name,score_count,record_date) + VALUES ($1,$2,$3,$4,$5)` + _, err = tx.Exec(sql, mapID, request.CategoryID, request.UserName, request.ScoreCount, request.RecordDate) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if err = tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) + return + } + // Return response + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully created map summary.", + Data: request, + }) +} + +// PUT Map Summary +// +// @Description Edit map summary with specified map id. +// @Tags maps +// @Produce json +// @Param Authorization header string true "JWT Token" +// @Param id path int true "Map ID" +// @Param request body EditMapSummaryRequest true "Body" +// @Success 200 {object} models.Response{data=EditMapSummaryRequest} +// @Failure 400 {object} models.Response +// @Router /maps/{id}/summary [put] +func EditMapSummary(c *gin.Context) { + // Check if user exists + _, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) + return + } + mod, exists := c.Get("mod") + if !exists || !mod.(bool) { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions.")) + return + } + // Bind parameter and body + id := c.Param("id") + mapID, err := strconv.Atoi(id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var request EditMapSummaryRequest + if err := c.BindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Start database transaction + tx, err := database.DB.Begin() + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) + return + } + defer tx.Rollback() + // Fetch route category and score count + var categoryID, scoreCount, historyID int + sql := `SELECT mr.category_id, mr.score_count FROM map_routes mr INNER JOIN maps m ON m.id = mr.map_id WHERE m.id = $1 AND mr.id = $2` + err = database.DB.QueryRow(sql, mapID, request.RouteID).Scan(&categoryID, &scoreCount) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + sql = `SELECT mh.id FROM map_history mh WHERE mh.score_count = $1 AND mh.category_id = $2 AND mh.map_id = $3` + err = database.DB.QueryRow(sql, scoreCount, categoryID, mapID).Scan(&historyID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Update database with new data + sql = `UPDATE map_routes SET score_count = $2, description = $3, showcase = $4 WHERE id = $1` + _, err = tx.Exec(sql, request.RouteID, request.ScoreCount, request.Description, request.Showcase) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + sql = `UPDATE map_history SET user_name = $2, score_count = $3, record_date = $4 WHERE id = $1` + _, err = tx.Exec(sql, historyID, request.UserName, request.ScoreCount, request.RecordDate) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if err = tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) + return + } + // Return response + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully updated map summary.", + Data: request, + }) +} + +// DELETE Map Summary +// +// @Description Delete map summary with specified map id. +// @Tags maps +// @Produce json +// @Param Authorization header string true "JWT Token" +// @Param id path int true "Map ID" +// @Param request body DeleteMapSummaryRequest true "Body" +// @Success 200 {object} models.Response{data=DeleteMapSummaryRequest} +// @Failure 400 {object} models.Response +// @Router /maps/{id}/summary [delete] +func DeleteMapSummary(c *gin.Context) { + // Check if user exists + _, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) + return + } + mod, exists := c.Get("mod") + if !exists || !mod.(bool) { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions.")) + return + } + // Bind parameter and body + id := c.Param("id") + mapID, err := strconv.Atoi(id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var request DeleteMapSummaryRequest + if err := c.BindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Start database transaction + tx, err := database.DB.Begin() + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) + return + } + defer tx.Rollback() + // Fetch route category and score count + var checkMapID, scoreCount, mapHistoryID int + sql := `SELECT m.id, mr.score_count FROM maps m INNER JOIN map_routes mr ON m.id=mr.map_id WHERE m.id = $1 AND mr.id = $2` + err = database.DB.QueryRow(sql, mapID, request.RouteID).Scan(&checkMapID, &scoreCount) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if mapID != checkMapID { + c.JSON(http.StatusBadRequest, models.ErrorResponse("Map ID does not exist.")) + return + } + sql = `SELECT mh.id FROM maps m INNER JOIN map_routes mr ON m.id=mr.map_id INNER JOIN map_history mh ON m.id=mh.map_id WHERE m.id = $1 AND mr.id = $2 AND mh.score_count = $3` + err = database.DB.QueryRow(sql, mapID, request.RouteID, scoreCount).Scan(&mapHistoryID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Update database with new data + sql = `DELETE FROM map_routes mr WHERE mr.id = $1 ` + _, err = tx.Exec(sql, request.RouteID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + sql = `DELETE FROM map_history mh WHERE mh.id = $1` + _, err = tx.Exec(sql, mapHistoryID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if err = tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) + return + } + // Return response + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully delete map summary.", + Data: request, + }) +} + +// PUT Map Image +// +// @Description Edit map image with specified map id. +// @Tags maps +// @Produce json +// @Param Authorization header string true "JWT Token" +// @Param id path int true "Map ID" +// @Param request body EditMapImageRequest true "Body" +// @Success 200 {object} models.Response{data=EditMapImageRequest} +// @Failure 400 {object} models.Response +// @Router /maps/{id}/image [put] +func EditMapImage(c *gin.Context) { + // Check if user exists + _, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) + return + } + mod, exists := c.Get("mod") + if !exists || !mod.(bool) { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions.")) + return + } + // Bind parameter and body + id := c.Param("id") + mapID, err := strconv.Atoi(id) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + var request EditMapImageRequest + if err := c.BindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Update database with new data + sql := `UPDATE maps SET image = $2 WHERE id = $1` + _, err = database.DB.Exec(sql, mapID, request.Image) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Return response + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully updated map image.", + Data: request, + }) +} diff --git a/backend/handlers/record.go b/backend/handlers/record.go new file mode 100644 index 0000000..00c9b7d --- /dev/null +++ b/backend/handlers/record.go @@ -0,0 +1,292 @@ +package handlers + +import ( + "context" + "encoding/base64" + "io" + "log" + "mime/multipart" + "net/http" + "os" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/pektezol/leastportalshub/backend/database" + "github.com/pektezol/leastportalshub/backend/models" + "github.com/pektezol/leastportalshub/backend/parser" + "golang.org/x/oauth2/google" + "golang.org/x/oauth2/jwt" + "google.golang.org/api/drive/v3" +) + +type RecordRequest struct { + HostDemo *multipart.FileHeader `json:"host_demo" form:"host_demo" binding:"required" swaggerignore:"true"` + PartnerDemo *multipart.FileHeader `json:"partner_demo" form:"partner_demo" swaggerignore:"true"` + IsPartnerOrange bool `json:"is_partner_orange" form:"is_partner_orange"` + PartnerID string `json:"partner_id" form:"partner_id"` +} + +type RecordResponse struct { + ScoreCount int `json:"score_count"` + ScoreTime int `json:"score_time"` +} + +// POST Record +// +// @Description Post record with demo of a specific map. +// @Tags maps +// @Accept mpfd +// @Produce json +// @Param id path int true "Map ID" +// @Param Authorization header string true "JWT Token" +// @Param host_demo formData file true "Host Demo" +// @Param partner_demo formData file false "Partner Demo" +// @Param is_partner_orange formData boolean false "Is Partner Orange" +// @Param partner_id formData string false "Partner ID" +// @Success 200 {object} models.Response{data=RecordResponse} +// @Failure 400 {object} models.Response +// @Failure 401 {object} models.Response +// @Router /maps/{id}/record [post] +func CreateRecordWithDemo(c *gin.Context) { + mapId := c.Param("id") + // Check if user exists + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) + return + } + // Check if map is sp or mp + var gameName string + var isCoop bool + var isDisabled bool + sql := `SELECT g.name, m.is_disabled FROM maps m INNER JOIN games g ON m.game_id=g.id WHERE m.id = $1` + err := database.DB.QueryRow(sql, mapId).Scan(&gameName, &isDisabled) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if isDisabled { + c.JSON(http.StatusBadRequest, models.ErrorResponse("Map is not available for competitive boards.")) + return + } + if gameName == "Portal 2 - Cooperative" { + isCoop = true + } + // Get record request + var record RecordRequest + if err := c.ShouldBind(&record); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if isCoop && (record.PartnerDemo == nil || record.PartnerID == "") { + c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid entry for coop record submission.")) + return + } + // Demo files + demoFiles := []*multipart.FileHeader{record.HostDemo} + if isCoop { + demoFiles = append(demoFiles, record.PartnerDemo) + } + var hostDemoUUID, hostDemoFileID, partnerDemoUUID, partnerDemoFileID string + var hostDemoScoreCount, hostDemoScoreTime int + client := serviceAccount() + srv, err := drive.New(client) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Create database transaction for inserts + tx, err := database.DB.Begin() + if err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) + return + } + // Defer to a rollback in case anything fails + defer tx.Rollback() + for i, header := range demoFiles { + uuid := uuid.New().String() + // Upload & insert into demos + err = c.SaveUploadedFile(header, "backend/parser/"+uuid+".dem") + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + defer os.Remove("backend/parser/" + uuid + ".dem") + f, err := os.Open("backend/parser/" + uuid + ".dem") + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + defer f.Close() + file, err := createFile(srv, uuid+".dem", "application/octet-stream", f, os.Getenv("GOOGLE_FOLDER_ID")) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + hostDemoScoreCount, hostDemoScoreTime, err = parser.ProcessDemo("backend/parser/" + uuid + ".dem") + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if i == 0 { + hostDemoFileID = file.Id + hostDemoUUID = uuid + } else if i == 1 { + partnerDemoFileID = file.Id + partnerDemoUUID = uuid + } + _, err = tx.Exec(`INSERT INTO demos (id,location_id) VALUES ($1,$2)`, uuid, file.Id) + if err != nil { + deleteFile(srv, file.Id) + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + } + // Insert into records + if isCoop { + sql := `INSERT INTO records_mp(map_id,score_count,score_time,host_id,partner_id,host_demo_id,partner_demo_id) + VALUES($1, $2, $3, $4, $5, $6, $7)` + var hostID string + var partnerID string + if record.IsPartnerOrange { + hostID = user.(models.User).SteamID + partnerID = record.PartnerID + } else { + partnerID = user.(models.User).SteamID + hostID = record.PartnerID + } + _, err := tx.Exec(sql, mapId, hostDemoScoreCount, hostDemoScoreTime, hostID, partnerID, hostDemoUUID, partnerDemoUUID) + if err != nil { + deleteFile(srv, hostDemoFileID) + deleteFile(srv, partnerDemoFileID) + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // If a new world record based on portal count + // if record.ScoreCount < wrScore { + // _, err := tx.Exec(`UPDATE maps SET wr_score = $1, wr_time = $2 WHERE id = $3`, record.ScoreCount, record.ScoreTime, mapId) + // if err != nil { + // c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + // return + // } + // } + } else { + sql := `INSERT INTO records_sp(map_id,score_count,score_time,user_id,demo_id) + VALUES($1, $2, $3, $4, $5)` + _, err := tx.Exec(sql, mapId, hostDemoScoreCount, hostDemoScoreTime, user.(models.User).SteamID, hostDemoUUID) + if err != nil { + deleteFile(srv, hostDemoFileID) + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // If a new world record based on portal count + // if record.ScoreCount < wrScore { + // _, err := tx.Exec(`UPDATE maps SET wr_score = $1, wr_time = $2 WHERE id = $3`, record.ScoreCount, record.ScoreTime, mapId) + // if err != nil { + // c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + // return + // } + // } + } + if err = tx.Commit(); err != nil { + c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error())) + return + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully created record.", + Data: RecordResponse{ScoreCount: hostDemoScoreCount, ScoreTime: hostDemoScoreTime}, + }) +} + +// GET Demo +// +// @Description Get demo with specified demo uuid. +// @Tags demo +// @Accept json +// @Produce octet-stream +// @Param uuid query string true "Demo UUID" +// @Success 200 {file} binary "Demo File" +// @Failure 400 {object} models.Response +// @Router /demos [get] +func DownloadDemoWithID(c *gin.Context) { + uuid := c.Query("uuid") + var locationID string + if uuid == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid id given.")) + return + } + err := database.DB.QueryRow(`SELECT location_id FROM demos WHERE id = $1`, uuid).Scan(&locationID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if locationID == "" { + c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid id given.")) + return + } + url := "https://drive.google.com/uc?export=download&id=" + locationID + fileName := uuid + ".dem" + output, err := os.Create(fileName) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + defer os.Remove(fileName) + defer output.Close() + response, err := http.Get(url) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + defer response.Body.Close() + _, err = io.Copy(output, response.Body) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Downloaded file + c.Header("Content-Description", "File Transfer") + c.Header("Content-Transfer-Encoding", "binary") + c.Header("Content-Disposition", "attachment; filename="+fileName) + c.Header("Content-Type", "application/octet-stream") + c.File(fileName) + // c.FileAttachment() +} + +// Use Service account +func serviceAccount() *http.Client { + privateKey, _ := base64.StdEncoding.DecodeString(os.Getenv("GOOGLE_PRIVATE_KEY_BASE64")) + config := &jwt.Config{ + Email: os.Getenv("GOOGLE_CLIENT_EMAIL"), + PrivateKey: []byte(privateKey), + Scopes: []string{ + drive.DriveScope, + }, + TokenURL: google.JWTTokenURL, + } + client := config.Client(context.Background()) + return client +} + +// Create Gdrive file +func createFile(service *drive.Service, name string, mimeType string, content io.Reader, parentId string) (*drive.File, error) { + f := &drive.File{ + MimeType: mimeType, + Name: name, + Parents: []string{parentId}, + } + file, err := service.Files.Create(f).Media(content).Do() + + if err != nil { + log.Println("Could not create file: " + err.Error()) + return nil, err + } + + return file, nil +} + +// Delete Gdrive file +func deleteFile(service *drive.Service, fileId string) { + service.Files.Delete(fileId) +} diff --git a/backend/handlers/user.go b/backend/handlers/user.go new file mode 100644 index 0000000..51eadb4 --- /dev/null +++ b/backend/handlers/user.go @@ -0,0 +1,383 @@ +package handlers + +import ( + "net/http" + "os" + "regexp" + "time" + + "github.com/gin-gonic/gin" + "github.com/pektezol/leastportalshub/backend/database" + "github.com/pektezol/leastportalshub/backend/models" +) + +type ProfileResponse struct { + Profile bool `json:"profile"` + SteamID string `json:"steam_id"` + UserName string `json:"user_name"` + AvatarLink string `json:"avatar_link"` + CountryCode string `json:"country_code"` + Titles []models.Title `json:"titles"` + Links models.Links `json:"links"` + Rankings ProfileRankings `json:"rankings"` + Records ProfileRecords `json:"records"` +} + +type ProfileRankings struct { + Overall ProfileRankingsDetails `json:"overall"` + Singleplayer ProfileRankingsDetails `json:"singleplayer"` + Cooperative ProfileRankingsDetails `json:"cooperative"` +} + +type ProfileRankingsDetails struct { + Rank int `json:"rank"` + CompletionCount int `json:"completion_count"` + CompletionTotal int `json:"completion_total"` +} + +type ProfileRecords struct { + P2Singleplayer []ProfileRecordsDetails `json:"portal2_singleplayer"` + P2Cooperative []ProfileRecordsDetails `json:"portal2_cooperative"` +} + +type ProfileRecordsDetails struct { + MapID int `json:"map_id"` + MapName string `json:"map_name"` + Scores []ProfileScores `json:"scores"` +} + +type ProfileScores struct { + DemoID string `json:"demo_id"` + ScoreCount int `json:"score_count"` + ScoreTime int `json:"score_time"` + Date time.Time `json:"date"` +} + +type ScoreResponse struct { + MapID int `json:"map_id"` + Records any `json:"records"` +} + +// GET Profile +// +// @Description Get profile page of session user. +// @Tags users +// @Accept json +// @Produce json +// @Param Authorization header string true "JWT Token" +// @Success 200 {object} models.Response{data=ProfileResponse} +// @Failure 400 {object} models.Response +// @Failure 401 {object} models.Response +// @Router /profile [get] +func Profile(c *gin.Context) { + // Check if user exists + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) + return + } + // Get user links + links := models.Links{} + sql := `SELECT u.p2sr, u.steam, u.youtube, u.twitch FROM users u WHERE u.steam_id = $1` + err := database.DB.QueryRow(sql, user.(models.User).SteamID).Scan(&links.P2SR, &links.Steam, &links.YouTube, &links.Twitch) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // TODO: Get rankings (all maps done in one game) + records := ProfileRecords{ + P2Singleplayer: []ProfileRecordsDetails{}, + P2Cooperative: []ProfileRecordsDetails{}, + } + // Get singleplayer records + sql = `SELECT m.game_id, sp.map_id, m."name", sp.score_count, sp.score_time, sp.demo_id, sp.record_date + FROM records_sp sp INNER JOIN maps m ON sp.map_id = m.id WHERE sp.user_id = $1 ORDER BY sp.map_id, sp.score_count, sp.score_time;` + rows, err := database.DB.Query(sql, user.(models.User).SteamID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + for rows.Next() { + var mapID int + var mapName string + var gameID int + score := ProfileScores{} + rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date) + if gameID != 1 { + continue + } + // More than one record in one map + if len(records.P2Singleplayer) != 0 && mapID == records.P2Singleplayer[len(records.P2Singleplayer)-1].MapID { + records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score) + continue + } + // New map + records.P2Singleplayer = append(records.P2Singleplayer, ProfileRecordsDetails{ + MapID: mapID, + MapName: mapName, + Scores: []ProfileScores{}, + }) + records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score) + } + // Get multiplayer records + sql = `SELECT m.game_id, mp.map_id, m."name", mp.score_count, mp.score_time, CASE WHEN host_id = $1 THEN mp.host_demo_id WHEN partner_id = $1 THEN mp.partner_demo_id END demo_id, mp.record_date + FROM records_mp mp INNER JOIN maps m ON mp.map_id = m.id WHERE mp.host_id = $1 OR mp.partner_id = $1 ORDER BY mp.map_id, mp.score_count, mp.score_time;` + rows, err = database.DB.Query(sql, user.(models.User).SteamID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + for rows.Next() { + var mapID int + var mapName string + var gameID int + score := ProfileScores{} + rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date) + if gameID != 1 { + continue + } + // More than one record in one map + if len(records.P2Cooperative) != 0 && mapID == records.P2Cooperative[len(records.P2Cooperative)-1].MapID { + records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score) + continue + } + // New map + records.P2Cooperative = append(records.P2Cooperative, ProfileRecordsDetails{ + MapID: mapID, + MapName: mapName, + Scores: []ProfileScores{}, + }) + records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score) + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully retrieved user scores.", + Data: ProfileResponse{ + Profile: true, + SteamID: user.(models.User).SteamID, + UserName: user.(models.User).UserName, + AvatarLink: user.(models.User).AvatarLink, + CountryCode: user.(models.User).CountryCode, + Titles: user.(models.User).Titles, + Links: links, + Rankings: ProfileRankings{}, + Records: records, + }, + }) +} + +// GET User +// +// @Description Get profile page of another user. +// @Tags users +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} models.Response{data=ProfileResponse} +// @Failure 400 {object} models.Response +// @Failure 404 {object} models.Response +// @Router /users/{id} [get] +func FetchUser(c *gin.Context) { + id := c.Param("id") + // Check if id is all numbers and 17 length + match, _ := regexp.MatchString("^[0-9]{17}$", id) + if !match { + c.JSON(http.StatusNotFound, models.ErrorResponse("User not found.")) + return + } + // Check if user exists + var user models.User + links := models.Links{} + sql := `SELECT u.steam_id, u.user_name, u.avatar_link, u.country_code, u.created_at, u.updated_at, u.p2sr, u.steam, u.youtube, u.twitch FROM users u WHERE u.steam_id = $1` + err := database.DB.QueryRow(sql, id).Scan(&user.SteamID, &user.UserName, &user.AvatarLink, &user.CountryCode, &user.CreatedAt, &user.UpdatedAt, &links.P2SR, &links.Steam, &links.YouTube, &links.Twitch) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + if user.SteamID == "" { + // User does not exist + c.JSON(http.StatusNotFound, models.ErrorResponse("User not found.")) + return + } + // Get user titles + sql = `SELECT t.title_name, t.title_color FROM titles t + INNER JOIN user_titles ut ON t.id=ut.title_id WHERE ut.user_id = $1` + rows, err := database.DB.Query(sql, user.SteamID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + for rows.Next() { + var title models.Title + if err := rows.Scan(&title.Name, &title.Color); err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + user.Titles = append(user.Titles, title) + } + // TODO: Get rankings (all maps done in one game) + records := ProfileRecords{ + P2Singleplayer: []ProfileRecordsDetails{}, + P2Cooperative: []ProfileRecordsDetails{}, + } + // Get singleplayer records + sql = `SELECT m.game_id, sp.map_id, m."name", sp.score_count, sp.score_time, sp.demo_id, sp.record_date + FROM records_sp sp INNER JOIN maps m ON sp.map_id = m.id WHERE sp.user_id = $1 ORDER BY sp.map_id, sp.score_count, sp.score_time;` + rows, err = database.DB.Query(sql, user.SteamID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + for rows.Next() { + var mapID int + var mapName string + var gameID int + score := ProfileScores{} + rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date) + if gameID != 1 { + continue + } + // More than one record in one map + if len(records.P2Singleplayer) != 0 && mapID == records.P2Singleplayer[len(records.P2Singleplayer)-1].MapID { + records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score) + continue + } + // New map + records.P2Singleplayer = append(records.P2Singleplayer, ProfileRecordsDetails{ + MapID: mapID, + MapName: mapName, + Scores: []ProfileScores{}, + }) + records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score) + } + // Get multiplayer records + sql = `SELECT m.game_id, mp.map_id, m."name", mp.score_count, mp.score_time, CASE WHEN host_id = $1 THEN mp.host_demo_id WHEN partner_id = $1 THEN mp.partner_demo_id END demo_id, mp.record_date + FROM records_mp mp INNER JOIN maps m ON mp.map_id = m.id WHERE mp.host_id = $1 OR mp.partner_id = $1 ORDER BY mp.map_id, mp.score_count, mp.score_time;` + rows, err = database.DB.Query(sql, user.SteamID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + for rows.Next() { + var mapID int + var mapName string + var gameID int + score := ProfileScores{} + rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date) + if gameID != 1 { + continue + } + // More than one record in one map + if len(records.P2Cooperative) != 0 && mapID == records.P2Cooperative[len(records.P2Cooperative)-1].MapID { + records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score) + continue + } + // New map + records.P2Cooperative = append(records.P2Cooperative, ProfileRecordsDetails{ + MapID: mapID, + MapName: mapName, + Scores: []ProfileScores{}, + }) + records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score) + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully retrieved user scores.", + Data: ProfileResponse{ + Profile: true, + SteamID: user.SteamID, + UserName: user.UserName, + AvatarLink: user.AvatarLink, + CountryCode: user.CountryCode, + Titles: user.Titles, + Links: links, + Rankings: ProfileRankings{}, + Records: records, + }, + }) +} + +// PUT Profile +// +// @Description Update profile page of session user. +// @Tags users +// @Accept json +// @Produce json +// @Param Authorization header string true "JWT Token" +// @Success 200 {object} models.Response{data=ProfileResponse} +// @Failure 400 {object} models.Response +// @Failure 401 {object} models.Response +// @Router /profile [post] +func UpdateUser(c *gin.Context) { + // Check if user exists + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) + return + } + profile, err := GetPlayerSummaries(user.(models.User).SteamID, os.Getenv("API_KEY")) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + // Update profile + _, err = database.DB.Exec(`UPDATE users SET username = $1, avatar_link = $2, country_code = $3, updated_at = $4 + WHERE steam_id = $5`, profile.PersonaName, profile.AvatarFull, profile.LocCountryCode, time.Now().UTC(), user.(models.User).SteamID) + if err != nil { + c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error())) + return + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully updated user.", + Data: ProfileResponse{ + Profile: true, + SteamID: user.(models.User).SteamID, + UserName: profile.PersonaName, + AvatarLink: profile.AvatarFull, + CountryCode: profile.LocCountryCode, + }, + }) +} + +// PUT Profile/CountryCode +// +// @Description Update country code of session user. +// @Tags users +// @Accept json +// @Produce json +// @Param Authorization header string true "JWT Token" +// @Param country_code query string true "Country Code [XX]" +// @Success 200 {object} models.Response +// @Failure 400 {object} models.Response +// @Failure 401 {object} models.Response +// @Router /profile [put] +func UpdateCountryCode(c *gin.Context) { + // Check if user exists + user, exists := c.Get("user") + if !exists { + c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in.")) + return + } + code := c.Query("country_code") + if code == "" { + c.JSON(http.StatusNotFound, models.ErrorResponse("Enter a valid country code.")) + return + } + var validCode string + err := database.DB.QueryRow(`SELECT country_code FROM countries WHERE country_code = $1`, code).Scan(&validCode) + if err != nil { + c.JSON(http.StatusNotFound, models.ErrorResponse(err.Error())) + return + } + // Valid code, update profile + _, err = database.DB.Exec(`UPDATE users SET country_code = $1 WHERE steam_id = $2`, validCode, user.(models.User).SteamID) + if err != nil { + c.JSON(http.StatusNotFound, models.ErrorResponse(err.Error())) + return + } + c.JSON(http.StatusOK, models.Response{ + Success: true, + Message: "Successfully updated country code.", + }) +} diff --git a/backend/middleware/auth.go b/backend/middleware/auth.go deleted file mode 100644 index e2c84fa..0000000 --- a/backend/middleware/auth.go +++ /dev/null @@ -1,65 +0,0 @@ -package middleware - -import ( - "fmt" - "os" - "time" - - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v4" - "github.com/pektezol/leastportalshub/backend/database" - "github.com/pektezol/leastportalshub/backend/models" -) - -func CheckAuth(c *gin.Context) { - tokenString := c.GetHeader("Authorization") - // Validate token - token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"]) - } - return []byte(os.Getenv("SECRET_KEY")), nil - }) - if token == nil { - c.Next() - return - } - if err != nil { - c.Next() - return - } - if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { - // Check exp - if float64(time.Now().Unix()) > claims["exp"].(float64) { - c.Next() - return - } - // Get user from DB - var user models.User - database.DB.QueryRow(`SELECT u.steam_id, u.user_name, u.avatar_link, u.country_code, u.created_at, u.updated_at FROM users u WHERE steam_id = $1`, claims["sub"]).Scan( - &user.SteamID, &user.UserName, &user.AvatarLink, - &user.CountryCode, &user.CreatedAt, &user.UpdatedAt) - if user.SteamID == "" { - c.Next() - return - } - // Get user titles from DB - var moderator bool - user.Titles = []models.Title{} - rows, _ := database.DB.Query(`SELECT t.title_name, t.title_color FROM titles t INNER JOIN user_titles ut ON t.id=ut.title_id WHERE ut.user_id = $1`, user.SteamID) - for rows.Next() { - var title models.Title - rows.Scan(&title.Name, &title.Color) - if title.Name == "Moderator" { - moderator = true - } - user.Titles = append(user.Titles, title) - } - c.Set("user", user) - c.Set("mod", moderator) - c.Next() - } else { - c.Next() - return - } -} diff --git a/backend/routes/routes.go b/backend/routes/routes.go deleted file mode 100644 index 0b80678..0000000 --- a/backend/routes/routes.go +++ /dev/null @@ -1,41 +0,0 @@ -package routes - -import ( - "github.com/gin-gonic/gin" - "github.com/pektezol/leastportalshub/backend/controllers" - "github.com/pektezol/leastportalshub/backend/middleware" - swaggerfiles "github.com/swaggo/files" - ginSwagger "github.com/swaggo/gin-swagger" -) - -func InitRoutes(router *gin.Engine) { - api := router.Group("/api") - { - v1 := api.Group("/v1") - v1.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) - v1.GET("/", func(c *gin.Context) { - c.File("docs/index.html") - }) - v1.GET("/token", controllers.GetCookie) - v1.DELETE("/token", controllers.DeleteCookie) - v1.GET("/home", middleware.CheckAuth, controllers.Home) - v1.GET("/login", controllers.Login) - v1.GET("/profile", middleware.CheckAuth, controllers.Profile) - v1.PUT("/profile", middleware.CheckAuth, controllers.UpdateCountryCode) - v1.POST("/profile", middleware.CheckAuth, controllers.UpdateUser) - v1.GET("/users/:id", middleware.CheckAuth, controllers.FetchUser) - v1.GET("/demos", controllers.DownloadDemoWithID) - v1.GET("/maps/:id/summary", controllers.FetchMapSummary) - v1.POST("/maps/:id/summary", middleware.CheckAuth, controllers.CreateMapSummary) - v1.PUT("/maps/:id/summary", middleware.CheckAuth, controllers.EditMapSummary) - v1.DELETE("/maps/:id/summary", middleware.CheckAuth, controllers.DeleteMapSummary) - v1.PUT("/maps/:id/image", middleware.CheckAuth, controllers.EditMapImage) - v1.GET("/maps/:id/leaderboards", controllers.FetchMapLeaderboards) - v1.POST("/maps/:id/record", middleware.CheckAuth, controllers.CreateRecordWithDemo) - v1.GET("/rankings", controllers.Rankings) - v1.GET("/search", controllers.SearchWithQuery) - v1.GET("/games", controllers.FetchGames) - v1.GET("/games/:id", controllers.FetchChapters) - v1.GET("/chapters/:id", controllers.FetchChapterMaps) - } -} diff --git a/main.go b/main.go index 69f7c86..868db1e 100644 --- a/main.go +++ b/main.go @@ -7,8 +7,8 @@ import ( "github.com/gin-gonic/gin" "github.com/joho/godotenv" + "github.com/pektezol/leastportalshub/backend/api" "github.com/pektezol/leastportalshub/backend/database" - "github.com/pektezol/leastportalshub/backend/routes" _ "github.com/pektezol/leastportalshub/docs" ) @@ -19,8 +19,8 @@ import ( // @license.name GNU General Public License, Version 2 // @license.url https://www.gnu.org/licenses/old-licenses/gpl-2.0.html -// @host lp.ardapektezol.com/api -// @BasePath /v1 +// @host lp.ardapektezol.com/api +// @BasePath /v1 func main() { if os.Getenv("ENV") == "PROD" { gin.SetMode(gin.ReleaseMode) @@ -31,6 +31,6 @@ func main() { } router := gin.Default() database.ConnectDB() - routes.InitRoutes(router) + api.InitRoutes(router) router.Run(fmt.Sprintf(":%s", os.Getenv("PORT"))) } -- cgit v1.2.3