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:
Houmgaor
2026-03-21 20:14:58 +01:00
parent dbbfb927f8
commit 5fe1b22550
9 changed files with 679 additions and 0 deletions

View File

@@ -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