feat: add real-time log viewer to miniapp Config tab
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 <noreply@anthropic.com>
This commit is contained in:
parent
792fcd9731
commit
346b3dcd35
11 changed files with 1026 additions and 79 deletions
19
CLAUDE.md
Normal file
19
CLAUDE.md
Normal file
|
|
@ -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).
|
||||||
|
|
@ -277,6 +277,11 @@ Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話でき
|
||||||
```bash
|
```bash
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Mini App(ダッシュボード)
|
||||||
|
|
||||||
|
PicoClaw には Telegram Mini App が含まれており、チャット内から GUI ダッシュボードを利用できます。Config タブにはリアルタイムの **ログビューアー** があり、WebSocket で 3 種類のログストリーム(picoclaw / telego / dev-console)を表示します。コンポーネントフィルタ、スナップショット保存(tar.gz、14日後自動削除)にも対応しています。
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
12
README.md
12
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 |
|
| **Plan** | View plan phases/steps as a checklist, tap to mark done, start new plans |
|
||||||
| **Skills** | Browse and invoke skills with a message input |
|
| **Skills** | Browse and invoke skills with a message input |
|
||||||
| **Session** | View active sessions and token usage stats (requires `--stats` flag for stats) |
|
| **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):**
|
**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).
|
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.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
|
|
|
||||||
|
|
@ -298,6 +298,8 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
|
||||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
|
||||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/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` 后,日志查看器仅限白名单用户使用。
|
||||||
|
|
||||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||||
|
|
||||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||||
|
|
|
||||||
|
|
@ -235,7 +235,7 @@ func gatewayCmd() {
|
||||||
provider := &agentLoopDataProvider{loop: agentLoop, workspace: cfg.WorkspacePath()}
|
provider := &agentLoopDataProvider{loop: agentLoop, workspace: cfg.WorkspacePath()}
|
||||||
sender := &telegramCommandSender{bus: msgBus}
|
sender := &telegramCommandSender{bus: msgBus}
|
||||||
miniappNotifier = miniapp.NewStateNotifier()
|
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
|
agentLoop.OnStateChange = miniappNotifier.Notify
|
||||||
handler.RegisterRoutes(healthServer.Mux())
|
handler.RegisterRoutes(healthServer.Mux())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 telegramMaxMessageChars = 3900
|
||||||
const markdownTableMaxWidth = 42
|
const markdownTableMaxWidth = 42
|
||||||
const markdownTableMinColWidth = 6
|
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...)
|
bot, err := telego.NewBot(telegramCfg.Token, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,74 @@ var (
|
||||||
FATAL: "FATAL",
|
FATAL: "FATAL",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
levelFromName = map[string]LogLevel{
|
||||||
|
"DEBUG": DEBUG,
|
||||||
|
"INFO": INFO,
|
||||||
|
"WARN": WARN,
|
||||||
|
"ERROR": ERROR,
|
||||||
|
"FATAL": FATAL,
|
||||||
|
}
|
||||||
|
|
||||||
currentLevel = INFO
|
currentLevel = INFO
|
||||||
logger *Logger
|
logger *Logger
|
||||||
once sync.Once
|
once sync.Once
|
||||||
mu sync.RWMutex
|
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 {
|
type Logger struct {
|
||||||
file *os.File
|
file *os.File
|
||||||
}
|
}
|
||||||
|
|
@ -52,6 +114,7 @@ type LogEntry struct {
|
||||||
func init() {
|
func init() {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
logger = &Logger{}
|
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 {
|
if logger.file != nil {
|
||||||
jsonData, err := json.Marshal(entry)
|
jsonData, err := json.Marshal(entry)
|
||||||
if err == nil {
|
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) {
|
func FatalCF(component string, message string, fields map[string]any) {
|
||||||
logMessage(FATAL, component, message, fields)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLogLevelFiltering(t *testing.T) {
|
func TestLogLevelFiltering(t *testing.T) {
|
||||||
|
|
@ -137,3 +138,178 @@ func TestLoggerHelperFunctions(t *testing.T) {
|
||||||
DebugC("test", "Debug with component")
|
DebugC("test", "Debug with component")
|
||||||
WarnF("Warning with fields", map[string]any{"key": "value"})
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package miniapp
|
package miniapp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"embed"
|
"embed"
|
||||||
|
|
@ -13,12 +15,16 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/stats"
|
"github.com/sipeed/picoclaw/pkg/stats"
|
||||||
)
|
)
|
||||||
|
|
@ -180,10 +186,12 @@ type DevTargetManager interface {
|
||||||
|
|
||||||
// Handler serves the Mini App HTML and API endpoints.
|
// Handler serves the Mini App HTML and API endpoints.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
provider DataProvider
|
provider DataProvider
|
||||||
sender CommandSender
|
sender CommandSender
|
||||||
botToken string
|
botToken string
|
||||||
notifier *StateNotifier
|
notifier *StateNotifier
|
||||||
|
allowList []string
|
||||||
|
workspace string
|
||||||
|
|
||||||
devMu sync.RWMutex
|
devMu sync.RWMutex
|
||||||
devTarget *url.URL
|
devTarget *url.URL
|
||||||
|
|
@ -191,15 +199,44 @@ type Handler struct {
|
||||||
devTargets map[string]*DevTarget // registered targets (ID→DevTarget)
|
devTargets map[string]*DevTarget // registered targets (ID→DevTarget)
|
||||||
devNextID int
|
devNextID int
|
||||||
devActiveID string
|
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.
|
// 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{
|
return &Handler{
|
||||||
provider: provider,
|
provider: provider,
|
||||||
sender: sender,
|
sender: sender,
|
||||||
botToken: botToken,
|
botToken: botToken,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
|
allowList: allowList,
|
||||||
|
workspace: workspace,
|
||||||
devTargets: make(map[string]*DevTarget),
|
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.
|
// devProxyScript is the JavaScript injected into HTML responses from the dev proxy.
|
||||||
// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like
|
// It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like
|
||||||
// "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount.
|
// "/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 = `<script data-dev-proxy>
|
const devProxyScript = `<script data-dev-proxy>
|
||||||
(function(){
|
(function(){
|
||||||
var B='/miniapp/dev';
|
var B='/miniapp/dev';
|
||||||
|
|
@ -382,6 +420,30 @@ const devProxyScript = `<script data-dev-proxy>
|
||||||
arguments[1]=rw(u);
|
arguments[1]=rw(u);
|
||||||
return _o.apply(this,arguments);
|
return _o.apply(this,arguments);
|
||||||
};
|
};
|
||||||
|
// Console capture: batch POST to /miniapp/dev/console
|
||||||
|
var _cl=console.log,_cw=console.warn,_ce=console.error,_ci=console.info;
|
||||||
|
var _buf=[],_timer=null;
|
||||||
|
function _flush(){
|
||||||
|
_timer=null;
|
||||||
|
if(!_buf.length)return;
|
||||||
|
var batch=_buf.splice(0,20);
|
||||||
|
try{navigator.sendBeacon('/miniapp/dev/console',JSON.stringify(batch));}catch(e){}
|
||||||
|
}
|
||||||
|
function _cap(level,args){
|
||||||
|
var msg=Array.prototype.map.call(args,function(a){
|
||||||
|
try{return typeof a==='object'?JSON.stringify(a):String(a);}catch(e){return String(a);}
|
||||||
|
}).join(' ');
|
||||||
|
if(msg.length>1024)msg=msg.substring(0,1024);
|
||||||
|
_buf.push({level:level,message:msg,timestamp:new Date().toISOString()});
|
||||||
|
if(_buf.length>=20){if(_timer){clearTimeout(_timer);_timer=null;}_flush();}
|
||||||
|
else if(!_timer){_timer=setTimeout(_flush,500);}
|
||||||
|
}
|
||||||
|
console.log=function(){_cap('log',arguments);_cl.apply(console,arguments);};
|
||||||
|
console.warn=function(){_cap('warn',arguments);_cw.apply(console,arguments);};
|
||||||
|
console.error=function(){_cap('error',arguments);_ce.apply(console,arguments);};
|
||||||
|
console.info=function(){_cap('info',arguments);_ci.apply(console,arguments);};
|
||||||
|
window.onerror=function(m,s,l,c,e){_cap('error',[m,'at',s+':'+l+':'+c]);};
|
||||||
|
window.onunhandledrejection=function(e){_cap('error',['Unhandled rejection:',e.reason]);};
|
||||||
})();
|
})();
|
||||||
</script>`
|
</script>`
|
||||||
|
|
||||||
|
|
@ -441,6 +503,10 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||||
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
mux.HandleFunc("/miniapp/api/git", h.requireAuth(h.apiGit))
|
||||||
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
mux.HandleFunc("/miniapp/api/dev", h.requireAuth(h.apiDev))
|
||||||
mux.HandleFunc("/miniapp/api/events", h.requireAuth(h.apiEvents))
|
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)
|
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)
|
http.Error(w, `{"error":"invalid initData"}`, http.StatusUnauthorized)
|
||||||
return
|
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)
|
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) {
|
func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) {
|
||||||
skillsList := h.provider.ListSkills()
|
skillsList := h.provider.ListSkills()
|
||||||
writeJSON(w, skillsList)
|
writeJSON(w, skillsList)
|
||||||
|
|
@ -716,7 +808,282 @@ func writeJSON(w http.ResponseWriter, v any) {
|
||||||
json.NewEncoder(w).Encode(v)
|
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
|
// See https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||||
func ValidateInitData(initData, botToken string) bool {
|
func ValidateInitData(initData, botToken string) bool {
|
||||||
values, err := url.ParseQuery(initData)
|
values, err := url.ParseQuery(initData)
|
||||||
|
|
@ -729,6 +1096,17 @@ func ValidateInitData(initData, botToken string) bool {
|
||||||
return false
|
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",
|
// Build the data-check-string: sort all key=value pairs except "hash",
|
||||||
// join with newlines.
|
// join with newlines.
|
||||||
var pairs []string
|
var pairs []string
|
||||||
|
|
|
||||||
|
|
@ -57,9 +57,9 @@ func TestValidateInitData(t *testing.T) {
|
||||||
|
|
||||||
t.Run("valid initData", func(t *testing.T) {
|
t.Run("valid initData", func(t *testing.T) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"query_id": "AAHdF6IQAAAAAN0XohDhrOrc",
|
"query_id": "AAHdF6IQAAAAAN0XohDhrOrc",
|
||||||
"user": `{"id":279058397,"first_name":"Vlad"}`,
|
"user": `{"id":279058397,"first_name":"Vlad"}`,
|
||||||
"auth_date": "1234567890",
|
"auth_date": freshAuthDate(),
|
||||||
}
|
}
|
||||||
initData := buildInitData(params, botToken)
|
initData := buildInitData(params, botToken)
|
||||||
if !ValidateInitData(initData, botToken) {
|
if !ValidateInitData(initData, botToken) {
|
||||||
|
|
@ -69,9 +69,9 @@ func TestValidateInitData(t *testing.T) {
|
||||||
|
|
||||||
t.Run("tampered data", func(t *testing.T) {
|
t.Run("tampered data", func(t *testing.T) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"query_id": "AAHdF6IQAAAAAN0XohDhrOrc",
|
"query_id": "AAHdF6IQAAAAAN0XohDhrOrc",
|
||||||
"user": `{"id":279058397,"first_name":"Vlad"}`,
|
"user": `{"id":279058397,"first_name":"Vlad"}`,
|
||||||
"auth_date": "1234567890",
|
"auth_date": freshAuthDate(),
|
||||||
}
|
}
|
||||||
initData := buildInitData(params, botToken)
|
initData := buildInitData(params, botToken)
|
||||||
// Tamper with the data
|
// Tamper with the data
|
||||||
|
|
@ -83,7 +83,7 @@ func TestValidateInitData(t *testing.T) {
|
||||||
|
|
||||||
t.Run("wrong bot token", func(t *testing.T) {
|
t.Run("wrong bot token", func(t *testing.T) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"auth_date": "1234567890",
|
"auth_date": freshAuthDate(),
|
||||||
}
|
}
|
||||||
initData := buildInitData(params, botToken)
|
initData := buildInitData(params, botToken)
|
||||||
if ValidateInitData(initData, "wrong-token") {
|
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) {
|
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")
|
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"
|
const testBotToken = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||||
|
|
||||||
|
func freshAuthDate() string {
|
||||||
|
return strconv.FormatInt(time.Now().Unix(), 10)
|
||||||
|
}
|
||||||
|
|
||||||
func testInitData() string {
|
func testInitData() string {
|
||||||
return buildInitData(map[string]string{
|
return buildInitData(map[string]string{
|
||||||
"user": `{"id":279058397,"first_name":"Test"}`,
|
"user": `{"id":279058397,"first_name":"Test"}`,
|
||||||
"auth_date": "1234567890",
|
"auth_date": freshAuthDate(),
|
||||||
}, testBotToken)
|
}, testBotToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSSE_AuthRequired(t *testing.T) {
|
func TestSSE_AuthRequired(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -210,7 +225,7 @@ func TestSSE_AuthRequired(t *testing.T) {
|
||||||
|
|
||||||
func TestSSE_Headers(t *testing.T) {
|
func TestSSE_Headers(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -233,7 +248,7 @@ func TestSSE_Headers(t *testing.T) {
|
||||||
|
|
||||||
func TestSSE_InitialEvents(t *testing.T) {
|
func TestSSE_InitialEvents(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -309,7 +324,7 @@ func TestStateNotifier_SubscribeCycleNoLeak(t *testing.T) {
|
||||||
|
|
||||||
func TestSSE_ClientDisconnectCleansUp(t *testing.T) {
|
func TestSSE_ClientDisconnectCleansUp(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -362,7 +377,7 @@ func TestSSE_ClientDisconnectCleansUp(t *testing.T) {
|
||||||
func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
provider := &mutatingDataProvider{}
|
provider := &mutatingDataProvider{}
|
||||||
h := NewHandler(provider, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(provider, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -391,7 +406,7 @@ func TestSSE_NotifyDrivesSubsequentEvents(t *testing.T) {
|
||||||
|
|
||||||
func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
|
func TestSSE_DiffDedupSuppressesDuplicate(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -463,7 +478,7 @@ func (m *mutatingDataProvider) GetGitRepoDetail(name string) GitInfo {
|
||||||
// ── Dev proxy tests ──
|
// ── Dev proxy tests ──
|
||||||
|
|
||||||
func TestDevProxy_RegisterAndActivate(t *testing.T) {
|
func TestDevProxy_RegisterAndActivate(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
|
|
||||||
// Initially empty
|
// Initially empty
|
||||||
if got := h.GetDevTarget(); got != "" {
|
if got := h.GetDevTarget(); got != "" {
|
||||||
|
|
@ -502,7 +517,7 @@ func TestDevProxy_RegisterAndActivate(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_UnregisterActive(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")
|
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
@ -519,21 +534,21 @@ func TestDevProxy_UnregisterActive(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_UnregisterNotFound(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 {
|
if err := h.UnregisterDevTarget("999"); err == nil {
|
||||||
t.Error("expected error for non-existent target")
|
t.Error("expected error for non-existent target")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_ActivateNotFound(t *testing.T) {
|
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 {
|
if err := h.ActivateDevTarget("999"); err == nil {
|
||||||
t.Error("expected error for non-existent target")
|
t.Error("expected error for non-existent target")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_LocalhostOnly(t *testing.T) {
|
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
|
// External host should be rejected at registration
|
||||||
if _, err := h.RegisterDevTarget("ext", "http://example.com:3000"); err == nil {
|
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) {
|
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")
|
id, _ := h.RegisterDevTarget("front", "http://localhost:3000")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
@ -568,7 +583,7 @@ func TestDevProxy_IPv4Rewrite(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_ListDevTargets(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("api", "http://localhost:8080")
|
||||||
h.RegisterDevTarget("frontend", "http://localhost:3000")
|
h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||||
|
|
@ -594,7 +609,7 @@ func TestDevProxy_ReverseProxy(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer backend.Close()
|
defer backend.Close()
|
||||||
|
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -621,7 +636,7 @@ func TestDevProxy_ReverseProxy(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_ErrorHandler(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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -643,7 +658,7 @@ func TestDevProxy_ErrorHandler(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_503WhenNotConfigured(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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -658,7 +673,7 @@ func TestDevProxy_503WhenNotConfigured(t *testing.T) {
|
||||||
|
|
||||||
func TestDevProxy_APIEndpoint(t *testing.T) {
|
func TestDevProxy_APIEndpoint(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -724,7 +739,7 @@ func TestDevProxy_APIEndpoint(t *testing.T) {
|
||||||
// ── Registration edge cases ──
|
// ── Registration edge cases ──
|
||||||
|
|
||||||
func TestDevProxy_RegisterUniqueIDs(t *testing.T) {
|
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")
|
id1, _ := h.RegisterDevTarget("a", "http://localhost:3000")
|
||||||
id2, _ := h.RegisterDevTarget("b", "http://localhost:3001")
|
id2, _ := h.RegisterDevTarget("b", "http://localhost:3001")
|
||||||
|
|
@ -736,7 +751,7 @@ func TestDevProxy_RegisterUniqueIDs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_RegisterInvalidURL(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 {
|
if _, err := h.RegisterDevTarget("bad", "://not-a-url"); err == nil {
|
||||||
t.Error("expected error for malformed URL")
|
t.Error("expected error for malformed URL")
|
||||||
|
|
@ -744,7 +759,7 @@ func TestDevProxy_RegisterInvalidURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_RegisterVariousLocalhost(t *testing.T) {
|
func TestDevProxy_RegisterVariousLocalhost(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -778,7 +793,7 @@ func TestDevProxy_RegisterVariousLocalhost(t *testing.T) {
|
||||||
// ── Activation switching ──
|
// ── Activation switching ──
|
||||||
|
|
||||||
func TestDevProxy_SwitchActiveTarget(t *testing.T) {
|
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")
|
id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||||
|
|
@ -801,7 +816,7 @@ func TestDevProxy_SwitchActiveTarget(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_ReactivateSameTarget(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")
|
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
@ -816,7 +831,7 @@ func TestDevProxy_ReactivateSameTarget(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_ActivateAfterDeactivate(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")
|
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
@ -834,7 +849,7 @@ func TestDevProxy_ActivateAfterDeactivate(t *testing.T) {
|
||||||
// ── Unregister edge cases ──
|
// ── Unregister edge cases ──
|
||||||
|
|
||||||
func TestDevProxy_UnregisterInactiveTarget(t *testing.T) {
|
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")
|
id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||||
|
|
@ -853,7 +868,7 @@ func TestDevProxy_UnregisterInactiveTarget(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_UnregisterTwice(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")
|
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
h.UnregisterDevTarget(id)
|
h.UnregisterDevTarget(id)
|
||||||
|
|
@ -866,7 +881,7 @@ func TestDevProxy_UnregisterTwice(t *testing.T) {
|
||||||
// ── IPv4 rewrite edge cases ──
|
// ── IPv4 rewrite edge cases ──
|
||||||
|
|
||||||
func TestDevProxy_IPv4NoRewriteFor127(t *testing.T) {
|
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")
|
id, _ := h.RegisterDevTarget("api", "http://127.0.0.1:8080")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
@ -878,7 +893,7 @@ func TestDevProxy_IPv4NoRewriteFor127(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_IPv4NoRewriteForIPv6(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")
|
id, _ := h.RegisterDevTarget("api", "http://[::1]:9000")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
@ -891,7 +906,7 @@ func TestDevProxy_IPv4NoRewriteForIPv6(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_IPv4RewriteLocalhostNoPort(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")
|
id, _ := h.RegisterDevTarget("api", "http://localhost")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
@ -908,7 +923,7 @@ func TestDevProxy_IPv4RewriteLocalhostNoPort(t *testing.T) {
|
||||||
// ── devStatus ──
|
// ── devStatus ──
|
||||||
|
|
||||||
func TestDevProxy_DevStatusEmpty(t *testing.T) {
|
func TestDevProxy_DevStatusEmpty(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
|
|
||||||
status := h.devStatus()
|
status := h.devStatus()
|
||||||
if status["active"] != false {
|
if status["active"] != false {
|
||||||
|
|
@ -927,7 +942,7 @@ func TestDevProxy_DevStatusEmpty(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_DevStatusTargetsButNoActive(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("api", "http://localhost:8080")
|
||||||
h.RegisterDevTarget("frontend", "http://localhost:3000")
|
h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||||
|
|
@ -943,7 +958,7 @@ func TestDevProxy_DevStatusTargetsButNoActive(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_DevStatusReturnsOriginalURL(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")
|
id, _ := h.RegisterDevTarget("front", "http://localhost:3000")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
@ -956,7 +971,7 @@ func TestDevProxy_DevStatusReturnsOriginalURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_DevStatusActiveID(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")
|
id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||||
|
|
@ -986,7 +1001,7 @@ func TestDevProxy_DevStatusActiveID(t *testing.T) {
|
||||||
// ── ListDevTargets ──
|
// ── ListDevTargets ──
|
||||||
|
|
||||||
func TestDevProxy_ListDevTargetsEmpty(t *testing.T) {
|
func TestDevProxy_ListDevTargetsEmpty(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
targets := h.ListDevTargets()
|
targets := h.ListDevTargets()
|
||||||
if len(targets) != 0 {
|
if len(targets) != 0 {
|
||||||
t.Errorf("expected 0, got %d", len(targets))
|
t.Errorf("expected 0, got %d", len(targets))
|
||||||
|
|
@ -994,7 +1009,7 @@ func TestDevProxy_ListDevTargetsEmpty(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_ListDevTargetsStableOrder(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
|
// Register in reverse order of expected sort
|
||||||
h.RegisterDevTarget("c", "http://localhost:3003")
|
h.RegisterDevTarget("c", "http://localhost:3003")
|
||||||
|
|
@ -1010,7 +1025,7 @@ func TestDevProxy_ListDevTargetsStableOrder(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_ListDevTargetsAfterUnregister(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")
|
id1, _ := h.RegisterDevTarget("a", "http://localhost:3001")
|
||||||
h.RegisterDevTarget("b", "http://localhost:3002")
|
h.RegisterDevTarget("b", "http://localhost:3002")
|
||||||
|
|
@ -1035,7 +1050,7 @@ func TestDevProxy_PathStripping(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer backend.Close()
|
defer backend.Close()
|
||||||
|
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
id, _ := h.RegisterDevTarget("back", backend.URL)
|
id, _ := h.RegisterDevTarget("back", backend.URL)
|
||||||
|
|
@ -1062,7 +1077,7 @@ func TestDevProxy_PathStripping(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_RootPathStripRedirect(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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
id, _ := h.RegisterDevTarget("back", "http://127.0.0.1:19999")
|
id, _ := h.RegisterDevTarget("back", "http://127.0.0.1:19999")
|
||||||
|
|
@ -1080,7 +1095,7 @@ func TestDevProxy_RootPathStripRedirect(t *testing.T) {
|
||||||
// ── ErrorHandler details ──
|
// ── ErrorHandler details ──
|
||||||
|
|
||||||
func TestDevProxy_ErrorHandlerHTMLContent(t *testing.T) {
|
func TestDevProxy_ErrorHandlerHTMLContent(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -1114,7 +1129,7 @@ func TestDevProxy_NotifierTriggeredOnRegister(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
ch := notifier.Subscribe()
|
ch := notifier.Subscribe()
|
||||||
defer notifier.Unsubscribe(ch)
|
defer notifier.Unsubscribe(ch)
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
|
|
||||||
h.RegisterDevTarget("api", "http://localhost:8080")
|
h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
|
|
||||||
|
|
@ -1127,7 +1142,7 @@ func TestDevProxy_NotifierTriggeredOnRegister(t *testing.T) {
|
||||||
|
|
||||||
func TestDevProxy_NotifierTriggeredOnUnregister(t *testing.T) {
|
func TestDevProxy_NotifierTriggeredOnUnregister(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
|
|
||||||
ch := notifier.Subscribe()
|
ch := notifier.Subscribe()
|
||||||
|
|
@ -1144,7 +1159,7 @@ func TestDevProxy_NotifierTriggeredOnUnregister(t *testing.T) {
|
||||||
|
|
||||||
func TestDevProxy_NotifierTriggeredOnActivate(t *testing.T) {
|
func TestDevProxy_NotifierTriggeredOnActivate(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
|
|
||||||
ch := notifier.Subscribe()
|
ch := notifier.Subscribe()
|
||||||
|
|
@ -1161,7 +1176,7 @@ func TestDevProxy_NotifierTriggeredOnActivate(t *testing.T) {
|
||||||
|
|
||||||
func TestDevProxy_NotifierTriggeredOnDeactivate(t *testing.T) {
|
func TestDevProxy_NotifierTriggeredOnDeactivate(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
id, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
h.ActivateDevTarget(id)
|
h.ActivateDevTarget(id)
|
||||||
|
|
||||||
|
|
@ -1180,7 +1195,7 @@ func TestDevProxy_NotifierTriggeredOnDeactivate(t *testing.T) {
|
||||||
// ── API endpoint edge cases ──
|
// ── API endpoint edge cases ──
|
||||||
|
|
||||||
func TestDevAPI_InvalidJSON(t *testing.T) {
|
func TestDevAPI_InvalidJSON(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1199,7 +1214,7 @@ func TestDevAPI_InvalidJSON(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevAPI_UnknownAction(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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1220,7 +1235,7 @@ func TestDevAPI_UnknownAction(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevAPI_ActivateMissingID(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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1241,7 +1256,7 @@ func TestDevAPI_ActivateMissingID(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevAPI_ActivateNonExistentID(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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1262,7 +1277,7 @@ func TestDevAPI_ActivateNonExistentID(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevAPI_MethodNotAllowed(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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1281,7 +1296,7 @@ func TestDevAPI_MethodNotAllowed(t *testing.T) {
|
||||||
|
|
||||||
func TestDevAPI_GetReturnsTargetsArray(t *testing.T) {
|
func TestDevAPI_GetReturnsTargetsArray(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1314,7 +1329,7 @@ func TestDevAPI_GetReturnsTargetsArray(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevAPI_DeactivateWhenAlreadyInactive(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()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1342,7 +1357,7 @@ func TestDevAPI_DeactivateWhenAlreadyInactive(t *testing.T) {
|
||||||
// ── Concurrency ──
|
// ── Concurrency ──
|
||||||
|
|
||||||
func TestDevProxy_ConcurrentRegisterActivate(t *testing.T) {
|
func TestDevProxy_ConcurrentRegisterActivate(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
|
|
||||||
const n = 50
|
const n = 50
|
||||||
done := make(chan struct{}, n)
|
done := make(chan struct{}, n)
|
||||||
|
|
@ -1373,7 +1388,7 @@ func TestDevProxy_ConcurrentRegisterActivate(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDevProxy_ConcurrentActivateDeactivate(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")
|
id1, _ := h.RegisterDevTarget("api", "http://localhost:8080")
|
||||||
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
id2, _ := h.RegisterDevTarget("frontend", "http://localhost:3000")
|
||||||
|
|
@ -1407,7 +1422,7 @@ func TestDevProxy_ConcurrentActivateDeactivate(t *testing.T) {
|
||||||
|
|
||||||
func TestSSE_DevEventContainsTargets(t *testing.T) {
|
func TestSSE_DevEventContainsTargets(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -1518,7 +1533,7 @@ func TestEscapeHTMLString(t *testing.T) {
|
||||||
// ── Handler implements DevTargetManager ──
|
// ── Handler implements DevTargetManager ──
|
||||||
|
|
||||||
func TestHandler_ImplementsDevTargetManager(t *testing.T) {
|
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
|
var _ DevTargetManager = h // compile-time check
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1558,7 +1573,7 @@ func TestDevProxy_FullLifecycle(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer backend2.Close()
|
defer backend2.Close()
|
||||||
|
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -1648,7 +1663,7 @@ func TestDevProxy_FullLifecycle(t *testing.T) {
|
||||||
|
|
||||||
func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
|
func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
|
||||||
notifier := NewStateNotifier()
|
notifier := NewStateNotifier()
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier)
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, notifier, nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1684,7 +1699,7 @@ func TestSSE_DevEventUpdatesOnActivateDeactivate(t *testing.T) {
|
||||||
// ── API auth on /miniapp/api/dev ──
|
// ── API auth on /miniapp/api/dev ──
|
||||||
|
|
||||||
func TestDevAPI_AuthRequired(t *testing.T) {
|
func TestDevAPI_AuthRequired(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -1714,7 +1729,7 @@ func TestDevProxy_NoAuthRequired(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer backend.Close()
|
defer backend.Close()
|
||||||
|
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
id, _ := h.RegisterDevTarget("back", backend.URL)
|
id, _ := h.RegisterDevTarget("back", backend.URL)
|
||||||
|
|
@ -1735,7 +1750,7 @@ func TestDevProxy_NoAuthRequired(t *testing.T) {
|
||||||
// ── API response JSON structure ──
|
// ── API response JSON structure ──
|
||||||
|
|
||||||
func TestDevAPI_ResponseTargetFields(t *testing.T) {
|
func TestDevAPI_ResponseTargetFields(t *testing.T) {
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
ts := httptest.NewServer(mux)
|
ts := httptest.NewServer(mux)
|
||||||
|
|
@ -1798,7 +1813,7 @@ func TestDevProxy_HostHeaderForwarded(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer backend.Close()
|
defer backend.Close()
|
||||||
|
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
id, _ := h.RegisterDevTarget("back", backend.URL)
|
id, _ := h.RegisterDevTarget("back", backend.URL)
|
||||||
|
|
@ -1920,7 +1935,7 @@ func TestDevProxy_ResponseRewriting(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer backend.Close()
|
defer backend.Close()
|
||||||
|
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -1958,7 +1973,7 @@ func TestDevProxy_ResponseRewriting_NonHTML(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer backend.Close()
|
defer backend.Close()
|
||||||
|
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
@ -1989,7 +2004,7 @@ func TestDevProxy_ResponseRewriting_ContentLength(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer backend.Close()
|
defer backend.Close()
|
||||||
|
|
||||||
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier())
|
h := NewHandler(&mockDataProvider{}, &mockSender{}, testBotToken, NewStateNotifier(), nil, "")
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
h.RegisterRoutes(mux)
|
h.RegisterRoutes(mux)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -705,6 +705,68 @@
|
||||||
background: rgba(255, 59, 48, 0.1);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -752,6 +814,21 @@
|
||||||
<button class="send-btn" onclick="sendCustomCmd()">Send</button>
|
<button class="send-btn" onclick="sendCustomCmd()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card glass" id="logs-section">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||||
|
<span class="card-title" style="margin:0">Logs</span>
|
||||||
|
<span id="logs-status" class="dev-target-dot"></span>
|
||||||
|
</div>
|
||||||
|
<div class="log-filter-chips">
|
||||||
|
<button class="log-filter-chip active" data-component="">All</button>
|
||||||
|
<button class="log-filter-chip" data-component="telego">Telego</button>
|
||||||
|
<button class="log-filter-chip" data-component="dev-console">Console</button>
|
||||||
|
</div>
|
||||||
|
<div id="logs-content"></div>
|
||||||
|
<div class="log-actions">
|
||||||
|
<button class="log-snap-btn" onclick="saveLogSnapshot()">Save Snapshot</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="git" class="panel">
|
<div id="git" class="panel">
|
||||||
|
|
@ -813,6 +890,8 @@ tabs.forEach((tab, index) => {
|
||||||
if (p === 'session' && !fresh) loadSession();
|
if (p === 'session' && !fresh) loadSession();
|
||||||
if (p === 'git') loadGit();
|
if (p === 'git') loadGit();
|
||||||
if (p === 'dev' && !fresh) loadDev();
|
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)
|
// Initial load (fallback for tabs not covered by initial SSE burst)
|
||||||
loadPlan();
|
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 ? '<span class="log-comp">' + escapeHtml(e.component) + '</span>' : '';
|
||||||
|
html += '<div class="log-entry">' +
|
||||||
|
'<span class="log-ts">' + ts + '</span>' +
|
||||||
|
'<span class="log-badge ' + lvl + '">' + lvl + '</span>' +
|
||||||
|
comp +
|
||||||
|
'<span class="log-msg">' + escapeHtml(e.message || '') + '</span>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue