feat: split client tooling and add OAuth server login
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
// Package config owns the client-side connection settings consumed by the
|
||||
// standalone setup tool and the Local Identity plugin.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
clientlayout "bd2server/internal/client/layout"
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaVersion = 2
|
||||
FileName = "bd2.client.json"
|
||||
|
||||
CDNOfficial CDNMode = "official"
|
||||
CDNLocal CDNMode = "local"
|
||||
CDNServer CDNMode = "server"
|
||||
)
|
||||
|
||||
type CDNMode string
|
||||
|
||||
type Settings struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ServerOrigin string `json:"server_origin"`
|
||||
CDNMode CDNMode `json:"cdn_mode"`
|
||||
LocalResourceDirectory string `json:"local_resource_directory,omitempty"`
|
||||
}
|
||||
|
||||
func Path(gameDir string) string {
|
||||
if installation, err := clientlayout.Resolve(gameDir); err == nil {
|
||||
return filepath.Join(installation.Config, FileName)
|
||||
}
|
||||
return filepath.Join(filepath.Clean(gameDir), "BepInEx", "config", FileName)
|
||||
}
|
||||
|
||||
func Normalize(in Settings) (Settings, error) {
|
||||
origin, err := NormalizeOrigin(in.ServerOrigin)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
localDirectory := strings.TrimSpace(in.LocalResourceDirectory)
|
||||
switch in.CDNMode {
|
||||
case CDNOfficial, CDNServer:
|
||||
if localDirectory != "" {
|
||||
return Settings{}, errors.New("client config: local_resource_directory is only valid in local mode")
|
||||
}
|
||||
case CDNLocal:
|
||||
if localDirectory == "" {
|
||||
return Settings{}, errors.New("client config: local mode requires local_resource_directory")
|
||||
}
|
||||
localDirectory, err = filepath.Abs(filepath.Clean(localDirectory))
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("client config: resolve local resource directory: %w", err)
|
||||
}
|
||||
default:
|
||||
return Settings{}, fmt.Errorf("client config: unsupported CDN mode %q", in.CDNMode)
|
||||
}
|
||||
return Settings{
|
||||
SchemaVersion: SchemaVersion,
|
||||
ServerOrigin: origin,
|
||||
CDNMode: in.CDNMode,
|
||||
LocalResourceDirectory: localDirectory,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NormalizeOrigin(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("client config: parse server origin: %w", err)
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", errors.New("client config: server address must use http or https")
|
||||
}
|
||||
if parsed.Host == "" {
|
||||
return "", errors.New("client config: server address must include a host")
|
||||
}
|
||||
if parsed.User != nil {
|
||||
return "", errors.New("client config: credentials are not allowed in the server address")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||
return "", errors.New("client config: server address cannot contain a query or fragment")
|
||||
}
|
||||
if parsed.Path != "" && parsed.Path != "/" {
|
||||
return "", errors.New("client config: enter only the server origin, without /game or another path")
|
||||
}
|
||||
if parsed.Scheme == "http" && !isLoopback(parsed.Hostname()) {
|
||||
return "", errors.New("client config: non-loopback servers must use https")
|
||||
}
|
||||
parsed.Path = ""
|
||||
parsed.RawPath = ""
|
||||
return strings.TrimSuffix(parsed.String(), "/"), nil
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func Save(gameDir string, in Settings) (Settings, error) {
|
||||
settings, err := Normalize(in)
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
path := Path(gameDir)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return Settings{}, fmt.Errorf("client config: create config directory: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".bd2-client-*.tmp")
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("client config: create temporary config: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err = temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("client config: stage config: %w", err)
|
||||
}
|
||||
if err := replaceFile(temporaryPath, path); err != nil {
|
||||
return Settings{}, fmt.Errorf("client config: install config: %w", err)
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func Load(gameDir string) (Settings, error) {
|
||||
data, err := os.ReadFile(Path(gameDir))
|
||||
if err != nil {
|
||||
return Settings{}, err
|
||||
}
|
||||
var settings Settings
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&settings); err != nil {
|
||||
return Settings{}, fmt.Errorf("client config: decode: %w", err)
|
||||
}
|
||||
if settings.SchemaVersion != SchemaVersion {
|
||||
return Settings{}, fmt.Errorf("client config: unsupported schema_version %d", settings.SchemaVersion)
|
||||
}
|
||||
return Normalize(settings)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
got, err := Normalize(Settings{ServerOrigin: " https://example.com:8443/ ", CDNMode: CDNServer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.SchemaVersion != SchemaVersion || got.ServerOrigin != "https://example.com:8443" || got.CDNMode != CDNServer {
|
||||
t.Fatalf("normalized=%+v", got)
|
||||
}
|
||||
for _, bad := range []string{"example.com", "ftp://example.com", "http://192.168.1.8:8080", "https://u:p@example.com", "https://example.com/game/", "https://example.com?q=1"} {
|
||||
if _, err := Normalize(Settings{ServerOrigin: bad, CDNMode: CDNOfficial}); err == nil {
|
||||
t.Errorf("accepted origin %q", bad)
|
||||
}
|
||||
}
|
||||
localRoot := t.TempDir()
|
||||
local, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: localRoot})
|
||||
if err != nil || local.LocalResourceDirectory != localRoot {
|
||||
t.Fatalf("local=%+v err=%v", local, err)
|
||||
}
|
||||
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal}); err == nil {
|
||||
t.Fatal("accepted local mode without a resource directory")
|
||||
}
|
||||
if _, err := Normalize(Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNOfficial, LocalResourceDirectory: localRoot}); err == nil {
|
||||
t.Fatal("accepted a local resource directory in official mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
want := Settings{ServerOrigin: "http://127.0.0.1:8080", CDNMode: CDNLocal, LocalResourceDirectory: t.TempDir()}
|
||||
if _, err := Save(dir, want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ServerOrigin != want.ServerOrigin || got.CDNMode != want.CDNMode || got.SchemaVersion != SchemaVersion || got.LocalResourceDirectory != want.LocalResourceDirectory {
|
||||
t.Fatalf("loaded=%+v", got)
|
||||
}
|
||||
data, err := os.ReadFile(Path(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "secret") || strings.Contains(string(data), "token") {
|
||||
t.Fatalf("client config unexpectedly stores a credential: %s", data)
|
||||
}
|
||||
updated := Settings{ServerOrigin: "https://friends.example:8443", CDNMode: CDNServer}
|
||||
if _, err := Save(dir, updated); err != nil {
|
||||
t.Fatalf("replace config: %v", err)
|
||||
}
|
||||
got, err = Load(dir)
|
||||
if err != nil || got.ServerOrigin != updated.ServerOrigin || got.CDNMode != updated.CDNMode {
|
||||
t.Fatalf("replaced=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveOmitsLocalDirectoryOutsideLocalMode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, err := Save(dir, Settings{ServerOrigin: "https://example.com", CDNMode: CDNOfficial}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(Path(dir))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "local_resource_directory") {
|
||||
t.Fatalf("official config contains local directory field: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathUsesMacAppSiblingBepInEx(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
app := filepath.Join(parent, "BrownDust II.app")
|
||||
for _, path := range []string{
|
||||
filepath.Join(app, "Contents", "MacOS", "BrownDust II"),
|
||||
filepath.Join(app, "Contents", "Resources", "Data", "resources.assets"),
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("test"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
want := filepath.Join(parent, "BepInEx", "config", FileName)
|
||||
if got := Path(app); got != want {
|
||||
t.Fatalf("Path()=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const preferencesSchemaVersion = 1
|
||||
|
||||
type Preferences struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
GameDirectory string `json:"game_directory"`
|
||||
}
|
||||
|
||||
func PreferencesPath() (string, error) {
|
||||
root, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate user configuration directory: %w", err)
|
||||
}
|
||||
return filepath.Join(root, "BD2 Client Studio", "preferences.json"), nil
|
||||
}
|
||||
|
||||
func LoadPreferences() (Preferences, error) {
|
||||
path, err := PreferencesPath()
|
||||
if err != nil {
|
||||
return Preferences{}, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return Preferences{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Preferences{}, fmt.Errorf("read client preferences: %w", err)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
var preferences Preferences
|
||||
if err := decoder.Decode(&preferences); err != nil {
|
||||
return Preferences{}, fmt.Errorf("decode client preferences: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return Preferences{}, errors.New("client preferences must contain exactly one JSON object")
|
||||
}
|
||||
if preferences.SchemaVersion != preferencesSchemaVersion || strings.TrimSpace(preferences.GameDirectory) == "" {
|
||||
return Preferences{}, errors.New("client preferences are invalid")
|
||||
}
|
||||
preferences.GameDirectory = filepath.Clean(preferences.GameDirectory)
|
||||
return preferences, nil
|
||||
}
|
||||
|
||||
func SavePreferences(gameDirectory string) error {
|
||||
abs, err := filepath.Abs(filepath.Clean(strings.TrimSpace(gameDirectory)))
|
||||
if err != nil || strings.TrimSpace(gameDirectory) == "" {
|
||||
return errors.New("client preferences require a valid game directory")
|
||||
}
|
||||
path, err := PreferencesPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create client preferences directory: %w", err)
|
||||
}
|
||||
data, err := json.MarshalIndent(Preferences{SchemaVersion: preferencesSchemaVersion, GameDirectory: abs}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".preferences-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("stage client preferences: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
if err = temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(data)
|
||||
}
|
||||
if err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("stage client preferences: %w", err)
|
||||
}
|
||||
if err := replaceFile(temporaryPath, path); err != nil {
|
||||
return fmt.Errorf("install client preferences: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreferencesRoundTrip(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv("APPDATA", root)
|
||||
game := filepath.Join(root, "game")
|
||||
if err := SavePreferences(game); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadPreferences()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, _ := filepath.Abs(game)
|
||||
if got.SchemaVersion != preferencesSchemaVersion || got.GameDirectory != want {
|
||||
t.Fatalf("preferences=%+v", got)
|
||||
}
|
||||
path, err := PreferencesPath()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const ReleaseFileName = "versions.json"
|
||||
|
||||
var (
|
||||
clientVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
|
||||
resourceVersionPattern = regexp.MustCompile(`^[0-9]{14}$`)
|
||||
)
|
||||
|
||||
// ReleaseVersions is the exact client/resource tuple supported by one
|
||||
// bd2client distribution. The release package carries the authoritative
|
||||
// versions.json next to bd2client.exe.
|
||||
type ReleaseVersions struct {
|
||||
ClientVersion string `json:"client_version"`
|
||||
GameDataVersion string `json:"game_data_version"`
|
||||
BundleVersion string `json:"bundle_version"`
|
||||
SeedDirectory string `json:"seed_directory"`
|
||||
Plugins struct {
|
||||
LocalIdentity string `json:"local_identity"`
|
||||
CaptureEnvironment string `json:"capture_environment"`
|
||||
LoginUI string `json:"login_ui"`
|
||||
} `json:"plugins"`
|
||||
}
|
||||
|
||||
func LoadReleaseVersions(path string) (ReleaseVersions, error) {
|
||||
data, err := os.ReadFile(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return ReleaseVersions{}, fmt.Errorf("read client release versions: %w", err)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
var versions ReleaseVersions
|
||||
if err := decoder.Decode(&versions); err != nil {
|
||||
return ReleaseVersions{}, fmt.Errorf("decode client release versions: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
return ReleaseVersions{}, errors.New("client release versions must contain exactly one JSON object")
|
||||
}
|
||||
if !clientVersionPattern.MatchString(versions.ClientVersion) {
|
||||
return ReleaseVersions{}, fmt.Errorf("invalid client_version %q", versions.ClientVersion)
|
||||
}
|
||||
if !resourceVersionPattern.MatchString(versions.BundleVersion) || !resourceVersionPattern.MatchString(versions.GameDataVersion) {
|
||||
return ReleaseVersions{}, errors.New("bundle_version and game_data_version must be 14-digit timestamps")
|
||||
}
|
||||
if versions.SeedDirectory == "" ||
|
||||
versions.Plugins.LocalIdentity == "" || versions.Plugins.LoginUI == "" || versions.Plugins.CaptureEnvironment == "" {
|
||||
return ReleaseVersions{}, errors.New("client release versions are incomplete")
|
||||
}
|
||||
return versions, nil
|
||||
}
|
||||
|
||||
func ReleaseVersionsBesideExecutable() (ReleaseVersions, error) {
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return ReleaseVersions{}, fmt.Errorf("locate bd2client executable: %w", err)
|
||||
}
|
||||
return LoadReleaseVersions(filepath.Join(filepath.Dir(executable), ReleaseFileName))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadReleaseVersions(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ReleaseFileName)
|
||||
data := `{"client_version":"2.35.10","game_data_version":"20260923193640","bundle_version":"20260921135230","seed_directory":"go/seed/v2_35_10","plugins":{"local_identity":"0.6.0","capture_environment":"0.2.0","login_ui":"0.1.0"}}`
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := LoadReleaseVersions(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ClientVersion != "2.35.10" || got.BundleVersion != "20260921135230" || got.GameDataVersion != "20260923193640" {
|
||||
t.Fatalf("versions=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadReleaseVersionsRejectsUnknownAndTrailingData(t *testing.T) {
|
||||
for name, data := range map[string]string{
|
||||
"unknown": `{"client_version":"2.35.10","unknown":true}`,
|
||||
"trailing": `{}` + `{}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ReleaseFileName)
|
||||
if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadReleaseVersions(path); err == nil {
|
||||
t.Fatal("accepted invalid release versions")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
||||
func replaceFile(source, destination string) error {
|
||||
return os.Rename(source, destination)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var moveFileEx = syscall.NewLazyDLL("kernel32.dll").NewProc("MoveFileExW")
|
||||
|
||||
func replaceFile(source, destination string) error {
|
||||
sourcePtr, err := syscall.UTF16PtrFromString(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
destinationPtr, err := syscall.UTF16PtrFromString(destination)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, _, callErr := moveFileEx.Call(
|
||||
uintptr(unsafe.Pointer(sourcePtr)),
|
||||
uintptr(unsafe.Pointer(destinationPtr)),
|
||||
0x1|0x8, // MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
|
||||
)
|
||||
if result == 0 {
|
||||
if callErr != syscall.Errno(0) {
|
||||
return callErr
|
||||
}
|
||||
return os.ErrInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user