183 lines
5.4 KiB
Go
183 lines
5.4 KiB
Go
package handler
|
|
|
|
import (
|
|
"15-puzzle/internal/model"
|
|
"15-puzzle/internal/validator"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"path"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type ctxUserID string
|
|
|
|
const (
|
|
WebAppInitDataHeader = "Web-App-Init-Data"
|
|
WebAppExtraCodeHeader = "Web-App-Extra-Code"
|
|
WebAppHtmlFile = "tgwebapp.html"
|
|
ctxDataUserID ctxUserID = "user_id"
|
|
)
|
|
|
|
type Repository interface {
|
|
RegisterGameStart(UserID int) (model.User, error)
|
|
RegisterGameSolve(UserID, moves int) (model.User, error)
|
|
Stats(UserID int) (model.User, error)
|
|
Monitoring() (model.Monitoring, error)
|
|
Rating() []int
|
|
}
|
|
|
|
func NewHandler(repo Repository, token, code, ctxRoot, staticDir, projectLink string) http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
if abs, err := filepath.Abs(staticDir); err == nil {
|
|
staticDir = abs
|
|
}
|
|
|
|
mux.Handle(http.MethodGet+" /static/", http.StripPrefix("/static", http.FileServer(http.Dir(staticDir))))
|
|
mux.Handle(http.MethodGet+" /puzzle.html", staticFileHandler(path.Join(staticDir, WebAppHtmlFile)))
|
|
|
|
apiMux := http.NewServeMux()
|
|
apiMux.Handle(http.MethodGet+" /info", apiInfoHandler(model.Info{ProjectLink: projectLink}))
|
|
apiMux.Handle(http.MethodPut+" /start", apiStartHandler(repo))
|
|
apiMux.Handle(http.MethodPut+" /solve", apiSolveHandler(repo))
|
|
apiMux.Handle(http.MethodGet+" /stats", apiStatsHandler(repo))
|
|
apiMux.Handle(http.MethodGet+" /monitoring", apiMonitoringHandler(repo, code))
|
|
apiKey := validator.EncodeHmacSha256([]byte(token), []byte("WebAppData"))
|
|
mux.Handle("/api/", authHandler(apiKey, http.StripPrefix("/api", apiMux)))
|
|
|
|
root := http.NewServeMux()
|
|
root.Handle(strings.TrimRight(ctxRoot, "/")+"/", http.StripPrefix(strings.TrimRight(ctxRoot, "/"), mux))
|
|
|
|
return root
|
|
}
|
|
|
|
func staticFileHandler(name string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeFile(w, r, name)
|
|
})
|
|
}
|
|
|
|
func authHandler(apiKey []byte, h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
key := apiKey
|
|
if v, ok := r.Header[WebAppInitDataHeader]; !ok || len(v) != 1 {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
} else if ok, userID, err := validator.ValidUser(key, v[0]); err != nil {
|
|
slog.Error(fmt.Sprintf("validator: %s", err))
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
} else if !ok {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
} else {
|
|
h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxDataUserID, userID)))
|
|
}
|
|
})
|
|
}
|
|
|
|
func apiInfoHandler(i model.Info) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
writeResponse(w, model.ApiResponse{Info: &i})
|
|
})
|
|
}
|
|
|
|
func apiStartHandler(repo Repository) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, r, repo.Rating, repo.RegisterGameStart)
|
|
})
|
|
}
|
|
|
|
func apiSolveHandler(repo Repository) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
v := r.URL.Query().Get("moves")
|
|
if moves, err := strconv.Atoi(r.URL.Query().Get("moves")); err != nil {
|
|
errorResponse(w, http.StatusBadRequest, fmt.Errorf("invalid value: %q", v))
|
|
} else {
|
|
respond(w, r, repo.Rating, func(u int) (model.User, error) { return repo.RegisterGameSolve(u, moves) })
|
|
}
|
|
})
|
|
}
|
|
|
|
func apiStatsHandler(repo Repository) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, r, repo.Rating, repo.Stats)
|
|
})
|
|
}
|
|
|
|
func apiMonitoringHandler(repo Repository, code string) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if code == "" || code != r.Header.Get(WebAppExtraCodeHeader) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
m, err := repo.Monitoring()
|
|
if err != nil {
|
|
errorResponse(w, http.StatusInternalServerError, fmt.Errorf("fetch monitoring: %s", err))
|
|
return
|
|
}
|
|
writeResponse(w, model.ApiResponse{Monitoring: &m})
|
|
})
|
|
}
|
|
|
|
func respond(w http.ResponseWriter, r *http.Request, rating func() []int, action func(int) (model.User, error)) {
|
|
userID, ok := r.Context().Value(ctxDataUserID).(int)
|
|
if !ok {
|
|
errorResponse(w, http.StatusInternalServerError, fmt.Errorf("unsupported context value type: %T", userID))
|
|
return
|
|
}
|
|
|
|
u, err := action(userID)
|
|
if err != nil {
|
|
if u.UserID == 0 {
|
|
errorResponse(w, http.StatusNotFound, fmt.Errorf("user_id=%d not found", userID))
|
|
} else {
|
|
errorResponse(w, http.StatusInternalServerError, fmt.Errorf("user_id=%d game action: %s", userID, err))
|
|
}
|
|
return
|
|
}
|
|
|
|
stats := &model.Stats{
|
|
GamesStarted: u.GamesStarted,
|
|
GamesSolved: u.GamesSolved,
|
|
Rank: rankPosition(userID, rating()),
|
|
}
|
|
|
|
writeResponse(w, model.ApiResponse{Stats: stats, Monitoring: u.Monitoring})
|
|
}
|
|
|
|
func rankPosition(UserID int, rating []int) int {
|
|
for i, uid := range rating {
|
|
if uid == UserID {
|
|
return i + 1
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func errorResponse(w http.ResponseWriter, code int, err error) {
|
|
slog.Error(err.Error())
|
|
w.WriteHeader(code)
|
|
s := err.Error()
|
|
writeResponse(w, model.ApiResponse{Err: &s})
|
|
}
|
|
|
|
func writeResponse(w http.ResponseWriter, r model.ApiResponse) {
|
|
b, err := json.Marshal(&r)
|
|
if err != nil {
|
|
msg := fmt.Sprintf("response json marshal: %s", err)
|
|
slog.Error(msg)
|
|
b = []byte(msg)
|
|
} else {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
}
|
|
if _, err := w.Write(b); err != nil {
|
|
slog.Error(fmt.Sprintf("send json response: %s", err))
|
|
}
|
|
}
|