Files
Erupe/server/api/endpoints.go
Houmgaor 6f7852cc12 feat(api): add GET /v2/server/info endpoint for launcher compatibility
Exposes the server's configured client mode in a form that mhf-outpost
and other launcher tools can consume to warn users when their local game
version does not match what the server expects.

Returns clientMode (raw, e.g. "G9.1") and manifestId (normalized for
mhf-outpost: lowercase, dots stripped, e.g. "g91"). No auth required.
2026-03-23 19:55:55 +01:00

770 lines
24 KiB
Go

package api
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"encoding/xml"
"errors"
"erupe-ce/common/gametime"
"erupe-ce/common/mhfcourse"
cfg "erupe-ce/config"
"erupe-ce/server/channelserver/compression/nullcomp"
"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[:], "<PAGE>"))
}
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)
}
// ServerInfoResponse is the JSON payload returned by GET /v2/server/info.
// It exposes the server's configured game version in a form that launcher
// tools (e.g. mhf-outpost) can use to check version compatibility.
type ServerInfoResponse struct {
// ClientMode is the version string as configured in Erupe (e.g. "ZZ", "G10.1").
ClientMode string `json:"clientMode"`
// ManifestID is the normalized form of ClientMode (lowercase, dots removed)
// matching mhf-outpost manifest IDs (e.g. "zz", "g101").
ManifestID string `json:"manifestId"`
// Name is the server software name.
Name string `json:"name"`
}
// ServerInfo handles GET /v2/server/info, returning the server's configured
// game version in a format compatible with mhf-outpost manifest IDs.
func (s *APIServer) ServerInfo(w http.ResponseWriter, r *http.Request) {
clientMode := s.erupeConfig.ClientMode
resp := ServerInfoResponse{
ClientMode: clientMode,
ManifestID: strings.ToLower(strings.ReplaceAll(clientMode, ".", "")),
Name: "Erupe-CE",
}
w.Header().Set("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",
})
}
// ImportSave handles POST /v2/characters/{id}/import.
// The request body must contain a one-time import_token (granted by an admin
// via saveutil) plus a character export blob in the same format as ExportSave.
func (s *APIServer) ImportSave(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
userID, _ := UserIDFromContext(ctx)
var charID uint32
if _, err := fmt.Sscanf(mux.Vars(r)["id"], "%d", &charID); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid character ID")
return
}
var req struct {
ImportToken string `json:"import_token"`
Character map[string]interface{} `json:"character"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "Malformed request body")
return
}
if req.ImportToken == "" {
writeError(w, http.StatusBadRequest, "missing_token", "import_token is required")
return
}
blobs, err := saveBlobsFromMap(req.Character)
if err != nil {
s.logger.Warn("ImportSave: failed to extract blobs", zap.Error(err), zap.Uint32("charID", charID))
writeError(w, http.StatusBadRequest, "invalid_request", "Invalid save data: "+err.Error())
return
}
// Compute savedata hash server-side.
if len(blobs.Savedata) > 0 {
decompressed, err := nullcomp.Decompress(blobs.Savedata)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "savedata decompression failed")
return
}
h := sha256.Sum256(decompressed)
blobs.SavedataHash = h[:]
}
if err := s.charRepo.ImportSave(ctx, charID, userID, req.ImportToken, blobs); err != nil {
s.logger.Warn("ImportSave: failed", zap.Error(err), zap.Uint32("charID", charID))
writeError(w, http.StatusForbidden, "import_denied", "Import token invalid, expired, or character not owned by user")
return
}
s.logger.Info("ImportSave: save imported successfully", zap.Uint32("charID", charID), zap.Uint32("userID", userID))
w.WriteHeader(http.StatusOK)
}
// saveBlobsFromMap extracts save blob columns from an export character map.
// Values must be base64-encoded strings (as produced by json.Marshal on []byte).
func saveBlobsFromMap(m map[string]interface{}) (SaveBlobs, error) {
var b SaveBlobs
var err error
b.Savedata, err = extractBlob(m, "savedata")
if err != nil {
return b, err
}
b.Decomyset, err = extractBlob(m, "decomyset")
if err != nil {
return b, err
}
b.Hunternavi, err = extractBlob(m, "hunternavi")
if err != nil {
return b, err
}
b.Otomoairou, err = extractBlob(m, "otomoairou")
if err != nil {
return b, err
}
b.Partner, err = extractBlob(m, "partner")
if err != nil {
return b, err
}
b.Platebox, err = extractBlob(m, "platebox")
if err != nil {
return b, err
}
b.Platedata, err = extractBlob(m, "platedata")
if err != nil {
return b, err
}
b.Platemyset, err = extractBlob(m, "platemyset")
if err != nil {
return b, err
}
b.Rengokudata, err = extractBlob(m, "rengokudata")
if err != nil {
return b, err
}
b.Savemercenary, err = extractBlob(m, "savemercenary")
if err != nil {
return b, err
}
b.GachaItems, err = extractBlob(m, "gacha_items")
if err != nil {
return b, err
}
b.HouseInfo, err = extractBlob(m, "house_info")
if err != nil {
return b, err
}
b.LoginBoost, err = extractBlob(m, "login_boost")
if err != nil {
return b, err
}
b.SkinHist, err = extractBlob(m, "skin_hist")
if err != nil {
return b, err
}
b.Scenariodata, err = extractBlob(m, "scenariodata")
if err != nil {
return b, err
}
b.Savefavoritequest, err = extractBlob(m, "savefavoritequest")
if err != nil {
return b, err
}
b.Mezfes, err = extractBlob(m, "mezfes")
if err != nil {
return b, err
}
return b, nil
}
// extractBlob decodes a single base64-encoded blob from a character export map.
// Returns nil (not an error) if the key is absent or its value is JSON null.
func extractBlob(m map[string]interface{}, key string) ([]byte, error) {
v, ok := m[key]
if !ok || v == nil {
return nil, nil
}
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("field %q: expected base64 string, got %T", key, v)
}
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("field %q: base64 decode: %w", key, err)
}
return b, nil
}