mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 15:43:49 +01:00
feat(save-transfer): add saveutil CLI and token-gated import endpoint
Adds two complementary paths for transferring character save data between
Erupe instances without breaking the SHA-256 integrity check system:
- `cmd/saveutil/`: admin CLI with `import`, `export`, `grant-import`, and
`revoke-import` subcommands. Direct DB access; no server running required.
- `POST /v2/characters/{id}/import`: player-facing API endpoint gated behind
a one-time token issued by `saveutil grant-import` (default TTL 24 h).
Token is validated and consumed atomically to prevent TOCTOU races.
- Migration `0013_save_transfer`: `savedata_import_token` and
`savedata_import_token_expiry` columns on `characters` table.
- Both paths decompress incoming savedata and recompute the SHA-256 hash
server-side, so the integrity check remains valid after import.
- README documents both methods and the per-character hash-reset workaround.
Closes #183.
This commit is contained in:
@@ -94,6 +94,7 @@ func (s *APIServer) Start() error {
|
||||
v2Auth.HandleFunc("/characters/{id}/delete", s.DeleteCharacter).Methods("POST")
|
||||
v2Auth.HandleFunc("/characters/{id}", s.DeleteCharacter).Methods("DELETE")
|
||||
v2Auth.HandleFunc("/characters/{id}/export", s.ExportSave).Methods("GET")
|
||||
v2Auth.HandleFunc("/characters/{id}/import", s.ImportSave).Methods("POST")
|
||||
|
||||
handler := handlers.CORS(
|
||||
handlers.AllowedHeaders([]string{"Content-Type", "Authorization"}),
|
||||
|
||||
@@ -2,13 +2,16 @@ 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"
|
||||
@@ -590,3 +593,151 @@ func (s *APIServer) Health(w http.ResponseWriter, r *http.Request) {
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
@@ -89,3 +91,80 @@ func (r *APICharacterRepository) ExportSave(ctx context.Context, userID, charID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *APICharacterRepository) GrantImportToken(ctx context.Context, charID, userID uint32, token string, expiry time.Time) error {
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE characters SET savedata_import_token=$1, savedata_import_token_expiry=$2
|
||||
WHERE id=$3 AND user_id=$4 AND deleted=false`,
|
||||
token, expiry, charID, userID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errors.New("character not found or not owned by user")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *APICharacterRepository) RevokeImportToken(ctx context.Context, charID, userID uint32) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE characters SET savedata_import_token=NULL, savedata_import_token_expiry=NULL
|
||||
WHERE id=$1 AND user_id=$2`,
|
||||
charID, userID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *APICharacterRepository) ImportSave(ctx context.Context, charID, userID uint32, token string, blobs SaveBlobs) error {
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Validate token ownership and expiry, then clear it — all in one UPDATE.
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE characters
|
||||
SET savedata_import_token=NULL, savedata_import_token_expiry=NULL
|
||||
WHERE id=$1 AND user_id=$2
|
||||
AND savedata_import_token=$3
|
||||
AND savedata_import_token_expiry > now()`,
|
||||
charID, userID, token,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return errors.New("import token invalid, expired, or character not owned by user")
|
||||
}
|
||||
|
||||
// Write all save blobs.
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`UPDATE characters SET
|
||||
savedata=$1, savedata_hash=$2, decomyset=$3, hunternavi=$4,
|
||||
otomoairou=$5, partner=$6, platebox=$7, platedata=$8,
|
||||
platemyset=$9, rengokudata=$10, savemercenary=$11, gacha_items=$12,
|
||||
house_info=$13, login_boost=$14, skin_hist=$15, scenariodata=$16,
|
||||
savefavoritequest=$17, mezfes=$18
|
||||
WHERE id=$19`,
|
||||
blobs.Savedata, blobs.SavedataHash, blobs.Decomyset, blobs.Hunternavi,
|
||||
blobs.Otomoairou, blobs.Partner, blobs.Platebox, blobs.Platedata,
|
||||
blobs.Platemyset, blobs.Rengokudata, blobs.Savemercenary, blobs.GachaItems,
|
||||
blobs.HouseInfo, blobs.LoginBoost, blobs.SkinHist, blobs.Scenariodata,
|
||||
blobs.Savefavoritequest, blobs.Mezfes,
|
||||
charID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -8,6 +8,29 @@ import (
|
||||
// Repository interfaces decouple API server business logic from concrete
|
||||
// PostgreSQL implementations, enabling mock/stub injection for unit tests.
|
||||
|
||||
// SaveBlobs holds the transferable save data columns for a character.
|
||||
// SavedataHash must be set by the caller (SHA-256 of decompressed Savedata).
|
||||
type SaveBlobs struct {
|
||||
Savedata []byte
|
||||
SavedataHash []byte
|
||||
Decomyset []byte
|
||||
Hunternavi []byte
|
||||
Otomoairou []byte
|
||||
Partner []byte
|
||||
Platebox []byte
|
||||
Platedata []byte
|
||||
Platemyset []byte
|
||||
Rengokudata []byte
|
||||
Savemercenary []byte
|
||||
GachaItems []byte
|
||||
HouseInfo []byte
|
||||
LoginBoost []byte
|
||||
SkinHist []byte
|
||||
Scenariodata []byte
|
||||
Savefavoritequest []byte
|
||||
Mezfes []byte
|
||||
}
|
||||
|
||||
// APIUserRepo defines the contract for user-related data access.
|
||||
type APIUserRepo interface {
|
||||
// Register creates a new user and returns their ID and rights.
|
||||
@@ -42,6 +65,13 @@ type APICharacterRepo interface {
|
||||
GetForUser(ctx context.Context, userID uint32) ([]Character, error)
|
||||
// ExportSave returns the full character row as a map.
|
||||
ExportSave(ctx context.Context, userID, charID uint32) (map[string]interface{}, error)
|
||||
// GrantImportToken sets a one-time import token for a character owned by userID.
|
||||
GrantImportToken(ctx context.Context, charID, userID uint32, token string, expiry time.Time) error
|
||||
// RevokeImportToken clears any pending import token for a character owned by userID.
|
||||
RevokeImportToken(ctx context.Context, charID, userID uint32) error
|
||||
// ImportSave atomically validates+consumes the import token and writes all save blobs.
|
||||
// Returns an error if the token is invalid, expired, or the character doesn't belong to userID.
|
||||
ImportSave(ctx context.Context, charID, userID uint32, token string, blobs SaveBlobs) error
|
||||
}
|
||||
|
||||
// APIEventRepo defines the contract for read-only event data access.
|
||||
|
||||
@@ -72,6 +72,10 @@ type mockAPICharacterRepo struct {
|
||||
|
||||
exportResult map[string]interface{}
|
||||
exportErr error
|
||||
|
||||
grantImportTokenErr error
|
||||
revokeImportTokenErr error
|
||||
importSaveErr error
|
||||
}
|
||||
|
||||
func (m *mockAPICharacterRepo) GetNewCharacter(_ context.Context, _ uint32) (Character, error) {
|
||||
@@ -106,6 +110,18 @@ func (m *mockAPICharacterRepo) ExportSave(_ context.Context, _, _ uint32) (map[s
|
||||
return m.exportResult, m.exportErr
|
||||
}
|
||||
|
||||
func (m *mockAPICharacterRepo) GrantImportToken(_ context.Context, _, _ uint32, _ string, _ time.Time) error {
|
||||
return m.grantImportTokenErr
|
||||
}
|
||||
|
||||
func (m *mockAPICharacterRepo) RevokeImportToken(_ context.Context, _, _ uint32) error {
|
||||
return m.revokeImportTokenErr
|
||||
}
|
||||
|
||||
func (m *mockAPICharacterRepo) ImportSave(_ context.Context, _, _ uint32, _ string, _ SaveBlobs) error {
|
||||
return m.importSaveErr
|
||||
}
|
||||
|
||||
// mockAPIEventRepo implements APIEventRepo for testing.
|
||||
type mockAPIEventRepo struct {
|
||||
featureWeapon *FeatureWeaponRow
|
||||
|
||||
6
server/migrations/sql/0013_save_transfer.sql
Normal file
6
server/migrations/sql/0013_save_transfer.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Save transfer tokens: one-time admin-granted permission for a character
|
||||
-- to receive an imported save via the API endpoint.
|
||||
-- NULL means no import is pending for this character.
|
||||
ALTER TABLE characters
|
||||
ADD COLUMN IF NOT EXISTS savedata_import_token TEXT,
|
||||
ADD COLUMN IF NOT EXISTS savedata_import_token_expiry TIMESTAMPTZ;
|
||||
Reference in New Issue
Block a user