From f06b21d4270c53941579ca461b4f073d49fb1b2f Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:45:09 +0900 Subject: [PATCH] feat: add real-time log viewer to miniapp Config tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WebSocket-based log streaming to the Telegram Mini App dashboard with three log sources (picoclaw, telego, dev-console), component filtering, and tar.gz snapshot export. Security hardening: - Add allow_from authorization to Mini App endpoints - Gate /dev/console endpoint on active dev proxy - Fix rate limiter TOCTOU race (atomic → mutex) - Restrict WebSocket CheckOrigin to Telegram/localhost origins - Add initData auth_date expiry check (24h) - Strip Caller field from all log output Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 19 ++ README.ja.md | 5 + README.md | 12 +- README.zh.md | 2 + cmd/picoclaw/cmd_gateway.go | 2 +- pkg/channels/telegram.go | 13 ++ pkg/logger/logger.go | 145 +++++++++++++ pkg/logger/logger_test.go | 176 +++++++++++++++ pkg/miniapp/miniapp.go | 390 +++++++++++++++++++++++++++++++++- pkg/miniapp/miniapp_test.go | 157 +++++++------- pkg/miniapp/static/index.html | 184 ++++++++++++++++ 11 files changed, 1026 insertions(+), 79 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..01b7dd318 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +# CLAUDE.md + +## Project: picoclaw + +Go-based AI agent with multi-channel messaging (Telegram, Discord, Slack, etc.) and a Telegram Mini App UI. + +## Build & Test + +```bash +go build ./... +go test ./... +go vet ./... +``` + +Lint: `golangci-lint run` + +## Security TODOs + +- **Log Fields masking**: `LogEntry.Fields` (`map[string]any`) is exposed via WebSocket (`/miniapp/api/logs/ws`) and snapshots (`/miniapp/api/logs/snapshot`). If any code logs sensitive values (tokens, API keys, passwords) in Fields, they will be visible to Mini App users. Add a sanitizer in `RecentLogs()` and `wsLogs()` that masks values for keys matching patterns like `token`, `key`, `secret`, `password`, `authorization`. Track in: `pkg/logger/logger.go` (RecentLogs), `pkg/miniapp/miniapp.go` (wsLogs stream). diff --git a/README.ja.md b/README.ja.md index c66b1e3de..96cf26fb2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -277,6 +277,11 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき ```bash picoclaw gateway ``` + +#### Mini App(ダッシュボード) + +PicoClaw には Telegram Mini App が含まれており、チャット内から GUI ダッシュボードを利用できます。Config タブにはリアルタイムの **ログビューアー** があり、WebSocket で 3 種類のログストリーム(picoclaw / telego / dev-console)を表示します。コンポーネントフィルタ、スナップショット保存(tar.gz、14日後自動削除)にも対応しています。 + diff --git a/README.md b/README.md index a38f852e9..5186cd3df 100644 --- a/README.md +++ b/README.md @@ -330,7 +330,7 @@ When Telegram is enabled, PicoClaw automatically registers a "Dashboard" menu bu | **Plan** | View plan phases/steps as a checklist, tap to mark done, start new plans | | **Skills** | Browse and invoke skills with a message input | | **Session** | View active sessions and token usage stats (requires `--stats` flag for stats) | -| **Config** | Quick command buttons and custom command input | +| **Config** | Quick command buttons, custom command input, and **Log Viewer** | **Setup — Tailscale (recommended for self-hosting):** @@ -370,6 +370,16 @@ If you already have an HTTPS endpoint (e.g., reverse proxy, Cloudflare Tunnel), When `web_app_url` is set, PicoClaw skips Tailscale auto-detection and serves the Mini App over HTTP (your reverse proxy handles TLS). +**Log Viewer (Config tab):** + +The Config tab includes a real-time log viewer that streams logs via WebSocket. It supports three log streams: + +- **picoclaw** — agent, tools, and channel activity logs +- **telego** — Telegram library internal logs +- **dev-console** — JavaScript console output from Dev Preview iframes + +Features: component filter chips, auto-reconnect, and **Save Snapshot** to download a `.tar.gz` archive of the current log buffer (auto-deleted after 14 days). When `allow_from` is configured, the log viewer is restricted to allowed users only. +
diff --git a/README.zh.md b/README.zh.md index 4f090dd70..f2b36d1e3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -298,6 +298,8 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 | **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) | | **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) | +**Mini App(仪表盘):** Telegram Mini App 的 Config 标签页内置实时**日志查看器**,通过 WebSocket 展示三种日志流(picoclaw / telego / dev-console)。支持组件筛选、快照保存(tar.gz,14天自动清理)。配置 `allow_from` 后,日志查看器仅限白名单用户使用。 + ## ClawdChat 加入 Agent 社交网络 只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index ca5d9c821..794924f19 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -235,7 +235,7 @@ func gatewayCmd() { provider := &agentLoopDataProvider{loop: agentLoop, workspace: cfg.WorkspacePath()} sender := &telegramCommandSender{bus: msgBus} miniappNotifier = miniapp.NewStateNotifier() - handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier) + handler := miniapp.NewHandler(provider, sender, cfg.Channels.Telegram.Token, miniappNotifier, cfg.Channels.Telegram.AllowFrom, cfg.WorkspacePath()) agentLoop.OnStateChange = miniappNotifier.Notify handler.RegisterRoutes(healthServer.Mux()) diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index f1a0f5266..3c63a88d4 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -47,6 +47,17 @@ func (c *thinkingCancel) Cancel() { } } +// telegoLogAdapter routes telego library logs through picoclaw's logger. +type telegoLogAdapter struct{} + +func (telegoLogAdapter) Debugf(format string, args ...any) { + logger.DebugCF("telego", fmt.Sprintf(format, args...), nil) +} + +func (telegoLogAdapter) Errorf(format string, args ...any) { + logger.ErrorCF("telego", fmt.Sprintf(format, args...), nil) +} + const telegramMaxMessageChars = 3900 const markdownTableMaxWidth = 42 const markdownTableMinColWidth = 6 @@ -74,6 +85,8 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann })) } + opts = append(opts, telego.WithLogger(telegoLogAdapter{})) + bot, err := telego.NewBot(telegramCfg.Token, opts...) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 54de66bf9..70809e654 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -30,12 +30,74 @@ var ( FATAL: "FATAL", } + levelFromName = map[string]LogLevel{ + "DEBUG": DEBUG, + "INFO": INFO, + "WARN": WARN, + "ERROR": ERROR, + "FATAL": FATAL, + } + currentLevel = INFO logger *Logger once sync.Once mu sync.RWMutex + + ringBuf *logRingBuffer + logSubs []*LogSubscriber + logSubsMu sync.Mutex ) +const ringBufSize = 300 + +// logRingBuffer is a fixed-size circular buffer for log entries. +type logRingBuffer struct { + entries []LogEntry + head int + count int + seq uint64 + mu sync.RWMutex +} + +func newLogRingBuffer(size int) *logRingBuffer { + return &logRingBuffer{ + entries: make([]LogEntry, size), + } +} + +func (rb *logRingBuffer) push(entry LogEntry) { + rb.mu.Lock() + rb.entries[rb.head] = entry + rb.head = (rb.head + 1) % len(rb.entries) + if rb.count < len(rb.entries) { + rb.count++ + } + rb.seq++ + rb.mu.Unlock() +} + +func (rb *logRingBuffer) recent(limit int) []LogEntry { + rb.mu.RLock() + defer rb.mu.RUnlock() + + n := rb.count + if limit > 0 && limit < n { + n = limit + } + result := make([]LogEntry, n) + start := (rb.head - n + len(rb.entries)) % len(rb.entries) + for i := 0; i < n; i++ { + result[i] = rb.entries[(start+i)%len(rb.entries)] + } + return result +} + +// LogSubscriber receives log entries matching its filter. +type LogSubscriber struct { + Ch chan LogEntry + filter func(LogEntry) bool +} + type Logger struct { file *os.File } @@ -52,6 +114,7 @@ type LogEntry struct { func init() { once.Do(func() { logger = &Logger{} + ringBuf = newLogRingBuffer(ringBufSize) }) } @@ -116,6 +179,10 @@ func logMessage(level LogLevel, component string, message string, fields map[str } } + // Push to ring buffer and broadcast to subscribers + ringBuf.push(entry) + broadcastToSubscribers(entry) + if logger.file != nil { jsonData, err := json.Marshal(entry) if err == nil { @@ -237,3 +304,81 @@ func FatalF(message string, fields map[string]any) { func FatalCF(component string, message string, fields map[string]any) { logMessage(FATAL, component, message, fields) } + +// broadcastToSubscribers sends an entry to all matching subscribers (non-blocking). +func broadcastToSubscribers(entry LogEntry) { + logSubsMu.Lock() + subs := make([]*LogSubscriber, len(logSubs)) + copy(subs, logSubs) + logSubsMu.Unlock() + + for _, sub := range subs { + if sub.filter != nil && !sub.filter(entry) { + continue + } + select { + case sub.Ch <- entry: + default: + // drop if subscriber channel is full + } + } +} + +// RecentLogs returns recent log entries from the ring buffer, optionally filtered +// by minimum level and component. The Caller field is stripped for security. +func RecentLogs(minLevel LogLevel, component string, limit int) []LogEntry { + all := ringBuf.recent(0) // get all + result := make([]LogEntry, 0, limit) + for i := len(all) - 1; i >= 0 && len(result) < limit; i-- { + e := all[i] + if lvl, ok := levelFromName[e.Level]; ok && lvl < minLevel { + continue + } + if component != "" && e.Component != component { + continue + } + e.Caller = "" // strip for security + result = append(result, e) + } + // Reverse so oldest first + for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 { + result[i], result[j] = result[j], result[i] + } + return result +} + +// Subscribe registers a new log subscriber with an optional filter. +// The returned LogSubscriber's Ch channel has a buffer of 64 entries. +func Subscribe(filter func(LogEntry) bool) *LogSubscriber { + sub := &LogSubscriber{ + Ch: make(chan LogEntry, 64), + filter: filter, + } + logSubsMu.Lock() + logSubs = append(logSubs, sub) + logSubsMu.Unlock() + return sub +} + +// Unsubscribe removes a subscriber and closes its channel. +func Unsubscribe(sub *LogSubscriber) { + logSubsMu.Lock() + for i, s := range logSubs { + if s == sub { + logSubs = append(logSubs[:i], logSubs[i+1:]...) + break + } + } + logSubsMu.Unlock() + close(sub.Ch) +} + +// ParseLevel converts a level name string to a LogLevel. +// Returns INFO if the string is not recognized. +func ParseLevel(s string) LogLevel { + s = strings.ToUpper(strings.TrimSpace(s)) + if lvl, ok := levelFromName[s]; ok { + return lvl + } + return INFO +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 6e6f8dfa8..73fad89ac 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -2,6 +2,7 @@ package logger import ( "testing" + "time" ) func TestLogLevelFiltering(t *testing.T) { @@ -137,3 +138,178 @@ func TestLoggerHelperFunctions(t *testing.T) { DebugC("test", "Debug with component") WarnF("Warning with fields", map[string]any{"key": "value"}) } + +// ── Ring buffer tests ── + +func TestRingBuffer_PushAndRecent(t *testing.T) { + rb := newLogRingBuffer(5) + + for i := 0; i < 3; i++ { + rb.push(LogEntry{Message: "msg" + string(rune('A'+i))}) + } + + got := rb.recent(0) + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + if got[0].Message != "msgA" || got[2].Message != "msgC" { + t.Errorf("unexpected order: %v", got) + } +} + +func TestRingBuffer_Wrap(t *testing.T) { + rb := newLogRingBuffer(3) + for i := 0; i < 5; i++ { + rb.push(LogEntry{Message: string(rune('A' + i))}) + } + + got := rb.recent(0) + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + // Should have C, D, E (oldest two dropped) + if got[0].Message != "C" || got[1].Message != "D" || got[2].Message != "E" { + t.Errorf("expected [C,D,E], got [%s,%s,%s]", got[0].Message, got[1].Message, got[2].Message) + } +} + +func TestRingBuffer_RecentLimit(t *testing.T) { + rb := newLogRingBuffer(10) + for i := 0; i < 8; i++ { + rb.push(LogEntry{Message: string(rune('A' + i))}) + } + + got := rb.recent(3) + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + if got[0].Message != "F" || got[2].Message != "H" { + t.Errorf("expected last 3 entries, got %v", got) + } +} + +func TestRecentLogs_FilterByLevel(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + // Log messages at different levels + DebugC("test", "debug msg") + InfoC("test", "info msg") + WarnC("test", "warn msg") + ErrorC("test", "error msg") + + got := RecentLogs(WARN, "", 100) + for _, e := range got { + if e.Level == "DEBUG" || e.Level == "INFO" { + t.Errorf("unexpected level %s in result with minLevel=WARN", e.Level) + } + } +} + +func TestRecentLogs_FilterByComponent(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + InfoC("alpha", "from alpha") + InfoC("beta", "from beta") + InfoC("alpha", "another from alpha") + + got := RecentLogs(DEBUG, "alpha", 100) + for _, e := range got { + if e.Component != "alpha" { + t.Errorf("unexpected component %s in result with component=alpha", e.Component) + } + } +} + +func TestRecentLogs_CallerStripped(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + InfoC("test", "caller test") + + got := RecentLogs(DEBUG, "", 100) + for _, e := range got { + if e.Caller != "" { + t.Errorf("Caller should be stripped, got %q", e.Caller) + } + } +} + +func TestSubscribe_ReceivesEntries(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + sub := Subscribe(nil) + defer Unsubscribe(sub) + + InfoC("sub-test", "hello subscriber") + + select { + case entry := <-sub.Ch: + if entry.Message != "hello subscriber" { + t.Errorf("expected 'hello subscriber', got %q", entry.Message) + } + case <-time.After(time.Second): + t.Error("timed out waiting for log entry") + } +} + +func TestSubscribe_FilterApplied(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + sub := Subscribe(func(e LogEntry) bool { + return e.Component == "target" + }) + defer Unsubscribe(sub) + + InfoC("other", "should be filtered out") + InfoC("target", "should arrive") + + select { + case entry := <-sub.Ch: + if entry.Component != "target" { + t.Errorf("expected component=target, got %q", entry.Component) + } + case <-time.After(time.Second): + t.Error("timed out waiting for filtered entry") + } +} + +func TestUnsubscribe_ClosesChannel(t *testing.T) { + sub := Subscribe(nil) + Unsubscribe(sub) + + _, ok := <-sub.Ch + if ok { + t.Error("expected channel to be closed after Unsubscribe") + } +} + +func TestParseLevel(t *testing.T) { + tests := []struct { + input string + want LogLevel + }{ + {"debug", DEBUG}, + {"DEBUG", DEBUG}, + {"info", INFO}, + {"WARN", WARN}, + {"error", ERROR}, + {"fatal", FATAL}, + {"unknown", INFO}, + {"", INFO}, + } + for _, tt := range tests { + got := ParseLevel(tt.input) + if got != tt.want { + t.Errorf("ParseLevel(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 515c1a5ae..211f7629f 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -1,7 +1,9 @@ package miniapp import ( + "archive/tar" "bytes" + "compress/gzip" "crypto/hmac" "crypto/sha256" "embed" @@ -13,12 +15,16 @@ import ( "net/http" "net/http/httputil" "net/url" + "os" + "path/filepath" "sort" "strconv" "strings" "sync" "time" + "github.com/gorilla/websocket" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/stats" ) @@ -180,10 +186,12 @@ type DevTargetManager interface { // Handler serves the Mini App HTML and API endpoints. type Handler struct { - provider DataProvider - sender CommandSender - botToken string - notifier *StateNotifier + provider DataProvider + sender CommandSender + botToken string + notifier *StateNotifier + allowList []string + workspace string devMu sync.RWMutex devTarget *url.URL @@ -191,15 +199,44 @@ type Handler struct { devTargets map[string]*DevTarget // registered targets (ID→DevTarget) devNextID int devActiveID string + + wsClients []*wsClient + wsClientsMu sync.Mutex + + consoleMu sync.Mutex + consoleReqCount int + consoleReqSec int64 +} + +const maxWSClients = 4 + +type wsClient struct { + conn *websocket.Conn +} + +var wsUpgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true // non-browser clients (e.g. curl) + } + // Allow Telegram WebApp origins and localhost for dev + return strings.HasSuffix(origin, ".telegram.org") || + strings.HasSuffix(origin, ".t.me") || + strings.HasPrefix(origin, "http://localhost") || + strings.HasPrefix(origin, "http://127.0.0.1") + }, } // NewHandler creates a new Mini App handler. -func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier) *Handler { +func NewHandler(provider DataProvider, sender CommandSender, botToken string, notifier *StateNotifier, allowList []string, workspace string) *Handler { return &Handler{ provider: provider, sender: sender, botToken: botToken, notifier: notifier, + allowList: allowList, + workspace: workspace, devTargets: make(map[string]*DevTarget), } } @@ -364,6 +401,7 @@ func (h *Handler) ListDevTargets() []DevTarget { // devProxyScript is the JavaScript injected into HTML responses from the dev proxy. // It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like // "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount. +// It also captures console.log/warn/error/info and forwards them to the server. const devProxyScript = `` @@ -441,6 +503,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit)) mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev)) mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents)) + mux.HandleFunc("/miniapp/api/logs/ws", h.requireAuth(h.wsLogs)) + mux.HandleFunc("/miniapp/api/logs/snapshot", h.requireAuth(h.apiLogsSnapshot)) + mux.HandleFunc("/miniapp/api/logs/snapshot/", h.requireAuth(h.apiLogsSnapshotDownload)) + mux.HandleFunc("/miniapp/dev/console", h.apiDevConsole) mux.HandleFunc("/miniapp/dev/", h.serveDevProxy) } @@ -465,10 +531,36 @@ func (h *Handler) requireAuth(next http.HandlerFunc) http.HandlerFunc { http.Error(w, `{"error":"invalid initData"}`, http.StatusUnauthorized) return } + if len(h.allowList) > 0 { + userID, _ := extractUserFromInitData(initData) + if userID == "" || !isAllowed(userID, h.allowList) { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + return + } + } next(w, r) } } +// isAllowed checks whether userID matches any entry in the allow list. +// Logic mirrors BaseChannel.IsAllowed without importing channels package. +func isAllowed(userID string, allowList []string) bool { + if len(allowList) == 0 { + return true + } + for _, allowed := range allowList { + trimmed := strings.TrimPrefix(allowed, "@") + allowedID := trimmed + if idx := strings.Index(trimmed, "|"); idx > 0 { + allowedID = trimmed[:idx] + } + if userID == allowed || userID == trimmed || userID == allowedID { + return true + } + } + return false +} + func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) { skillsList := h.provider.ListSkills() writeJSON(w, skillsList) @@ -716,7 +808,282 @@ func writeJSON(w http.ResponseWriter, v any) { json.NewEncoder(w).Encode(v) } -// ValidateInitData verifies the Telegram WebApp initData HMAC-SHA256 signature. +// apiDevConsole receives console output from dev preview iframes. +func (h *Handler) apiDevConsole(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + // Only accept console posts when dev proxy is active + if h.GetDevTarget() == "" { + http.Error(w, `{"error":"not available"}`, http.StatusNotFound) + return + } + + // Simple rate limit: max 10 requests per second + now := time.Now().Unix() + h.consoleMu.Lock() + if h.consoleReqSec != now { + h.consoleReqSec = now + h.consoleReqCount = 0 + } + h.consoleReqCount++ + over := h.consoleReqCount > 10 + h.consoleMu.Unlock() + if over { + http.Error(w, `{"error":"rate limit"}`, http.StatusTooManyRequests) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 32*1024)) + if err != nil { + http.Error(w, `{"error":"bad request"}`, http.StatusBadRequest) + return + } + + var entries []struct { + Level string `json:"level"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &entries); err != nil { + http.Error(w, `{"error":"invalid JSON"}`, http.StatusBadRequest) + return + } + + // Cap at 20 entries per batch + if len(entries) > 20 { + entries = entries[:20] + } + + for _, e := range entries { + msg := e.Message + if len(msg) > 1024 { + msg = msg[:1024] + } + switch e.Level { + case "warn": + logger.WarnC("dev-console", msg) + case "error": + logger.ErrorC("dev-console", msg) + default: + logger.InfoC("dev-console", msg) + } + } + + w.WriteHeader(http.StatusNoContent) +} + +// wsLogs serves a WebSocket endpoint that streams log entries in real time. +func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) { + // Parse filter params + component := r.URL.Query().Get("component") + levelStr := r.URL.Query().Get("level") + minLevel := logger.INFO + if levelStr != "" { + minLevel = logger.ParseLevel(levelStr) + } + + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + + client := &wsClient{conn: conn} + + // Enforce max WS clients: evict oldest if full + h.wsClientsMu.Lock() + if len(h.wsClients) >= maxWSClients { + oldest := h.wsClients[0] + h.wsClients = h.wsClients[1:] + oldest.conn.Close() + } + h.wsClients = append(h.wsClients, client) + h.wsClientsMu.Unlock() + + defer func() { + h.wsClientsMu.Lock() + for i, c := range h.wsClients { + if c == client { + h.wsClients = append(h.wsClients[:i], h.wsClients[i+1:]...) + break + } + } + h.wsClientsMu.Unlock() + conn.Close() + }() + + // Build filter function + filter := func(e logger.LogEntry) bool { + if lvl := logger.ParseLevel(e.Level); lvl < minLevel { + return false + } + if component != "" && e.Component != component { + return false + } + return true + } + + sub := logger.Subscribe(filter) + defer logger.Unsubscribe(sub) + + // Send initial data + initial := logger.RecentLogs(minLevel, component, 50) + if err := conn.WriteJSON(map[string]any{"type": "init", "entries": initial}); err != nil { + return + } + + // Close detection goroutine + done := make(chan struct{}) + go func() { + defer close(done) + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + }() + + // Stream loop + for { + select { + case entry, ok := <-sub.Ch: + if !ok { + return + } + entry.Caller = "" // strip for security + if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil { + return + } + case <-done: + return + } + } +} + +// apiLogsSnapshot creates a tar.gz snapshot of the current log buffer. +func (h *Handler) apiLogsSnapshot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + entries := logger.RecentLogs(logger.DEBUG, "", 300) + + snapshotDir := filepath.Join(h.workspace, "logs", "snapshots") + if err := os.MkdirAll(snapshotDir, 0o755); err != nil { + http.Error(w, `{"error":"cannot create snapshot dir"}`, http.StatusInternalServerError) + return + } + + id := time.Now().UTC().Format("20060102-150405") + filename := fmt.Sprintf("picoclaw-logs-%s.tar.gz", id) + snapshotPath := filepath.Join(snapshotDir, filename) + + // Create tar.gz + f, err := os.Create(snapshotPath) + if err != nil { + http.Error(w, `{"error":"cannot create snapshot file"}`, http.StatusInternalServerError) + return + } + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + prefix := fmt.Sprintf("picoclaw-logs-%s/", id) + + // logs.json + logsJSON, _ := json.MarshalIndent(entries, "", " ") + _ = tw.WriteHeader(&tar.Header{ + Name: prefix + "logs.json", + Size: int64(len(logsJSON)), + Mode: 0o644, + ModTime: time.Now(), + }) + _, _ = tw.Write(logsJSON) + + // metadata.json + hostname, _ := os.Hostname() + meta := map[string]any{ + "version": "1", + "hostname": hostname, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "entry_count": len(entries), + } + metaJSON, _ := json.MarshalIndent(meta, "", " ") + _ = tw.WriteHeader(&tar.Header{ + Name: prefix + "metadata.json", + Size: int64(len(metaJSON)), + Mode: 0o644, + ModTime: time.Now(), + }) + _, _ = tw.Write(metaJSON) + + tw.Close() + gw.Close() + f.Close() + + // Cleanup old snapshots (>14 days) + go cleanOldSnapshots(snapshotDir, 14*24*time.Hour) + + downloadURL := fmt.Sprintf("/miniapp/api/logs/snapshot/%s", id) + writeJSON(w, map[string]string{"id": id, "download_url": downloadURL}) +} + +// apiLogsSnapshotDownload serves a snapshot tar.gz file. +func (h *Handler) apiLogsSnapshotDownload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + id := strings.TrimPrefix(r.URL.Path, "/miniapp/api/logs/snapshot/") + id = filepath.Base(id) // path traversal prevention + + if id == "" || id == "." || id == ".." { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } + + filename := fmt.Sprintf("picoclaw-logs-%s.tar.gz", id) + snapshotPath := filepath.Join(h.workspace, "logs", "snapshots", filename) + + if _, err := os.Stat(snapshotPath); os.IsNotExist(err) { + http.Error(w, `{"error":"not found"}`, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + http.ServeFile(w, r, snapshotPath) +} + +// cleanOldSnapshots removes snapshot files older than maxAge. +func cleanOldSnapshots(dir string, maxAge time.Duration) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-maxAge) + for _, e := range entries { + if e.IsDir() { + continue + } + info, err := e.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + os.Remove(filepath.Join(dir, e.Name())) + } + } +} + +// initDataMaxAge is the maximum age of initData before it is considered expired. +const initDataMaxAge = 24 * time.Hour + +// ValidateInitData verifies the Telegram WebApp initData HMAC-SHA256 signature +// and checks that auth_date is not older than initDataMaxAge. // See https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app func ValidateInitData(initData, botToken string) bool { values, err := url.ParseQuery(initData) @@ -729,6 +1096,17 @@ func ValidateInitData(initData, botToken string) bool { return false } + // Check auth_date freshness + if authDateStr := values.Get("auth_date"); authDateStr != "" { + authDate, err := strconv.ParseInt(authDateStr, 10, 64) + if err != nil { + return false + } + if time.Since(time.Unix(authDate, 0)) > initDataMaxAge { + return false + } + } + // Build the data-check-string: sort all key=value pairs except "hash", // join with newlines. var pairs []string diff --git a/pkg/miniapp/miniapp_test.go b/pkg/miniapp/miniapp_test.go index 3f962c4fb..6085ab1e3 100644 --- a/pkg/miniapp/miniapp_test.go +++ b/pkg/miniapp/miniapp_test.go @@ -57,9 +57,9 @@ func TestValidateInitData(t *testing.T) { t.Run("valid initData", func(t *testing.T) { params := map[string]string{ - "query_id": "AAHdF6IQAAAAAN0XohDhrOrc", - "user": `{"id":279058397,"first_name":"Vlad"}`, - "auth_date": "1234567890", + "query_id": "AAHdF6IQAAAAAN0XohDhrOrc", + "user": `{"id":279058397,"first_name":"Vlad"}`, + "auth_date": freshAuthDate(), } initData := buildInitData(params, botToken) if !ValidateInitData(initData, botToken) { @@ -69,9 +69,9 @@ func TestValidateInitData(t *testing.T) { t.Run("tampered data", func(t *testing.T) { params := map[string]string{ - "query_id": "AAHdF6IQAAAAAN0XohDhrOrc", - "user": `{"id":279058397,"first_name":"Vlad"}`, - "auth_date": "1234567890", + "query_id": "AAHdF6IQAAAAAN0XohDhrOrc", + "user": `{"id":279058397,"first_name":"Vlad"}`, + "auth_date": freshAuthDate(), } initData := buildInitData(params, botToken) // Tamper with the data @@ -83,7 +83,7 @@ func TestValidateInitData(t *testing.T) { t.Run("wrong bot token", func(t *testing.T) { params := map[string]string{ - "auth_date": "1234567890", + "auth_date": freshAuthDate(), } initData := buildInitData(params, botToken) if ValidateInitData(initData, "wrong-token") { @@ -91,8 +91,19 @@ func TestValidateInitData(t *testing.T) { } }) + t.Run("expired auth_date", func(t *testing.T) { + params := map[string]string{ + "auth_date": "1234567890", + } + initData := buildInitData(params, botToken) + if ValidateInitData(initData, botToken) { + t.Error("ValidateInitData() returned true for expired auth_date") + } + }) + t.Run("missing hash", func(t *testing.T) { - if ValidateInitData("auth_date=1234567890", botToken) { + now := freshAuthDate() + if ValidateInitData("auth_date="+now, botToken) { t.Error("ValidateInitData() returned true for missing hash") } }) @@ -186,16 +197,20 @@ func (m *mockSender) SendCommand(senderID, chatID, command string) {} const testBotToken = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" +func freshAuthDate() string { + return strconv.FormatInt(time.Now().Unix(), 10) +} + func testInitData() string { return buildInitData(map[string]string{ "user": `{"id":279058397,"first_name":"Test"}`, - "auth_date": "1234567890", + "auth_date": freshAuthDate(), }, testBotToken) } func TestSSE_AuthRequired(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -210,7 +225,7 @@ func TestSSE_AuthRequired(t *testing.T) { func TestSSE_Headers(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -233,7 +248,7 @@ func TestSSE_Headers(t *testing.T) { func TestSSE_InitialEvents(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -309,7 +324,7 @@ func TestStateNotifier_SubscribeCycleNoLeak(t *testing.T) { func TestSSE_ClientDisconnectCleansUp(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -362,7 +377,7 @@ func TestSSE_ClientDisconnectCleansUp(t *testing.T) { func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) { notifier := NewStateNotifier() provider := &mutatingDataProvider{} - h := NewHandler(provider, &mockSender{}, testBotToken, notifier) + h := NewHandler(provider, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -391,7 +406,7 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) { func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -463,7 +478,7 @@ func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo { // ── Dev proxy tests ── func TestDevProxy_RegisterAndActivate(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") // Initially empty if got := h.GetDevTarget(); got != "" { @@ -502,7 +517,7 @@ func TestDevProxy_RegisterAndActivate(t *testing.T) { } func TestDevProxy_UnregisterActive(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("api", "http://localhost:8080") h.ActivateDevTarget(id) @@ -519,21 +534,21 @@ func TestDevProxy_UnregisterActive(t *testing.T) { } func TestDevProxy_UnregisterNotFound(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") if err := h.UnregisterDevTarget("999"); err == nil { t.Error("expected error for non-existent target") } } func TestDevProxy_ActivateNotFound(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") if err := h.ActivateDevTarget("999"); err == nil { t.Error("expected error for non-existent target") } } func TestDevProxy_LocalhostOnly(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") // External host should be rejected at registration if _, err := h.RegisterDevTarget("ext", "http://example.com:3000"); err == nil { @@ -552,7 +567,7 @@ func TestDevProxy_LocalhostOnly(t *testing.T) { } func TestDevProxy_IPv4Rewrite(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("front", "http://localhost:3000") h.ActivateDevTarget(id) @@ -568,7 +583,7 @@ func TestDevProxy_IPv4Rewrite(t *testing.T) { } func TestDevProxy_ListDevTargets(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") h.RegisterDevTarget("api", "http://localhost:8080") h.RegisterDevTarget("frontend", "http://localhost:3000") @@ -594,7 +609,7 @@ func TestDevProxy_ReverseProxy(t *testing.T) { })) defer backend.Close() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -621,7 +636,7 @@ func TestDevProxy_ReverseProxy(t *testing.T) { } func TestDevProxy_ErrorHandler(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -643,7 +658,7 @@ func TestDevProxy_ErrorHandler(t *testing.T) { } func TestDevProxy_503WhenNotConfigured(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -658,7 +673,7 @@ func TestDevProxy_503WhenNotConfigured(t *testing.T) { func TestDevProxy_APIEndpoint(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -724,7 +739,7 @@ func TestDevProxy_APIEndpoint(t *testing.T) { // ── Registration edge cases ── func TestDevProxy_RegisterUniqueIDs(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id1, _ := h.RegisterDevTarget("a", "http://localhost:3000") id2, _ := h.RegisterDevTarget("b", "http://localhost:3001") @@ -736,7 +751,7 @@ func TestDevProxy_RegisterUniqueIDs(t *testing.T) { } func TestDevProxy_RegisterInvalidURL(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") if _, err := h.RegisterDevTarget("bad", "://not-a-url"); err == nil { t.Error("expected error for malformed URL") @@ -744,7 +759,7 @@ func TestDevProxy_RegisterInvalidURL(t *testing.T) { } func TestDevProxy_RegisterVariousLocalhost(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") cases := []struct { name string @@ -778,7 +793,7 @@ func TestDevProxy_RegisterVariousLocalhost(t *testing.T) { // ── Activation switching ── func TestDevProxy_SwitchActiveTarget(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id1, _ := h.RegisterDevTarget("api", "http://localhost:8080") id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000") @@ -801,7 +816,7 @@ func TestDevProxy_SwitchActiveTarget(t *testing.T) { } func TestDevProxy_ReactivateSameTarget(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("api", "http://localhost:8080") h.ActivateDevTarget(id) @@ -816,7 +831,7 @@ func TestDevProxy_ReactivateSameTarget(t *testing.T) { } func TestDevProxy_ActivateAfterDeactivate(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("api", "http://localhost:8080") h.ActivateDevTarget(id) @@ -834,7 +849,7 @@ func TestDevProxy_ActivateAfterDeactivate(t *testing.T) { // ── Unregister edge cases ── func TestDevProxy_UnregisterInactiveTarget(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id1, _ := h.RegisterDevTarget("api", "http://localhost:8080") id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000") @@ -853,7 +868,7 @@ func TestDevProxy_UnregisterInactiveTarget(t *testing.T) { } func TestDevProxy_UnregisterTwice(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("api", "http://localhost:8080") h.UnregisterDevTarget(id) @@ -866,7 +881,7 @@ func TestDevProxy_UnregisterTwice(t *testing.T) { // ── IPv4 rewrite edge cases ── func TestDevProxy_IPv4NoRewriteFor127(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("api", "http://127.0.0.1:8080") h.ActivateDevTarget(id) @@ -878,7 +893,7 @@ func TestDevProxy_IPv4NoRewriteFor127(t *testing.T) { } func TestDevProxy_IPv4NoRewriteForIPv6(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("api", "http://[::1]:9000") h.ActivateDevTarget(id) @@ -891,7 +906,7 @@ func TestDevProxy_IPv4NoRewriteForIPv6(t *testing.T) { } func TestDevProxy_IPv4RewriteLocalhostNoPort(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("api", "http://localhost") h.ActivateDevTarget(id) @@ -908,7 +923,7 @@ func TestDevProxy_IPv4RewriteLocalhostNoPort(t *testing.T) { // ── devStatus ── func TestDevProxy_DevStatusEmpty(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") status := h.devStatus() if status["active"] != false { @@ -927,7 +942,7 @@ func TestDevProxy_DevStatusEmpty(t *testing.T) { } func TestDevProxy_DevStatusTargetsButNoActive(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") h.RegisterDevTarget("api", "http://localhost:8080") h.RegisterDevTarget("frontend", "http://localhost:3000") @@ -943,7 +958,7 @@ func TestDevProxy_DevStatusTargetsButNoActive(t *testing.T) { } func TestDevProxy_DevStatusReturnsOriginalURL(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id, _ := h.RegisterDevTarget("front", "http://localhost:3000") h.ActivateDevTarget(id) @@ -956,7 +971,7 @@ func TestDevProxy_DevStatusReturnsOriginalURL(t *testing.T) { } func TestDevProxy_DevStatusActiveID(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id1, _ := h.RegisterDevTarget("api", "http://localhost:8080") id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000") @@ -986,7 +1001,7 @@ func TestDevProxy_DevStatusActiveID(t *testing.T) { // ── ListDevTargets ── func TestDevProxy_ListDevTargetsEmpty(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") targets := h.ListDevTargets() if len(targets) != 0 { t.Errorf("expected 0, got %d", len(targets)) @@ -994,7 +1009,7 @@ func TestDevProxy_ListDevTargetsEmpty(t *testing.T) { } func TestDevProxy_ListDevTargetsStableOrder(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") // Register in reverse order of expected sort h.RegisterDevTarget("c", "http://localhost:3003") @@ -1010,7 +1025,7 @@ func TestDevProxy_ListDevTargetsStableOrder(t *testing.T) { } func TestDevProxy_ListDevTargetsAfterUnregister(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id1, _ := h.RegisterDevTarget("a", "http://localhost:3001") h.RegisterDevTarget("b", "http://localhost:3002") @@ -1035,7 +1050,7 @@ func TestDevProxy_PathStripping(t *testing.T) { })) defer backend.Close() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) id, _ := h.RegisterDevTarget("back", backend.URL) @@ -1062,7 +1077,7 @@ func TestDevProxy_PathStripping(t *testing.T) { } func TestDevProxy_RootPathStripRedirect(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) id, _ := h.RegisterDevTarget("back", "http://127.0.0.1:19999") @@ -1080,7 +1095,7 @@ func TestDevProxy_RootPathStripRedirect(t *testing.T) { // ── ErrorHandler details ── func TestDevProxy_ErrorHandlerHTMLContent(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -1114,7 +1129,7 @@ func TestDevProxy_NotifierTriggeredOnRegister(t *testing.T) { notifier := NewStateNotifier() ch := notifier.Subscribe() defer notifier.Unsubscribe(ch) - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") h.RegisterDevTarget("api", "http://localhost:8080") @@ -1127,7 +1142,7 @@ func TestDevProxy_NotifierTriggeredOnRegister(t *testing.T) { func TestDevProxy_NotifierTriggeredOnUnregister(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") id, _ := h.RegisterDevTarget("api", "http://localhost:8080") ch := notifier.Subscribe() @@ -1144,7 +1159,7 @@ func TestDevProxy_NotifierTriggeredOnUnregister(t *testing.T) { func TestDevProxy_NotifierTriggeredOnActivate(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") id, _ := h.RegisterDevTarget("api", "http://localhost:8080") ch := notifier.Subscribe() @@ -1161,7 +1176,7 @@ func TestDevProxy_NotifierTriggeredOnActivate(t *testing.T) { func TestDevProxy_NotifierTriggeredOnDeactivate(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") id, _ := h.RegisterDevTarget("api", "http://localhost:8080") h.ActivateDevTarget(id) @@ -1180,7 +1195,7 @@ func TestDevProxy_NotifierTriggeredOnDeactivate(t *testing.T) { // ── API endpoint edge cases ── func TestDevAPI_InvalidJSON(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1199,7 +1214,7 @@ func TestDevAPI_InvalidJSON(t *testing.T) { } func TestDevAPI_UnknownAction(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1220,7 +1235,7 @@ func TestDevAPI_UnknownAction(t *testing.T) { } func TestDevAPI_ActivateMissingID(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1241,7 +1256,7 @@ func TestDevAPI_ActivateMissingID(t *testing.T) { } func TestDevAPI_ActivateNonExistentID(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1262,7 +1277,7 @@ func TestDevAPI_ActivateNonExistentID(t *testing.T) { } func TestDevAPI_MethodNotAllowed(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1281,7 +1296,7 @@ func TestDevAPI_MethodNotAllowed(t *testing.T) { func TestDevAPI_GetReturnsTargetsArray(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1314,7 +1329,7 @@ func TestDevAPI_GetReturnsTargetsArray(t *testing.T) { } func TestDevAPI_DeactivateWhenAlreadyInactive(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1342,7 +1357,7 @@ func TestDevAPI_DeactivateWhenAlreadyInactive(t *testing.T) { // ── Concurrency ── func TestDevProxy_ConcurrentRegisterActivate(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") const n = 50 done := make(chan struct{}, n) @@ -1373,7 +1388,7 @@ func TestDevProxy_ConcurrentRegisterActivate(t *testing.T) { } func TestDevProxy_ConcurrentActivateDeactivate(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") id1, _ := h.RegisterDevTarget("api", "http://localhost:8080") id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000") @@ -1407,7 +1422,7 @@ func TestDevProxy_ConcurrentActivateDeactivate(t *testing.T) { func TestSSE_DevEventContainsTargets(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -1518,7 +1533,7 @@ func TestEscapeHTMLString(t *testing.T) { // ── Handler implements DevTargetManager ── func TestHandler_ImplementsDevTargetManager(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") var _ DevTargetManager = h // compile-time check } @@ -1558,7 +1573,7 @@ func TestDevProxy_FullLifecycle(t *testing.T) { })) defer backend2.Close() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -1648,7 +1663,7 @@ func TestDevProxy_FullLifecycle(t *testing.T) { func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) { notifier := NewStateNotifier() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1684,7 +1699,7 @@ func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) { // ── API auth on /miniapp/api/dev ── func TestDevAPI_AuthRequired(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -1714,7 +1729,7 @@ func TestDevProxy_NoAuthRequired(t *testing.T) { })) defer backend.Close() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) id, _ := h.RegisterDevTarget("back", backend.URL) @@ -1735,7 +1750,7 @@ func TestDevProxy_NoAuthRequired(t *testing.T) { // ── API response JSON structure ── func TestDevAPI_ResponseTargetFields(t *testing.T) { - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) ts := httptest.NewServer(mux) @@ -1798,7 +1813,7 @@ func TestDevProxy_HostHeaderForwarded(t *testing.T) { })) defer backend.Close() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) id, _ := h.RegisterDevTarget("back", backend.URL) @@ -1920,7 +1935,7 @@ func TestDevProxy_ResponseRewriting(t *testing.T) { })) defer backend.Close() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -1958,7 +1973,7 @@ func TestDevProxy_ResponseRewriting_NonHTML(t *testing.T) { })) defer backend.Close() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) @@ -1989,7 +2004,7 @@ func TestDevProxy_ResponseRewriting_ContentLength(t *testing.T) { })) defer backend.Close() - h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier()) + h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "") mux := http.NewServeMux() h.RegisterRoutes(mux) diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 583b38efe..866075467 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -705,6 +705,68 @@ background: rgba(255, 59, 48, 0.1); } + /* Log viewer */ + .log-filter-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; } + .log-filter-chip { + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 500; + border: 1px solid var(--glass-border-interactive); + background: var(--glass-bg); + color: var(--hint); + cursor: pointer; + transition: all 0.2s; + -webkit-tap-highlight-color: transparent; + } + .log-filter-chip.active { + background: var(--btn); + color: var(--btn-text); + border-color: var(--btn); + } + #logs-content { + max-height: 50vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + } + .log-entry { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 3px 0; + font-size: 11px; + font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; + line-height: 1.4; + border-bottom: 1px solid var(--glass-divider); + } + .log-ts { color: var(--hint); flex-shrink: 0; white-space: nowrap; } + .log-badge { + flex-shrink: 0; + padding: 0 4px; + border-radius: 4px; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + line-height: 16px; + } + .log-badge.error { background: rgba(255,59,48,0.15); color: #ff3b30; } + .log-badge.warn { background: rgba(255,204,0,0.15); color: #cc9900; } + .log-badge.info { background: rgba(52,199,89,0.12); color: #34c759; } + .log-badge.debug { background: rgba(142,142,147,0.12); color: #8e8e93; } + .log-comp { color: var(--link); flex-shrink: 0; font-size: 10px; } + .log-msg { flex: 1; word-break: break-all; color: var(--text); } + .log-actions { display: flex; gap: 8px; align-items: center; margin-top: 8px; } + .log-snap-btn { + padding: 6px 12px; + border-radius: 10px; + font-size: 12px; + font-weight: 500; + background: var(--glass-bg); + border: 1px solid var(--glass-border-interactive); + color: var(--text); + cursor: pointer; + } + @@ -752,6 +814,21 @@ +
+
+ Logs + +
+
+ + + +
+
+
+ +
+
@@ -813,6 +890,8 @@ tabs.forEach((tab, index) => { if (p === 'session' && !fresh) loadSession(); if (p === 'git') loadGit(); if (p === 'dev' && !fresh) loadDev(); + if (p === 'config') connectLogsWs(); + else disconnectLogsWs(); }); }); @@ -1452,6 +1531,111 @@ connectSSE(); // Initial load (fallback for tabs not covered by initial SSE burst) loadPlan(); + +// ── Logs WebSocket ── +var logsWs = null; +var logsComponent = ''; +var logsEntries = []; +var logsReconnectTimer = null; + +function connectLogsWs() { + if (logsWs && logsWs.readyState <= 1) return; + var wsProto = (location.protocol === 'https:') ? 'wss:' : 'ws:'; + var wsUrl = wsProto + '//' + location.host + '/miniapp/api/logs/ws?initData=' + + encodeURIComponent(initData); + if (logsComponent) wsUrl += '&component=' + encodeURIComponent(logsComponent); + + logsWs = new WebSocket(wsUrl); + var statusDot = document.getElementById('logs-status'); + + logsWs.onopen = function() { + statusDot.classList.add('on'); + }; + + logsWs.onmessage = function(e) { + var msg = JSON.parse(e.data); + if (msg.type === 'init') { + logsEntries = msg.entries || []; + } else if (msg.type === 'entry') { + logsEntries.push(msg.entry); + if (logsEntries.length > 200) logsEntries.shift(); + } + renderLogs(); + }; + + logsWs.onclose = function() { + statusDot.classList.remove('on'); + logsWs = null; + // Auto-reconnect if Config tab is active + var activeTab = document.querySelector('.tab.active'); + if (activeTab && activeTab.dataset.panel === 'config') { + logsReconnectTimer = setTimeout(connectLogsWs, 3000); + } + }; + + logsWs.onerror = function() { + // onclose will handle reconnect + }; +} + +function disconnectLogsWs() { + if (logsReconnectTimer) { clearTimeout(logsReconnectTimer); logsReconnectTimer = null; } + if (logsWs) { logsWs.close(); logsWs = null; } + document.getElementById('logs-status').classList.remove('on'); +} + +function renderLogs() { + var container = document.getElementById('logs-content'); + var wasScrolledToBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 30; + var html = ''; + for (var i = 0; i < logsEntries.length; i++) { + var e = logsEntries[i]; + var lvl = (e.level || 'info').toLowerCase(); + var ts = e.timestamp ? e.timestamp.substring(11, 19) : ''; + var comp = e.component ? '' + escapeHtml(e.component) + '' : ''; + html += '
' + + '' + ts + '' + + '' + lvl + '' + + comp + + '' + escapeHtml(e.message || '') + '' + + '
'; + } + container.innerHTML = html; + if (wasScrolledToBottom) container.scrollTop = container.scrollHeight; +} + +// Filter chip click +document.querySelector('.log-filter-chips').addEventListener('click', function(e) { + var chip = e.target.closest('.log-filter-chip'); + if (!chip) return; + document.querySelectorAll('.log-filter-chip').forEach(function(c) { c.classList.remove('active'); }); + chip.classList.add('active'); + logsComponent = chip.dataset.component || ''; + logsEntries = []; + renderLogs(); + disconnectLogsWs(); + connectLogsWs(); +}); + +async function saveLogSnapshot() { + try { + var res = await fetch(API_BASE + '/miniapp/api/logs/snapshot?initData=' + encodeURIComponent(initData), { + method: 'POST' + }); + if (!res.ok) throw new Error('API error: ' + res.status); + var data = await res.json(); + if (data.download_url) { + var a = document.createElement('a'); + a.href = API_BASE + data.download_url + '?initData=' + encodeURIComponent(initData); + a.download = ''; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + } catch(e) { + // silent fail + } +}