package api import ( "context" "database/sql" "encoding/json" "encoding/xml" "errors" "erupe-ce/common/gametime" "erupe-ce/common/mhfcourse" cfg "erupe-ce/config" "fmt" "image" "image/jpeg" "io" "net/http" "os" "path/filepath" "regexp" "strings" "time" "github.com/gorilla/mux" "github.com/lib/pq" "go.uber.org/zap" "golang.org/x/crypto/bcrypt" ) // Notification type constants for launcher messages. const ( // NotificationDefault represents a standard notification. NotificationDefault = iota // NotificationNew represents a new/unread notification. NotificationNew ) // LauncherResponse is the JSON payload returned by the /launcher endpoint, // containing banners, messages, and links for the game launcher UI. type LauncherResponse struct { Banners []cfg.APISignBanner `json:"banners"` Messages []cfg.APISignMessage `json:"messages"` Links []cfg.APISignLink `json:"links"` } // User represents an authenticated user's session credentials and permissions. type User struct { TokenID uint32 `json:"tokenId"` Token string `json:"token"` Rights uint32 `json:"rights"` } // Character represents a player character's summary data as returned by the API. type Character struct { ID uint32 `json:"id"` Name string `json:"name"` IsFemale bool `json:"isFemale" db:"is_female"` Weapon uint32 `json:"weapon" db:"weapon_type"` 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. type MezFes struct { ID uint32 `json:"id"` Start uint32 `json:"start"` End uint32 `json:"end"` SoloTickets uint32 `json:"soloTickets"` GroupTickets uint32 `json:"groupTickets"` Stalls []uint32 `json:"stalls"` } // 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"` Courses []CourseInfo `json:"courses"` MezFes *MezFes `json:"mezFes"` PatchServer string `json:"patchServer"` } // ExportData wraps a character's full database row for save export. type ExportData struct { Character map[string]interface{} `json:"character"` } func (s *APIServer) newAuthData(userID uint32, userRights uint32, userTokenID uint32, userToken string, characters []Character) AuthData { resp := AuthData{ CurrentTS: uint32(gametime.Adjusted().Unix()), ExpiryTS: uint32(s.getReturnExpiry(userID).Unix()), EntranceCount: 1, User: User{ Rights: userRights, TokenID: userTokenID, Token: userToken, }, Characters: characters, 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 } } resp.MezFes = s.buildMezFes() if !s.erupeConfig.HideLoginNotice { resp.Notices = append(resp.Notices, strings.Join(s.erupeConfig.LoginNotices[:], "")) } return resp } // VersionResponse is the JSON payload returned by the /version endpoint. type VersionResponse struct { ClientMode string `json:"clientMode"` Name string `json:"name"` } // Version handles GET /version and returns the server name and client mode. func (s *APIServer) Version(w http.ResponseWriter, r *http.Request) { resp := VersionResponse{ ClientMode: s.erupeConfig.ClientMode, Name: "Erupe-CE", } w.Header().Add("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } // Launcher handles GET /launcher and returns banners, messages, and links for the launcher UI. func (s *APIServer) Launcher(w http.ResponseWriter, r *http.Request) { var respData LauncherResponse respData.Banners = s.erupeConfig.API.Banners respData.Messages = s.erupeConfig.API.Messages respData.Links = s.erupeConfig.API.Links w.Header().Add("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(respData) } // Login handles POST /login, authenticating a user by username and password // and returning a session token with character data. func (s *APIServer) Login(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var reqData struct { Username string `json:"username"` Password string `json:"password"` } 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 } userID, password, userRights, err := s.userRepo.GetCredentials(ctx, reqData.Username) if err == sql.ErrNoRows { writeError(w, http.StatusBadRequest, "invalid_username", "Username not found") return } else if err != nil { s.logger.Warn("SQL query error", zap.Error(err)) writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } if bcrypt.CompareHashAndPassword([]byte(password), []byte(reqData.Password)) != nil { 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)) 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)) writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } if characters == nil { characters = []Character{} } respData := s.newAuthData(userID, userRights, userTokenID, userToken, characters) w.Header().Add("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(respData) } // Register handles POST /register, creating a new user account and returning // a session token. func (s *APIServer) Register(w http.ResponseWriter, r *http.Request) { ctx := r.Context() var reqData struct { Username string `json:"username"` Password string `json:"password"` } 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 } if reqData.Username == "" || reqData.Password == "" { writeError(w, http.StatusBadRequest, "missing_fields", "Username and password required") return } s.logger.Info("Creating account", zap.String("username", reqData.Username)) userID, userRights, err := s.createNewUser(ctx, reqData.Username, reqData.Password) if err != nil { var pqErr *pq.Error if errors.As(err, &pqErr) && pqErr.Constraint == "users_username_key" { writeError(w, http.StatusBadRequest, "username_exists", "Username already taken") return } s.logger.Error("Error checking user", zap.Error(err), zap.String("username", reqData.Username)) 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)) writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } respData := s.newAuthData(userID, userRights, userTokenID, userToken, []Character{}) w.Header().Add("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(respData) } // 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() 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)) writeError(w, http.StatusInternalServerError, "internal_error", "Internal server error") return } if s.erupeConfig.DebugOptions.MaxLauncherHR { character.HR = 7 } w.Header().Add("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(character) } // 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() 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 := 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 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() 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 } character, err := s.exportSave(ctx, userID, charID) if err != nil { 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{ Character: character, } w.Header().Add("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(save) } // ScreenShotGet handles GET /api/ss/bbs/{id}, serving a previously uploaded // screenshot image by its token ID. func (s *APIServer) ScreenShotGet(w http.ResponseWriter, r *http.Request) { // Get the 'id' parameter from the URL token := mux.Vars(r)["id"] var tokenPattern = regexp.MustCompile(`[A-Za-z0-9]+`) if !tokenPattern.MatchString(token) || token == "" { http.Error(w, "Not Valid Token", http.StatusBadRequest) } // Open the image file safePath := s.erupeConfig.Screenshots.OutputDir path := filepath.Join(safePath, fmt.Sprintf("%s.jpg", token)) result, err := verifyPath(path, safePath, s.logger) if err != nil { s.logger.Warn("Screenshot path verification failed", zap.Error(err)) } else { s.logger.Debug("Screenshot canonical path", zap.String("path", result)) file, err := os.Open(result) if err != nil { http.Error(w, "Image not found", http.StatusNotFound) return } defer func() { _ = file.Close() }() // Set content type header to image/jpeg w.Header().Set("Content-Type", "image/jpeg") // Copy the image content to the response writer if _, err := io.Copy(w, file); err != nil { http.Error(w, "Unable to send image", http.StatusInternalServerError) return } } } // ScreenShot handles POST /api/ss/bbs/upload.php, accepting a JPEG image // upload from the game client and saving it to the configured output directory. func (s *APIServer) ScreenShot(w http.ResponseWriter, r *http.Request) { type Result struct { XMLName xml.Name `xml:"result"` Code string `xml:"code"` } writeResult := func(code string) { w.Header().Set("Content-Type", "text/xml") xmlData, err := xml.Marshal(Result{Code: code}) if err != nil { http.Error(w, "Unable to marshal XML", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) _, _ = w.Write(xmlData) } if !s.erupeConfig.Screenshots.Enabled { writeResult("400") return } if r.Method != http.MethodPost { writeResult("405") return } var tokenPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`) token := r.FormValue("token") if !tokenPattern.MatchString(token) { writeResult("401") return } file, _, err := r.FormFile("img") if err != nil { writeResult("400") return } img, _, err := image.Decode(file) if err != nil { writeResult("400") return } safePath := s.erupeConfig.Screenshots.OutputDir path := filepath.Join(safePath, fmt.Sprintf("%s.jpg", token)) verified, err := verifyPath(path, safePath, s.logger) if err != nil { writeResult("500") return } if err := os.MkdirAll(safePath, os.ModePerm); err != nil { s.logger.Error("Error writing screenshot, could not create folder", zap.Error(err)) writeResult("500") return } outputFile, err := os.Create(verified) if err != nil { s.logger.Error("Error writing screenshot, could not create file", zap.Error(err)) writeResult("500") return } defer func() { _ = outputFile.Close() }() if err := jpeg.Encode(outputFile, img, &jpeg.Options{Quality: s.erupeConfig.Screenshots.UploadQuality}); err != nil { s.logger.Error("Error writing screenshot, could not write file", zap.Error(err)) writeResult("500") return } 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) { w.Header().Set("Content-Type", "application/json") if s.db == nil { w.WriteHeader(http.StatusServiceUnavailable) _ = json.NewEncoder(w).Encode(map[string]string{ "status": "unhealthy", "error": "database not configured", }) return } ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() if err := s.db.PingContext(ctx); err != nil { w.WriteHeader(http.StatusServiceUnavailable) _ = json.NewEncoder(w).Encode(map[string]string{ "status": "unhealthy", "error": err.Error(), }) return } _ = json.NewEncoder(w).Encode(map[string]string{ "status": "ok", }) }