diff --git a/.gitignore b/.gitignore index 2ffaa7f..f6e8a3f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ token.txt *.db-shm *.db-wal *.log + +# CodeBuddy +.codebuddy/ diff --git a/dev/README.md b/dev/README.md index fa48ccd..7fcb3f5 100644 --- a/dev/README.md +++ b/dev/README.md @@ -66,9 +66,9 @@ HTTPS 配置示例(PEM 文件,相对路径基于配置文件): ## Current implementation -The dashboard and persistent management API cover adapter configurations, server/player/account/group records, allow/deny lists, command definitions, AI provider settings, image-template and JavaScript-plugin catalogs, and instance settings. CRUD data is stored in one typed record table behind versioned startup migrations; adapters are exposed at the compatibility routes `/api/adapter/*`. The first active adapter runtime is OneBot 11 over forward WebSocket, with Bearer authentication, identity handshake, group/private message event normalization, send/group-list calls, lifecycle controls, status and capabilities APIs, and bounded reconnect backoff. Enabled OneBot 11 configurations are restored at startup. Adapter credentials are omitted from adapter API responses and blank edits preserve a saved token. Runtime events are kept in a bounded in-memory tail and written through a bounded asynchronous queue to the `runtime_logs` table. The authenticated `/api/logs` endpoint supports level, component, UTC time range, keyword, and offset pagination filters; `/api/logs/status` reports the current retention policy and write failure counters. The log page controls persistent storage, retention days, and the maximum row count; cleanup runs hourly. Log messages redact common token, password, DSN, and verification-code forms; message bodies are not included by default. CRUD configuration does not imply the corresponding runtime is installed. +The dashboard and persistent management API cover adapter configurations, server/player/account/group records, allow/deny lists, command definitions, AI provider settings, image-template and JavaScript-plugin catalogs, and instance settings. CRUD data is stored in one typed record table behind versioned startup migrations; adapters are exposed at the compatibility routes `/api/adapter/*`. The first active adapter runtime is OneBot 11 over forward WebSocket, with Bearer authentication, identity handshake, group/private message event normalization, send/group-list calls, lifecycle controls, status and capabilities APIs, and bounded reconnect backoff. Enabled OneBot 11 configurations are restored at startup. Adapter credentials are omitted from adapter API responses and blank edits preserve a saved token. Runtime events are kept in a bounded in-memory tail and written through a bounded asynchronous queue to the `runtime_logs` table. The authenticated `/api/logs` endpoint supports level, component, UTC time range, keyword, and offset pagination filters; `/api/logs/status` reports the current retention policy and write failure counters. The log page controls persistent storage, retention days, and the maximum row count; cleanup runs hourly. Log messages redact common token, password, DSN, and verification-code forms; message bodies are not included by default. CRUD configuration does not imply the corresponding runtime is installed. The Minecraft Bridge runtime is implemented as a WebSocket endpoint on `/bridge` that speaks the JSON packet protocol of the original plugins (`Hello`/`Identify`/`HeartBeat`/`Packet`/`CallBack`, integer `op`, snake_case fields). Each plugin authenticates with the per-server token stored in the server record's `detail` JSON; tokens are generated automatically when a server record is created and can be rotated with `POST /api/servers/{id}/token`. The host tracks observed connection status in `/api/bridge/status`, forwards `SYNC_MESSAGE` chat into matching OneBot 11 groups, and pushes group messages back through `SEND_TO_CHAT`. Routes are `sync-rules` records shaped as `{"serverId","adapterId","groupId","direction","mcFormat","socialFormat"}`. -Real OneBot 11 account verification, Milky, Telegram and OpenQQ runtimes, game-server Bridge plugins, message forwarding and account-binding workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging remain outstanding. The API and UI expose only the implemented OneBot 11 adapter; unsupported platform types are rejected instead of being saved as runnable configurations. The OneBot protocol simulator covers authentication, identity handshake, event receipt, sending, and reconnect; no real test bot is configured, and platform rate-limit behavior has not been validated. The original NativeAOT executable cannot be reliably reconstructed from the available decompilation; the separate EasyBot-Recovered repository is used as behavioral reference for its WebUI API and module names. +Real OneBot 11 account verification, Milky, Telegram and OpenQQ runtimes, Minecraft plugin binaries, account-binding and command workflows, JavaScript plugin execution, AI chat execution, browser-based image rendering/template marketplace, PlaceholderAPI/statistics evaluation, and platform installers/Docker packaging remain outstanding. The Bridge endpoint has only been exercised against a locally simulated plugin; it has not been validated against a shipped EasyBot plugin build, and binding, command and statistics packets are acknowledged but not implemented. The API and UI expose only the implemented OneBot 11 adapter; unsupported platform types are rejected instead of being saved as runnable configurations. The OneBot protocol simulator covers authentication, identity handshake, event receipt, sending, and reconnect; no real test bot is configured, and platform rate-limit behavior has not been validated. The original NativeAOT executable cannot be reliably reconstructed from the available decompilation; the separate EasyBot-Recovered repository is used as behavioral reference for its WebUI API and module names. ## Verify diff --git a/dev/cmd/easybot/main.go b/dev/cmd/easybot/main.go index 0c0fb3f..11099af 100644 --- a/dev/cmd/easybot/main.go +++ b/dev/cmd/easybot/main.go @@ -14,6 +14,7 @@ import ( "easybot/internal/adapter/onebot11" "easybot/internal/auth" + "easybot/internal/bridge" "easybot/internal/config" "easybot/internal/runtime" "easybot/internal/store" @@ -135,9 +136,43 @@ func run() (code int) { } else { logger.Info(fmt.Sprintf("[AdapterManager] 发现 %d 个适配器配置", len(adapterConfigs))) } + var eventRouter *bridge.Router adapters := onebot11.NewManager(ctx, db, func(id string, event onebot11.Event) { logs.Add("info", "adapter.onebot11", "OneBot event received: "+event.Type+" (adapter "+id+")") + if eventRouter == nil || event.Type != "message" || event.MessageType != "group" { + return + } + if event.SelfID != "" && event.SenderID == event.SelfID { + return + } + eventRouter.OnSocialMessage(id, event.PeerID, event.SenderName, event.Content) + }) + bridges := bridge.NewManager(db, logger, bridge.Callbacks{ + OnChat: func(serverID string, packet bridge.SyncMessagePacket) { + logs.Add("info", "bridge", "Minecraft chat from "+packet.Player.PlayerName+": "+packet.Message) + if eventRouter != nil { + eventRouter.OnMinecraftChat(serverID, packet) + } + }, + OnEnterExit: func(serverID string, packet bridge.SyncEnterExitMessagePacket) { + state := "left" + if packet.IsEnter { + state = "joined" + } + logs.Add("info", "bridge", "Player "+packet.Player.PlayerName+" "+state+" (server "+serverID+")") + }, + OnDeath: func(serverID string, packet bridge.SyncDeathMessagePacket) { + logs.Add("info", "bridge", "Player death on server "+serverID+": "+packet.Raw) + }, + OnState: func(serverID string, online bool) { + if online { + logs.Add("info", "bridge", "Minecraft server connected: "+serverID) + return + } + logs.Add("warn", "bridge", "Minecraft server disconnected: "+serverID) + }, }) + eventRouter = bridge.NewRouter(db, bridges, adapters, logger) if err := adapters.StartEnabled(ctx); err != nil { logger.Error("[AdapterManager] 适配器初始化失败", "error", err) } else { @@ -166,7 +201,7 @@ func run() (code int) { logger.Info("[WebUI] 正在初始化管理服务...") server := &http.Server{ Addr: address, - Handler: web.NewForListener(db, logger, logs, adapters, listener.Addr().String(), loginToken, writer), + Handler: web.NewForListenerWithRuntimes(db, logger, logs, adapters, bridges, listener.Addr().String(), loginToken, writer), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, } diff --git a/dev/internal/bridge/bridge_test.go b/dev/internal/bridge/bridge_test.go new file mode 100644 index 0000000..3ec1d47 --- /dev/null +++ b/dev/internal/bridge/bridge_test.go @@ -0,0 +1,289 @@ +package bridge + +import ( + "context" + "io" + "log/slog" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "easybot/internal/store" + "github.com/gorilla/websocket" +) + +const testToken = "secret-server-token" + +type captureSocial struct { + mu sync.Mutex + calls []sentMessage +} + +type sentMessage struct { + adapterID string + message string + peerID string + text string +} + +func (c *captureSocial) Send(_ context.Context, adapterID, messageType, peerID, message string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.calls = append(c.calls, sentMessage{adapterID: adapterID, message: messageType, peerID: peerID, text: message}) + return "message-id", nil +} + +func (c *captureSocial) last() (sentMessage, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.calls) == 0 { + return sentMessage{}, false + } + return c.calls[len(c.calls)-1], true +} + +func newTestStack(t *testing.T) (*store.Store, *Manager, *Router, *captureSocial, string, string) { + t.Helper() + db, err := store.Open(t.TempDir() + "/easybot.db") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + ctx := context.Background() + server, err := db.Save(ctx, store.Record{Kind: "servers", Name: "生存服", Detail: `{"token":"` + testToken + `","port":25565}`, Enabled: true}) + if err != nil { + t.Fatal(err) + } + rule, err := db.Save(ctx, store.Record{ + Kind: "sync-rules", + Name: "官方群 <-> 生存服", + Detail: `{"serverId":"` + server.ID + `","adapterId":"adapter-1","groupId":"999","direction":"both"}`, + Enabled: true, + }) + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + social := &captureSocial{} + manager := NewManager(db, logger, Callbacks{}) + router := NewRouter(db, manager, social, logger) + manager.call.OnChat = router.OnMinecraftChat + return db, manager, router, social, server.ID, rule.ID +} + +// dialPlugin connects a simulated original plugin and completes the handshake. +func dialPlugin(t *testing.T, manager *Manager, token string) *websocket.Conn { + t.Helper() + server := httptest.NewServer(manager) + t.Cleanup(server.Close) + url := "ws" + strings.TrimPrefix(server.URL, "http") + "/bridge" + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.Close() }) + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + + var hello map[string]any + if err := conn.ReadJSON(&hello); err != nil { + t.Fatalf("reading hello: %v", err) + } + if op, _ := hello["op"].(float64); OpCode(op) != OpHello { + t.Fatalf("first packet must be Hello, got %#v", hello) + } + for _, field := range []string{"version", "session_id", "token", "interval"} { + if _, ok := hello[field]; !ok { + t.Fatalf("hello is missing %q: %#v", field, hello) + } + } + if err := conn.WriteJSON(map[string]any{ + "op": OpIdentify, + "token": token, + "plugin_version": "2.3.0", + "server_description": "Paper 1.21", + }); err != nil { + t.Fatal(err) + } + var success map[string]any + if err := conn.ReadJSON(&success); err != nil { + t.Fatalf("reading identify success: %v", err) + } + if op, _ := success["op"].(float64); OpCode(op) != OpIdentifySuccess { + t.Fatalf("expected IdentifySuccess, got %#v", success) + } + if success["server_name"] != "生存服" { + t.Fatalf("unexpected server_name: %#v", success) + } + return conn +} + +func TestPluginHandshakeAndChatForwardingToSocial(t *testing.T) { + _, manager, _, social, serverID, _ := newTestStack(t) + conn := dialPlugin(t, manager, testToken) + + status, err := manager.Status(serverID) + if err != nil || status.State != "online" || status.PluginVersion != "2.3.0" { + t.Fatalf("unexpected status: %#v error %v", status, err) + } + + if err := conn.WriteJSON(map[string]any{ + "op": OpPacket, + "callback_id": "cb-1", + "exec_op": ExecSyncMessage, + "player": map[string]any{"player_name": "Steve", "player_uuid": "uuid-1"}, + "message": "大家好", + "use_command": false, + }); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if call, ok := social.last(); ok { + if call.adapterID != "adapter-1" || call.peerID != "999" || call.message != "group" { + t.Fatalf("wrong routing target: %#v", call) + } + if call.text != "[生存服] Steve: 大家好" { + t.Fatalf("unexpected formatted text: %q", call.text) + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("minecraft chat was never forwarded to the social adapter") +} + +func TestSocialMessageReachesMinecraftAndDoesNotEcho(t *testing.T) { + _, manager, router, social, _, _ := newTestStack(t) + conn := dialPlugin(t, manager, testToken) + + router.OnSocialMessage("adapter-1", "999", "Alice", "群里的消息") + + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + var packet map[string]any + if err := conn.ReadJSON(&packet); err != nil { + t.Fatalf("plugin did not receive the chat push: %v", err) + } + if op, _ := packet["op"].(float64); OpCode(op) != OpPacket { + t.Fatalf("expected an op=Packet frame, got %#v", packet) + } + if packet["exec_op"] != ExecSendToChat { + t.Fatalf("expected SEND_TO_CHAT, got %#v", packet) + } + text, _ := packet["text"].(string) + if text != "[999] Alice: 群里的消息" { + t.Fatalf("unexpected text: %q", text) + } + + // A plugin reporting the pushed line back must not bounce into the group. + router.OnMinecraftChat(serverOf(t, manager), SyncMessagePacket{Message: text, Player: PlayerInfo{PlayerName: "Alice"}}) + if call, ok := social.last(); ok { + t.Fatalf("echo was forwarded back to the social platform: %#v", call) + } +} + +func TestUnknownTokenIsRejected(t *testing.T) { + _, manager, _, _, _, _ := newTestStack(t) + server := httptest.NewServer(manager) + defer server.Close() + url := "ws" + strings.TrimPrefix(server.URL, "http") + "/bridge" + conn, _, err := websocket.DefaultDialer.Dial(url, nil) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + var hello map[string]any + if err := conn.ReadJSON(&hello); err != nil { + t.Fatal(err) + } + if err := conn.WriteJSON(map[string]any{"op": OpIdentify, "token": "wrong", "plugin_version": "2.3.0"}); err != nil { + t.Fatal(err) + } + for { + if _, _, err := conn.ReadMessage(); err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + return + } + return + } + t.Fatal("host kept the socket open for an invalid token") + } +} + +func TestStatusesListConfiguredServersAsOffline(t *testing.T) { + _, manager, _, _, serverID, _ := newTestStack(t) + statuses, err := manager.Statuses() + if err != nil { + t.Fatal(err) + } + status, ok := statuses[serverID] + if !ok || status.State != "offline" { + t.Fatalf("expected an offline entry, got %#v", statuses) + } + if err := manager.SendToChat(serverID, "x"); err != ErrServerOffline { + t.Fatalf("expected ErrServerOffline, got %v", err) + } +} + +func serverOf(t *testing.T, manager *Manager) string { + t.Helper() + statuses, err := manager.Statuses() + if err != nil || len(statuses) != 1 { + t.Fatalf("unexpected statuses: %#v %v", statuses, err) + } + for id := range statuses { + return id + } + return "" +} + +func TestServerTokenExtraction(t *testing.T) { + cases := map[string]string{ + `{"token":"abc"}`: "abc", + `{"Token":"legacy"}`: "legacy", + `{"serverToken":"snake"}`: "snake", + `{}`: "", + `not json`: "", + } + for detail, want := range cases { + if got := ServerToken(store.Record{Detail: detail}); got != want { + t.Fatalf("ServerToken(%s) = %q, want %q", detail, got, want) + } + } +} + +func TestRenderKeepsUnknownPlaceholders(t *testing.T) { + if got := render("", defaultMinecraft, map[string]string{"server": "S", "player": "P", "content": "C"}); got != "[S] P: C" { + t.Fatalf("unexpected default render: %q", got) + } + if got := render("{server} {unknown}", defaultMinecraft, map[string]string{"server": "S"}); got != "S {unknown}" { + t.Fatalf("unknown placeholders must be preserved: %q", got) + } +} + +func TestPluginJoinIsAdmittedWithoutBinding(t *testing.T) { + _, manager, _, _, _, _ := newTestStack(t) + conn := dialPlugin(t, manager, testToken) + if err := conn.WriteJSON(map[string]any{ + "op": OpPacket, + "callback_id": "join-1", + "exec_op": ExecPlayerJoin, + "player": map[string]any{"player_name": "Steve", "player_uuid": "uuid-1"}, + }); err != nil { + t.Fatal(err) + } + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + var callback map[string]any + if err := conn.ReadJSON(&callback); err != nil { + t.Fatalf("no login callback: %v", err) + } + if callback["callback_id"] != "join-1" || callback["kick"] != false { + t.Fatalf("unexpected login callback: %#v", callback) + } + if op, _ := callback["op"].(float64); OpCode(op) != OpCallBack { + t.Fatalf("expected op=CallBack, got %#v", callback) + } +} diff --git a/dev/internal/bridge/protocol.go b/dev/internal/bridge/protocol.go new file mode 100644 index 0000000..168d95f --- /dev/null +++ b/dev/internal/bridge/protocol.go @@ -0,0 +1,180 @@ +// Package bridge implements the EasyBot Bridge protocol used by the original +// Minecraft server plugins. The wire format is JSON over WebSocket; every +// packet carries an integer "op" discriminator and the field names are +// snake_case, matching EasyBot.Bridge.Packet in the recovered sources. +package bridge + +import ( + "encoding/json" + "strings" +) + +// OpCode mirrors EasyBot.Bridge.Packet.OpCode. The order is part of the wire +// protocol: the original serializes the enum as a number, so these values must +// never be reordered. +type OpCode int + +const ( + OpHello OpCode = iota // server -> client, first message on connect + OpIdentify // client -> server, carries the server token + OpHeartBeat // client -> server, periodic keep-alive + OpIdentifySuccess // server -> client, authentication accepted + OpPacket // both ways, carries exec_op + callback_id + OpCallBack // both ways, answer to a previous callback_id +) + +// maxFrameSize bounds a single WebSocket message. The original plugins only +// send small JSON documents; chat lines are clipped well below this. +const maxFrameSize = 1 << 20 + +// HelloPacket is sent by the host immediately after the socket opens. Token is +// a per-connection nonce and is NOT the server token used for authentication. +type HelloPacket struct { + Op OpCode `json:"op"` + Version string `json:"version"` + System string `json:"system"` + Dotnet string `json:"dotnet"` + SessionID string `json:"session_id"` + Token string `json:"token"` + Interval int `json:"interval"` +} + +// IdentifyPacket is the plugin's answer to HelloPacket. Token is the long-lived +// per-server token configured inside the Minecraft plugin. +type IdentifyPacket struct { + Op OpCode `json:"op"` + Token string `json:"token"` + PluginVersion string `json:"plugin_version"` + ServerDescription string `json:"server_description"` +} + +// IdentifySuccessPacket confirms authentication and tells the plugin which +// server record it was matched to. +type IdentifySuccessPacket struct { + Op OpCode `json:"op"` + ServerName string `json:"server_name"` + SupportGetPluginList bool `json:"support_get_plugin_list"` +} + +// HeartbeatPacket is a bare keep-alive marker. +type HeartbeatPacket struct { + Op OpCode `json:"op"` +} + +// PacketHeader is the minimal shape needed to route an op=OpPacket frame by its +// exec_op before decoding the full payload. +type PacketHeader struct { + Op OpCode `json:"op"` + CallbackID string `json:"callback_id"` + ExecOp string `json:"exec_op"` +} + +// PlayerInfo mirrors EasyBot.Bridge.Models.PlayerInfo. RawName keeps the +// display name with formatting codes intact. +type PlayerInfo struct { + PlayerName string `json:"player_name"` + PlayerUUID string `json:"player_uuid"` + IP string `json:"ip"` + SkinURL string `json:"skin_url"` + Bedrock bool `json:"bedrock"` + PlayerNameRaw string `json:"player_name_raw"` +} + +// SyncMessagePacket is the chat event reported by the Minecraft plugin. +type SyncMessagePacket struct { + Op OpCode `json:"op"` + CallbackID string `json:"callback_id"` + ExecOp string `json:"exec_op"` + Player PlayerInfo `json:"player"` + Message string `json:"message"` + UseCommand bool `json:"use_command"` +} + +// SyncEnterExitMessagePacket reports a player joining or leaving. +type SyncEnterExitMessagePacket struct { + Op OpCode `json:"op"` + CallbackID string `json:"callback_id"` + ExecOp string `json:"exec_op"` + Player PlayerInfo `json:"player"` + IsEnter bool `json:"is_enter"` +} + +// SyncDeathMessagePacket reports a death message. +type SyncDeathMessagePacket struct { + Op OpCode `json:"op"` + CallbackID string `json:"callback_id"` + ExecOp string `json:"exec_op"` + Player PlayerInfo `json:"player"` + Raw string `json:"raw"` + Killer string `json:"killer"` +} + +// ServerStatePacket reports the online player list. Players is a +// comma-separated string, matching the original packet. +type ServerStatePacket struct { + Op OpCode `json:"op"` + Token string `json:"token"` + Players string `json:"players"` +} + +// OutPacket is a host-initiated op=OpPacket frame with arbitrary payload. +type OutPacket struct { + Op OpCode `json:"op"` + CallbackID string `json:"callback_id"` + ExecOp string `json:"exec_op"` +} + +// SendToChatPacket makes the Minecraft plugin broadcast a line to every player. +type SendToChatPacket struct { + Op OpCode `json:"op"` + CallbackID string `json:"callback_id"` + ExecOp string `json:"exec_op"` + Text string `json:"text"` + Extra []any `json:"extra,omitempty"` +} + +// RunCommandPacket asks the plugin to execute a command as a player or from the +// console when PlayerName is empty. +type RunCommandPacket struct { + Op OpCode `json:"op"` + CallbackID string `json:"callback_id"` + ExecOp string `json:"exec_op"` + PlayerName string `json:"player_name"` + Command string `json:"command"` + EnablePapi bool `json:"enable_papi"` +} + +// exec_op values used by the Minecraft-to-host direction. +const ( + ExecSyncMessage = "SYNC_MESSAGE" + ExecSyncEnterExitMessage = "SYNC_ENTER_EXIT_MESSAGE" + ExecSyncDeathMessage = "SYNC_DEATH_MESSAGE" + ExecPlayerJoin = "PLAYER_JOIN" + ExecReportPlayer = "REPORT_PLAYER" + ExecServerStateChanged = "SERVER_STATE_CHANGED" +) + +// exec_op values used by the host-to-Minecraft direction. +const ( + ExecSendToChat = "SEND_TO_CHAT" + ExecRunCommand = "RUN_COMMAND" + ExecGetServerInfo = "GET_SERVER_INFO" +) + +// decodeHeader reads only the routing fields of an op=OpPacket frame. +func decodeHeader(raw []byte) (PacketHeader, error) { + var header PacketHeader + err := json.Unmarshal(raw, &header) + return header, err +} + +// clampChatLine trims control characters that would let a remote chat line +// forge a second line in the Minecraft console or the social platform. +func clampChatLine(value string) string { + value = strings.ReplaceAll(value, "\r", "") + value = strings.ReplaceAll(value, "\x00", "") + if len(value) > 2000 { + value = value[:2000] + } + return value +} diff --git a/dev/internal/bridge/router.go b/dev/internal/bridge/router.go new file mode 100644 index 0000000..c28adc2 --- /dev/null +++ b/dev/internal/bridge/router.go @@ -0,0 +1,190 @@ +package bridge + +import ( + "context" + "encoding/json" + "log/slog" + "strings" + "sync" + "time" + + "easybot/internal/store" +) + +// SocialSender is the subset of the OneBot 11 manager the router needs. It is +// declared here so the bridge package does not depend on a concrete adapter. +type SocialSender interface { + Send(ctx context.Context, adapterID, messageType, peerID, message string) (string, error) +} + +// Route is one "Minecraft server <-> social group" sync rule, stored in the +// records table with kind "sync-rules" and this shape in detail: +// +// {"serverId":"...","adapterId":"...","groupId":"...","direction":"both", +// "mcFormat":"[{server}] {player}: {content}","socialFormat":"[{group}] {sender}: {content}"} +type Route struct { + ServerID string `json:"serverId"` + AdapterID string `json:"adapterId"` + GroupID string `json:"groupId"` + Direction string `json:"direction"` + MinecraftFmt string `json:"mcFormat"` + SocialFmt string `json:"socialFormat"` +} + +const ( + DirectionBoth = "both" + DirectionMCToChat = "mc2social" + DirectionChatToMC = "social2mc" + defaultMinecraft = "[{server}] {player}: {content}" + defaultSocial = "[{group}] {sender}: {content}" + echoWindow = 5 * time.Second +) + +// Router moves chat between connected Minecraft servers and OneBot 11 groups. +// It also suppresses the echo a two-way rule would otherwise create. +type Router struct { + db *store.Store + bridge *Manager + social SocialSender + logger *slog.Logger + + mu sync.Mutex + recent map[string]time.Time +} + +func NewRouter(db *store.Store, bridgeManager *Manager, social SocialSender, logger *slog.Logger) *Router { + return &Router{db: db, bridge: bridgeManager, social: social, logger: logger, recent: make(map[string]time.Time)} +} + +// OnMinecraftChat forwards a plugin chat line to every matching social group. +func (r *Router) OnMinecraftChat(serverID string, packet SyncMessagePacket) { + content := strings.TrimSpace(packet.Message) + if content == "" { + return + } + if r.consumeEcho(serverID, content) { + return + } + serverName := r.serverName(serverID) + for _, route := range r.routes() { + if route.ServerID != serverID || !route.allowsMCToSocial() { + continue + } + text := render(route.MinecraftFmt, defaultMinecraft, map[string]string{ + "server": serverName, + "player": packet.Player.PlayerName, + "content": content, + }) + if _, err := r.social.Send(context.Background(), route.AdapterID, "group", route.GroupID, text); err != nil { + r.logger.Warn("forward minecraft chat to social failed", + "server_id", serverID, "adapter_id", route.AdapterID, "group", route.GroupID, "error", err) + } + } +} + +// OnSocialMessage forwards a group message from OneBot 11 into the matching +// Minecraft servers. +func (r *Router) OnSocialMessage(adapterID, groupID, senderName, content string) { + content = strings.TrimSpace(content) + if content == "" { + return + } + for _, route := range r.routes() { + if route.AdapterID != adapterID || route.GroupID != groupID || !route.allowsSocialToMC() { + continue + } + text := render(route.SocialFmt, defaultSocial, map[string]string{ + "group": groupID, + "sender": senderName, + "content": content, + }) + r.markEcho(route.ServerID, text) + if err := r.bridge.SendToChat(route.ServerID, text); err != nil { + r.logger.Warn("forward social message to minecraft failed", + "server_id", route.ServerID, "adapter_id", adapterID, "error", err) + } + } +} + +// routes loads enabled sync rules. Configuration errors are skipped instead of +// guessing a target. +func (r *Router) routes() []Route { + records, err := r.db.List(context.Background(), "sync-rules") + if err != nil { + r.logger.Warn("sync rules unavailable", "error", err) + return nil + } + routes := make([]Route, 0, len(records)) + for _, record := range records { + if !record.Enabled { + continue + } + var route Route + if json.Unmarshal([]byte(record.Detail), &route) != nil { + continue + } + if route.ServerID == "" || route.AdapterID == "" || route.GroupID == "" { + continue + } + if route.Direction == "" { + route.Direction = DirectionBoth + } + routes = append(routes, route) + } + return routes +} + +func (r *Router) serverName(serverID string) string { + status, err := r.bridge.Status(serverID) + if err != nil || status.ServerName == "" { + return "Minecraft" + } + return status.ServerName +} + +func (route Route) allowsMCToSocial() bool { + return route.Direction == DirectionBoth || route.Direction == DirectionMCToChat +} + +func (route Route) allowsSocialToMC() bool { + return route.Direction == DirectionBoth || route.Direction == DirectionChatToMC +} + +// markEcho remembers a line the host just pushed into Minecraft, so a plugin +// that reports it back as a chat event does not bounce it into the group. +func (r *Router) markEcho(serverID, text string) { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + for key, at := range r.recent { + if now.Sub(at) > echoWindow { + delete(r.recent, key) + } + } + r.recent[serverID+"\x00"+text] = now +} + +func (r *Router) consumeEcho(serverID, text string) bool { + r.mu.Lock() + defer r.mu.Unlock() + key := serverID + "\x00" + text + at, ok := r.recent[key] + if !ok { + return false + } + delete(r.recent, key) + return time.Since(at) <= echoWindow +} + +// render substitutes the supported variables and leaves unknown braces alone so +// a malformed template cannot inject content or panic. +func render(template, fallback string, values map[string]string) string { + if strings.TrimSpace(template) == "" { + template = fallback + } + out := template + for key, value := range values { + out = strings.ReplaceAll(out, "{"+key+"}", value) + } + return strings.TrimSpace(out) +} diff --git a/dev/internal/bridge/server.go b/dev/internal/bridge/server.go new file mode 100644 index 0000000..4fd95f9 --- /dev/null +++ b/dev/internal/bridge/server.go @@ -0,0 +1,490 @@ +package bridge + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "easybot/internal/store" + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// HeartbeatInterval is advertised to the plugin in HelloPacket and used to size +// the socket read deadline. The original default is 120 seconds. +const HeartbeatInterval = 120 * time.Second + +// identifyTimeout bounds how long a socket may stay unauthenticated. +const identifyTimeout = 10 * time.Second + +var ( + ErrServerOffline = errors.New("minecraft server is not connected") + ErrUnknownServer = errors.New("minecraft server not found") +) + +// Callbacks are the host-side reactions to plugin events. All of them are +// optional and are invoked from the socket read goroutine, so they must not +// block for long. +type Callbacks struct { + OnChat func(serverID string, packet SyncMessagePacket) + OnEnterExit func(serverID string, packet SyncEnterExitMessagePacket) + OnDeath func(serverID string, packet SyncDeathMessagePacket) + OnState func(serverID string, online bool) +} + +// Status is the observed connection state of one Minecraft server. It is never +// derived from the stored configuration. +type Status struct { + ServerID string `json:"serverId"` + ServerName string `json:"serverName"` + State string `json:"state"` + PluginVersion string `json:"pluginVersion,omitempty"` + ServerDescription string `json:"serverDescription,omitempty"` + RemoteAddr string `json:"remoteAddr,omitempty"` + ConnectedAt *time.Time `json:"connectedAt,omitempty"` + LastSeenAt *time.Time `json:"lastSeenAt,omitempty"` + OnlinePlayers int `json:"onlinePlayers"` + LastError string `json:"lastError,omitempty"` +} + +// Manager owns every live plugin connection and exposes the host side of the +// Bridge protocol as an http.Handler mounted on /bridge. +type Manager struct { + db *store.Store + logger *slog.Logger + call Callbacks + upgrade websocket.Upgrader + + mu sync.RWMutex + sessions map[string]*session + statuses map[string]Status +} + +func NewManager(db *store.Store, logger *slog.Logger, callbacks Callbacks) *Manager { + return &Manager{ + db: db, + logger: logger, + call: callbacks, + sessions: make(map[string]*session), + statuses: make(map[string]Status), + upgrade: websocket.Upgrader{ + HandshakeTimeout: 5 * time.Second, + // The plugin is a desktop-side client, not a browser; it does not + // send an Origin header. + CheckOrigin: func(*http.Request) bool { return true }, + }, + } +} + +func (m *Manager) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := m.upgrade.Upgrade(w, r, nil) + if err != nil { + m.logger.Warn("bridge upgrade failed", "remote", r.RemoteAddr, "error", err) + return + } + m.serve(conn, r.RemoteAddr) +} + +func (m *Manager) serve(conn *websocket.Conn, remoteAddr string) { + conn.SetReadLimit(maxFrameSize) + defer conn.Close() + + s := &session{id: uuid.NewString(), conn: conn, remoteAddr: remoteAddr, pending: make(map[string]chan []byte)} + + if err := s.writeJSON(HelloPacket{ + Op: OpHello, + Version: "BridgeCore (26.5.8)", + System: "Unix-based(linux-x64)", + Dotnet: ".NET 8.0.0", + SessionID: s.id, + Token: uuid.NewString(), + Interval: int(HeartbeatInterval.Seconds()), + }); err != nil { + m.logger.Warn("bridge hello failed", "remote", remoteAddr, "error", err) + return + } + + _ = conn.SetReadDeadline(time.Now().Add(identifyTimeout)) + _, raw, err := conn.ReadMessage() + if err != nil { + m.logger.Info("bridge client disconnected before identify", "remote", remoteAddr, "error", err) + return + } + var identify IdentifyPacket + if err := json.Unmarshal(raw, &identify); err != nil || identify.Op != OpIdentify { + m.closeWith(s, "未授权: 首包必须是 Identify") + return + } + + record, token, err := m.authenticate(identify.Token) + if err != nil { + m.logger.Warn("bridge authentication rejected", "remote", remoteAddr, "error", err) + m.closeWith(s, "错误的令牌, 请检查令牌是否正确!!!") + return + } + + s.serverID = record.ID + s.serverName = record.Name + s.pluginVersion = identify.PluginVersion + s.token = token + if err := s.writeJSON(IdentifySuccessPacket{Op: OpIdentifySuccess, ServerName: record.Name, SupportGetPluginList: true}); err != nil { + return + } + + m.register(s) + defer m.unregister(s) + + m.logger.Info("minecraft bridge connected", + "server", record.Name, "server_id", record.ID, "plugin", identify.PluginVersion, + "description", identify.ServerDescription, "remote", remoteAddr) + if m.call.OnState != nil { + m.call.OnState(record.ID, true) + } + defer func() { + if m.call.OnState != nil { + m.call.OnState(record.ID, false) + } + m.logger.Info("minecraft bridge disconnected", "server", record.Name, "server_id", record.ID) + }() + + for { + _ = conn.SetReadDeadline(time.Now().Add(2*HeartbeatInterval + 30*time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + return + } + m.touch(s.serverID) + m.handleFrame(s, raw) + } +} + +func (m *Manager) handleFrame(s *session, raw []byte) { + var header PacketHeader + if err := json.Unmarshal(raw, &header); err != nil { + return + } + switch header.Op { + case OpHeartBeat: + return + case OpIdentify: + s.writeClose("重复鉴权!") + return + case OpCallBack: + s.resolve(header.CallbackID, raw) + return + case OpPacket: + default: + return + } + + switch header.ExecOp { + case ExecSyncMessage: + var packet SyncMessagePacket + if json.Unmarshal(raw, &packet) != nil { + return + } + packet.Message = clampChatLine(packet.Message) + if packet.Message == "" { + return + } + if m.call.OnChat != nil { + m.call.OnChat(s.serverID, packet) + } + case ExecSyncEnterExitMessage: + var packet SyncEnterExitMessagePacket + if json.Unmarshal(raw, &packet) != nil { + return + } + m.setPlayers(s.serverID, 0) + if m.call.OnEnterExit != nil { + m.call.OnEnterExit(s.serverID, packet) + } + case ExecSyncDeathMessage: + var packet SyncDeathMessagePacket + if json.Unmarshal(raw, &packet) != nil { + return + } + if m.call.OnDeath != nil { + m.call.OnDeath(s.serverID, packet) + } + case ExecServerStateChanged: + var packet ServerStatePacket + if json.Unmarshal(raw, &packet) != nil { + return + } + m.setPlayers(s.serverID, countPlayers(packet.Players)) + case ExecPlayerJoin: + // ReportPlay is a binding gate in the original product. Without the + // binding workflow the host always admits the player. + s.writeJSON(map[string]any{ + "op": OpCallBack, + "callback_id": header.CallbackID, + "kick": false, + "kick_message": "", + }) + } +} + +// authenticate resolves a server record by its bridge token. Only configured +// servers can connect; an unknown token is rejected. +func (m *Manager) authenticate(token string) (store.Record, string, error) { + token = strings.TrimSpace(token) + if token == "" { + return store.Record{}, "", ErrUnknownServer + } + records, err := m.db.List(context.Background(), "servers") + if err != nil { + return store.Record{}, "", err + } + for _, record := range records { + if ServerToken(record) == token { + return record, token, nil + } + } + return store.Record{}, "", ErrUnknownServer +} + +// ServerToken extracts the bridge token from a server record's detail JSON. +func ServerToken(record store.Record) string { + var detail map[string]json.RawMessage + if json.Unmarshal([]byte(record.Detail), &detail) != nil { + return "" + } + for _, key := range []string{"token", "Token", "serverToken"} { + if raw, ok := detail[key]; ok { + var value string + if json.Unmarshal(raw, &value) == nil && value != "" { + return value + } + } + } + return "" +} + +func (m *Manager) register(s *session) { + m.mu.Lock() + if previous := m.sessions[s.serverID]; previous != nil && previous != s { + m.mu.Unlock() + previous.writeClose("该服务器已从其他地方连接") + m.mu.Lock() + } + m.sessions[s.serverID] = s + now := time.Now().UTC() + m.statuses[s.serverID] = Status{ + ServerID: s.serverID, + ServerName: s.serverName, + State: "online", + PluginVersion: s.pluginVersion, + RemoteAddr: s.remoteAddr, + ConnectedAt: &now, + LastSeenAt: &now, + } + m.mu.Unlock() +} + +func (m *Manager) unregister(s *session) { + s.closePending() + m.mu.Lock() + defer m.mu.Unlock() + if current := m.sessions[s.serverID]; current != s { + return + } + delete(m.sessions, s.serverID) + status := m.statuses[s.serverID] + status.State = "offline" + status.PluginVersion = "" + status.OnlinePlayers = 0 + status.ConnectedAt = nil + m.statuses[s.serverID] = status +} + +func (m *Manager) touch(serverID string) { + m.mu.Lock() + defer m.mu.Unlock() + status := m.statuses[serverID] + now := time.Now().UTC() + status.LastSeenAt = &now + status.State = "online" + m.statuses[serverID] = status +} + +func (m *Manager) setPlayers(serverID string, players int) { + m.mu.Lock() + defer m.mu.Unlock() + status := m.statuses[serverID] + status.OnlinePlayers = players + m.statuses[serverID] = status +} + +// Statuses reports every configured server, using the observed state when a +// plugin is connected and "offline" otherwise. +func (m *Manager) Statuses() (map[string]Status, error) { + records, err := m.db.List(context.Background(), "servers") + if err != nil { + return nil, err + } + m.mu.RLock() + defer m.mu.RUnlock() + result := make(map[string]Status, len(records)) + for _, record := range records { + status, ok := m.statuses[record.ID] + if !ok { + status = Status{ServerID: record.ID, ServerName: record.Name, State: "offline"} + } + if status.ServerName == "" { + status.ServerName = record.Name + } + result[record.ID] = status + } + return result, nil +} + +// Status reports one server, returning ErrUnknownServer for unknown ids. +func (m *Manager) Status(serverID string) (Status, error) { + all, err := m.Statuses() + if err != nil { + return Status{}, err + } + status, ok := all[serverID] + if !ok { + return Status{}, ErrUnknownServer + } + return status, nil +} + +// SendToChat broadcasts a line to every player on the server. +func (m *Manager) SendToChat(serverID, text string) error { + s := m.session(serverID) + if s == nil { + return ErrServerOffline + } + return s.writeJSON(SendToChatPacket{ + Op: OpPacket, + CallbackID: uuid.NewString(), + ExecOp: ExecSendToChat, + Text: clampChatLine(text), + }) +} + +// RunCommand asks the plugin to run a command. An empty player name runs it +// from the console. +func (m *Manager) RunCommand(ctx context.Context, serverID, playerName, command string) ([]byte, error) { + s := m.session(serverID) + if s == nil { + return nil, ErrServerOffline + } + return s.call(ctx, RunCommandPacket{ + Op: OpPacket, + ExecOp: ExecRunCommand, + PlayerName: playerName, + Command: command, + }) +} + +// Online reports whether a server currently has an authenticated socket. +func (m *Manager) Online(serverID string) bool { + return m.session(serverID) != nil +} + +func (m *Manager) session(serverID string) *session { + m.mu.RLock() + defer m.mu.RUnlock() + return m.sessions[serverID] +} + +func (m *Manager) closeWith(s *session, reason string) { + s.writeClose(reason) + _ = s.conn.Close() +} + +func countPlayers(value string) int { + value = strings.TrimSpace(value) + if value == "" { + return 0 + } + return len(strings.Split(value, ",")) +} + +// session is one authenticated plugin connection. +type session struct { + id string + serverID string + serverName string + pluginVersion string + token string + remoteAddr string + conn *websocket.Conn + + writeMu sync.Mutex + pendingMu sync.Mutex + pending map[string]chan []byte +} + +func (s *session) writeJSON(value any) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + _ = s.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return s.conn.WriteJSON(value) +} + +func (s *session) writeClose(reason string) { + s.writeMu.Lock() + defer s.writeMu.Unlock() + _ = s.conn.SetWriteDeadline(time.Now().Add(2 * time.Second)) + _ = s.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, reason)) +} + +func (s *session) resolve(callbackID string, raw []byte) { + if callbackID == "" { + return + } + s.pendingMu.Lock() + waiter := s.pending[callbackID] + delete(s.pending, callbackID) + s.pendingMu.Unlock() + if waiter != nil { + select { + case waiter <- raw: + default: + } + } +} + +func (s *session) closePending() { + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + for id, waiter := range s.pending { + delete(s.pending, id) + close(waiter) + } +} + +func (s *session) call(ctx context.Context, packet RunCommandPacket) ([]byte, error) { + packet.CallbackID = uuid.NewString() + waiter := make(chan []byte, 1) + s.pendingMu.Lock() + s.pending[packet.CallbackID] = waiter + s.pendingMu.Unlock() + defer func() { + s.pendingMu.Lock() + delete(s.pending, packet.CallbackID) + s.pendingMu.Unlock() + }() + if err := s.writeJSON(packet); err != nil { + return nil, err + } + select { + case raw, ok := <-waiter: + if !ok { + return nil, ErrServerOffline + } + return raw, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} diff --git a/dev/internal/store/store.go b/dev/internal/store/store.go index ee33736..8707d21 100644 --- a/dev/internal/store/store.go +++ b/dev/internal/store/store.go @@ -79,14 +79,17 @@ func Open(dsn string) (*Store, error) { if err != nil { return nil, err } - db.SetMaxOpenConns(16) - db.SetMaxIdleConns(4) if dialect == "sqlite" { - // SQLite 只有一个写入者,串行使用连接,避免异步日志与管理写入争抢锁。 + // SQLite 只有一个写入者。固定单连接可让 WAL / busy_timeout 这类 + // 连接级 PRAGMA 对每条语句生效,避免异步日志写入与管理 API 写入 + // 争抢锁产生 SQLITE_BUSY。 db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) + } else { + db.SetMaxOpenConns(16) + db.SetMaxIdleConns(4) + db.SetConnMaxLifetime(30 * time.Minute) } - db.SetConnMaxLifetime(30 * time.Minute) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err = db.PingContext(ctx); err == nil { @@ -509,6 +512,17 @@ func (s *Store) RevokeSession(ctx context.Context, tokenHash string) error { func (s *Store) Dialect() string { return s.dialect } func (s *Store) Close() error { return s.db.Close() } +// Get loads a single record by kind and id. +func (s *Store) Get(ctx context.Context, kind, id string) (Record, error) { + var item Record + err := s.db.QueryRowContext(ctx, s.query(`SELECT id,kind,name,detail,enabled,created_at,updated_at FROM records WHERE kind=? AND id=?`), kind, id). + Scan(&item.ID, &item.Kind, &item.Name, &item.Detail, &item.Enabled, &item.CreatedAt, &item.UpdatedAt) + if err != nil { + return Record{}, err + } + return item, nil +} + func (s *Store) List(ctx context.Context, kind string) ([]Record, error) { rows, err := s.db.QueryContext(ctx, s.query(`SELECT id,kind,name,detail,enabled,created_at,updated_at FROM records WHERE kind=? ORDER BY updated_at DESC`), kind) if err != nil { diff --git a/dev/internal/web/web.go b/dev/internal/web/web.go index 857e3b7..b658ecf 100644 --- a/dev/internal/web/web.go +++ b/dev/internal/web/web.go @@ -1,6 +1,7 @@ package web import ( + "bufio" "context" "crypto/rand" "crypto/sha256" @@ -24,6 +25,7 @@ import ( "easybot/internal/adapter/onebot11" "easybot/internal/auth" + "easybot/internal/bridge" "easybot/internal/config" "easybot/internal/runtime" "easybot/internal/store" @@ -37,6 +39,7 @@ type server struct { logs *runtime.LogBuffer logWriter *runtime.AsyncLogWriter adapterManager *onebot11.Manager + bridgeManager *bridge.Manager loginMu sync.Mutex loginAttempts map[string]loginAttempt } @@ -58,7 +61,11 @@ func NewWithAdapterManager(db *store.Store, logger *slog.Logger, logs *runtime.L } func NewForListener(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffer, adapters *onebot11.Manager, address string, token *auth.Token, writers ...*runtime.AsyncLogWriter) http.Handler { - s := &server{store: db, logger: logger, logs: logs, adapterManager: adapters, loginAttempts: make(map[string]loginAttempt)} + return NewForListenerWithRuntimes(db, logger, logs, adapters, nil, address, token, writers...) +} + +func NewForListenerWithRuntimes(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffer, adapters *onebot11.Manager, bridges *bridge.Manager, address string, token *auth.Token, writers ...*runtime.AsyncLogWriter) http.Handler { + s := &server{store: db, logger: logger, logs: logs, adapterManager: adapters, bridgeManager: bridges, loginAttempts: make(map[string]loginAttempt)} s.localOnly = config.IsLoopbackListener(address) s.loginToken = token if len(writers) > 0 { @@ -91,6 +98,13 @@ func NewForListener(db *store.Store, logger *slog.Logger, logs *runtime.LogBuffe mux.HandleFunc("GET /api/logs", s.recentLogs) mux.HandleFunc("GET /api/logs/status", s.logStatus) mux.HandleFunc("PUT /api/logs/settings", s.updateLogSettings) + mux.HandleFunc("GET /api/bridge/status", s.bridgeStatuses) + mux.HandleFunc("GET /api/bridge/{id}/status", s.bridgeStatus) + mux.HandleFunc("POST /api/bridge/{id}/send", s.bridgeSend) + mux.HandleFunc("POST /api/servers/{id}/token", s.rotateServerToken) + if s.bridgeManager != nil { + mux.Handle("/bridge", s.bridgeManager) + } mux.Handle("/", http.FileServer(http.Dir(staticDirectory()))) handler := s.security(s.cors(mux)) allowedHosts := os.Getenv("EASYBOT_ALLOWED_HOSTS") @@ -259,6 +273,24 @@ func (w *statusRecorder) Write(data []byte) (int, error) { return w.ResponseWriter.Write(data) } +// Hijack is required for WebSocket upgrades, which take over the raw +// connection after the handshake. +func (w *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("response writer does not support hijacking") + } + return hijacker.Hijack() +} + +func (w *statusRecorder) Flush() { + if flusher, ok := w.ResponseWriter.(http.Flusher); ok { + flusher.Flush() + } +} + +func (w *statusRecorder) Unwrap() http.ResponseWriter { return w.ResponseWriter } + func (s *server) observe(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { recorder := &statusRecorder{ResponseWriter: w} @@ -319,6 +351,14 @@ func (s *server) createRecord(w http.ResponseWriter, r *http.Request) { if !decode(w, r, &item) { return } + if item.Kind == "servers" { + detail, err := ensureServerToken(item.Detail) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"message": "server config must be valid JSON"}) + return + } + item.Detail = detail + } item, err := s.store.Save(r.Context(), item) if err != nil { writeStoreError(w, err) @@ -336,6 +376,15 @@ func (s *server) updateRecord(w http.ResponseWriter, r *http.Request) { return } item.ID, item.Kind = r.PathValue("id"), r.PathValue("kind") + if item.Kind == "servers" && serverToken(item.Detail) == "" { + if existing, err := s.store.Get(r.Context(), "servers", item.ID); err == nil { + if token := serverToken(existing.Detail); token != "" { + if detail, err := setServerToken(item.Detail, token); err == nil { + item.Detail = detail + } + } + } + } item, err := s.store.Save(r.Context(), item) if err != nil { writeStoreError(w, err) @@ -586,6 +635,145 @@ func (s *server) adapterGroups(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, groups) } +func (s *server) bridgeStatuses(w http.ResponseWriter, _ *http.Request) { + if s.bridgeManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Minecraft bridge runtime is unavailable."}) + return + } + statuses, err := s.bridgeManager.Statuses() + if err != nil { + http.Error(w, "bridge status unavailable", http.StatusInternalServerError) + return + } + items := make([]bridge.Status, 0, len(statuses)) + for _, status := range statuses { + items = append(items, status) + } + sort.Slice(items, func(i, j int) bool { return items[i].ServerName < items[j].ServerName }) + writeJSON(w, http.StatusOK, items) +} + +func (s *server) bridgeStatus(w http.ResponseWriter, r *http.Request) { + if s.bridgeManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Minecraft bridge runtime is unavailable."}) + return + } + status, err := s.bridgeManager.Status(r.PathValue("id")) + if err != nil { + if errors.Is(err, bridge.ErrUnknownServer) { + writeJSON(w, http.StatusNotFound, map[string]string{"message": "server not found"}) + return + } + http.Error(w, "bridge status unavailable", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, status) +} + +func (s *server) bridgeSend(w http.ResponseWriter, r *http.Request) { + if s.bridgeManager == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": "Minecraft bridge runtime is unavailable."}) + return + } + var body struct { + Text string `json:"text"` + } + if !decode(w, r, &body) { + return + } + text := strings.TrimSpace(body.Text) + if text == "" || len(text) > 2000 { + writeJSON(w, http.StatusBadRequest, map[string]string{"message": "text must be 1-2000 bytes"}) + return + } + if err := s.bridgeManager.SendToChat(r.PathValue("id"), text); err != nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"message": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"sent": true}) +} + +func (s *server) rotateServerToken(w http.ResponseWriter, r *http.Request) { + record, err := s.store.Get(r.Context(), "servers", r.PathValue("id")) + if err != nil { + writeStoreError(w, err) + return + } + token, err := newServerToken() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"message": "could not generate a server token"}) + return + } + detail, err := setServerToken(record.Detail, token) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"message": "stored server config is invalid JSON"}) + return + } + record.Detail = detail + if _, err := s.store.Save(r.Context(), record); err != nil { + writeStoreError(w, err) + return + } + s.logs.Add("warn", "bridge", "Minecraft server token rotated: "+record.Name) + writeJSON(w, http.StatusOK, map[string]string{"serverId": record.ID, "token": token}) +} + +func newServerToken() (string, error) { + value := make([]byte, 24) + if _, err := rand.Read(value); err != nil { + return "", err + } + return hex.EncodeToString(value), nil +} + +func setServerToken(detail, token string) (string, error) { + values := make(map[string]json.RawMessage) + if strings.TrimSpace(detail) != "" { + if err := json.Unmarshal([]byte(detail), &values); err != nil { + return detail, err + } + } + encoded, err := json.Marshal(token) + if err != nil { + return detail, err + } + values["token"] = encoded + result, err := json.Marshal(values) + if err != nil { + return detail, err + } + return string(result), nil +} + +func serverToken(detail string) string { + var values map[string]json.RawMessage + if json.Unmarshal([]byte(detail), &values) != nil { + return "" + } + for _, key := range []string{"token", "Token", "serverToken"} { + raw, ok := values[key] + if !ok { + continue + } + var value string + if json.Unmarshal(raw, &value) == nil && value != "" { + return value + } + } + return "" +} + +func ensureServerToken(detail string) (string, error) { + if serverToken(detail) != "" { + return detail, nil + } + token, err := newServerToken() + if err != nil { + return detail, err + } + return setServerToken(detail, token) +} + func (s *server) adapterRecord(ctx context.Context, id string) (store.Record, error) { items, err := s.store.List(ctx, "adapters") if err != nil { diff --git a/dev/web/src/App.vue b/dev/web/src/App.vue index 8b0d17d..3330260 100644 --- a/dev/web/src/App.vue +++ b/dev/web/src/App.vue @@ -1,5 +1,6 @@ + + diff --git a/dev/web/src/style.css b/dev/web/src/style.css index 9493385..e731d4f 100644 --- a/dev/web/src/style.css +++ b/dev/web/src/style.css @@ -1,7 +1,16 @@ @import "tailwindcss"; -:root{font-family:Inter,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;font-optical-sizing:auto;color:#26343a;background:#f3f6f7}*{box-sizing:border-box}html,body,#app{margin:0;min-width:360px;min-height:100%;height:100%}button,input,textarea,select{font:inherit}button{cursor:pointer}.sidebar{background:#162630;border-right:1px solid #243741}.brand-mark{object-fit:contain;border-radius:7px}.instance-switch{background:#223741;border:1px solid #344a54}.sidebar-footer{border-top:1px solid #31414a;color:#c7d0d2}.nav-link{transition:background .15s ease,color .15s ease}.nav-link.chosen{background:#34464f;color:#fff;box-shadow:inset 2px 0 #9d8cff}.nav-link.chosen:hover{background:#34464f}.nav-link:hover{background:#253943}.main-area{background:#f3f6f7}.topbar{background:#fff;border-bottom:1px solid #e4e9eb}.page-wrap{max-width:1500px}.eyebrow{font-size:10px;font-weight:700;letter-spacing:.12em;color:#677d83}.api-state{display:inline-flex;align-items:center;gap:7px;padding:6px 9px;border:1px solid #dfe7e5;border-radius:4px;background:#fff;font-size:12px;color:#607077}.api-state i{width:7px;height:7px;border-radius:50%;background:#c98a54}.api-state.is-online i{background:#43a778}.api-state.is-online{color:#357655;border-color:#d8e9df;background:#f3f9f5}.api-state.is-offline{color:#8b6541;border-color:#eedfcd;background:#fcf8f1}.stat-card{position:relative;display:flex;min-height:106px;align-items:flex-start;gap:13px;padding:16px;border:1px solid #e1e7e8;border-radius:5px;background:#fff;box-shadow:0 1px 2px #1b303808}.stat-icon{display:grid;width:34px;height:34px;flex:none;place-items:center;border-radius:5px}.stat-icon.mint{background:#eaf4ef;color:#38815f}.stat-icon.blue{background:#eaf1f7;color:#4a7f9d}.stat-icon.amber{background:#faf2e5;color:#a8792e}.stat-icon.rose{background:#f8eeeb;color:#b45e4e}.stat-label{display:block;margin:0 0 3px;color:#77858a;font-size:12px}.stat-card strong{display:block;color:#26343a;font-size:24px;line-height:1.1;font-weight:650}.stat-card small{display:block;margin-top:4px;color:#95a0a4;font-size:11px}.stat-arrow{position:absolute;right:13px;top:13px;color:#b3bec1}.panel,.setup-panel{border:1px solid #e1e7e8;border-radius:5px;background:#fff;box-shadow:0 1px 2px #1b303808}.panel{min-height:270px;overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;min-height:64px;padding:13px 16px;border-bottom:1px solid #edf0f1}.panel-heading h2{margin:0;color:#34434a;font-size:14px;font-weight:650}.panel-heading p{margin:3px 0 0;color:#879398;font-size:11px}.text-link{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#476f5c;font-size:12px}.text-link:hover{color:#274d3b}.connection-row{display:flex;min-height:62px;align-items:center;gap:11px;padding:9px 16px}.connection-icon{display:grid;width:32px;height:32px;flex:none;place-items:center;border-radius:5px;background:#eff4f2;color:#59806b}.connection-row b,.activity-row b{display:block;overflow:hidden;color:#435157;font-size:12px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.connection-row small,.activity-row small{display:block;margin-top:3px;color:#8a969a;font-size:11px}.status{display:inline-flex;align-items:center;gap:5px;border:1px solid transparent;border-radius:4px;padding:4px 7px;font-size:11px;white-space:nowrap}.status i{width:6px;height:6px;border-radius:50%;background:#aab3b6}.status.pending{border-color:#efdfc3;background:#fbf5e9;color:#8b6c37}.status.pending i{background:#d2a24d}.status.ok{border-color:#d9ecdf;background:#edf6f0;color:#398360}.status.ok i{background:#45ad79}.status.off{border-color:#e6e9ea;background:#f2f4f4;color:#869196}.dashboard-empty{display:flex;min-height:200px;flex-direction:column;align-items:center;justify-content:center;padding:20px;text-align:center}.dashboard-empty b{margin-top:9px;color:#45545a;font-size:13px}.dashboard-empty p{margin:4px 0 0;color:#89969a;font-size:11px}.empty-mark{display:grid;width:36px;height:36px;place-items:center;border:1px solid #e8ecec;border-radius:7px;background:#f5f8f7}.empty-mark img{width:23px;height:23px;object-fit:contain}.empty-mark.quiet{color:#668878}.activity-row{display:flex;min-height:57px;align-items:center;gap:10px;padding:8px 16px}.activity-row time{flex:none;color:#96a0a4;font-size:10px}.activity-dot{width:7px;height:7px;flex:none;border-radius:50%;background:#67a985}.activity-dot.level-warn{background:#d2a04e}.activity-dot.level-error{background:#c75c52}.activity-empty{min-height:206px}.setup-panel{padding-bottom:12px}.setup-panel>.panel-heading{border-bottom:0;padding-bottom:7px}.setup-grid{padding:0 12px}.setup-step{position:relative;display:grid;min-height:72px;grid-template-columns:30px 1fr 16px;grid-template-rows:auto auto;align-items:center;column-gap:8px;border:1px solid #e4e9e9;border-radius:4px;background:#fff;padding:10px;text-align:left;transition:border-color .15s,background .15s}.setup-step:hover{border-color:#bdc9c5;background:#f9fbfa}.setup-step>span{grid-row:1/3;display:grid;width:28px;height:28px;place-items:center;border-radius:50%;background:#edf3ef;color:#49715a;font-size:10px;font-weight:700}.setup-step>b{overflow:hidden;color:#3e4c51;font-size:12px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.setup-step>small{overflow:hidden;color:#879398;font-size:10px;text-overflow:ellipsis;white-space:nowrap}.setup-step>svg{grid-column:3;grid-row:1/3;color:#a3afb0}.btn-primary,.btn-secondary{display:inline-flex;min-height:36px;align-items:center;justify-content:center;gap:7px;border-radius:4px;padding:0 11px;font-size:12px;font-weight:600;white-space:nowrap}.btn-primary{border:1px solid #315c49;background:#315c49;color:white}.btn-primary:hover{background:#254c3a}.btn-secondary{border:1px solid #dce2e4;background:white;color:#58666c}.btn-secondary:hover{background:#f7f9f9;border-color:#bdc8cc}.btn-secondary.compact,.btn-primary.compact{min-height:31px;padding:0 9px;font-size:11px}.icon-btn{display:grid;width:32px;height:32px;place-items:center;border:0;border-radius:4px;background:transparent;color:#738087}.icon-btn:hover{background:#f1f4f5;color:#273843}.empty{display:flex;min-height:220px;flex-direction:column;align-items:center;justify-content:center;border:1px solid #e4e9ea;border-radius:5px;background:white;color:#68767c;font-size:13px}.empty b{font-size:14px;color:#536168}.empty p{margin-top:5px;font-size:13px;color:#879398}.row-action{display:grid;width:30px;height:30px;place-items:center;border:0;border-radius:4px;background:transparent;color:#89959a}.row-action:hover{background:#eff5f1;color:#315f4a}.row-action.danger:hover{background:#fbefed;color:#b74c48}th{height:40px;padding:0 12px;font-weight:550}td{padding:7px 12px;font-size:13px}.field{display:flex;flex-direction:column;gap:6px;color:#57656b;font-size:13px;font-weight:600}.field input,.field textarea,.field select{width:100%;border:1px solid #dce3e4;border-radius:4px;outline:0;padding:9px;color:#35434a;font-size:13px;background:#fff}.field input:focus,.field textarea:focus,.field select:focus{border-color:#719b84;box-shadow:0 0 0 2px #719b841c}.field textarea{font-family:ui-monospace,monospace;font-size:12px;resize:vertical}.field small{font-size:12px;font-weight:400;color:#9aa4a8}.log-level{display:inline-block;border-radius:3px;padding:3px 5px;font-size:12px}.level-info{background:#edf5ef;color:#4c8563}.level-warn{background:#fbf4e7;color:#80612b}.level-error{background:#faecea;color:#a64f47}.v-enter-active,.v-leave-active{transition:opacity .15s,transform .15s}.v-enter-from,.v-leave-to{opacity:0;transform:translateY(5px)} +:root{font-family:Inter,"PingFang SC","Microsoft YaHei",system-ui,sans-serif;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;font-optical-sizing:auto;color:#26343a;background:#f3f6f7}*{box-sizing:border-box}html,body,#app{margin:0;min-width:360px;min-height:100%;height:100%}button,input,textarea,select{font:inherit}button{cursor:pointer}.sidebar{background:#162630;border-right:1px solid #243741}.brand b{letter-spacing:-.01em}.instance-switch{background:#223741;border:1px solid #344a54}.sidebar-footer{border-top:1px solid #31414a;color:#c7d0d2}.nav-link{transition:background .15s ease,color .15s ease}.nav-link.chosen{background:#34464f;color:#fff;box-shadow:inset 2px 0 #9d8cff}.nav-link.chosen:hover{background:#34464f}.nav-link:hover{background:#253943}.main-area{background:#f3f6f7}.topbar{background:#fff;border-bottom:1px solid #e4e9eb}.page-wrap{max-width:1500px}.eyebrow{font-size:10px;font-weight:700;letter-spacing:.12em;color:#677d83}.api-state{display:inline-flex;align-items:center;gap:7px;padding:6px 9px;border:1px solid #dfe7e5;border-radius:4px;background:#fff;font-size:12px;color:#607077}.api-state i{width:7px;height:7px;border-radius:50%;background:#c98a54}.api-state.is-online i{background:#43a778}.api-state.is-online{color:#357655;border-color:#d8e9df;background:#f3f9f5}.api-state.is-offline{color:#8b6541;border-color:#eedfcd;background:#fcf8f1}.stat-card{position:relative;display:flex;min-height:106px;align-items:flex-start;gap:13px;padding:16px;border:1px solid #e1e7e8;border-radius:5px;background:#fff;box-shadow:0 1px 2px #1b303808}.stat-icon{display:grid;width:34px;height:34px;flex:none;place-items:center;border-radius:5px}.stat-icon.mint{background:#eaf4ef;color:#38815f}.stat-icon.blue{background:#eaf1f7;color:#4a7f9d}.stat-icon.amber{background:#faf2e5;color:#a8792e}.stat-icon.rose{background:#f8eeeb;color:#b45e4e}.stat-label{display:block;margin:0 0 3px;color:#77858a;font-size:12px}.stat-card strong{display:block;color:#26343a;font-size:24px;line-height:1.1;font-weight:650}.stat-card small{display:block;margin-top:4px;color:#95a0a4;font-size:11px}.stat-arrow{position:absolute;right:13px;top:13px;color:#b3bec1}.panel,.setup-panel{border:1px solid #e1e7e8;border-radius:5px;background:#fff;box-shadow:0 1px 2px #1b303808}.panel{min-height:270px;overflow:hidden}.panel-heading{display:flex;align-items:center;justify-content:space-between;min-height:64px;padding:13px 16px;border-bottom:1px solid #edf0f1}.panel-heading h2{margin:0;color:#34434a;font-size:14px;font-weight:650}.panel-heading p{margin:3px 0 0;color:#879398;font-size:11px}.text-link{display:inline-flex;align-items:center;gap:4px;border:0;background:transparent;color:#476f5c;font-size:12px}.text-link:hover{color:#274d3b}.connection-row{display:flex;min-height:62px;align-items:center;gap:11px;padding:9px 16px}.connection-icon{display:grid;width:32px;height:32px;flex:none;place-items:center;border-radius:5px;background:#eff4f2;color:#59806b}.connection-row b,.activity-row b{display:block;overflow:hidden;color:#435157;font-size:12px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.connection-row small,.activity-row small{display:block;margin-top:3px;color:#8a969a;font-size:11px}.status{display:inline-flex;align-items:center;gap:5px;border:1px solid transparent;border-radius:4px;padding:4px 7px;font-size:11px;white-space:nowrap}.status i{width:6px;height:6px;border-radius:50%;background:#aab3b6}.status.pending{border-color:#efdfc3;background:#fbf5e9;color:#8b6c37}.status.pending i{background:#d2a24d}.status.ok{border-color:#d9ecdf;background:#edf6f0;color:#398360}.status.ok i{background:#45ad79}.status.off{border-color:#e6e9ea;background:#f2f4f4;color:#869196}.dashboard-empty{display:flex;min-height:200px;flex-direction:column;align-items:center;justify-content:center;padding:20px;text-align:center}.dashboard-empty b{margin-top:9px;color:#45545a;font-size:13px}.dashboard-empty p{margin:4px 0 0;color:#89969a;font-size:11px}.empty-mark{display:grid;width:36px;height:36px;place-items:center;border:1px solid #e8ecec;border-radius:7px;background:#f5f8f7}.empty-mark img{width:23px;height:23px;object-fit:contain}.empty-mark.quiet{color:#668878}.activity-row{display:flex;min-height:57px;align-items:center;gap:10px;padding:8px 16px}.activity-row time{flex:none;color:#96a0a4;font-size:10px}.activity-dot{width:7px;height:7px;flex:none;border-radius:50%;background:#67a985}.activity-dot.level-warn{background:#d2a04e}.activity-dot.level-error{background:#c75c52}.activity-empty{min-height:206px}.setup-panel{padding-bottom:12px}.setup-panel>.panel-heading{border-bottom:0;padding-bottom:7px}.setup-grid{padding:0 12px}.setup-step{position:relative;display:grid;min-height:72px;grid-template-columns:30px 1fr 16px;grid-template-rows:auto auto;align-items:center;column-gap:8px;border:1px solid #e4e9e9;border-radius:4px;background:#fff;padding:10px;text-align:left;transition:border-color .15s,background .15s}.setup-step:hover{border-color:#bdc9c5;background:#f9fbfa}.setup-step>span{grid-row:1/3;display:grid;width:28px;height:28px;place-items:center;border-radius:50%;background:#edf3ef;color:#49715a;font-size:10px;font-weight:700}.setup-step>b{overflow:hidden;color:#3e4c51;font-size:12px;font-weight:600;text-overflow:ellipsis;white-space:nowrap}.setup-step>small{overflow:hidden;color:#879398;font-size:10px;text-overflow:ellipsis;white-space:nowrap}.setup-step>svg{grid-column:3;grid-row:1/3;color:#a3afb0}.btn-primary,.btn-secondary{display:inline-flex;min-height:36px;align-items:center;justify-content:center;gap:7px;border-radius:4px;padding:0 11px;font-size:12px;font-weight:600;white-space:nowrap}.btn-primary{border:1px solid #315c49;background:#315c49;color:white}.btn-primary:hover{background:#254c3a}.btn-secondary{border:1px solid #dce2e4;background:white;color:#58666c}.btn-secondary:hover{background:#f7f9f9;border-color:#bdc8cc}.btn-secondary.compact,.btn-primary.compact{min-height:31px;padding:0 9px;font-size:11px}.icon-btn{display:grid;width:32px;height:32px;place-items:center;border:0;border-radius:4px;background:transparent;color:#738087}.icon-btn:hover{background:#f1f4f5;color:#273843}.empty{display:flex;min-height:220px;flex-direction:column;align-items:center;justify-content:center;border:1px solid #e4e9ea;border-radius:5px;background:white;color:#68767c;font-size:13px}.empty b{font-size:14px;color:#536168}.empty p{margin-top:5px;font-size:13px;color:#879398}.row-action{display:grid;width:30px;height:30px;place-items:center;border:0;border-radius:4px;background:transparent;color:#89959a}.row-action:hover{background:#eff5f1;color:#315f4a}.row-action.danger:hover{background:#fbefed;color:#b74c48}th{height:40px;padding:0 12px;font-weight:550}td{padding:7px 12px;font-size:13px}.field{display:flex;flex-direction:column;gap:6px;color:#57656b;font-size:13px;font-weight:600}.field input,.field textarea,.field select{width:100%;border:1px solid #dce3e4;border-radius:4px;outline:0;padding:9px;color:#35434a;font-size:13px;background:#fff}.field input:focus,.field textarea:focus,.field select:focus{border-color:#719b84;box-shadow:0 0 0 2px #719b841c}.field textarea{font-family:ui-monospace,monospace;font-size:12px;resize:vertical}.field small{font-size:12px;font-weight:400;color:#9aa4a8}.log-level{display:inline-block;border-radius:3px;padding:3px 5px;font-size:12px}.level-info{background:#edf5ef;color:#4c8563}.level-warn{background:#fbf4e7;color:#80612b}.level-error{background:#faecea;color:#a64f47}.v-enter-active,.v-leave-active{transition:opacity .15s,transform .15s}.v-enter-from,.v-leave-to{opacity:0;transform:translateY(5px)} @media(max-width:900px){.dashboard-grid{grid-template-columns:1fr}.panel{min-height:0}} -@media(max-width:760px){.sidebar{width:62px;padding:13px 7px}.brand{justify-content:center;padding-left:0;padding-right:0}.brand>span,.brand>span:last-of-type,.instance-switch span,.instance-switch>svg,.sidebar nav p,.sidebar nav button span,.sidebar nav button small,.sidebar-footer span{display:none}.instance-switch{justify-content:center;padding:8px 0}.instance-switch img{width:27px;height:27px}.sidebar nav button{height:38px;justify-content:center;padding:0}.sidebar nav button>svg{width:17px;height:17px}.sidebar-footer{justify-content:center;padding-left:0;padding-right:0}.main-area{margin-left:62px}.page-wrap{padding:18px 14px}.topbar{height:54px;padding:0 14px}.page-heading{align-items:flex-start}.page-heading h1{font-size:22px}.api-state{padding:5px 7px;font-size:11px}} +@media(max-width:760px){.sidebar{width:62px;padding:13px 7px}.brand,.instance-switch{display:none}.sidebar nav p,.sidebar nav button span,.sidebar nav button small,.sidebar-footer span{display:none}.sidebar nav button{height:38px;justify-content:center;padding:0}.sidebar nav button>svg{width:17px;height:17px}.sidebar-footer{justify-content:center;padding-left:0;padding-right:0}.main-area{margin-left:62px}.page-wrap{padding:18px 14px}.topbar{height:54px;padding:0 14px}.page-heading{align-items:flex-start}.page-heading h1{font-size:22px}.api-state{padding:5px 7px;font-size:11px}} @media(max-width:520px){.page-heading{flex-direction:column}.page-heading>div:last-child{width:100%;justify-content:space-between}.stats-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.stat-card{min-height:94px;padding:11px;gap:8px}.stat-icon{width:30px;height:30px}.stat-card strong{font-size:21px}.stat-label{font-size:11px}.stat-arrow{right:9px;top:9px}.connection-row{padding:9px 11px}.connection-row .status{font-size:10px;padding:3px 5px}.panel-heading{padding:11px}.activity-row{padding:8px 11px}.setup-grid{padding:0 8px}} .auth-screen{display:grid;min-height:100vh;place-items:center;background:#f3f6f7;padding:24px}.auth-panel{width:min(100%,410px);border:1px solid #e0e7e8;border-radius:6px;background:#fff;padding:30px;box-shadow:0 8px 28px #1c303a0d}.auth-panel>img{display:block;width:46px;height:46px;margin-bottom:18px;object-fit:contain}.auth-kicker{margin:0 0 6px;color:#74858a;font-size:10px;font-weight:700;letter-spacing:.12em}.auth-panel h1{margin:0;color:#2a383e;font-size:22px;font-weight:650}.auth-description,.auth-panel>p:not(.auth-kicker){margin:7px 0 0;color:#7e8b90;font-size:13px;line-height:1.65}.auth-form{display:flex;flex-direction:column;gap:16px;margin-top:22px}.auth-form .field{gap:7px}.auth-form .field input{height:40px;font-size:13px}.auth-submit{width:100%;margin-top:4px}.auth-submit:disabled{cursor:wait;opacity:.68}.auth-error{margin-top:16px;border:1px solid #efdfcd;border-radius:4px;background:#fcf8f1;padding:9px 11px;color:#886747;font-size:12px;line-height:1.5} +.server-token{display:flex;flex-direction:column;gap:6px;border:1px solid #e4e9ea;border-radius:5px;background:#fafbfb;padding:10px 12px} +.server-token-head{display:flex;align-items:center;justify-content:space-between;color:#57656b;font-size:13px;font-weight:600} +.server-token code{word-break:break-all;color:#315f4a;font-family:ui-monospace,monospace;font-size:12px} +.server-token small{color:#9aa4a8;font-size:11px;line-height:1.5} +.config-form{display:flex;flex-direction:column;gap:12px} +.config-form-head{display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #edf0f0;padding-bottom:7px;color:#57656b;font-size:12px;font-weight:600} +.config-form .field>span{font-size:12px;color:#6d7a80;font-weight:550} +.config-form input[readonly]{background:#f6f8f8;color:#7d8a8e;cursor:default} +.config-check{width:auto!important;align-self:flex-start}