mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-25 17:12:52 +01:00
feat(api): add v2 routes, auth middleware, structured errors, and server status endpoint
Introduces incremental API improvements for custom launcher support (mhf-iel, stratic-dev's Rust launcher): - Standardize all error responses to JSON envelopes with error/message - Add Bearer token auth middleware for v2 routes (legacy body-token preserved) - Add `returning` (>90d inactive) and `courses` fields to auth response - Add /v2/ route prefix with HTTP method enforcement - Add GET /v2/server/status for MezFes, featured weapon, and event status - Add APIEventRepo for read-only event data access Closes #44
This commit is contained in:
@@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- API: Standardized JSON error responses (`{"error":"...","message":"..."}`) across all endpoints via `writeError` helper
|
||||||
|
- API: Auth middleware extracting `Authorization: Bearer <token>` header for v2 routes; legacy body-token auth preserved
|
||||||
|
- API: `returning` field on characters (true if last login > 90 days ago) and `courses` field on auth data (derived from user rights)
|
||||||
|
- API: `/v2/` route prefix with HTTP method enforcement alongside legacy routes
|
||||||
|
- API: `GET /v2/server/status` endpoint returning MezFes schedule, featured weapon, and festa/diva event status
|
||||||
|
- API: `APIEventRepo` interface and read-only implementation for feature weapons and events
|
||||||
- Catch-up migration (`0002_catch_up_patches.sql`) for databases with partially-applied patch schemas — idempotent no-op on fresh or fully-patched databases, fills gaps for partial installations
|
- Catch-up migration (`0002_catch_up_patches.sql`) for databases with partially-applied patch schemas — idempotent no-op on fresh or fully-patched databases, fills gaps for partial installations
|
||||||
- Embedded auto-migrating database schema system (`server/migrations/`): the server binary now contains all SQL schemas and runs migrations automatically on startup — no more `pg_restore`, manual patch ordering, or external `schemas/` directory needed
|
- Embedded auto-migrating database schema system (`server/migrations/`): the server binary now contains all SQL schemas and runs migrations automatically on startup — no more `pg_restore`, manual patch ordering, or external `schemas/` directory needed
|
||||||
- Setup wizard: web-based first-run configuration at `http://localhost:8080` when `config.json` is missing — guides users through database connection, schema initialization, and server settings
|
- Setup wizard: web-based first-run configuration at `http://localhost:8080` when `config.json` is missing — guides users through database connection, schema initialization, and server settings
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type APIServer struct {
|
|||||||
userRepo APIUserRepo
|
userRepo APIUserRepo
|
||||||
charRepo APICharacterRepo
|
charRepo APICharacterRepo
|
||||||
sessionRepo APISessionRepo
|
sessionRepo APISessionRepo
|
||||||
|
eventRepo APIEventRepo
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
isShuttingDown bool
|
isShuttingDown bool
|
||||||
}
|
}
|
||||||
@@ -47,6 +48,7 @@ func NewAPIServer(config *Config) *APIServer {
|
|||||||
s.userRepo = NewAPIUserRepository(config.DB)
|
s.userRepo = NewAPIUserRepository(config.DB)
|
||||||
s.charRepo = NewAPICharacterRepository(config.DB)
|
s.charRepo = NewAPICharacterRepository(config.DB)
|
||||||
s.sessionRepo = NewAPISessionRepository(config.DB)
|
s.sessionRepo = NewAPISessionRepository(config.DB)
|
||||||
|
s.eventRepo = NewAPIEventRepository(config.DB)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -55,6 +57,8 @@ func NewAPIServer(config *Config) *APIServer {
|
|||||||
func (s *APIServer) Start() error {
|
func (s *APIServer) Start() error {
|
||||||
// Set up the routes responsible for serving the launcher HTML, serverlist, unique name check, and JP auth.
|
// Set up the routes responsible for serving the launcher HTML, serverlist, unique name check, and JP auth.
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
// Legacy routes (unchanged, no method enforcement)
|
||||||
r.HandleFunc("/launcher", s.Launcher)
|
r.HandleFunc("/launcher", s.Launcher)
|
||||||
r.HandleFunc("/login", s.Login)
|
r.HandleFunc("/login", s.Login)
|
||||||
r.HandleFunc("/register", s.Register)
|
r.HandleFunc("/register", s.Register)
|
||||||
@@ -66,7 +70,26 @@ func (s *APIServer) Start() error {
|
|||||||
r.HandleFunc("/", s.LandingPage)
|
r.HandleFunc("/", s.LandingPage)
|
||||||
r.HandleFunc("/health", s.Health)
|
r.HandleFunc("/health", s.Health)
|
||||||
r.HandleFunc("/version", s.Version)
|
r.HandleFunc("/version", s.Version)
|
||||||
handler := handlers.CORS(handlers.AllowedHeaders([]string{"Content-Type"}))(r)
|
|
||||||
|
// V2 routes (with HTTP method enforcement)
|
||||||
|
v2 := r.PathPrefix("/v2").Subrouter()
|
||||||
|
v2.HandleFunc("/login", s.Login).Methods("POST")
|
||||||
|
v2.HandleFunc("/register", s.Register).Methods("POST")
|
||||||
|
v2.HandleFunc("/launcher", s.Launcher).Methods("GET")
|
||||||
|
v2.HandleFunc("/version", s.Version).Methods("GET")
|
||||||
|
v2.HandleFunc("/health", s.Health).Methods("GET")
|
||||||
|
v2.HandleFunc("/server/status", s.ServerStatus).Methods("GET")
|
||||||
|
|
||||||
|
// V2 authenticated routes
|
||||||
|
v2Auth := v2.PathPrefix("").Subrouter()
|
||||||
|
v2Auth.Use(s.AuthMiddleware)
|
||||||
|
v2Auth.HandleFunc("/characters", s.CreateCharacter).Methods("POST")
|
||||||
|
v2Auth.HandleFunc("/characters/{id}/delete", s.DeleteCharacter).Methods("POST")
|
||||||
|
v2Auth.HandleFunc("/characters/{id}/export", s.ExportSave).Methods("GET")
|
||||||
|
|
||||||
|
handler := handlers.CORS(
|
||||||
|
handlers.AllowedHeaders([]string{"Content-Type", "Authorization"}),
|
||||||
|
)(r)
|
||||||
s.httpServer.Handler = handlers.LoggingHandler(os.Stdout, handler)
|
s.httpServer.Handler = handlers.LoggingHandler(os.Stdout, handler)
|
||||||
s.httpServer.Addr = fmt.Sprintf(":%d", s.erupeConfig.API.Port)
|
s.httpServer.Addr = fmt.Sprintf(":%d", s.erupeConfig.API.Port)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"errors"
|
"errors"
|
||||||
"erupe-ce/common/gametime"
|
"erupe-ce/common/gametime"
|
||||||
|
"erupe-ce/common/mhfcourse"
|
||||||
cfg "erupe-ce/config"
|
cfg "erupe-ce/config"
|
||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
@@ -57,6 +58,13 @@ type Character struct {
|
|||||||
HR uint32 `json:"hr" db:"hr"`
|
HR uint32 `json:"hr" db:"hr"`
|
||||||
GR uint32 `json:"gr"`
|
GR uint32 `json:"gr"`
|
||||||
LastLogin int32 `json:"lastLogin" db:"last_login"`
|
LastLogin int32 `json:"lastLogin" db:"last_login"`
|
||||||
|
Returning bool `json:"returning"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CourseInfo describes an active subscription course for the authenticated user.
|
||||||
|
type CourseInfo struct {
|
||||||
|
ID uint16 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MezFes represents the current Mezeporta Festival event schedule and ticket configuration.
|
// MezFes represents the current Mezeporta Festival event schedule and ticket configuration.
|
||||||
@@ -72,14 +80,15 @@ type MezFes struct {
|
|||||||
// AuthData is the JSON payload returned after successful login or registration,
|
// AuthData is the JSON payload returned after successful login or registration,
|
||||||
// containing session info, character list, event data, and server notices.
|
// containing session info, character list, event data, and server notices.
|
||||||
type AuthData struct {
|
type AuthData struct {
|
||||||
CurrentTS uint32 `json:"currentTs"`
|
CurrentTS uint32 `json:"currentTs"`
|
||||||
ExpiryTS uint32 `json:"expiryTs"`
|
ExpiryTS uint32 `json:"expiryTs"`
|
||||||
EntranceCount uint32 `json:"entranceCount"`
|
EntranceCount uint32 `json:"entranceCount"`
|
||||||
Notices []string `json:"notices"`
|
Notices []string `json:"notices"`
|
||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
Characters []Character `json:"characters"`
|
Characters []Character `json:"characters"`
|
||||||
MezFes *MezFes `json:"mezFes"`
|
Courses []CourseInfo `json:"courses"`
|
||||||
PatchServer string `json:"patchServer"`
|
MezFes *MezFes `json:"mezFes"`
|
||||||
|
PatchServer string `json:"patchServer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportData wraps a character's full database row for save export.
|
// ExportData wraps a character's full database row for save export.
|
||||||
@@ -101,23 +110,27 @@ func (s *APIServer) newAuthData(userID uint32, userRights uint32, userTokenID ui
|
|||||||
PatchServer: s.erupeConfig.API.PatchServer,
|
PatchServer: s.erupeConfig.API.PatchServer,
|
||||||
Notices: []string{},
|
Notices: []string{},
|
||||||
}
|
}
|
||||||
|
// Compute returning status per character
|
||||||
|
ninetyDaysAgo := time.Now().Add(-90 * 24 * time.Hour)
|
||||||
|
for i := range resp.Characters {
|
||||||
|
resp.Characters[i].Returning = time.Unix(int64(resp.Characters[i].LastLogin), 0).Before(ninetyDaysAgo)
|
||||||
|
}
|
||||||
|
// Derive active courses from user rights
|
||||||
|
courses, _ := mhfcourse.GetCourseStruct(userRights, s.erupeConfig.DefaultCourses)
|
||||||
|
resp.Courses = make([]CourseInfo, 0, len(courses))
|
||||||
|
for _, c := range courses {
|
||||||
|
name := ""
|
||||||
|
if aliases := c.Aliases(); len(aliases) > 0 {
|
||||||
|
name = aliases[0]
|
||||||
|
}
|
||||||
|
resp.Courses = append(resp.Courses, CourseInfo{ID: c.ID, Name: name})
|
||||||
|
}
|
||||||
if s.erupeConfig.DebugOptions.MaxLauncherHR {
|
if s.erupeConfig.DebugOptions.MaxLauncherHR {
|
||||||
for i := range resp.Characters {
|
for i := range resp.Characters {
|
||||||
resp.Characters[i].HR = 7
|
resp.Characters[i].HR = 7
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stalls := []uint32{10, 3, 6, 9, 4, 8, 5, 7}
|
resp.MezFes = s.buildMezFes()
|
||||||
if s.erupeConfig.GameplayOptions.MezFesSwitchMinigame {
|
|
||||||
stalls[4] = 2
|
|
||||||
}
|
|
||||||
resp.MezFes = &MezFes{
|
|
||||||
ID: uint32(gametime.WeekStart().Unix()),
|
|
||||||
Start: uint32(gametime.WeekStart().Add(-time.Duration(s.erupeConfig.GameplayOptions.MezFesDuration) * time.Second).Unix()),
|
|
||||||
End: uint32(gametime.WeekNext().Unix()),
|
|
||||||
SoloTickets: s.erupeConfig.GameplayOptions.MezFesSoloTickets,
|
|
||||||
GroupTickets: s.erupeConfig.GameplayOptions.MezFesGroupTickets,
|
|
||||||
Stalls: stalls,
|
|
||||||
}
|
|
||||||
if !s.erupeConfig.HideLoginNotice {
|
if !s.erupeConfig.HideLoginNotice {
|
||||||
resp.Notices = append(resp.Notices, strings.Join(s.erupeConfig.LoginNotices[:], "<PAGE>"))
|
resp.Notices = append(resp.Notices, strings.Join(s.erupeConfig.LoginNotices[:], "<PAGE>"))
|
||||||
}
|
}
|
||||||
@@ -160,35 +173,33 @@ func (s *APIServer) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
||||||
s.logger.Error("JSON decode error", zap.Error(err))
|
s.logger.Error("JSON decode error", zap.Error(err))
|
||||||
w.WriteHeader(400)
|
writeError(w, http.StatusBadRequest, "invalid_request", "Malformed request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID, password, userRights, err := s.userRepo.GetCredentials(ctx, reqData.Username)
|
userID, password, userRights, err := s.userRepo.GetCredentials(ctx, reqData.Username)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
w.WriteHeader(400)
|
writeError(w, http.StatusBadRequest, "invalid_username", "Username not found")
|
||||||
_, _ = w.Write([]byte("username-error"))
|
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
s.logger.Warn("SQL query error", zap.Error(err))
|
s.logger.Warn("SQL query error", zap.Error(err))
|
||||||
w.WriteHeader(500)
|
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if bcrypt.CompareHashAndPassword([]byte(password), []byte(reqData.Password)) != nil {
|
if bcrypt.CompareHashAndPassword([]byte(password), []byte(reqData.Password)) != nil {
|
||||||
w.WriteHeader(400)
|
writeError(w, http.StatusBadRequest, "invalid_password", "Incorrect password")
|
||||||
_, _ = w.Write([]byte("password-error"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userTokenID, userToken, err := s.createLoginToken(ctx, userID)
|
userTokenID, userToken, err := s.createLoginToken(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("Error registering login token", zap.Error(err))
|
s.logger.Warn("Error registering login token", zap.Error(err))
|
||||||
w.WriteHeader(500)
|
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
characters, err := s.getCharactersForUser(ctx, userID)
|
characters, err := s.getCharactersForUser(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Warn("Error getting characters from DB", zap.Error(err))
|
s.logger.Warn("Error getting characters from DB", zap.Error(err))
|
||||||
w.WriteHeader(500)
|
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if characters == nil {
|
if characters == nil {
|
||||||
@@ -209,11 +220,11 @@ func (s *APIServer) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
||||||
s.logger.Error("JSON decode error", zap.Error(err))
|
s.logger.Error("JSON decode error", zap.Error(err))
|
||||||
w.WriteHeader(400)
|
writeError(w, http.StatusBadRequest, "invalid_request", "Malformed request body")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if reqData.Username == "" || reqData.Password == "" {
|
if reqData.Username == "" || reqData.Password == "" {
|
||||||
w.WriteHeader(400)
|
writeError(w, http.StatusBadRequest, "missing_fields", "Username and password required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Info("Creating account", zap.String("username", reqData.Username))
|
s.logger.Info("Creating account", zap.String("username", reqData.Username))
|
||||||
@@ -221,19 +232,18 @@ func (s *APIServer) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
var pqErr *pq.Error
|
var pqErr *pq.Error
|
||||||
if errors.As(err, &pqErr) && pqErr.Constraint == "users_username_key" {
|
if errors.As(err, &pqErr) && pqErr.Constraint == "users_username_key" {
|
||||||
w.WriteHeader(400)
|
writeError(w, http.StatusBadRequest, "username_exists", "Username already taken")
|
||||||
_, _ = w.Write([]byte("username-exists-error"))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.logger.Error("Error checking user", zap.Error(err), zap.String("username", reqData.Username))
|
s.logger.Error("Error checking user", zap.Error(err), zap.String("username", reqData.Username))
|
||||||
w.WriteHeader(500)
|
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userTokenID, userToken, err := s.createLoginToken(ctx, userID)
|
userTokenID, userToken, err := s.createLoginToken(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Error registering login token", zap.Error(err))
|
s.logger.Error("Error registering login token", zap.Error(err))
|
||||||
w.WriteHeader(500)
|
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respData := s.newAuthData(userID, userRights, userTokenID, userToken, []Character{})
|
respData := s.newAuthData(userID, userRights, userTokenID, userToken, []Character{})
|
||||||
@@ -241,28 +251,32 @@ func (s *APIServer) Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(respData)
|
_ = json.NewEncoder(w).Encode(respData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCharacter handles POST /character/create, creating a new character
|
// CreateCharacter handles POST /characters (v2) or POST /character/create (legacy),
|
||||||
// slot for the authenticated user.
|
// creating a new character slot for the authenticated user.
|
||||||
func (s *APIServer) CreateCharacter(w http.ResponseWriter, r *http.Request) {
|
func (s *APIServer) CreateCharacter(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
var reqData struct {
|
userID, ok := UserIDFromContext(ctx)
|
||||||
Token string `json:"token"`
|
if !ok {
|
||||||
}
|
// Legacy path: read token from body
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
var reqData struct {
|
||||||
s.logger.Error("JSON decode error", zap.Error(err))
|
Token string `json:"token"`
|
||||||
w.WriteHeader(400)
|
}
|
||||||
return
|
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
||||||
}
|
s.logger.Error("JSON decode error", zap.Error(err))
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "Malformed request body")
|
||||||
userID, err := s.userIDFromToken(ctx, reqData.Token)
|
return
|
||||||
if err != nil {
|
}
|
||||||
w.WriteHeader(401)
|
var err error
|
||||||
return
|
userID, err = s.userIDFromToken(ctx, reqData.Token)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
character, err := s.createCharacter(ctx, userID)
|
character, err := s.createCharacter(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to create character", zap.Error(err), zap.String("token", reqData.Token))
|
s.logger.Error("Failed to create character", zap.Error(err))
|
||||||
w.WriteHeader(500)
|
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.erupeConfig.DebugOptions.MaxLauncherHR {
|
if s.erupeConfig.DebugOptions.MaxLauncherHR {
|
||||||
@@ -272,55 +286,87 @@ func (s *APIServer) CreateCharacter(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(character)
|
_ = json.NewEncoder(w).Encode(character)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteCharacter handles POST /character/delete, soft-deleting an existing
|
// DeleteCharacter handles POST /characters/{id}/delete (v2) or POST /character/delete (legacy),
|
||||||
// character or removing an unfinished one.
|
// soft-deleting an existing character or removing an unfinished one.
|
||||||
func (s *APIServer) DeleteCharacter(w http.ResponseWriter, r *http.Request) {
|
func (s *APIServer) DeleteCharacter(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
var reqData struct {
|
userID, ok := UserIDFromContext(ctx)
|
||||||
Token string `json:"token"`
|
var charID uint32
|
||||||
CharID uint32 `json:"charId"`
|
if ok {
|
||||||
|
// V2 path: user ID from middleware, char ID from URL
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
if idStr, exists := vars["id"]; exists {
|
||||||
|
if _, err := fmt.Sscanf(idStr, "%d", &charID); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid character ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy path: read token and charId from body
|
||||||
|
var reqData struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
CharID uint32 `json:"charId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
||||||
|
s.logger.Error("JSON decode error", zap.Error(err))
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "Malformed request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
userID, err = s.userIDFromToken(ctx, reqData.Token)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
charID = reqData.CharID
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
if err := s.deleteCharacter(ctx, userID, charID); err != nil {
|
||||||
s.logger.Error("JSON decode error", zap.Error(err))
|
s.logger.Error("Failed to delete character", zap.Error(err), zap.Uint32("charID", charID))
|
||||||
w.WriteHeader(400)
|
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, err := s.userIDFromToken(ctx, reqData.Token)
|
|
||||||
if err != nil {
|
|
||||||
w.WriteHeader(401)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.deleteCharacter(ctx, userID, reqData.CharID); err != nil {
|
|
||||||
s.logger.Error("Failed to delete character", zap.Error(err), zap.String("token", reqData.Token), zap.Uint32("charID", reqData.CharID))
|
|
||||||
w.WriteHeader(500)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(struct{}{})
|
_ = json.NewEncoder(w).Encode(struct{}{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportSave handles POST /character/export, returning the full character
|
// ExportSave handles GET /characters/{id}/export (v2) or POST /character/export (legacy),
|
||||||
// database row as JSON for backup purposes.
|
// returning the full character database row as JSON for backup purposes.
|
||||||
func (s *APIServer) ExportSave(w http.ResponseWriter, r *http.Request) {
|
func (s *APIServer) ExportSave(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
var reqData struct {
|
userID, ok := UserIDFromContext(ctx)
|
||||||
Token string `json:"token"`
|
var charID uint32
|
||||||
CharID uint32 `json:"charId"`
|
if ok {
|
||||||
|
// V2 path: user ID from middleware, char ID from URL
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
if idStr, exists := vars["id"]; exists {
|
||||||
|
if _, err := fmt.Sscanf(idStr, "%d", &charID); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid character ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy path: read token and charId from body
|
||||||
|
var reqData struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
CharID uint32 `json:"charId"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
||||||
|
s.logger.Error("JSON decode error", zap.Error(err))
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_request", "Malformed request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
userID, err = s.userIDFromToken(ctx, reqData.Token)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
charID = reqData.CharID
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil {
|
character, err := s.exportSave(ctx, userID, charID)
|
||||||
s.logger.Error("JSON decode error", zap.Error(err))
|
|
||||||
w.WriteHeader(400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID, err := s.userIDFromToken(ctx, reqData.Token)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(401)
|
s.logger.Error("Failed to export save", zap.Error(err), zap.Uint32("charID", charID))
|
||||||
return
|
writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error")
|
||||||
}
|
|
||||||
character, err := s.exportSave(ctx, userID, reqData.CharID)
|
|
||||||
if err != nil {
|
|
||||||
s.logger.Error("Failed to export save", zap.Error(err), zap.String("token", reqData.Token), zap.Uint32("charID", reqData.CharID))
|
|
||||||
w.WriteHeader(500)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
save := ExportData{
|
save := ExportData{
|
||||||
@@ -445,6 +491,79 @@ func (s *APIServer) ScreenShot(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeResult("200")
|
writeResult("200")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *APIServer) buildMezFes() *MezFes {
|
||||||
|
stalls := []uint32{10, 3, 6, 9, 4, 8, 5, 7}
|
||||||
|
if s.erupeConfig.GameplayOptions.MezFesSwitchMinigame {
|
||||||
|
stalls[4] = 2
|
||||||
|
}
|
||||||
|
return &MezFes{
|
||||||
|
ID: uint32(gametime.WeekStart().Unix()),
|
||||||
|
Start: uint32(gametime.WeekStart().Add(-time.Duration(s.erupeConfig.GameplayOptions.MezFesDuration) * time.Second).Unix()),
|
||||||
|
End: uint32(gametime.WeekNext().Unix()),
|
||||||
|
SoloTickets: s.erupeConfig.GameplayOptions.MezFesSoloTickets,
|
||||||
|
GroupTickets: s.erupeConfig.GameplayOptions.MezFesGroupTickets,
|
||||||
|
Stalls: stalls,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerStatusResponse is the JSON payload returned by GET /v2/server/status.
|
||||||
|
type ServerStatusResponse struct {
|
||||||
|
MezFes *MezFes `json:"mezFes"`
|
||||||
|
FeaturedWeapon *FeatureWeaponInfo `json:"featuredWeapon"`
|
||||||
|
Events EventStatus `json:"events"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureWeaponInfo describes the currently featured weapons.
|
||||||
|
type FeatureWeaponInfo struct {
|
||||||
|
StartTime uint32 `json:"startTime"`
|
||||||
|
ActiveFeatures uint32 `json:"activeFeatures"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventStatus indicates which recurring events are currently active.
|
||||||
|
type EventStatus struct {
|
||||||
|
FestaActive bool `json:"festaActive"`
|
||||||
|
DivaActive bool `json:"divaActive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerStatus handles GET /v2/server/status, returning MezFes schedule,
|
||||||
|
// featured weapon, and event activity status.
|
||||||
|
func (s *APIServer) ServerStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
resp := ServerStatusResponse{
|
||||||
|
MezFes: s.buildMezFes(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.eventRepo != nil {
|
||||||
|
weekStart := gametime.WeekStart()
|
||||||
|
fw, err := s.eventRepo.GetFeatureWeapon(ctx, weekStart)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to query feature weapon", zap.Error(err))
|
||||||
|
} else if fw != nil {
|
||||||
|
resp.FeaturedWeapon = &FeatureWeaponInfo{
|
||||||
|
StartTime: uint32(fw.StartTime.Unix()),
|
||||||
|
ActiveFeatures: fw.ActiveFeatures,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
festaEvents, err := s.eventRepo.GetActiveEvents(ctx, "festa")
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to query festa events", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
resp.Events.FestaActive = len(festaEvents) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
divaEvents, err := s.eventRepo.GetActiveEvents(ctx, "diva")
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to query diva events", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
resp.Events.DivaActive = len(divaEvents) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
// Health handles GET /health, returning the server's health status.
|
// Health handles GET /health, returning the server's health status.
|
||||||
// It pings the database to verify connectivity.
|
// It pings the database to verify connectivity.
|
||||||
func (s *APIServer) Health(w http.ResponseWriter, r *http.Request) {
|
func (s *APIServer) Health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -162,8 +162,12 @@ func TestLoginEndpoint_UsernameNotFound(t *testing.T) {
|
|||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Errorf("status = %d, want 400", rec.Code)
|
t.Errorf("status = %d, want 400", rec.Code)
|
||||||
}
|
}
|
||||||
if rec.Body.String() != "username-error" {
|
var errResp ErrorResponse
|
||||||
t.Errorf("body = %q, want username-error", rec.Body.String())
|
if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil {
|
||||||
|
t.Fatalf("decode error: %v", err)
|
||||||
|
}
|
||||||
|
if errResp.Error != "invalid_username" {
|
||||||
|
t.Errorf("error = %q, want invalid_username", errResp.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +199,12 @@ func TestLoginEndpoint_WrongPassword(t *testing.T) {
|
|||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Errorf("status = %d, want 400", rec.Code)
|
t.Errorf("status = %d, want 400", rec.Code)
|
||||||
}
|
}
|
||||||
if rec.Body.String() != "password-error" {
|
var errResp ErrorResponse
|
||||||
t.Errorf("body = %q, want password-error", rec.Body.String())
|
if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil {
|
||||||
|
t.Fatalf("decode error: %v", err)
|
||||||
|
}
|
||||||
|
if errResp.Error != "invalid_password" {
|
||||||
|
t.Errorf("error = %q, want invalid_password", errResp.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
server/api/errors.go
Normal file
21
server/api/errors.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorResponse is the standard JSON error envelope returned by all API endpoints.
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(ErrorResponse{
|
||||||
|
Error: code,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
38
server/api/middleware.go
Normal file
38
server/api/middleware.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const userIDKey contextKey = "userID"
|
||||||
|
|
||||||
|
// UserIDFromContext extracts the authenticated user ID from the request context.
|
||||||
|
// Returns the user ID and true if present, or 0 and false otherwise.
|
||||||
|
func UserIDFromContext(ctx context.Context) (uint32, bool) {
|
||||||
|
uid, ok := ctx.Value(userIDKey).(uint32)
|
||||||
|
return uid, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthMiddleware extracts a Bearer token from the Authorization header,
|
||||||
|
// validates it, and injects the user ID into the request context.
|
||||||
|
func (s *APIServer) AuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if auth == "" || !strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := strings.TrimPrefix(auth, "Bearer ")
|
||||||
|
userID, err := s.userIDFromToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized", "Invalid or expired token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userIDKey, userID)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
122
server/api/middleware_test.go
Normal file
122
server/api/middleware_test.go
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAuthMiddleware_MissingHeader(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := server.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("handler should not be called")
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/test", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
var errResp ErrorResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil {
|
||||||
|
t.Fatalf("decode error: %v", err)
|
||||||
|
}
|
||||||
|
if errResp.Error != "unauthorized" {
|
||||||
|
t.Errorf("error = %q, want unauthorized", errResp.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_MalformedHeader(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := server.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("handler should not be called")
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_InvalidToken(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
sessionRepo: &mockAPISessionRepo{
|
||||||
|
userIDErr: sql.ErrNoRows,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := server.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Error("handler should not be called")
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer invalid-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthMiddleware_ValidToken(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
sessionRepo: &mockAPISessionRepo{
|
||||||
|
userID: 42,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var gotUserID uint32
|
||||||
|
var gotOK bool
|
||||||
|
handler := server.AuthMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotUserID, gotOK = UserIDFromContext(r.Context())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/test", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer valid-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
if !gotOK {
|
||||||
|
t.Fatal("userID not found in context")
|
||||||
|
}
|
||||||
|
if gotUserID != 42 {
|
||||||
|
t.Errorf("userID = %d, want 42", gotUserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserIDFromContext_Missing(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/test", nil)
|
||||||
|
uid, ok := UserIDFromContext(req.Context())
|
||||||
|
if ok {
|
||||||
|
t.Errorf("expected ok=false, got uid=%d", uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
server/api/repo_event.go
Normal file
41
server/api/repo_event.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiEventRepository struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAPIEventRepository creates an APIEventRepo backed by PostgreSQL.
|
||||||
|
func NewAPIEventRepository(db *sqlx.DB) APIEventRepo {
|
||||||
|
return &apiEventRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiEventRepository) GetFeatureWeapon(ctx context.Context, startTime time.Time) (*FeatureWeaponRow, error) {
|
||||||
|
var row FeatureWeaponRow
|
||||||
|
err := r.db.GetContext(ctx, &row, `SELECT start_time, featured FROM feature_weapon WHERE start_time=$1`, startTime)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *apiEventRepository) GetActiveEvents(ctx context.Context, eventType string) ([]EventRow, error) {
|
||||||
|
var rows []EventRow
|
||||||
|
err := r.db.SelectContext(ctx, &rows,
|
||||||
|
`SELECT id, (EXTRACT(epoch FROM start_time)::int) as start_time FROM events WHERE event_type=$1`, eventType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
@@ -44,6 +44,26 @@ type APICharacterRepo interface {
|
|||||||
ExportSave(ctx context.Context, userID, charID uint32) (map[string]interface{}, error)
|
ExportSave(ctx context.Context, userID, charID uint32) (map[string]interface{}, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIEventRepo defines the contract for read-only event data access.
|
||||||
|
type APIEventRepo interface {
|
||||||
|
// GetFeatureWeapon returns the feature weapon entry for the given week start time.
|
||||||
|
GetFeatureWeapon(ctx context.Context, startTime time.Time) (*FeatureWeaponRow, error)
|
||||||
|
// GetActiveEvents returns all events of the given type.
|
||||||
|
GetActiveEvents(ctx context.Context, eventType string) ([]EventRow, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeatureWeaponRow holds a single feature_weapon table row.
|
||||||
|
type FeatureWeaponRow struct {
|
||||||
|
StartTime time.Time `db:"start_time"`
|
||||||
|
ActiveFeatures uint32 `db:"featured"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventRow holds a single events table row with epoch start time.
|
||||||
|
type EventRow struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
StartTime int64 `db:"start_time"`
|
||||||
|
}
|
||||||
|
|
||||||
// APISessionRepo defines the contract for session/token data access.
|
// APISessionRepo defines the contract for session/token data access.
|
||||||
type APISessionRepo interface {
|
type APISessionRepo interface {
|
||||||
// CreateToken inserts a new sign session and returns its ID and token.
|
// CreateToken inserts a new sign session and returns its ID and token.
|
||||||
|
|||||||
@@ -106,6 +106,23 @@ func (m *mockAPICharacterRepo) ExportSave(_ context.Context, _, _ uint32) (map[s
|
|||||||
return m.exportResult, m.exportErr
|
return m.exportResult, m.exportErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockAPIEventRepo implements APIEventRepo for testing.
|
||||||
|
type mockAPIEventRepo struct {
|
||||||
|
featureWeapon *FeatureWeaponRow
|
||||||
|
featureWeaponErr error
|
||||||
|
|
||||||
|
events []EventRow
|
||||||
|
eventsErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIEventRepo) GetFeatureWeapon(_ context.Context, _ time.Time) (*FeatureWeaponRow, error) {
|
||||||
|
return m.featureWeapon, m.featureWeaponErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockAPIEventRepo) GetActiveEvents(_ context.Context, _ string) ([]EventRow, error) {
|
||||||
|
return m.events, m.eventsErr
|
||||||
|
}
|
||||||
|
|
||||||
// mockAPISessionRepo implements APISessionRepo for testing.
|
// mockAPISessionRepo implements APISessionRepo for testing.
|
||||||
type mockAPISessionRepo struct {
|
type mockAPISessionRepo struct {
|
||||||
createTokenID uint32
|
createTokenID uint32
|
||||||
|
|||||||
318
server/api/routing_test.go
Normal file
318
server/api/routing_test.go
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestRouter builds the same mux router as Start() without starting an HTTP server.
|
||||||
|
func newTestRouter(s *APIServer) *mux.Router {
|
||||||
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
// Legacy routes
|
||||||
|
r.HandleFunc("/launcher", s.Launcher)
|
||||||
|
r.HandleFunc("/login", s.Login)
|
||||||
|
r.HandleFunc("/register", s.Register)
|
||||||
|
r.HandleFunc("/character/create", s.CreateCharacter)
|
||||||
|
r.HandleFunc("/character/delete", s.DeleteCharacter)
|
||||||
|
r.HandleFunc("/character/export", s.ExportSave)
|
||||||
|
r.HandleFunc("/health", s.Health)
|
||||||
|
r.HandleFunc("/version", s.Version)
|
||||||
|
|
||||||
|
// V2 routes
|
||||||
|
v2 := r.PathPrefix("/v2").Subrouter()
|
||||||
|
v2.HandleFunc("/login", s.Login).Methods("POST")
|
||||||
|
v2.HandleFunc("/register", s.Register).Methods("POST")
|
||||||
|
v2.HandleFunc("/launcher", s.Launcher).Methods("GET")
|
||||||
|
v2.HandleFunc("/version", s.Version).Methods("GET")
|
||||||
|
v2.HandleFunc("/health", s.Health).Methods("GET")
|
||||||
|
|
||||||
|
// V2 authenticated routes
|
||||||
|
v2Auth := v2.PathPrefix("").Subrouter()
|
||||||
|
v2Auth.Use(s.AuthMiddleware)
|
||||||
|
v2Auth.HandleFunc("/characters", s.CreateCharacter).Methods("POST")
|
||||||
|
v2Auth.HandleFunc("/characters/{id}/delete", s.DeleteCharacter).Methods("POST")
|
||||||
|
v2Auth.HandleFunc("/characters/{id}/export", s.ExportSave).Methods("GET")
|
||||||
|
|
||||||
|
v2.HandleFunc("/server/status", s.ServerStatus).Methods("GET")
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2LoginRoute(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
hash, _ := bcrypt.GenerateFromPassword([]byte("pass"), bcrypt.MinCost)
|
||||||
|
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
userRepo: &mockAPIUserRepo{
|
||||||
|
credentialsID: 1,
|
||||||
|
credentialsPassword: string(hash),
|
||||||
|
credentialsRights: 30,
|
||||||
|
lastLogin: time.Now(),
|
||||||
|
returnExpiry: time.Now().Add(time.Hour * 24 * 30),
|
||||||
|
},
|
||||||
|
sessionRepo: &mockAPISessionRepo{createTokenID: 1},
|
||||||
|
charRepo: &mockAPICharacterRepo{characters: []Character{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"username": "user", "password": "pass"})
|
||||||
|
req := httptest.NewRequest("POST", "/v2/login", bytes.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("POST /v2/login: status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2LoginRejectsGET(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/v2/login", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// gorilla/mux subrouters return 404 for method mismatches (not 405)
|
||||||
|
if rec.Code == http.StatusOK {
|
||||||
|
t.Error("GET /v2/login should not succeed (POST only)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2HealthRoute(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
db: nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/v2/health", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
// 503 because db is nil, but it should route correctly
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("GET /v2/health: status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2VersionRoute(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/v2/version", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /v2/version: status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2CharactersRequiresAuth(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
sessionRepo: &mockAPISessionRepo{
|
||||||
|
userIDErr: sql.ErrNoRows,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
// No auth header
|
||||||
|
req := httptest.NewRequest("POST", "/v2/characters", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("POST /v2/characters (no auth): status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2CharactersWithAuth(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
sessionRepo: &mockAPISessionRepo{userID: 1},
|
||||||
|
charRepo: &mockAPICharacterRepo{
|
||||||
|
newCharacter: Character{ID: 5, Name: "NewChar"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/v2/characters", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer valid-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("POST /v2/characters (with auth): status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2DeleteCharacterRoute(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
sessionRepo: &mockAPISessionRepo{userID: 1},
|
||||||
|
charRepo: &mockAPICharacterRepo{isNewResult: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/v2/characters/5/delete", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer valid-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("POST /v2/characters/5/delete: status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2ExportCharacterRoute(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
sessionRepo: &mockAPISessionRepo{userID: 1},
|
||||||
|
charRepo: &mockAPICharacterRepo{
|
||||||
|
exportResult: map[string]interface{}{"name": "Hunter"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/v2/characters/1/export", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer valid-token")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /v2/characters/1/export: status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2ServerStatusRoute_NoEventRepo(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/v2/server/status", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /v2/server/status: status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ServerStatusResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.MezFes == nil {
|
||||||
|
t.Error("MezFes should not be nil")
|
||||||
|
}
|
||||||
|
if resp.FeaturedWeapon != nil {
|
||||||
|
t.Error("FeaturedWeapon should be nil without event repo")
|
||||||
|
}
|
||||||
|
if resp.Events.FestaActive || resp.Events.DivaActive {
|
||||||
|
t.Error("events should be inactive without event repo")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestV2ServerStatusRoute_WithEvents(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
eventRepo: &mockAPIEventRepo{
|
||||||
|
featureWeapon: &FeatureWeaponRow{
|
||||||
|
StartTime: time.Now(),
|
||||||
|
ActiveFeatures: 0xFF,
|
||||||
|
},
|
||||||
|
events: []EventRow{{ID: 1, StartTime: time.Now().Unix()}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/v2/server/status", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp ServerStatusResponse
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
|
||||||
|
t.Fatalf("decode error: %v", err)
|
||||||
|
}
|
||||||
|
if resp.FeaturedWeapon == nil {
|
||||||
|
t.Fatal("FeaturedWeapon should not be nil")
|
||||||
|
}
|
||||||
|
if resp.FeaturedWeapon.ActiveFeatures != 0xFF {
|
||||||
|
t.Errorf("ActiveFeatures = %d, want 255", resp.FeaturedWeapon.ActiveFeatures)
|
||||||
|
}
|
||||||
|
// Both festa and diva use the same mock events slice, so both are active
|
||||||
|
if !resp.Events.FestaActive {
|
||||||
|
t.Error("FestaActive should be true")
|
||||||
|
}
|
||||||
|
if !resp.Events.DivaActive {
|
||||||
|
t.Error("DivaActive should be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyRoutesStillWork(t *testing.T) {
|
||||||
|
logger := NewTestLogger(t)
|
||||||
|
server := &APIServer{
|
||||||
|
logger: logger,
|
||||||
|
erupeConfig: NewTestConfig(),
|
||||||
|
}
|
||||||
|
|
||||||
|
router := newTestRouter(server)
|
||||||
|
|
||||||
|
// Legacy /version should work with GET
|
||||||
|
req := httptest.NewRequest("GET", "/version", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("GET /version (legacy): status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user