Files
browser/plugins/examples/scum-server-plugin/companion/adapters.go
T

364 lines
13 KiB
Go

package companion
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"unicode/utf8"
)
var errAdapterUnsupported = errors.New("runtime capability is unavailable")
// AuthorizedConfigPort is supplied through the platform-authorized Run channel.
// It exposes logical, allowlisted configuration values only; it never exposes a
// path, DSN, credential, arbitrary command, or database handle.
type AuthorizedConfigPort interface {
ReadConfig(context.Context) (map[string]string, error)
ApplyConfigPatch(context.Context, string, []ConfigFieldPatch) (map[string]string, error)
}
type ConfigFieldPatch struct {
Key string
Value string
}
// AuthorizedGameDataPort is a typed, Run-owned read/patch boundary. Implementations
// must probe their local schema, use field allowlists and safe windows, and return
// bounded snapshots rather than rows or connection details.
type AuthorizedGameDataPort interface {
ReadPlayerState(context.Context, string, []string) (PlayerStateSnapshot, error)
ApplyPlayerState(context.Context, PlayerStatePatch) (PlayerStateSnapshot, error)
}
type PlayerStateSnapshot struct {
PlayerID string
StateVersion string
SafeWindow bool
Fields map[string]float64
}
type PlayerStatePatch struct {
PlayerID string
ExpectedStateVersion string
Fields []StateFieldPatch
}
type StateFieldPatch struct {
Key string
Before float64
After float64
}
// AuthorizedRewardPort accepts only a frozen grant and typed items. It cannot
// receive SQL, a raw database row, a shell command, an RCON command, or secrets.
type AuthorizedRewardPort interface {
DeliverReward(context.Context, RewardGrant) (DeliveryReceipt, error)
}
type RewardGrant struct {
GrantID string
PlayerID string
Items []RewardItem
}
type RewardItem struct {
CatalogCode string
Quantity int
}
type DeliveryReceipt struct{ Outcome string }
const fixedNotificationType = 4
type UE4SSNotificationPort interface {
SendPlayerNotification(context.Context, ue4SSPlayerNotification) (UE4SSNotificationReceipt, error)
}
type UE4SSNotificationReceipt struct{ Accepted bool }
type ue4SSPlayerNotification struct {
ServerID string
RecipientSteamID string
Message string
chatType int
protectedAuditCommand string
}
type UE4SSVehicleSpawnPort interface {
SpawnVehicle(context.Context, ue4SSVehicleSpawn) (UE4SSVehicleSpawnReceipt, error)
}
type UE4SSVehicleSpawnOutcome string
const (
UE4SSVehicleSpawnAccepted UE4SSVehicleSpawnOutcome = "accepted"
UE4SSVehicleSpawnRejected UE4SSVehicleSpawnOutcome = "rejected"
UE4SSVehicleSpawnUnknown UE4SSVehicleSpawnOutcome = "unknown"
)
type UE4SSVehicleSpawnReceipt struct{ Outcome UE4SSVehicleSpawnOutcome }
type ue4SSVehicleSpawn struct {
ServerID string
VehicleCode string
protectedAuditCommand string
}
// RuntimeAdapter is bound to one server. Availability is discovered from its
// configured typed ports and declared capabilities. A failed probe affects
// only its operation.
type RuntimeAdapter struct {
BoundServerID string
Config AuthorizedConfigPort
GameData AuthorizedGameDataPort
Rewards AuthorizedRewardPort
Notification UE4SSNotificationPort
VehicleSpawn UE4SSVehicleSpawnPort
DiagnosticsState map[string]string
}
func (adapter RuntimeAdapter) ServerBinding() string { return adapter.BoundServerID }
func (adapter RuntimeAdapter) ReadConfiguration(ctx context.Context) (map[string]any, error) {
if adapter.Config == nil {
return nil, errAdapterUnsupported
}
fields, err := adapter.Config.ReadConfig(ctx)
if err != nil {
return nil, err
}
return map[string]any{"fields": redactConfigValues(fields)}, nil
}
func (adapter RuntimeAdapter) PatchConfiguration(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Config == nil {
return nil, errAdapterUnsupported
}
revision, _ := payload["revision"].(string)
raw, _ := payload["fields"].([]any)
fields := make([]ConfigFieldPatch, 0, len(raw))
for _, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("configuration patch payload is invalid")
}
key, keyOK := item["key"].(string)
fieldValue, valueOK := item["value"].(string)
if !keyOK || !valueOK || !supportedConfigKey(key) {
return nil, fmt.Errorf("configuration patch field is unsupported")
}
fields = append(fields, ConfigFieldPatch{Key: key, Value: fieldValue})
}
applied, err := adapter.Config.ApplyConfigPatch(ctx, revision, fields)
if err != nil {
return nil, err
}
return map[string]any{"appliedFields": redactConfigValues(applied)}, nil
}
func (adapter RuntimeAdapter) Diagnostics(context.Context) (map[string]any, error) {
state := map[string]any{"adapter": "runtime-capability"}
for key, value := range adapter.DiagnosticsState {
if safeDiagnosticField(key, value) {
state[key] = value
}
}
return state, nil
}
func (adapter RuntimeAdapter) PatchGameState(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.GameData == nil {
return nil, errAdapterUnsupported
}
playerID, _ := payload["playerId"].(string)
expected, _ := payload["expectedStateVersion"].(string)
raw, _ := payload["changes"].([]any)
fields, err := statePatchFields(raw)
if err != nil {
return nil, err
}
before, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields))
if err != nil {
return nil, err
}
if before.PlayerID != playerID || before.StateVersion != expected || !before.SafeWindow || !stateMatches(before.Fields, fields) {
return map[string]any{"outcome": "failed"}, nil
}
after, err := adapter.GameData.ApplyPlayerState(ctx, PlayerStatePatch{PlayerID: playerID, ExpectedStateVersion: expected, Fields: fields})
if err != nil {
return map[string]any{"outcome": "unknown"}, nil
}
confirmed, err := adapter.GameData.ReadPlayerState(ctx, playerID, statePatchKeys(fields))
if err != nil || after.PlayerID != playerID || !stateApplied(confirmed.Fields, fields) {
return map[string]any{"outcome": "unknown"}, nil
}
return map[string]any{"outcome": "succeeded", "changedFields": len(fields)}, nil
}
func (adapter RuntimeAdapter) DeliverReward(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.Rewards == nil {
return nil, errAdapterUnsupported
}
grant, err := rewardGrant(payload)
if err != nil {
return nil, err
}
receipt, err := adapter.Rewards.DeliverReward(ctx, grant)
if err != nil || receipt.Outcome == "unknown" {
return map[string]any{"outcome": "unknown"}, nil
}
if receipt.Outcome != "delivered" {
return map[string]any{"outcome": "failed"}, nil
}
return map[string]any{"outcome": "delivered"}, nil
}
func (adapter RuntimeAdapter) NotifyPlayer(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.Notification == nil {
return nil, errAdapterUnsupported
}
playerID, playerOK := payload["playerId"].(string)
message, messageOK := payload["message"].(string)
notification, err := newUE4SSPlayerNotification(adapter.BoundServerID, playerID, message)
if !playerOK || !messageOK || err != nil {
return nil, fmt.Errorf("notification payload is invalid")
}
receipt, err := adapter.Notification.SendPlayerNotification(ctx, notification)
if err != nil {
return nil, fmt.Errorf("notification transport failed")
}
return map[string]any{"accepted": receipt.Accepted}, nil
}
func (adapter RuntimeAdapter) SpawnVehicle(ctx context.Context, payload map[string]any) (map[string]any, error) {
if adapter.BoundServerID == "" || adapter.VehicleSpawn == nil {
return nil, errAdapterUnsupported
}
vehicleCode, ok := payload["vehicleCode"].(string)
spawn, err := newUE4SSVehicleSpawn(adapter.BoundServerID, vehicleCode)
if !ok || err != nil {
return nil, fmt.Errorf("vehicle spawn payload is invalid")
}
receipt, err := adapter.VehicleSpawn.SpawnVehicle(ctx, spawn)
if err != nil || receipt.Outcome == UE4SSVehicleSpawnUnknown {
return map[string]any{"outcome": "unknown"}, nil
}
if receipt.Outcome == UE4SSVehicleSpawnRejected {
return map[string]any{"outcome": "failed"}, nil
}
if receipt.Outcome != UE4SSVehicleSpawnAccepted {
return map[string]any{"outcome": "unknown"}, nil
}
return map[string]any{"outcome": "succeeded"}, nil
}
func newUE4SSPlayerNotification(serverID, playerID, message string) (ue4SSPlayerNotification, error) {
if strings.TrimSpace(serverID) == "" || !steamID64(playerID) || !validNotificationMessage(message) {
return ue4SSPlayerNotification{}, fmt.Errorf("invalid typed notification")
}
return ue4SSPlayerNotification{ServerID: serverID, RecipientSteamID: playerID, Message: message, chatType: fixedNotificationType, protectedAuditCommand: "SendChat 4 \"" + escapeUE4SSChatMessage(message) + "\" " + playerID}, nil
}
func newUE4SSVehicleSpawn(serverID, vehicleCode string) (ue4SSVehicleSpawn, error) {
if strings.TrimSpace(serverID) == "" || !supportedVehicleSpawnCode(vehicleCode) {
return ue4SSVehicleSpawn{}, fmt.Errorf("invalid typed vehicle spawn")
}
return ue4SSVehicleSpawn{ServerID: serverID, VehicleCode: vehicleCode, protectedAuditCommand: "#spawnvehicle " + vehicleCode}, nil
}
func steamID64(value string) bool {
if len(value) != 17 {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func validNotificationMessage(value string) bool {
if value == "" || len(value) > 200 || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if character < 0x20 || character == 0x7f {
return false
}
}
return true
}
func escapeUE4SSChatMessage(value string) string {
return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value)
}
func supportedVehicleSpawnCode(value string) bool {
return map[string]bool{"BPC_Laika_C": true, "BPC_WolfsWagen_C": true}[value]
}
func supportedConfigKey(key string) bool {
return map[string]bool{"ServerName": true, "GamePort": true, "QueryPort": true, "MaxPlayers": true, "WelcomeMessage": true}[key]
}
func redactConfigValues(values map[string]string) map[string]string {
result := map[string]string{}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if supportedConfigKey(key) && safeDiagnosticField(key, values[key]) {
result[key] = values[key]
}
}
return result
}
func safeDiagnosticField(key, value string) bool {
lowered := strings.ToLower(key + "=" + value)
return !strings.Contains(lowered, "path") && !strings.Contains(lowered, "credential") && !strings.Contains(lowered, "password") && !strings.Contains(lowered, "bearer ") && !strings.Contains(lowered, "rcon") && !strings.Contains(lowered, "sql") && !strings.Contains(lowered, "://")
}
func statePatchFields(raw []any) ([]StateFieldPatch, error) {
if len(raw) == 0 || len(raw) > 8 {
return nil, fmt.Errorf("state patch payload is invalid")
}
fields := make([]StateFieldPatch, 0, len(raw))
for _, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return nil, fmt.Errorf("state patch payload is invalid")
}
key, _ := item["fieldKey"].(string)
before, beforeOK := item["before"].(float64)
after, afterOK := item["after"].(float64)
if key == "" || !beforeOK || !afterOK {
return nil, fmt.Errorf("state patch payload is invalid")
}
fields = append(fields, StateFieldPatch{Key: key, Before: before, After: after})
}
return fields, nil
}
func statePatchKeys(fields []StateFieldPatch) []string {
keys := make([]string, 0, len(fields))
for _, field := range fields {
keys = append(keys, field.Key)
}
return keys
}
func stateMatches(values map[string]float64, fields []StateFieldPatch) bool {
for _, field := range fields {
if values[field.Key] != field.Before {
return false
}
}
return true
}
func stateApplied(values map[string]float64, fields []StateFieldPatch) bool {
for _, field := range fields {
if values[field.Key] != field.After {
return false
}
}
return true
}
func rewardGrant(payload map[string]any) (RewardGrant, error) {
grantID, grantOK := payload["grantId"].(string)
playerID, playerOK := payload["playerId"].(string)
raw, itemsOK := payload["items"].([]any)
if !grantOK || !playerOK || !itemsOK || len(raw) == 0 || len(raw) > 8 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items := make([]RewardItem, 0, len(raw))
for _, value := range raw {
item, ok := value.(map[string]any)
if !ok {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
code, codeOK := item["catalogCode"].(string)
quantity, quantityOK := item["quantity"].(float64)
if !codeOK || !quantityOK || quantity < 1 || quantity > 99 {
return RewardGrant{}, fmt.Errorf("reward payload is invalid")
}
items = append(items, RewardItem{CatalogCode: code, Quantity: int(quantity)})
}
return RewardGrant{GrantID: grantID, PlayerID: playerID, Items: items}, nil
}