aboutsummaryrefslogtreecommitdiff
path: root/backend/handlers
diff options
context:
space:
mode:
authorArda Serdar Pektezol <1669855+pektezol@users.noreply.github.com>2023-08-26 08:53:24 +0300
committerArda Serdar Pektezol <1669855+pektezol@users.noreply.github.com>2023-08-26 08:53:24 +0300
commitf1b7589b2936335957a6a1da1eea3d66233ad0ce (patch)
tree1975af217c190f5dbdb23b96015cef45206302d4 /backend/handlers
parentdocs: profile improvement swagger (#51) (diff)
downloadlphub-f1b7589b2936335957a6a1da1eea3d66233ad0ce.tar.gz
lphub-f1b7589b2936335957a6a1da1eea3d66233ad0ce.tar.bz2
lphub-f1b7589b2936335957a6a1da1eea3d66233ad0ce.zip
refactor: reorganizing packages
Former-commit-id: 99410223654c2a5ffc15fdab6ec3e921b5410cba
Diffstat (limited to 'backend/handlers')
-rw-r--r--backend/handlers/home.go294
-rw-r--r--backend/handlers/login.go166
-rw-r--r--backend/handlers/map.go314
-rw-r--r--backend/handlers/mod.go334
-rw-r--r--backend/handlers/record.go292
-rw-r--r--backend/handlers/user.go383
6 files changed, 1783 insertions, 0 deletions
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 @@
1package handlers
2
3import (
4 "log"
5 "net/http"
6 "strings"
7
8 "github.com/gin-gonic/gin"
9 "github.com/pektezol/leastportalshub/backend/database"
10 "github.com/pektezol/leastportalshub/backend/models"
11)
12
13type SearchResponse struct {
14 Players []models.UserShort `json:"players"`
15 Maps []models.MapShort `json:"maps"`
16}
17
18type RankingsResponse struct {
19 RankingsSP []models.UserRanking `json:"rankings_sp"`
20 RankingsMP []models.UserRanking `json:"rankings_mp"`
21}
22
23func Home(c *gin.Context) {
24 user, exists := c.Get("user")
25 if !exists {
26 c.JSON(200, "no id, not auth")
27 } else {
28 c.JSON(200, gin.H{
29 "output": user,
30 })
31 }
32}
33
34// GET Rankings
35//
36// @Description Get rankings of every player.
37// @Tags rankings
38// @Produce json
39// @Success 200 {object} models.Response{data=RankingsResponse}
40// @Failure 400 {object} models.Response
41// @Router /rankings [get]
42func Rankings(c *gin.Context) {
43 rows, err := database.DB.Query(`SELECT steam_id, user_name FROM users`)
44 if err != nil {
45 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
46 return
47 }
48 var spRankings []models.UserRanking
49 var mpRankings []models.UserRanking
50 for rows.Next() {
51 var userID, username string
52 err := rows.Scan(&userID, &username)
53 if err != nil {
54 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
55 return
56 }
57 // Getting all sp records for each user
58 var uniqueSingleUserRecords, totalSingleMaps int
59 sql := `SELECT COUNT(DISTINCT map_id), (SELECT COUNT(map_name) FROM maps
60 WHERE is_coop = FALSE AND is_disabled = false) FROM records_sp WHERE user_id = $1`
61 err = database.DB.QueryRow(sql, userID).Scan(&uniqueSingleUserRecords, &totalSingleMaps)
62 if err != nil {
63 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
64 return
65 }
66 // Has all singleplayer records
67 if uniqueSingleUserRecords == totalSingleMaps {
68 var ranking models.UserRanking
69 ranking.UserID = userID
70 ranking.UserName = username
71 sql := `SELECT DISTINCT map_id, score_count FROM records_sp WHERE user_id = $1 ORDER BY map_id, score_count`
72 rows, err := database.DB.Query(sql, userID)
73 if err != nil {
74 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
75 return
76 }
77 totalScore := 0
78 var maps []int
79 for rows.Next() {
80 var mapID, scoreCount int
81 rows.Scan(&mapID, &scoreCount)
82 if len(maps) != 0 && maps[len(maps)-1] == mapID {
83 continue
84 }
85 totalScore += scoreCount
86 maps = append(maps, mapID)
87 }
88 ranking.TotalScore = totalScore
89 spRankings = append(spRankings, ranking)
90 }
91 // Getting all mp records for each user
92 var uniqueMultiUserRecords, totalMultiMaps int
93 sql = `SELECT COUNT(DISTINCT map_id), (SELECT COUNT(map_name) FROM maps
94 WHERE is_coop = TRUE AND is_disabled = false) FROM records_mp WHERE host_id = $1 OR partner_id = $2`
95 err = database.DB.QueryRow(sql, userID, userID).Scan(&uniqueMultiUserRecords, &totalMultiMaps)
96 if err != nil {
97 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
98 return
99 }
100 // Has all singleplayer records
101 if uniqueMultiUserRecords == totalMultiMaps {
102 var ranking models.UserRanking
103 ranking.UserID = userID
104 ranking.UserName = username
105 sql := `SELECT DISTINCT map_id, score_count FROM records_mp WHERE host_id = $1 OR partner_id = $2 ORDER BY map_id, score_count`
106 rows, err := database.DB.Query(sql, userID, userID)
107 if err != nil {
108 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
109 return
110 }
111 totalScore := 0
112 var maps []int
113 for rows.Next() {
114 var mapID, scoreCount int
115 rows.Scan(&mapID, &scoreCount)
116 if len(maps) != 0 && maps[len(maps)-1] == mapID {
117 continue
118 }
119 totalScore += scoreCount
120 maps = append(maps, mapID)
121 }
122 ranking.TotalScore = totalScore
123 mpRankings = append(mpRankings, ranking)
124 }
125 }
126 c.JSON(http.StatusOK, models.Response{
127 Success: true,
128 Message: "Successfully retrieved rankings.",
129 Data: RankingsResponse{
130 RankingsSP: spRankings,
131 RankingsMP: mpRankings,
132 },
133 })
134}
135
136// GET Search With Query
137//
138// @Description Get all user and map data matching to the query.
139// @Tags search
140// @Produce json
141// @Param q query string false "Search user or map name."
142// @Success 200 {object} models.Response{data=SearchResponse}
143// @Failure 400 {object} models.Response
144// @Router /search [get]
145func SearchWithQuery(c *gin.Context) {
146 query := c.Query("q")
147 query = strings.ToLower(query)
148 log.Println(query)
149 var response SearchResponse
150 // Cache all maps for faster response
151 var maps = []models.MapShort{
152 {ID: 1, Name: "Container Ride"},
153 {ID: 2, Name: "Portal Carousel"},
154 {ID: 3, Name: "Portal Gun"},
155 {ID: 4, Name: "Smooth Jazz"},
156 {ID: 5, Name: "Cube Momentum"},
157 {ID: 6, Name: "Future Starter"},
158 {ID: 7, Name: "Secret Panel"},
159 {ID: 8, Name: "Wakeup"},
160 {ID: 9, Name: "Incinerator"},
161 {ID: 10, Name: "Laser Intro"},
162 {ID: 11, Name: "Laser Stairs"},
163 {ID: 12, Name: "Dual Lasers"},
164 {ID: 13, Name: "Laser Over Goo"},
165 {ID: 14, Name: "Catapult Intro"},
166 {ID: 15, Name: "Trust Fling"},
167 {ID: 16, Name: "Pit Flings"},
168 {ID: 17, Name: "Fizzler Intro"},
169 {ID: 18, Name: "Ceiling Catapult"},
170 {ID: 19, Name: "Ricochet"},
171 {ID: 20, Name: "Bridge Intro"},
172 {ID: 21, Name: "Bridge The Gap"},
173 {ID: 22, Name: "Turret Intro"},
174 {ID: 23, Name: "Laser Relays"},
175 {ID: 24, Name: "Turret Blocker"},
176 {ID: 25, Name: "Laser vs Turret"},
177 {ID: 26, Name: "Pull The Rug"},
178 {ID: 27, Name: "Column Blocker"},
179 {ID: 28, Name: "Laser Chaining"},
180 {ID: 29, Name: "Triple Laser"},
181 {ID: 30, Name: "Jail Break"},
182 {ID: 31, Name: "Escape"},
183 {ID: 32, Name: "Turret Factory"},
184 {ID: 33, Name: "Turret Sabotage"},
185 {ID: 34, Name: "Neurotoxin Sabotage"},
186 {ID: 35, Name: "Core"},
187 {ID: 36, Name: "Underground"},
188 {ID: 37, Name: "Cave Johnson"},
189 {ID: 38, Name: "Repulsion Intro"},
190 {ID: 39, Name: "Bomb Flings"},
191 {ID: 40, Name: "Crazy Box"},
192 {ID: 41, Name: "PotatOS"},
193 {ID: 42, Name: "Propulsion Intro"},
194 {ID: 43, Name: "Propulsion Flings"},
195 {ID: 44, Name: "Conversion Intro"},
196 {ID: 45, Name: "Three Gels"},
197 {ID: 46, Name: "Test"},
198 {ID: 47, Name: "Funnel Intro"},
199 {ID: 48, Name: "Ceiling Button"},
200 {ID: 49, Name: "Wall Button"},
201 {ID: 50, Name: "Polarity"},
202 {ID: 51, Name: "Funnel Catch"},
203 {ID: 52, Name: "Stop The Box"},
204 {ID: 53, Name: "Laser Catapult"},
205 {ID: 54, Name: "Laser Platform"},
206 {ID: 55, Name: "Propulsion Catch"},
207 {ID: 56, Name: "Repulsion Polarity"},
208 {ID: 57, Name: "Finale 1"},
209 {ID: 58, Name: "Finale 2"},
210 {ID: 59, Name: "Finale 3"},
211 {ID: 60, Name: "Finale 4"},
212 {ID: 61, Name: "Calibration"},
213 {ID: 62, Name: "Hub"},
214 {ID: 63, Name: "Doors"},
215 {ID: 64, Name: "Buttons"},
216 {ID: 65, Name: "Lasers"},
217 {ID: 66, Name: "Rat Maze"},
218 {ID: 67, Name: "Laser Crusher"},
219 {ID: 68, Name: "Behind The Scenes"},
220 {ID: 69, Name: "Flings"},
221 {ID: 70, Name: "Infinifling"},
222 {ID: 71, Name: "Team Retrieval"},
223 {ID: 72, Name: "Vertical Flings"},
224 {ID: 73, Name: "Catapults"},
225 {ID: 74, Name: "Multifling"},
226 {ID: 75, Name: "Fling Crushers"},
227 {ID: 76, Name: "Industrial Fan"},
228 {ID: 77, Name: "Cooperative Bridges"},
229 {ID: 78, Name: "Bridge Swap"},
230 {ID: 79, Name: "Fling Block"},
231 {ID: 80, Name: "Catapult Block"},
232 {ID: 81, Name: "Bridge Fling"},
233 {ID: 82, Name: "Turret Walls"},
234 {ID: 83, Name: "Turret Assasin"},
235 {ID: 84, Name: "Bridge Testing"},
236 {ID: 85, Name: "Cooperative Funnels"},
237 {ID: 86, Name: "Funnel Drill"},
238 {ID: 87, Name: "Funnel Catch"},
239 {ID: 88, Name: "Funnel Laser"},
240 {ID: 89, Name: "Cooperative Polarity"},
241 {ID: 90, Name: "Funnel Hop"},
242 {ID: 91, Name: "Advanced Polarity"},
243 {ID: 92, Name: "Funnel Maze"},
244 {ID: 93, Name: "Turret Warehouse"},
245 {ID: 94, Name: "Repulsion Jumps"},
246 {ID: 95, Name: "Double Bounce"},
247 {ID: 96, Name: "Bridge Repulsion"},
248 {ID: 97, Name: "Wall Repulsion"},
249 {ID: 98, Name: "Propulsion Crushers"},
250 {ID: 99, Name: "Turret Ninja"},
251 {ID: 100, Name: "Propulsion Retrieval"},
252 {ID: 101, Name: "Vault Entrance"},
253 {ID: 102, Name: "Seperation"},
254 {ID: 103, Name: "Triple Axis"},
255 {ID: 104, Name: "Catapult Catch"},
256 {ID: 105, Name: "Bridge Gels"},
257 {ID: 106, Name: "Maintenance"},
258 {ID: 107, Name: "Bridge Catch"},
259 {ID: 108, Name: "Double Lift"},
260 {ID: 109, Name: "Gel Maze"},
261 {ID: 110, Name: "Crazier Box"},
262 }
263 var filteredMaps []models.MapShort
264 for _, m := range maps {
265 if strings.Contains(strings.ToLower(m.Name), strings.ToLower(query)) {
266 filteredMaps = append(filteredMaps, m)
267 }
268 }
269 response.Maps = filteredMaps
270 if len(response.Maps) == 0 {
271 response.Maps = []models.MapShort{}
272 }
273 rows, err := database.DB.Query("SELECT steam_id, user_name FROM users WHERE lower(user_name) LIKE $1", "%"+query+"%")
274 if err != nil {
275 log.Fatal(err)
276 }
277 defer rows.Close()
278 for rows.Next() {
279 var user models.UserShort
280 if err := rows.Scan(&user.SteamID, &user.UserName); err != nil {
281 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
282 return
283 }
284 response.Players = append(response.Players, user)
285 }
286 if len(response.Players) == 0 {
287 response.Players = []models.UserShort{}
288 }
289 c.JSON(http.StatusOK, models.Response{
290 Success: true,
291 Message: "Search successfully retrieved.",
292 Data: response,
293 })
294}
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 @@
1package handlers
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "net/http"
8 "os"
9 "time"
10
11 "github.com/gin-gonic/gin"
12 "github.com/golang-jwt/jwt/v4"
13 "github.com/pektezol/leastportalshub/backend/database"
14 "github.com/pektezol/leastportalshub/backend/models"
15 "github.com/solovev/steam_go"
16)
17
18type LoginResponse struct {
19 Token string `json:"token"`
20}
21
22// Login
23//
24// @Description Get (redirect) login page for Steam auth.
25// @Tags login
26// @Accept json
27// @Produce json
28// @Success 200 {object} models.Response{data=LoginResponse}
29// @Failure 400 {object} models.Response
30// @Router /login [get]
31func Login(c *gin.Context) {
32 openID := steam_go.NewOpenId(c.Request)
33 switch openID.Mode() {
34 case "":
35 c.Redirect(http.StatusMovedPermanently, openID.AuthUrl())
36 case "cancel":
37 c.Redirect(http.StatusMovedPermanently, "/")
38 default:
39 steamID, err := openID.ValidateAndGetId()
40 if err != nil {
41 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
42 return
43 }
44 // Create user if new
45 var checkSteamID int64
46 database.DB.QueryRow("SELECT steam_id FROM users WHERE steam_id = $1", steamID).Scan(&checkSteamID)
47 // User does not exist
48 if checkSteamID == 0 {
49 user, err := GetPlayerSummaries(steamID, os.Getenv("API_KEY"))
50 if err != nil {
51 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
52 return
53 }
54 // Empty country code check
55 if user.LocCountryCode == "" {
56 user.LocCountryCode = "XX"
57 }
58 // Insert new user to database
59 database.DB.Exec(`INSERT INTO users (steam_id, user_name, avatar_link, country_code)
60 VALUES ($1, $2, $3, $4)`, steamID, user.PersonaName, user.AvatarFull, user.LocCountryCode)
61 }
62 moderator := false
63 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)
64 for rows.Next() {
65 var title string
66 rows.Scan(&title)
67 if title == "Moderator" {
68 moderator = true
69 }
70 }
71 // Generate JWT token
72 token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
73 "sub": steamID,
74 "exp": time.Now().Add(time.Hour * 24 * 30).Unix(),
75 "mod": moderator,
76 })
77 // Sign and get the complete encoded token as a string using the secret
78 tokenString, err := token.SignedString([]byte(os.Getenv("SECRET_KEY")))
79 if err != nil {
80 c.JSON(http.StatusBadRequest, models.ErrorResponse("Failed to generate token."))
81 return
82 }
83 c.SetCookie("token", tokenString, 3600*24*30, "/", "", true, true)
84 c.Redirect(http.StatusTemporaryRedirect, "/")
85 // c.JSON(http.StatusOK, models.Response{
86 // Success: true,
87 // Message: "Successfully generated token.",
88 // Data: LoginResponse{
89 // Token: tokenString,
90 // },
91 // })
92 return
93 }
94}
95
96// GET Token
97//
98// @Description Gets the token cookie value from the user.
99// @Tags auth
100// @Produce json
101//
102// @Success 200 {object} models.Response{data=LoginResponse}
103// @Failure 404 {object} models.Response
104// @Router /token [get]
105func GetCookie(c *gin.Context) {
106 cookie, err := c.Cookie("token")
107 if err != nil {
108 c.JSON(http.StatusNotFound, models.ErrorResponse("No token cookie found."))
109 return
110 }
111 c.JSON(http.StatusOK, models.Response{
112 Success: true,
113 Message: "Token cookie successfully retrieved.",
114 Data: LoginResponse{
115 Token: cookie,
116 },
117 })
118}
119
120// DELETE Token
121//
122// @Description Deletes the token cookie from the user.
123// @Tags auth
124// @Produce json
125//
126// @Success 200 {object} models.Response{data=LoginResponse}
127// @Failure 404 {object} models.Response
128// @Router /token [delete]
129func DeleteCookie(c *gin.Context) {
130 cookie, err := c.Cookie("token")
131 if err != nil {
132 c.JSON(http.StatusNotFound, models.ErrorResponse("No token cookie found."))
133 return
134 }
135 c.SetCookie("token", "", -1, "/", "", true, true)
136 c.JSON(http.StatusOK, models.Response{
137 Success: true,
138 Message: "Token cookie successfully deleted.",
139 Data: LoginResponse{
140 Token: cookie,
141 },
142 })
143}
144
145func GetPlayerSummaries(steamId, apiKey string) (*models.PlayerSummaries, error) {
146 url := fmt.Sprintf("http://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=%s&steamids=%s", apiKey, steamId)
147 resp, err := http.Get(url)
148 if err != nil {
149 return nil, err
150 }
151 body, err := io.ReadAll(resp.Body)
152 if err != nil {
153 return nil, err
154 }
155
156 type Result struct {
157 Response struct {
158 Players []models.PlayerSummaries `json:"players"`
159 } `json:"response"`
160 }
161 var data Result
162 if err := json.Unmarshal(body, &data); err != nil {
163 return nil, err
164 }
165 return &data.Response.Players[0], err
166}
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 @@
1package handlers
2
3import (
4 "net/http"
5 "strconv"
6
7 "github.com/gin-gonic/gin"
8 "github.com/pektezol/leastportalshub/backend/database"
9 "github.com/pektezol/leastportalshub/backend/models"
10)
11
12type MapSummaryResponse struct {
13 Map models.Map `json:"map"`
14 Summary models.MapSummary `json:"summary"`
15}
16
17type ChaptersResponse struct {
18 Game models.Game `json:"game"`
19 Chapters []models.Chapter `json:"chapters"`
20}
21
22type ChapterMapsResponse struct {
23 Chapter models.Chapter `json:"chapter"`
24 Maps []models.MapShort `json:"maps"`
25}
26
27// GET Map Summary
28//
29// @Description Get map summary with specified id.
30// @Tags maps
31// @Produce json
32// @Param id path int true "Map ID"
33// @Success 200 {object} models.Response{data=MapSummaryResponse}
34// @Failure 400 {object} models.Response
35// @Router /maps/{id}/summary [get]
36func FetchMapSummary(c *gin.Context) {
37 id := c.Param("id")
38 response := MapSummaryResponse{Map: models.Map{}, Summary: models.MapSummary{Routes: []models.MapRoute{}}}
39 intID, err := strconv.Atoi(id)
40 if err != nil {
41 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
42 return
43 }
44 // Get map data
45 response.Map.ID = intID
46 sql := `SELECT m.id, g.name, c.name, m.name, m.image, g.is_coop
47 FROM maps m
48 INNER JOIN games g ON m.game_id = g.id
49 INNER JOIN chapters c ON m.chapter_id = c.id
50 WHERE m.id = $1`
51 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)
52 if err != nil {
53 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
54 return
55 }
56 // Get map routes and histories
57 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
58 INNER JOIN categories c ON r.category_id = c.id
59 INNER JOIN map_history h ON r.map_id = h.map_id AND r.category_id = h.category_id
60 LEFT JOIN map_ratings rt ON r.map_id = rt.map_id AND r.category_id = rt.category_id
61 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
62 ORDER BY h.record_date ASC;`
63 rows, err := database.DB.Query(sql, id)
64 if err != nil {
65 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
66 return
67 }
68 for rows.Next() {
69 route := models.MapRoute{Category: models.Category{}, History: models.MapHistory{}}
70 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)
71 if err != nil {
72 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
73 return
74 }
75 response.Summary.Routes = append(response.Summary.Routes, route)
76 }
77 // Return response
78 c.JSON(http.StatusOK, models.Response{
79 Success: true,
80 Message: "Successfully retrieved map summary.",
81 Data: response,
82 })
83}
84
85// GET Map Leaderboards
86//
87// @Description Get map leaderboards with specified id.
88// @Tags maps
89// @Produce json
90// @Param id path int true "Map ID"
91// @Success 200 {object} models.Response{data=models.Map{data=models.MapRecords}}
92// @Failure 400 {object} models.Response
93// @Router /maps/{id}/leaderboards [get]
94func FetchMapLeaderboards(c *gin.Context) {
95 // TODO: make new response type
96 id := c.Param("id")
97 // Get map data
98 var mapData models.Map
99 var mapRecordsData models.MapRecords
100 var isDisabled bool
101 intID, err := strconv.Atoi(id)
102 if err != nil {
103 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
104 return
105 }
106 mapData.ID = intID
107 sql := `SELECT g.name, c.name, m.name, is_disabled, m.image
108 FROM maps m
109 INNER JOIN games g ON m.game_id = g.id
110 INNER JOIN chapters c ON m.chapter_id = c.id
111 WHERE m.id = $1`
112 err = database.DB.QueryRow(sql, id).Scan(&mapData.GameName, &mapData.ChapterName, &mapData.MapName, &isDisabled, &mapData.Image)
113 if err != nil {
114 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
115 return
116 }
117 if isDisabled {
118 c.JSON(http.StatusBadRequest, models.ErrorResponse("Map is not available for competitive boards."))
119 return
120 }
121 // TODO: avatar and names for host & partner
122 // Get records from the map
123 if mapData.GameName == "Portal 2 - Cooperative" {
124 var records []models.RecordMP
125 sql = `SELECT id, host_id, partner_id, score_count, score_time, host_demo_id, partner_demo_id, record_date
126 FROM (
127 SELECT id, host_id, partner_id, score_count, score_time, host_demo_id, partner_demo_id, record_date,
128 ROW_NUMBER() OVER (PARTITION BY host_id, partner_id ORDER BY score_count, score_time) AS rn
129 FROM records_mp
130 WHERE map_id = $1
131 ) sub
132 WHERE rn = 1`
133 rows, err := database.DB.Query(sql, id)
134 if err != nil {
135 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
136 return
137 }
138 placement := 1
139 ties := 0
140 for rows.Next() {
141 var record models.RecordMP
142 err := rows.Scan(&record.RecordID, &record.HostID, &record.PartnerID, &record.ScoreCount, &record.ScoreTime, &record.HostDemoID, &record.PartnerDemoID, &record.RecordDate)
143 if err != nil {
144 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
145 return
146 }
147 if len(records) != 0 && records[len(records)-1].ScoreTime == record.ScoreTime {
148 ties++
149 record.Placement = placement - ties
150 } else {
151 record.Placement = placement
152 }
153 records = append(records, record)
154 placement++
155 }
156 mapRecordsData.Records = records
157 } else {
158 var records []models.RecordSP
159 sql = `SELECT id, user_id, users.user_name, users.avatar_link, score_count, score_time, demo_id, record_date
160 FROM (
161 SELECT id, user_id, score_count, score_time, demo_id, record_date,
162 ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY score_count, score_time) AS rn
163 FROM records_sp
164 WHERE map_id = $1
165 ) sub
166 INNER JOIN users ON user_id = users.steam_id
167 WHERE rn = 1`
168 rows, err := database.DB.Query(sql, id)
169 if err != nil {
170 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
171 return
172 }
173 placement := 1
174 ties := 0
175 for rows.Next() {
176 var record models.RecordSP
177 err := rows.Scan(&record.RecordID, &record.UserID, &record.UserName, &record.UserAvatar, &record.ScoreCount, &record.ScoreTime, &record.DemoID, &record.RecordDate)
178 if err != nil {
179 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
180 return
181 }
182 if len(records) != 0 && records[len(records)-1].ScoreTime == record.ScoreTime {
183 ties++
184 record.Placement = placement - ties
185 } else {
186 record.Placement = placement
187 }
188 records = append(records, record)
189 placement++
190 }
191 mapRecordsData.Records = records
192 }
193 // mapData.Data = mapRecordsData
194 // Return response
195 c.JSON(http.StatusOK, models.Response{
196 Success: true,
197 Message: "Successfully retrieved map leaderboards.",
198 Data: mapData,
199 })
200}
201
202// GET Games
203//
204// @Description Get games from the leaderboards.
205// @Tags games & chapters
206// @Produce json
207// @Success 200 {object} models.Response{data=[]models.Game}
208// @Failure 400 {object} models.Response
209// @Router /games [get]
210func FetchGames(c *gin.Context) {
211 rows, err := database.DB.Query(`SELECT id, name, is_coop FROM games`)
212 if err != nil {
213 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
214 return
215 }
216 var games []models.Game
217 for rows.Next() {
218 var game models.Game
219 if err := rows.Scan(&game.ID, &game.Name, &game.IsCoop); err != nil {
220 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
221 return
222 }
223 games = append(games, game)
224 }
225 c.JSON(http.StatusOK, models.Response{
226 Success: true,
227 Message: "Successfully retrieved games.",
228 Data: games,
229 })
230}
231
232// GET Chapters of a Game
233//
234// @Description Get chapters from the specified game id.
235// @Tags games & chapters
236// @Produce json
237// @Param id path int true "Game ID"
238// @Success 200 {object} models.Response{data=ChaptersResponse}
239// @Failure 400 {object} models.Response
240// @Router /games/{id} [get]
241func FetchChapters(c *gin.Context) {
242 gameID := c.Param("id")
243 intID, err := strconv.Atoi(gameID)
244 if err != nil {
245 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
246 return
247 }
248 var response ChaptersResponse
249 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)
250 if err != nil {
251 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
252 return
253 }
254 var chapters []models.Chapter
255 var gameName string
256 for rows.Next() {
257 var chapter models.Chapter
258 if err := rows.Scan(&chapter.ID, &chapter.Name, &gameName); err != nil {
259 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
260 return
261 }
262 chapters = append(chapters, chapter)
263 }
264 response.Game.ID = intID
265 response.Game.Name = gameName
266 response.Chapters = chapters
267 c.JSON(http.StatusOK, models.Response{
268 Success: true,
269 Message: "Successfully retrieved chapters.",
270 Data: response,
271 })
272}
273
274// GET Maps of a Chapter
275//
276// @Description Get maps from the specified chapter id.
277// @Tags games & chapters
278// @Produce json
279// @Param id path int true "Chapter ID"
280// @Success 200 {object} models.Response{data=ChapterMapsResponse}
281// @Failure 400 {object} models.Response
282// @Router /chapters/{id} [get]
283func FetchChapterMaps(c *gin.Context) {
284 chapterID := c.Param("id")
285 intID, err := strconv.Atoi(chapterID)
286 if err != nil {
287 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
288 return
289 }
290 var response ChapterMapsResponse
291 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)
292 if err != nil {
293 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
294 return
295 }
296 var maps []models.MapShort
297 var chapterName string
298 for rows.Next() {
299 var mapShort models.MapShort
300 if err := rows.Scan(&mapShort.ID, &mapShort.Name, &chapterName); err != nil {
301 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
302 return
303 }
304 maps = append(maps, mapShort)
305 }
306 response.Chapter.ID = intID
307 response.Chapter.Name = chapterName
308 response.Maps = maps
309 c.JSON(http.StatusOK, models.Response{
310 Success: true,
311 Message: "Successfully retrieved maps.",
312 Data: response,
313 })
314}
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 @@
1package handlers
2
3import (
4 "net/http"
5 "strconv"
6 "time"
7
8 "github.com/gin-gonic/gin"
9 "github.com/pektezol/leastportalshub/backend/database"
10 "github.com/pektezol/leastportalshub/backend/models"
11)
12
13type CreateMapSummaryRequest struct {
14 CategoryID int `json:"category_id" binding:"required"`
15 Description string `json:"description" binding:"required"`
16 Showcase string `json:"showcase"`
17 UserName string `json:"user_name" binding:"required"`
18 ScoreCount *int `json:"score_count" binding:"required"`
19 RecordDate time.Time `json:"record_date" binding:"required"`
20}
21
22type EditMapSummaryRequest struct {
23 RouteID int `json:"route_id" binding:"required"`
24 Description string `json:"description" binding:"required"`
25 Showcase string `json:"showcase"`
26 UserName string `json:"user_name" binding:"required"`
27 ScoreCount *int `json:"score_count" binding:"required"`
28 RecordDate time.Time `json:"record_date" binding:"required"`
29}
30
31type DeleteMapSummaryRequest struct {
32 RouteID int `json:"route_id" binding:"required"`
33}
34
35type EditMapImageRequest struct {
36 Image string `json:"image" binding:"required"`
37}
38
39// POST Map Summary
40//
41// @Description Create map summary with specified map id.
42// @Tags maps
43// @Produce json
44// @Param Authorization header string true "JWT Token"
45// @Param id path int true "Map ID"
46// @Param request body CreateMapSummaryRequest true "Body"
47// @Success 200 {object} models.Response{data=CreateMapSummaryRequest}
48// @Failure 400 {object} models.Response
49// @Router /maps/{id}/summary [post]
50func CreateMapSummary(c *gin.Context) {
51 // Check if user exists
52 _, exists := c.Get("user")
53 if !exists {
54 c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in."))
55 return
56 }
57 mod, exists := c.Get("mod")
58 if !exists || !mod.(bool) {
59 c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions."))
60 return
61 }
62 // Bind parameter and body
63 id := c.Param("id")
64 mapID, err := strconv.Atoi(id)
65 if err != nil {
66 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
67 return
68 }
69 var request CreateMapSummaryRequest
70 if err := c.BindJSON(&request); err != nil {
71 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
72 return
73 }
74 // Start database transaction
75 tx, err := database.DB.Begin()
76 if err != nil {
77 c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error()))
78 return
79 }
80 defer tx.Rollback()
81 // Fetch route category and score count
82 var checkMapID int
83 sql := `SELECT m.id FROM maps m WHERE m.id = $1`
84 err = database.DB.QueryRow(sql, mapID).Scan(&checkMapID)
85 if err != nil {
86 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
87 return
88 }
89 if mapID != checkMapID {
90 c.JSON(http.StatusBadRequest, models.ErrorResponse("Map ID does not exist."))
91 return
92 }
93 // Update database with new data
94 sql = `INSERT INTO map_routes (map_id,category_id,score_count,description,showcase)
95 VALUES ($1,$2,$3,$4,$5)`
96 _, err = tx.Exec(sql, mapID, request.CategoryID, request.ScoreCount, request.Description, request.Showcase)
97 if err != nil {
98 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
99 return
100 }
101 sql = `INSERT INTO map_history (map_id,category_id,user_name,score_count,record_date)
102 VALUES ($1,$2,$3,$4,$5)`
103 _, err = tx.Exec(sql, mapID, request.CategoryID, request.UserName, request.ScoreCount, request.RecordDate)
104 if err != nil {
105 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
106 return
107 }
108 if err = tx.Commit(); err != nil {
109 c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error()))
110 return
111 }
112 // Return response
113 c.JSON(http.StatusOK, models.Response{
114 Success: true,
115 Message: "Successfully created map summary.",
116 Data: request,
117 })
118}
119
120// PUT Map Summary
121//
122// @Description Edit map summary with specified map id.
123// @Tags maps
124// @Produce json
125// @Param Authorization header string true "JWT Token"
126// @Param id path int true "Map ID"
127// @Param request body EditMapSummaryRequest true "Body"
128// @Success 200 {object} models.Response{data=EditMapSummaryRequest}
129// @Failure 400 {object} models.Response
130// @Router /maps/{id}/summary [put]
131func EditMapSummary(c *gin.Context) {
132 // Check if user exists
133 _, exists := c.Get("user")
134 if !exists {
135 c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in."))
136 return
137 }
138 mod, exists := c.Get("mod")
139 if !exists || !mod.(bool) {
140 c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions."))
141 return
142 }
143 // Bind parameter and body
144 id := c.Param("id")
145 mapID, err := strconv.Atoi(id)
146 if err != nil {
147 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
148 return
149 }
150 var request EditMapSummaryRequest
151 if err := c.BindJSON(&request); err != nil {
152 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
153 return
154 }
155 // Start database transaction
156 tx, err := database.DB.Begin()
157 if err != nil {
158 c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error()))
159 return
160 }
161 defer tx.Rollback()
162 // Fetch route category and score count
163 var categoryID, scoreCount, historyID int
164 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`
165 err = database.DB.QueryRow(sql, mapID, request.RouteID).Scan(&categoryID, &scoreCount)
166 if err != nil {
167 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
168 return
169 }
170 sql = `SELECT mh.id FROM map_history mh WHERE mh.score_count = $1 AND mh.category_id = $2 AND mh.map_id = $3`
171 err = database.DB.QueryRow(sql, scoreCount, categoryID, mapID).Scan(&historyID)
172 if err != nil {
173 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
174 return
175 }
176 // Update database with new data
177 sql = `UPDATE map_routes SET score_count = $2, description = $3, showcase = $4 WHERE id = $1`
178 _, err = tx.Exec(sql, request.RouteID, request.ScoreCount, request.Description, request.Showcase)
179 if err != nil {
180 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
181 return
182 }
183 sql = `UPDATE map_history SET user_name = $2, score_count = $3, record_date = $4 WHERE id = $1`
184 _, err = tx.Exec(sql, historyID, request.UserName, request.ScoreCount, request.RecordDate)
185 if err != nil {
186 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
187 return
188 }
189 if err = tx.Commit(); err != nil {
190 c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error()))
191 return
192 }
193 // Return response
194 c.JSON(http.StatusOK, models.Response{
195 Success: true,
196 Message: "Successfully updated map summary.",
197 Data: request,
198 })
199}
200
201// DELETE Map Summary
202//
203// @Description Delete map summary with specified map id.
204// @Tags maps
205// @Produce json
206// @Param Authorization header string true "JWT Token"
207// @Param id path int true "Map ID"
208// @Param request body DeleteMapSummaryRequest true "Body"
209// @Success 200 {object} models.Response{data=DeleteMapSummaryRequest}
210// @Failure 400 {object} models.Response
211// @Router /maps/{id}/summary [delete]
212func DeleteMapSummary(c *gin.Context) {
213 // Check if user exists
214 _, exists := c.Get("user")
215 if !exists {
216 c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in."))
217 return
218 }
219 mod, exists := c.Get("mod")
220 if !exists || !mod.(bool) {
221 c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions."))
222 return
223 }
224 // Bind parameter and body
225 id := c.Param("id")
226 mapID, err := strconv.Atoi(id)
227 if err != nil {
228 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
229 return
230 }
231 var request DeleteMapSummaryRequest
232 if err := c.BindJSON(&request); err != nil {
233 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
234 return
235 }
236 // Start database transaction
237 tx, err := database.DB.Begin()
238 if err != nil {
239 c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error()))
240 return
241 }
242 defer tx.Rollback()
243 // Fetch route category and score count
244 var checkMapID, scoreCount, mapHistoryID int
245 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`
246 err = database.DB.QueryRow(sql, mapID, request.RouteID).Scan(&checkMapID, &scoreCount)
247 if err != nil {
248 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
249 return
250 }
251 if mapID != checkMapID {
252 c.JSON(http.StatusBadRequest, models.ErrorResponse("Map ID does not exist."))
253 return
254 }
255 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`
256 err = database.DB.QueryRow(sql, mapID, request.RouteID, scoreCount).Scan(&mapHistoryID)
257 if err != nil {
258 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
259 return
260 }
261 // Update database with new data
262 sql = `DELETE FROM map_routes mr WHERE mr.id = $1 `
263 _, err = tx.Exec(sql, request.RouteID)
264 if err != nil {
265 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
266 return
267 }
268 sql = `DELETE FROM map_history mh WHERE mh.id = $1`
269 _, err = tx.Exec(sql, mapHistoryID)
270 if err != nil {
271 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
272 return
273 }
274 if err = tx.Commit(); err != nil {
275 c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error()))
276 return
277 }
278 // Return response
279 c.JSON(http.StatusOK, models.Response{
280 Success: true,
281 Message: "Successfully delete map summary.",
282 Data: request,
283 })
284}
285
286// PUT Map Image
287//
288// @Description Edit map image with specified map id.
289// @Tags maps
290// @Produce json
291// @Param Authorization header string true "JWT Token"
292// @Param id path int true "Map ID"
293// @Param request body EditMapImageRequest true "Body"
294// @Success 200 {object} models.Response{data=EditMapImageRequest}
295// @Failure 400 {object} models.Response
296// @Router /maps/{id}/image [put]
297func EditMapImage(c *gin.Context) {
298 // Check if user exists
299 _, exists := c.Get("user")
300 if !exists {
301 c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in."))
302 return
303 }
304 mod, exists := c.Get("mod")
305 if !exists || !mod.(bool) {
306 c.JSON(http.StatusUnauthorized, models.ErrorResponse("Insufficient permissions."))
307 return
308 }
309 // Bind parameter and body
310 id := c.Param("id")
311 mapID, err := strconv.Atoi(id)
312 if err != nil {
313 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
314 return
315 }
316 var request EditMapImageRequest
317 if err := c.BindJSON(&request); err != nil {
318 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
319 return
320 }
321 // Update database with new data
322 sql := `UPDATE maps SET image = $2 WHERE id = $1`
323 _, err = database.DB.Exec(sql, mapID, request.Image)
324 if err != nil {
325 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
326 return
327 }
328 // Return response
329 c.JSON(http.StatusOK, models.Response{
330 Success: true,
331 Message: "Successfully updated map image.",
332 Data: request,
333 })
334}
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 @@
1package handlers
2
3import (
4 "context"
5 "encoding/base64"
6 "io"
7 "log"
8 "mime/multipart"
9 "net/http"
10 "os"
11
12 "github.com/gin-gonic/gin"
13 "github.com/google/uuid"
14 "github.com/pektezol/leastportalshub/backend/database"
15 "github.com/pektezol/leastportalshub/backend/models"
16 "github.com/pektezol/leastportalshub/backend/parser"
17 "golang.org/x/oauth2/google"
18 "golang.org/x/oauth2/jwt"
19 "google.golang.org/api/drive/v3"
20)
21
22type RecordRequest struct {
23 HostDemo *multipart.FileHeader `json:"host_demo" form:"host_demo" binding:"required" swaggerignore:"true"`
24 PartnerDemo *multipart.FileHeader `json:"partner_demo" form:"partner_demo" swaggerignore:"true"`
25 IsPartnerOrange bool `json:"is_partner_orange" form:"is_partner_orange"`
26 PartnerID string `json:"partner_id" form:"partner_id"`
27}
28
29type RecordResponse struct {
30 ScoreCount int `json:"score_count"`
31 ScoreTime int `json:"score_time"`
32}
33
34// POST Record
35//
36// @Description Post record with demo of a specific map.
37// @Tags maps
38// @Accept mpfd
39// @Produce json
40// @Param id path int true "Map ID"
41// @Param Authorization header string true "JWT Token"
42// @Param host_demo formData file true "Host Demo"
43// @Param partner_demo formData file false "Partner Demo"
44// @Param is_partner_orange formData boolean false "Is Partner Orange"
45// @Param partner_id formData string false "Partner ID"
46// @Success 200 {object} models.Response{data=RecordResponse}
47// @Failure 400 {object} models.Response
48// @Failure 401 {object} models.Response
49// @Router /maps/{id}/record [post]
50func CreateRecordWithDemo(c *gin.Context) {
51 mapId := c.Param("id")
52 // Check if user exists
53 user, exists := c.Get("user")
54 if !exists {
55 c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in."))
56 return
57 }
58 // Check if map is sp or mp
59 var gameName string
60 var isCoop bool
61 var isDisabled bool
62 sql := `SELECT g.name, m.is_disabled FROM maps m INNER JOIN games g ON m.game_id=g.id WHERE m.id = $1`
63 err := database.DB.QueryRow(sql, mapId).Scan(&gameName, &isDisabled)
64 if err != nil {
65 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
66 return
67 }
68 if isDisabled {
69 c.JSON(http.StatusBadRequest, models.ErrorResponse("Map is not available for competitive boards."))
70 return
71 }
72 if gameName == "Portal 2 - Cooperative" {
73 isCoop = true
74 }
75 // Get record request
76 var record RecordRequest
77 if err := c.ShouldBind(&record); err != nil {
78 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
79 return
80 }
81 if isCoop && (record.PartnerDemo == nil || record.PartnerID == "") {
82 c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid entry for coop record submission."))
83 return
84 }
85 // Demo files
86 demoFiles := []*multipart.FileHeader{record.HostDemo}
87 if isCoop {
88 demoFiles = append(demoFiles, record.PartnerDemo)
89 }
90 var hostDemoUUID, hostDemoFileID, partnerDemoUUID, partnerDemoFileID string
91 var hostDemoScoreCount, hostDemoScoreTime int
92 client := serviceAccount()
93 srv, err := drive.New(client)
94 if err != nil {
95 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
96 return
97 }
98 // Create database transaction for inserts
99 tx, err := database.DB.Begin()
100 if err != nil {
101 c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error()))
102 return
103 }
104 // Defer to a rollback in case anything fails
105 defer tx.Rollback()
106 for i, header := range demoFiles {
107 uuid := uuid.New().String()
108 // Upload & insert into demos
109 err = c.SaveUploadedFile(header, "backend/parser/"+uuid+".dem")
110 if err != nil {
111 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
112 return
113 }
114 defer os.Remove("backend/parser/" + uuid + ".dem")
115 f, err := os.Open("backend/parser/" + uuid + ".dem")
116 if err != nil {
117 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
118 return
119 }
120 defer f.Close()
121 file, err := createFile(srv, uuid+".dem", "application/octet-stream", f, os.Getenv("GOOGLE_FOLDER_ID"))
122 if err != nil {
123 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
124 return
125 }
126 hostDemoScoreCount, hostDemoScoreTime, err = parser.ProcessDemo("backend/parser/" + uuid + ".dem")
127 if err != nil {
128 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
129 return
130 }
131 if i == 0 {
132 hostDemoFileID = file.Id
133 hostDemoUUID = uuid
134 } else if i == 1 {
135 partnerDemoFileID = file.Id
136 partnerDemoUUID = uuid
137 }
138 _, err = tx.Exec(`INSERT INTO demos (id,location_id) VALUES ($1,$2)`, uuid, file.Id)
139 if err != nil {
140 deleteFile(srv, file.Id)
141 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
142 return
143 }
144 }
145 // Insert into records
146 if isCoop {
147 sql := `INSERT INTO records_mp(map_id,score_count,score_time,host_id,partner_id,host_demo_id,partner_demo_id)
148 VALUES($1, $2, $3, $4, $5, $6, $7)`
149 var hostID string
150 var partnerID string
151 if record.IsPartnerOrange {
152 hostID = user.(models.User).SteamID
153 partnerID = record.PartnerID
154 } else {
155 partnerID = user.(models.User).SteamID
156 hostID = record.PartnerID
157 }
158 _, err := tx.Exec(sql, mapId, hostDemoScoreCount, hostDemoScoreTime, hostID, partnerID, hostDemoUUID, partnerDemoUUID)
159 if err != nil {
160 deleteFile(srv, hostDemoFileID)
161 deleteFile(srv, partnerDemoFileID)
162 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
163 return
164 }
165 // If a new world record based on portal count
166 // if record.ScoreCount < wrScore {
167 // _, err := tx.Exec(`UPDATE maps SET wr_score = $1, wr_time = $2 WHERE id = $3`, record.ScoreCount, record.ScoreTime, mapId)
168 // if err != nil {
169 // c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
170 // return
171 // }
172 // }
173 } else {
174 sql := `INSERT INTO records_sp(map_id,score_count,score_time,user_id,demo_id)
175 VALUES($1, $2, $3, $4, $5)`
176 _, err := tx.Exec(sql, mapId, hostDemoScoreCount, hostDemoScoreTime, user.(models.User).SteamID, hostDemoUUID)
177 if err != nil {
178 deleteFile(srv, hostDemoFileID)
179 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
180 return
181 }
182 // If a new world record based on portal count
183 // if record.ScoreCount < wrScore {
184 // _, err := tx.Exec(`UPDATE maps SET wr_score = $1, wr_time = $2 WHERE id = $3`, record.ScoreCount, record.ScoreTime, mapId)
185 // if err != nil {
186 // c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
187 // return
188 // }
189 // }
190 }
191 if err = tx.Commit(); err != nil {
192 c.JSON(http.StatusInternalServerError, models.ErrorResponse(err.Error()))
193 return
194 }
195 c.JSON(http.StatusOK, models.Response{
196 Success: true,
197 Message: "Successfully created record.",
198 Data: RecordResponse{ScoreCount: hostDemoScoreCount, ScoreTime: hostDemoScoreTime},
199 })
200}
201
202// GET Demo
203//
204// @Description Get demo with specified demo uuid.
205// @Tags demo
206// @Accept json
207// @Produce octet-stream
208// @Param uuid query string true "Demo UUID"
209// @Success 200 {file} binary "Demo File"
210// @Failure 400 {object} models.Response
211// @Router /demos [get]
212func DownloadDemoWithID(c *gin.Context) {
213 uuid := c.Query("uuid")
214 var locationID string
215 if uuid == "" {
216 c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid id given."))
217 return
218 }
219 err := database.DB.QueryRow(`SELECT location_id FROM demos WHERE id = $1`, uuid).Scan(&locationID)
220 if err != nil {
221 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
222 return
223 }
224 if locationID == "" {
225 c.JSON(http.StatusBadRequest, models.ErrorResponse("Invalid id given."))
226 return
227 }
228 url := "https://drive.google.com/uc?export=download&id=" + locationID
229 fileName := uuid + ".dem"
230 output, err := os.Create(fileName)
231 if err != nil {
232 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
233 return
234 }
235 defer os.Remove(fileName)
236 defer output.Close()
237 response, err := http.Get(url)
238 if err != nil {
239 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
240 return
241 }
242 defer response.Body.Close()
243 _, err = io.Copy(output, response.Body)
244 if err != nil {
245 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
246 return
247 }
248 // Downloaded file
249 c.Header("Content-Description", "File Transfer")
250 c.Header("Content-Transfer-Encoding", "binary")
251 c.Header("Content-Disposition", "attachment; filename="+fileName)
252 c.Header("Content-Type", "application/octet-stream")
253 c.File(fileName)
254 // c.FileAttachment()
255}
256
257// Use Service account
258func serviceAccount() *http.Client {
259 privateKey, _ := base64.StdEncoding.DecodeString(os.Getenv("GOOGLE_PRIVATE_KEY_BASE64"))
260 config := &jwt.Config{
261 Email: os.Getenv("GOOGLE_CLIENT_EMAIL"),
262 PrivateKey: []byte(privateKey),
263 Scopes: []string{
264 drive.DriveScope,
265 },
266 TokenURL: google.JWTTokenURL,
267 }
268 client := config.Client(context.Background())
269 return client
270}
271
272// Create Gdrive file
273func createFile(service *drive.Service, name string, mimeType string, content io.Reader, parentId string) (*drive.File, error) {
274 f := &drive.File{
275 MimeType: mimeType,
276 Name: name,
277 Parents: []string{parentId},
278 }
279 file, err := service.Files.Create(f).Media(content).Do()
280
281 if err != nil {
282 log.Println("Could not create file: " + err.Error())
283 return nil, err
284 }
285
286 return file, nil
287}
288
289// Delete Gdrive file
290func deleteFile(service *drive.Service, fileId string) {
291 service.Files.Delete(fileId)
292}
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 @@
1package handlers
2
3import (
4 "net/http"
5 "os"
6 "regexp"
7 "time"
8
9 "github.com/gin-gonic/gin"
10 "github.com/pektezol/leastportalshub/backend/database"
11 "github.com/pektezol/leastportalshub/backend/models"
12)
13
14type ProfileResponse struct {
15 Profile bool `json:"profile"`
16 SteamID string `json:"steam_id"`
17 UserName string `json:"user_name"`
18 AvatarLink string `json:"avatar_link"`
19 CountryCode string `json:"country_code"`
20 Titles []models.Title `json:"titles"`
21 Links models.Links `json:"links"`
22 Rankings ProfileRankings `json:"rankings"`
23 Records ProfileRecords `json:"records"`
24}
25
26type ProfileRankings struct {
27 Overall ProfileRankingsDetails `json:"overall"`
28 Singleplayer ProfileRankingsDetails `json:"singleplayer"`
29 Cooperative ProfileRankingsDetails `json:"cooperative"`
30}
31
32type ProfileRankingsDetails struct {
33 Rank int `json:"rank"`
34 CompletionCount int `json:"completion_count"`
35 CompletionTotal int `json:"completion_total"`
36}
37
38type ProfileRecords struct {
39 P2Singleplayer []ProfileRecordsDetails `json:"portal2_singleplayer"`
40 P2Cooperative []ProfileRecordsDetails `json:"portal2_cooperative"`
41}
42
43type ProfileRecordsDetails struct {
44 MapID int `json:"map_id"`
45 MapName string `json:"map_name"`
46 Scores []ProfileScores `json:"scores"`
47}
48
49type ProfileScores struct {
50 DemoID string `json:"demo_id"`
51 ScoreCount int `json:"score_count"`
52 ScoreTime int `json:"score_time"`
53 Date time.Time `json:"date"`
54}
55
56type ScoreResponse struct {
57 MapID int `json:"map_id"`
58 Records any `json:"records"`
59}
60
61// GET Profile
62//
63// @Description Get profile page of session user.
64// @Tags users
65// @Accept json
66// @Produce json
67// @Param Authorization header string true "JWT Token"
68// @Success 200 {object} models.Response{data=ProfileResponse}
69// @Failure 400 {object} models.Response
70// @Failure 401 {object} models.Response
71// @Router /profile [get]
72func Profile(c *gin.Context) {
73 // Check if user exists
74 user, exists := c.Get("user")
75 if !exists {
76 c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in."))
77 return
78 }
79 // Get user links
80 links := models.Links{}
81 sql := `SELECT u.p2sr, u.steam, u.youtube, u.twitch FROM users u WHERE u.steam_id = $1`
82 err := database.DB.QueryRow(sql, user.(models.User).SteamID).Scan(&links.P2SR, &links.Steam, &links.YouTube, &links.Twitch)
83 if err != nil {
84 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
85 return
86 }
87 // TODO: Get rankings (all maps done in one game)
88 records := ProfileRecords{
89 P2Singleplayer: []ProfileRecordsDetails{},
90 P2Cooperative: []ProfileRecordsDetails{},
91 }
92 // Get singleplayer records
93 sql = `SELECT m.game_id, sp.map_id, m."name", sp.score_count, sp.score_time, sp.demo_id, sp.record_date
94 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;`
95 rows, err := database.DB.Query(sql, user.(models.User).SteamID)
96 if err != nil {
97 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
98 return
99 }
100 for rows.Next() {
101 var mapID int
102 var mapName string
103 var gameID int
104 score := ProfileScores{}
105 rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date)
106 if gameID != 1 {
107 continue
108 }
109 // More than one record in one map
110 if len(records.P2Singleplayer) != 0 && mapID == records.P2Singleplayer[len(records.P2Singleplayer)-1].MapID {
111 records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score)
112 continue
113 }
114 // New map
115 records.P2Singleplayer = append(records.P2Singleplayer, ProfileRecordsDetails{
116 MapID: mapID,
117 MapName: mapName,
118 Scores: []ProfileScores{},
119 })
120 records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score)
121 }
122 // Get multiplayer records
123 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
124 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;`
125 rows, err = database.DB.Query(sql, user.(models.User).SteamID)
126 if err != nil {
127 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
128 return
129 }
130 for rows.Next() {
131 var mapID int
132 var mapName string
133 var gameID int
134 score := ProfileScores{}
135 rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date)
136 if gameID != 1 {
137 continue
138 }
139 // More than one record in one map
140 if len(records.P2Cooperative) != 0 && mapID == records.P2Cooperative[len(records.P2Cooperative)-1].MapID {
141 records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score)
142 continue
143 }
144 // New map
145 records.P2Cooperative = append(records.P2Cooperative, ProfileRecordsDetails{
146 MapID: mapID,
147 MapName: mapName,
148 Scores: []ProfileScores{},
149 })
150 records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score)
151 }
152 c.JSON(http.StatusOK, models.Response{
153 Success: true,
154 Message: "Successfully retrieved user scores.",
155 Data: ProfileResponse{
156 Profile: true,
157 SteamID: user.(models.User).SteamID,
158 UserName: user.(models.User).UserName,
159 AvatarLink: user.(models.User).AvatarLink,
160 CountryCode: user.(models.User).CountryCode,
161 Titles: user.(models.User).Titles,
162 Links: links,
163 Rankings: ProfileRankings{},
164 Records: records,
165 },
166 })
167}
168
169// GET User
170//
171// @Description Get profile page of another user.
172// @Tags users
173// @Accept json
174// @Produce json
175// @Param id path int true "User ID"
176// @Success 200 {object} models.Response{data=ProfileResponse}
177// @Failure 400 {object} models.Response
178// @Failure 404 {object} models.Response
179// @Router /users/{id} [get]
180func FetchUser(c *gin.Context) {
181 id := c.Param("id")
182 // Check if id is all numbers and 17 length
183 match, _ := regexp.MatchString("^[0-9]{17}$", id)
184 if !match {
185 c.JSON(http.StatusNotFound, models.ErrorResponse("User not found."))
186 return
187 }
188 // Check if user exists
189 var user models.User
190 links := models.Links{}
191 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`
192 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)
193 if err != nil {
194 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
195 return
196 }
197 if user.SteamID == "" {
198 // User does not exist
199 c.JSON(http.StatusNotFound, models.ErrorResponse("User not found."))
200 return
201 }
202 // Get user titles
203 sql = `SELECT t.title_name, t.title_color FROM titles t
204 INNER JOIN user_titles ut ON t.id=ut.title_id WHERE ut.user_id = $1`
205 rows, err := database.DB.Query(sql, user.SteamID)
206 if err != nil {
207 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
208 return
209 }
210 for rows.Next() {
211 var title models.Title
212 if err := rows.Scan(&title.Name, &title.Color); err != nil {
213 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
214 return
215 }
216 user.Titles = append(user.Titles, title)
217 }
218 // TODO: Get rankings (all maps done in one game)
219 records := ProfileRecords{
220 P2Singleplayer: []ProfileRecordsDetails{},
221 P2Cooperative: []ProfileRecordsDetails{},
222 }
223 // Get singleplayer records
224 sql = `SELECT m.game_id, sp.map_id, m."name", sp.score_count, sp.score_time, sp.demo_id, sp.record_date
225 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;`
226 rows, err = database.DB.Query(sql, user.SteamID)
227 if err != nil {
228 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
229 return
230 }
231 for rows.Next() {
232 var mapID int
233 var mapName string
234 var gameID int
235 score := ProfileScores{}
236 rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date)
237 if gameID != 1 {
238 continue
239 }
240 // More than one record in one map
241 if len(records.P2Singleplayer) != 0 && mapID == records.P2Singleplayer[len(records.P2Singleplayer)-1].MapID {
242 records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score)
243 continue
244 }
245 // New map
246 records.P2Singleplayer = append(records.P2Singleplayer, ProfileRecordsDetails{
247 MapID: mapID,
248 MapName: mapName,
249 Scores: []ProfileScores{},
250 })
251 records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores = append(records.P2Singleplayer[len(records.P2Singleplayer)-1].Scores, score)
252 }
253 // Get multiplayer records
254 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
255 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;`
256 rows, err = database.DB.Query(sql, user.SteamID)
257 if err != nil {
258 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
259 return
260 }
261 for rows.Next() {
262 var mapID int
263 var mapName string
264 var gameID int
265 score := ProfileScores{}
266 rows.Scan(&gameID, &mapID, &mapName, &score.ScoreCount, &score.ScoreTime, &score.DemoID, &score.Date)
267 if gameID != 1 {
268 continue
269 }
270 // More than one record in one map
271 if len(records.P2Cooperative) != 0 && mapID == records.P2Cooperative[len(records.P2Cooperative)-1].MapID {
272 records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score)
273 continue
274 }
275 // New map
276 records.P2Cooperative = append(records.P2Cooperative, ProfileRecordsDetails{
277 MapID: mapID,
278 MapName: mapName,
279 Scores: []ProfileScores{},
280 })
281 records.P2Cooperative[len(records.P2Cooperative)-1].Scores = append(records.P2Cooperative[len(records.P2Cooperative)-1].Scores, score)
282 }
283 c.JSON(http.StatusOK, models.Response{
284 Success: true,
285 Message: "Successfully retrieved user scores.",
286 Data: ProfileResponse{
287 Profile: true,
288 SteamID: user.SteamID,
289 UserName: user.UserName,
290 AvatarLink: user.AvatarLink,
291 CountryCode: user.CountryCode,
292 Titles: user.Titles,
293 Links: links,
294 Rankings: ProfileRankings{},
295 Records: records,
296 },
297 })
298}
299
300// PUT Profile
301//
302// @Description Update profile page of session user.
303// @Tags users
304// @Accept json
305// @Produce json
306// @Param Authorization header string true "JWT Token"
307// @Success 200 {object} models.Response{data=ProfileResponse}
308// @Failure 400 {object} models.Response
309// @Failure 401 {object} models.Response
310// @Router /profile [post]
311func UpdateUser(c *gin.Context) {
312 // Check if user exists
313 user, exists := c.Get("user")
314 if !exists {
315 c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in."))
316 return
317 }
318 profile, err := GetPlayerSummaries(user.(models.User).SteamID, os.Getenv("API_KEY"))
319 if err != nil {
320 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
321 return
322 }
323 // Update profile
324 _, err = database.DB.Exec(`UPDATE users SET username = $1, avatar_link = $2, country_code = $3, updated_at = $4
325 WHERE steam_id = $5`, profile.PersonaName, profile.AvatarFull, profile.LocCountryCode, time.Now().UTC(), user.(models.User).SteamID)
326 if err != nil {
327 c.JSON(http.StatusBadRequest, models.ErrorResponse(err.Error()))
328 return
329 }
330 c.JSON(http.StatusOK, models.Response{
331 Success: true,
332 Message: "Successfully updated user.",
333 Data: ProfileResponse{
334 Profile: true,
335 SteamID: user.(models.User).SteamID,
336 UserName: profile.PersonaName,
337 AvatarLink: profile.AvatarFull,
338 CountryCode: profile.LocCountryCode,
339 },
340 })
341}
342
343// PUT Profile/CountryCode
344//
345// @Description Update country code of session user.
346// @Tags users
347// @Accept json
348// @Produce json
349// @Param Authorization header string true "JWT Token"
350// @Param country_code query string true "Country Code [XX]"
351// @Success 200 {object} models.Response
352// @Failure 400 {object} models.Response
353// @Failure 401 {object} models.Response
354// @Router /profile [put]
355func UpdateCountryCode(c *gin.Context) {
356 // Check if user exists
357 user, exists := c.Get("user")
358 if !exists {
359 c.JSON(http.StatusUnauthorized, models.ErrorResponse("User not logged in."))
360 return
361 }
362 code := c.Query("country_code")
363 if code == "" {
364 c.JSON(http.StatusNotFound, models.ErrorResponse("Enter a valid country code."))
365 return
366 }
367 var validCode string
368 err := database.DB.QueryRow(`SELECT country_code FROM countries WHERE country_code = $1`, code).Scan(&validCode)
369 if err != nil {
370 c.JSON(http.StatusNotFound, models.ErrorResponse(err.Error()))
371 return
372 }
373 // Valid code, update profile
374 _, err = database.DB.Exec(`UPDATE users SET country_code = $1 WHERE steam_id = $2`, validCode, user.(models.User).SteamID)
375 if err != nil {
376 c.JSON(http.StatusNotFound, models.ErrorResponse(err.Error()))
377 return
378 }
379 c.JSON(http.StatusOK, models.Response{
380 Success: true,
381 Message: "Successfully updated country code.",
382 })
383}