mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
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.
335 lines
9.0 KiB
Go
335 lines
9.0 KiB
Go
// saveutil is an admin CLI for Erupe save data management.
|
|
//
|
|
// Usage:
|
|
//
|
|
// saveutil import --config config.json --char-id 42 --file export.json
|
|
// saveutil export --config config.json --char-id 42 [--output export.json]
|
|
// saveutil grant-import --config config.json --char-id 42 [--ttl 24h]
|
|
// saveutil revoke-import --config config.json --char-id 42
|
|
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"erupe-ce/server/channelserver/compression/nullcomp"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
)
|
|
|
|
// dbConfig is the minimal config subset needed to connect to PostgreSQL.
|
|
type dbConfig struct {
|
|
Database struct {
|
|
Host string `json:"Host"`
|
|
Port int `json:"Port"`
|
|
User string `json:"User"`
|
|
Password string `json:"Password"`
|
|
Database string `json:"Database"`
|
|
} `json:"Database"`
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
cmd := os.Args[1]
|
|
args := os.Args[2:]
|
|
|
|
var err error
|
|
switch cmd {
|
|
case "import":
|
|
err = runImport(args)
|
|
case "export":
|
|
err = runExport(args)
|
|
case "grant-import":
|
|
err = runGrantImport(args)
|
|
case "revoke-import":
|
|
err = runRevokeImport(args)
|
|
default:
|
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", cmd)
|
|
printUsage()
|
|
os.Exit(1)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func printUsage() {
|
|
fmt.Fprintln(os.Stderr, `saveutil — Erupe save data admin tool
|
|
|
|
Commands:
|
|
import --config config.json --char-id N --file export.json
|
|
export --config config.json --char-id N [--output file.json]
|
|
grant-import --config config.json --char-id N [--ttl 24h]
|
|
revoke-import --config config.json --char-id N`)
|
|
}
|
|
|
|
// openDB parses config.json and returns an open database connection.
|
|
func openDB(configPath string) (*sqlx.DB, error) {
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read config: %w", err)
|
|
}
|
|
var cfg dbConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
}
|
|
dsn := fmt.Sprintf(
|
|
"host='%s' port='%d' user='%s' password='%s' dbname='%s' sslmode=disable",
|
|
cfg.Database.Host, cfg.Database.Port,
|
|
cfg.Database.User, cfg.Database.Password,
|
|
cfg.Database.Database,
|
|
)
|
|
db, err := sqlx.Open("postgres", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open db: %w", err)
|
|
}
|
|
if err := db.Ping(); err != nil {
|
|
return nil, fmt.Errorf("ping db: %w", err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// generateToken returns a 32-byte cryptographically random hex token.
|
|
func generateToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// --- import ---
|
|
|
|
func runImport(args []string) error {
|
|
fs := flag.NewFlagSet("import", flag.ExitOnError)
|
|
configPath := fs.String("config", "config.json", "Path to config.json")
|
|
charID := fs.Uint("char-id", 0, "Destination character ID")
|
|
filePath := fs.String("file", "", "Path to export JSON file (required)")
|
|
_ = fs.Parse(args)
|
|
|
|
if *charID == 0 {
|
|
return errors.New("--char-id is required")
|
|
}
|
|
if *filePath == "" {
|
|
return errors.New("--file is required")
|
|
}
|
|
|
|
db, err := openDB(*configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
// Read and parse the export JSON.
|
|
raw, err := os.ReadFile(*filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("read file: %w", err)
|
|
}
|
|
var export struct {
|
|
Character map[string]interface{} `json:"character"`
|
|
}
|
|
if err := json.Unmarshal(raw, &export); err != nil {
|
|
return fmt.Errorf("parse export JSON: %w", err)
|
|
}
|
|
if export.Character == nil {
|
|
return errors.New("export JSON has no 'character' key")
|
|
}
|
|
|
|
blobs, err := extractAllBlobs(export.Character)
|
|
if err != nil {
|
|
return fmt.Errorf("extract blobs: %w", err)
|
|
}
|
|
|
|
// Compute savedata hash.
|
|
var savedataHash []byte
|
|
if len(blobs["savedata"]) > 0 {
|
|
decompressed, err := nullcomp.Decompress(blobs["savedata"])
|
|
if err != nil {
|
|
return fmt.Errorf("decompress savedata: %w", err)
|
|
}
|
|
h := sha256.Sum256(decompressed)
|
|
savedataHash = h[:]
|
|
}
|
|
|
|
_, err = db.Exec(
|
|
`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,
|
|
savedata_import_token=NULL, savedata_import_token_expiry=NULL
|
|
WHERE id=$19`,
|
|
blobs["savedata"], savedataHash, blobs["decomyset"], blobs["hunternavi"],
|
|
blobs["otomoairou"], blobs["partner"], blobs["platebox"], blobs["platedata"],
|
|
blobs["platemyset"], blobs["rengokudata"], blobs["savemercenary"], blobs["gacha_items"],
|
|
blobs["house_info"], blobs["login_boost"], blobs["skin_hist"], blobs["scenariodata"],
|
|
blobs["savefavoritequest"], blobs["mezfes"],
|
|
*charID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update characters: %w", err)
|
|
}
|
|
fmt.Printf("Save data imported into character %d\n", *charID)
|
|
return nil
|
|
}
|
|
|
|
// --- export ---
|
|
|
|
func runExport(args []string) error {
|
|
fs := flag.NewFlagSet("export", flag.ExitOnError)
|
|
configPath := fs.String("config", "config.json", "Path to config.json")
|
|
charID := fs.Uint("char-id", 0, "Character ID to export")
|
|
outputPath := fs.String("output", "", "Output file (default: stdout)")
|
|
_ = fs.Parse(args)
|
|
|
|
if *charID == 0 {
|
|
return errors.New("--char-id is required")
|
|
}
|
|
|
|
db, err := openDB(*configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
row := db.QueryRowx("SELECT * FROM characters WHERE id=$1", *charID)
|
|
result := make(map[string]interface{})
|
|
if err := row.MapScan(result); err != nil {
|
|
return fmt.Errorf("query character: %w", err)
|
|
}
|
|
|
|
export := map[string]interface{}{"character": result}
|
|
enc := json.NewEncoder(os.Stdout)
|
|
if *outputPath != "" {
|
|
f, err := os.Create(*outputPath)
|
|
if err != nil {
|
|
return fmt.Errorf("create output file: %w", err)
|
|
}
|
|
defer func() { _ = f.Close() }()
|
|
enc = json.NewEncoder(f)
|
|
}
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(export); err != nil {
|
|
return fmt.Errorf("encode JSON: %w", err)
|
|
}
|
|
if *outputPath != "" {
|
|
fmt.Printf("Character %d exported to %s\n", *charID, *outputPath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- grant-import ---
|
|
|
|
func runGrantImport(args []string) error {
|
|
fs := flag.NewFlagSet("grant-import", flag.ExitOnError)
|
|
configPath := fs.String("config", "config.json", "Path to config.json")
|
|
charID := fs.Uint("char-id", 0, "Character ID to grant import permission for")
|
|
ttl := fs.Duration("ttl", 24*time.Hour, "Token validity duration (e.g. 24h, 48h)")
|
|
_ = fs.Parse(args)
|
|
|
|
if *charID == 0 {
|
|
return errors.New("--char-id is required")
|
|
}
|
|
|
|
db, err := openDB(*configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
token, err := generateToken()
|
|
if err != nil {
|
|
return fmt.Errorf("generate token: %w", err)
|
|
}
|
|
expiry := time.Now().Add(*ttl)
|
|
|
|
res, err := db.Exec(
|
|
`UPDATE characters SET savedata_import_token=$1, savedata_import_token_expiry=$2 WHERE id=$3`,
|
|
token, expiry, *charID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update characters: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("character %d not found", *charID)
|
|
}
|
|
|
|
fmt.Printf("Import token for character %d (expires %s):\n%s\n",
|
|
*charID, expiry.Format(time.RFC3339), token)
|
|
return nil
|
|
}
|
|
|
|
// --- revoke-import ---
|
|
|
|
func runRevokeImport(args []string) error {
|
|
fs := flag.NewFlagSet("revoke-import", flag.ExitOnError)
|
|
configPath := fs.String("config", "config.json", "Path to config.json")
|
|
charID := fs.Uint("char-id", 0, "Character ID to revoke import permission for")
|
|
_ = fs.Parse(args)
|
|
|
|
if *charID == 0 {
|
|
return errors.New("--char-id is required")
|
|
}
|
|
|
|
db, err := openDB(*configPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
_, err = db.Exec(
|
|
`UPDATE characters SET savedata_import_token=NULL, savedata_import_token_expiry=NULL WHERE id=$1`,
|
|
*charID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update characters: %w", err)
|
|
}
|
|
fmt.Printf("Import token revoked for character %d\n", *charID)
|
|
return nil
|
|
}
|
|
|
|
// blobColumns is the ordered list of transferable save blob column names.
|
|
var blobColumns = []string{
|
|
"savedata", "decomyset", "hunternavi", "otomoairou", "partner",
|
|
"platebox", "platedata", "platemyset", "rengokudata", "savemercenary",
|
|
"gacha_items", "house_info", "login_boost", "skin_hist", "scenariodata",
|
|
"savefavoritequest", "mezfes",
|
|
}
|
|
|
|
// extractAllBlobs decodes all save blob columns from a character export map.
|
|
func extractAllBlobs(m map[string]interface{}) (map[string][]byte, error) {
|
|
out := make(map[string][]byte, len(blobColumns))
|
|
for _, col := range blobColumns {
|
|
v, ok := m[col]
|
|
if !ok || v == nil {
|
|
out[col] = nil
|
|
continue
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("column %q: expected string, got %T", col, v)
|
|
}
|
|
b, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("column %q: base64: %w", col, err)
|
|
}
|
|
out[col] = b
|
|
}
|
|
return out, nil
|
|
}
|