diff --git a/CHANGELOG.md b/CHANGELOG.md index 90700ed44..6aa4f87f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- API: Standardized JSON error responses (`{"error":"...","message":"..."}`) across all endpoints via `writeError` helper +- API: Auth middleware extracting `Authorization: Bearer ` 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 - 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 diff --git a/server/api/api_server.go b/server/api/api_server.go index c250e1dee..f40176ef0 100644 --- a/server/api/api_server.go +++ b/server/api/api_server.go @@ -31,6 +31,7 @@ type APIServer struct { userRepo APIUserRepo charRepo APICharacterRepo sessionRepo APISessionRepo + eventRepo APIEventRepo httpServer *http.Server isShuttingDown bool } @@ -47,6 +48,7 @@ func NewAPIServer(config *Config) *APIServer { s.userRepo = NewAPIUserRepository(config.DB) s.charRepo = NewAPICharacterRepository(config.DB) s.sessionRepo = NewAPISessionRepository(config.DB) + s.eventRepo = NewAPIEventRepository(config.DB) } return s } @@ -55,6 +57,8 @@ func NewAPIServer(config *Config) *APIServer { func (s *APIServer) Start() error { // Set up the routes responsible for serving the launcher HTML, serverlist, unique name check, and JP auth. r := mux.NewRouter() + + // Legacy routes (unchanged, no method enforcement) r.HandleFunc("/launcher", s.Launcher) r.HandleFunc("/login", s.Login) r.HandleFunc("/register", s.Register) @@ -66,7 +70,26 @@ func (s *APIServer) Start() error { r.HandleFunc("/", s.LandingPage) r.HandleFunc("/health", s.Health) 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.Addr = fmt.Sprintf(":%d", s.erupeConfig.API.Port) diff --git a/server/api/endpoints.go b/server/api/endpoints.go index 598241d1a..50adb9e37 100644 --- a/server/api/endpoints.go +++ b/server/api/endpoints.go @@ -7,6 +7,7 @@ import ( "encoding/xml" "errors" "erupe-ce/common/gametime" + "erupe-ce/common/mhfcourse" cfg "erupe-ce/config" "fmt" "image" @@ -57,6 +58,13 @@ type Character struct { HR uint32 `json:"hr" db:"hr"` GR uint32 `json:"gr"` 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. @@ -72,14 +80,15 @@ type MezFes struct { // AuthData is the JSON payload returned after successful login or registration, // containing session info, character list, event data, and server notices. type AuthData struct { - CurrentTS uint32 `json:"currentTs"` - ExpiryTS uint32 `json:"expiryTs"` - EntranceCount uint32 `json:"entranceCount"` - Notices []string `json:"notices"` - User User `json:"user"` - Characters []Character `json:"characters"` - MezFes *MezFes `json:"mezFes"` - PatchServer string `json:"patchServer"` + CurrentTS uint32 `json:"currentTs"` + ExpiryTS uint32 `json:"expiryTs"` + EntranceCount uint32 `json:"entranceCount"` + Notices []string `json:"notices"` + User User `json:"user"` + Characters []Character `json:"characters"` + Courses []CourseInfo `json:"courses"` + MezFes *MezFes `json:"mezFes"` + PatchServer string `json:"patchServer"` } // 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, 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 { for i := range resp.Characters { resp.Characters[i].HR = 7 } } - stalls := []uint32{10, 3, 6, 9, 4, 8, 5, 7} - 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, - } + resp.MezFes = s.buildMezFes() if !s.erupeConfig.HideLoginNotice { resp.Notices = append(resp.Notices, strings.Join(s.erupeConfig.LoginNotices[:], "")) } @@ -160,35 +173,33 @@ func (s *APIServer) Login(w http.ResponseWriter, r *http.Request) { } if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil { s.logger.Error("JSON decode error", zap.Error(err)) - w.WriteHeader(400) + writeError(w, http.StatusBadRequest, "invalid_request", "Malformed request body") return } userID, password, userRights, err := s.userRepo.GetCredentials(ctx, reqData.Username) if err == sql.ErrNoRows { - w.WriteHeader(400) - _, _ = w.Write([]byte("username-error")) + writeError(w, http.StatusBadRequest, "invalid_username", "Username not found") return } else if err != nil { s.logger.Warn("SQL query error", zap.Error(err)) - w.WriteHeader(500) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } if bcrypt.CompareHashAndPassword([]byte(password), []byte(reqData.Password)) != nil { - w.WriteHeader(400) - _, _ = w.Write([]byte("password-error")) + writeError(w, http.StatusBadRequest, "invalid_password", "Incorrect password") return } userTokenID, userToken, err := s.createLoginToken(ctx, userID) if err != nil { s.logger.Warn("Error registering login token", zap.Error(err)) - w.WriteHeader(500) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } characters, err := s.getCharactersForUser(ctx, userID) if err != nil { s.logger.Warn("Error getting characters from DB", zap.Error(err)) - w.WriteHeader(500) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } 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 { s.logger.Error("JSON decode error", zap.Error(err)) - w.WriteHeader(400) + writeError(w, http.StatusBadRequest, "invalid_request", "Malformed request body") return } if reqData.Username == "" || reqData.Password == "" { - w.WriteHeader(400) + writeError(w, http.StatusBadRequest, "missing_fields", "Username and password required") return } 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 { var pqErr *pq.Error if errors.As(err, &pqErr) && pqErr.Constraint == "users_username_key" { - w.WriteHeader(400) - _, _ = w.Write([]byte("username-exists-error")) + writeError(w, http.StatusBadRequest, "username_exists", "Username already taken") return } 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 } userTokenID, userToken, err := s.createLoginToken(ctx, userID) if err != nil { s.logger.Error("Error registering login token", zap.Error(err)) - w.WriteHeader(500) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } 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) } -// CreateCharacter handles POST /character/create, creating a new character -// slot for the authenticated user. +// CreateCharacter handles POST /characters (v2) or POST /character/create (legacy), +// creating a new character slot for the authenticated user. func (s *APIServer) CreateCharacter(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - var reqData struct { - Token string `json:"token"` - } - if err := json.NewDecoder(r.Body).Decode(&reqData); err != nil { - s.logger.Error("JSON decode error", zap.Error(err)) - w.WriteHeader(400) - return - } - - userID, err := s.userIDFromToken(ctx, reqData.Token) - if err != nil { - w.WriteHeader(401) - return + userID, ok := UserIDFromContext(ctx) + if !ok { + // Legacy path: read token from body + var reqData struct { + Token string `json:"token"` + } + 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 + } } character, err := s.createCharacter(ctx, userID) if err != nil { - s.logger.Error("Failed to create character", zap.Error(err), zap.String("token", reqData.Token)) - w.WriteHeader(500) + s.logger.Error("Failed to create character", zap.Error(err)) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } if s.erupeConfig.DebugOptions.MaxLauncherHR { @@ -272,55 +286,87 @@ func (s *APIServer) CreateCharacter(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(character) } -// DeleteCharacter handles POST /character/delete, soft-deleting an existing -// character or removing an unfinished one. +// DeleteCharacter handles POST /characters/{id}/delete (v2) or POST /character/delete (legacy), +// soft-deleting an existing character or removing an unfinished one. func (s *APIServer) DeleteCharacter(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - var reqData struct { - Token string `json:"token"` - CharID uint32 `json:"charId"` + userID, ok := UserIDFromContext(ctx) + var charID uint32 + 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 { - s.logger.Error("JSON decode error", zap.Error(err)) - w.WriteHeader(400) - 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) + if err := s.deleteCharacter(ctx, userID, charID); err != nil { + s.logger.Error("Failed to delete character", zap.Error(err), zap.Uint32("charID", charID)) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } w.Header().Add("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(struct{}{}) } -// ExportSave handles POST /character/export, returning the full character -// database row as JSON for backup purposes. +// ExportSave handles GET /characters/{id}/export (v2) or POST /character/export (legacy), +// returning the full character database row as JSON for backup purposes. func (s *APIServer) ExportSave(w http.ResponseWriter, r *http.Request) { ctx := r.Context() - var reqData struct { - Token string `json:"token"` - CharID uint32 `json:"charId"` + userID, ok := UserIDFromContext(ctx) + var charID uint32 + 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 { - s.logger.Error("JSON decode error", zap.Error(err)) - w.WriteHeader(400) - return - } - userID, err := s.userIDFromToken(ctx, reqData.Token) + character, err := s.exportSave(ctx, userID, charID) if err != nil { - w.WriteHeader(401) - return - } - 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) + s.logger.Error("Failed to export save", zap.Error(err), zap.Uint32("charID", charID)) + writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } save := ExportData{ @@ -445,6 +491,79 @@ func (s *APIServer) ScreenShot(w http.ResponseWriter, r *http.Request) { 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. // It pings the database to verify connectivity. func (s *APIServer) Health(w http.ResponseWriter, r *http.Request) { diff --git a/server/api/endpoints_coverage_test.go b/server/api/endpoints_coverage_test.go index 84d04d928..9eaa26e7a 100644 --- a/server/api/endpoints_coverage_test.go +++ b/server/api/endpoints_coverage_test.go @@ -162,8 +162,12 @@ func TestLoginEndpoint_UsernameNotFound(t *testing.T) { if rec.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400", rec.Code) } - if rec.Body.String() != "username-error" { - t.Errorf("body = %q, want username-error", rec.Body.String()) + var errResp ErrorResponse + 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 { t.Errorf("status = %d, want 400", rec.Code) } - if rec.Body.String() != "password-error" { - t.Errorf("body = %q, want password-error", rec.Body.String()) + var errResp ErrorResponse + 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) } } diff --git a/server/api/errors.go b/server/api/errors.go new file mode 100644 index 000000000..99fa79c13 --- /dev/null +++ b/server/api/errors.go @@ -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, + }) +} diff --git a/server/api/middleware.go b/server/api/middleware.go new file mode 100644 index 000000000..c5d2da43a --- /dev/null +++ b/server/api/middleware.go @@ -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)) + }) +} diff --git a/server/api/middleware_test.go b/server/api/middleware_test.go new file mode 100644 index 000000000..b8db7b52f --- /dev/null +++ b/server/api/middleware_test.go @@ -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) + } +} diff --git a/server/api/repo_event.go b/server/api/repo_event.go new file mode 100644 index 000000000..c6a2cef2c --- /dev/null +++ b/server/api/repo_event.go @@ -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 +} diff --git a/server/api/repo_interfaces.go b/server/api/repo_interfaces.go index c0e24c3ec..383668764 100644 --- a/server/api/repo_interfaces.go +++ b/server/api/repo_interfaces.go @@ -44,6 +44,26 @@ type APICharacterRepo interface { 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. type APISessionRepo interface { // CreateToken inserts a new sign session and returns its ID and token. diff --git a/server/api/repo_mocks_test.go b/server/api/repo_mocks_test.go index ab4bce375..b7a23f5d0 100644 --- a/server/api/repo_mocks_test.go +++ b/server/api/repo_mocks_test.go @@ -106,6 +106,23 @@ func (m *mockAPICharacterRepo) ExportSave(_ context.Context, _, _ uint32) (map[s 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. type mockAPISessionRepo struct { createTokenID uint32 diff --git a/server/api/routing_test.go b/server/api/routing_test.go new file mode 100644 index 000000000..c336b3838 --- /dev/null +++ b/server/api/routing_test.go @@ -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) + } +}