mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 14:24:15 +02:00
chore(merge): merge develop into main for 9.4.0 cycle
Brings 53 develop commits (i18n, Diva, campaign, guild invites, save transfer, return/rookie guilds, hunting tournament, JSON quest/scenario loaders, Ghidra-derived user binary parsing, and misc fixes) onto main now that 9.3.2 has been tagged and released. Resolves two overlap zones: 1. Migration number collision. Main shipped 0010_fix_zero_rasta_id and 0011_fix_stale_boost_time in 9.3.2; develop had independently numbered 0010_campaign..0015_tournament. The migration runner keys applied versions by integer, so coexisting files with the same numeric prefix would silently skip each other. Develop's files have been renumbered to 0016..0021, leaving main's 0010/0011 intact. A schema_version rename script is required on any server that had already applied the old develop numbers (only frontier.mogapedia.fr at the time of this merge). 2. CHANGELOG.md. Develop's in-progress feature entries move into [Unreleased] with updated migration references; the [9.3.2] section is preserved verbatim. main.go version string bumped to 9.4.0-dev to mark the new cycle. Full test suite (go test -race ./...) passes.
This commit is contained in:
334
cmd/saveutil/main.go
Normal file
334
cmd/saveutil/main.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user