From 6753c541e787c540f35e89fd262ed04d9e766335 Mon Sep 17 00:00:00 2001 From: Sarto Date: Wed, 25 Mar 2026 19:40:53 +0330 Subject: [PATCH] feat: :sparkles: convert client from TUI to Web UI --- .gitignore | 4 + README.md | 85 +-- cmd/client/main.go | 136 +---- cmd/server/main.go | 19 +- e2e_test.go | 639 ++++++++++++++++++++ go.mod | 20 - go.sum | 41 -- internal/tui/app.go | 590 ------------------ internal/web/static/Vazirmatn-Regular.woff2 | Bin 0 -> 50684 bytes internal/web/static/index.html | 474 +++++++++++++++ internal/web/web.go | 415 +++++++++++++ scripts/install.sh | 68 +-- 12 files changed, 1623 insertions(+), 868 deletions(-) create mode 100644 e2e_test.go delete mode 100644 internal/tui/app.go create mode 100644 internal/web/static/Vazirmatn-Regular.woff2 create mode 100644 internal/web/static/index.html create mode 100644 internal/web/web.go diff --git a/.gitignore b/.gitignore index 491b5af..d33273c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,10 @@ Thumbs.db # Cache .thefeed/ +# Data directories +thefeeddata/ +data/ + # Session data session.json diff --git a/README.md b/README.md index d09ced8..00d9a8c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ DNS-based feed reader for Telegram channels. Designed for environments where onl ``` ┌──────────────┐ DNS TXT Query ┌──────────────┐ MTProto ┌──────────┐ │ Client │ ──────────────────────▸ │ Server │ ──────────────▸ │ Telegram │ -│ (TUI app) │ ◂────────────────────── │ (DNS auth) │ ◂────────────── │ API │ +│ (Web UI) │ ◂────────────────────── │ (DNS auth) │ ◂────────────── │ API │ └──────────────┘ Encrypted TXT └──────────────┘ └──────────┘ ``` @@ -16,13 +16,16 @@ DNS-based feed reader for Telegram channels. Designed for environments where onl - Serves feed data as encrypted DNS TXT responses - Random padding on responses to vary size (anti-DPI) - Session persistence — login once, run forever +- All data stored in a single directory **Client** (runs inside censored network): +- Browser-based web UI with RTL/Farsi support (VazirMatn font) +- Configure via the web UI — no CLI flags needed - Sends encrypted DNS TXT queries via available resolvers - Single-label base32 encoding (stealthier) or double-label hex - Rate limiting to respect resolver limits -- TUI with RTL/Farsi support, log panel showing DNS queries -- Built-in resolver scanner (file with IPs/CIDRs or single CIDR) +- Live DNS query log in the browser +- All data (config, cache) stored next to the binary ## Anti-DPI Features @@ -87,39 +90,40 @@ make build-server # First run: login to Telegram and save session ./build/thefeed-server \ --login-only \ + --data-dir ./data \ --domain t.example.com \ --key "your-secret-passphrase" \ - --channels configs/channels.txt \ --api-id 12345 \ --api-hash "your-api-hash" \ - --phone "+1234567890" \ - --session session.json + --phone "+1234567890" -# Normal run (uses saved session) +# Normal run (uses saved session from data directory) ./build/thefeed-server \ + --data-dir ./data \ --domain t.example.com \ --key "your-secret-passphrase" \ - --channels configs/channels.txt \ --api-id 12345 \ --api-hash "your-api-hash" \ --phone "+1234567890" \ - --session session.json \ --listen ":5300" ``` +All data files (session, channels) are stored in the `--data-dir` directory (default: `./data`). + Environment variables: `THEFEED_DOMAIN`, `THEFEED_KEY`, `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_PHONE`, `TELEGRAM_PASSWORD` #### Server Flags | Flag | Default | Description | |------|---------|-------------| +| `--data-dir` | `./data` | Data directory for channels, session, config | | `--domain` | | DNS domain (required) | | `--key` | | Encryption passphrase (required) | -| `--channels` | `channels.txt` | Path to channels file | +| `--channels` | `{data-dir}/channels.txt` | Path to channels file | | `--api-id` | | Telegram API ID (required) | | `--api-hash` | | Telegram API Hash (required) | | `--phone` | | Telegram phone number (required) | -| `--session` | `session.json` | Path to Telegram session file | +| `--session` | `{data-dir}/session.json` | Path to Telegram session file | | `--login-only` | `false` | Authenticate to Telegram, save session, exit | | `--listen` | `:5300` | DNS listen address | | `--padding` | `32` | Max random padding bytes (0=disabled) | @@ -131,55 +135,32 @@ Environment variables: `THEFEED_DOMAIN`, `THEFEED_KEY`, `TELEGRAM_API_ID`, `TELE # Build make build-client -# Basic usage -./build/thefeed-client \ - --domain t.example.com \ - --key "your-secret-passphrase" \ - --resolvers "8.8.8.8,1.1.1.1" +# Run (opens web UI in browser) +./build/thefeed-client -# With resolver scanning from file -./build/thefeed-client \ - --domain t.example.com \ - --key "your-secret-passphrase" \ - --scan configs/resolvers.txt \ - --rate 5 - -# Scan a CIDR range -./build/thefeed-client \ - --domain t.example.com \ - --key "your-secret-passphrase" \ - --scan "8.8.8.0/24" \ - --resolvers configs/resolvers.txt +# Custom data directory and port +./build/thefeed-client --data-dir ./mydata --port 9090 ``` +On first run, the client creates a `./thefeeddata/` directory next to where you run it. Open `http://127.0.0.1:8080` in your browser and configure your domain, passphrase, and resolvers through the Settings page. + +All configuration, cache, and data files are stored in the data directory. + #### Client Flags | Flag | Default | Description | |------|---------|-------------| -| `--domain` | | DNS domain (required) | -| `--key` | | Encryption passphrase (required) | -| `--resolvers` | | Comma-separated IPs or path to file | -| `--scan` | | File with IPs/CIDRs or single CIDR to scan | -| `--scan-workers` | `50` | Concurrent scanner workers | -| `--rate` | `0` | Max DNS queries/sec (0=unlimited) | -| `--query-mode` | `single` | `single` (base32) or `double` (hex) | -| `--cache` | `~/.thefeed/cache` | Cache directory | +| `--data-dir` | `./thefeeddata` | Data directory for config, cache | +| `--port` | `8080` | Web UI port | | `--version` | | Show version and exit | -### TUI Controls +### Web UI -| Key | Action | -|-----|--------| -| `Tab` / `←` / `→` | Cycle panels (channels → messages → log) | -| `j` / `k` / `↑` / `↓` | Navigate up/down | -| `r` | Refresh feed | -| `PgUp` / `PgDn` | Scroll content | -| `q` / `Ctrl+C` | Quit | - -The TUI has three panels: -- **Channels** (left): channel list with selection -- **Messages** (right): messages with RTL/Farsi support -- **Log** (bottom): DNS queries being sent (debug) +The browser-based UI has: +- **Channels sidebar** (left): channel list with selection +- **Messages panel** (right): messages with native RTL/Farsi rendering (VazirMatn font) +- **Log panel** (bottom): live DNS query log +- **Settings modal**: configure domain, passphrase, resolvers, query mode, rate limit ## Development @@ -265,11 +246,11 @@ systemctl restart thefeed-server journalctl -u thefeed-server -f # Update channels -sudo vi /etc/thefeed/channels.txt +sudo vi /opt/thefeed/data/channels.txt sudo systemctl restart thefeed-server # Update binary -cd thefeed && git pull && sudo bash scripts/install.sh +sudo bash scripts/install.sh ``` ## License diff --git a/cmd/client/main.go b/cmd/client/main.go index 6b026d1..f63a8c4 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -5,25 +5,14 @@ import ( "fmt" "log" "os" - "path/filepath" - "strings" - "sync" - "github.com/sartoopjj/thefeed/internal/client" - "github.com/sartoopjj/thefeed/internal/protocol" - "github.com/sartoopjj/thefeed/internal/tui" "github.com/sartoopjj/thefeed/internal/version" + "github.com/sartoopjj/thefeed/internal/web" ) func main() { - domain := flag.String("domain", "", "DNS domain (e.g., t.example.com)") - key := flag.String("key", "", "Encryption passphrase") - resolvers := flag.String("resolvers", "", "Comma-separated resolver IPs or path to resolvers file") - scanPath := flag.String("scan", "", "File with IPs/CIDRs to scan for resolvers, or a single CIDR (e.g., 8.8.8.0/24)") - cacheDir := flag.String("cache", "", "Cache directory (default: ~/.thefeed/cache)") - scanWorkers := flag.Int("scan-workers", 50, "Concurrent scanner workers") - rateLimit := flag.Float64("rate", 0, "Max DNS queries per second (0 = unlimited)") - queryMode := flag.String("query-mode", "single", "DNS query encoding: single (base32) or double (hex)") + dataDir := flag.String("data-dir", "./thefeeddata", "Data directory for config, cache, and sessions") + port := flag.Int("port", 8080, "Web UI port") showVersion := flag.Bool("version", false, "Show version and exit") flag.Parse() @@ -32,123 +21,12 @@ func main() { os.Exit(0) } - if *domain == "" { - *domain = os.Getenv("THEFEED_DOMAIN") - } - if *key == "" { - *key = os.Getenv("THEFEED_KEY") - } - - if *domain == "" || *key == "" { - fmt.Fprintln(os.Stderr, "Error: --domain and --key are required") - flag.Usage() - os.Exit(1) - } - - if *cacheDir == "" { - home, err := os.UserHomeDir() - if err != nil { - log.Fatalf("Get home dir: %v", err) - } - *cacheDir = filepath.Join(home, ".thefeed", "cache") - } - - cache, err := client.NewCache(*cacheDir) + srv, err := web.New(*dataDir, *port) if err != nil { - log.Fatalf("Create cache: %v", err) + log.Fatalf("Failed to start: %v", err) } - resolverList := parseResolvers(*resolvers) - - fetcher, err := client.NewFetcher(*domain, *key, resolverList) - if err != nil { - log.Fatalf("Create fetcher: %v", err) - } - - // Set query encoding mode - if *queryMode == "double" { - fetcher.SetQueryMode(protocol.QueryDoubleLabel) - } - - // Set rate limit - if *rateLimit > 0 { - fetcher.SetRateLimit(*rateLimit) - fmt.Printf("Rate limit: %.1f queries/sec\n", *rateLimit) - } - - // Scan for resolvers (supports file with IPs/CIDRs or a single CIDR) - if *scanPath != "" { - var mu sync.Mutex - var found []string - - scanner := client.NewResolverScanner(fetcher, *scanWorkers) - - // Check if it's a file - if _, statErr := os.Stat(*scanPath); statErr == nil { - fmt.Printf("Scanning resolvers from file %s...\n", *scanPath) - err := scanner.ScanFile(*scanPath, func(ip string) { - mu.Lock() - found = append(found, ip) - mu.Unlock() - fmt.Printf(" Found: %s\n", ip) - }) - if err != nil { - fmt.Printf("Scan warning: %v\n", err) - } - } else if strings.Contains(*scanPath, "/") { - // Treat as CIDR - fmt.Printf("Scanning %s for DNS resolvers...\n", *scanPath) - err := scanner.ScanCIDR(*scanPath, func(ip string) { - mu.Lock() - found = append(found, ip) - mu.Unlock() - fmt.Printf(" Found: %s\n", ip) - }) - if err != nil { - fmt.Printf("Scan warning: %v\n", err) - } - } else { - fmt.Fprintf(os.Stderr, "Error: --scan value %q is not a file or CIDR\n", *scanPath) - os.Exit(1) - } - - if len(found) > 0 { - all := append(resolverList, found...) - fetcher.SetResolvers(all) - fmt.Printf("Using %d resolvers\n", len(all)) - } - } - - if len(fetcher.Resolvers()) == 0 { - fmt.Fprintln(os.Stderr, "Error: no resolvers available. Use --resolvers or --scan") - os.Exit(1) - } - - if err := tui.Run(fetcher, cache); err != nil { - log.Fatalf("TUI error: %v", err) + if err := srv.Run(); err != nil { + log.Fatalf("Server error: %v", err) } } - -func parseResolvers(input string) []string { - if input == "" { - return nil - } - - if _, err := os.Stat(input); err == nil { - resolvers, err := client.LoadResolversFile(input) - if err != nil { - log.Printf("Warning: could not load resolvers file %s: %v", input, err) - } else { - return resolvers - } - } - - var resolvers []string - for _, r := range strings.Split(input, ",") { - r = strings.TrimSpace(r) - if r != "" { - resolvers = append(resolvers, r) - } - } - return resolvers -} diff --git a/cmd/server/main.go b/cmd/server/main.go index b3fc3ba..916669d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -8,6 +8,7 @@ import ( "log" "os" "os/signal" + "path/filepath" "strconv" "strings" "syscall" @@ -19,15 +20,16 @@ import ( ) func main() { + dataDir := flag.String("data-dir", "./data", "Data directory for channels, session, and config") listen := flag.String("listen", ":5300", "DNS listen address (host:port)") domain := flag.String("domain", "", "DNS domain (e.g., t.example.com)") key := flag.String("key", "", "Encryption passphrase") - channelsFile := flag.String("channels", "channels.txt", "Path to channels file") + channelsFile := flag.String("channels", "", "Path to channels file (default: {data-dir}/channels.txt)") apiID := flag.String("api-id", "", "Telegram API ID") apiHash := flag.String("api-hash", "", "Telegram API Hash") phone := flag.String("phone", "", "Telegram phone number") loginOnly := flag.Bool("login-only", false, "Authenticate to Telegram, save session, and exit") - sessionPath := flag.String("session", "session.json", "Path to Telegram session file") + sessionPath := flag.String("session", "", "Path to Telegram session file (default: {data-dir}/session.json)") maxPadding := flag.Int("padding", 32, "Max random padding bytes in DNS responses (anti-DPI, 0=disabled)") showVersion := flag.Bool("version", false, "Show version and exit") flag.Parse() @@ -37,6 +39,19 @@ func main() { os.Exit(0) } + // Create data directory + if err := os.MkdirAll(*dataDir, 0700); err != nil { + log.Fatalf("Create data dir: %v", err) + } + + // Default paths relative to data directory + if *channelsFile == "" { + *channelsFile = filepath.Join(*dataDir, "channels.txt") + } + if *sessionPath == "" { + *sessionPath = filepath.Join(*dataDir, "session.json") + } + if *domain == "" { *domain = os.Getenv("THEFEED_DOMAIN") } diff --git a/e2e_test.go b/e2e_test.go new file mode 100644 index 0000000..9a63a0a --- /dev/null +++ b/e2e_test.go @@ -0,0 +1,639 @@ +package thefeed_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/sartoopjj/thefeed/internal/client" + "github.com/sartoopjj/thefeed/internal/protocol" + "github.com/sartoopjj/thefeed/internal/server" + "github.com/sartoopjj/thefeed/internal/web" +) + +func findFreePort(t *testing.T, network string) int { + t.Helper() + switch network { + case "udp": + conn, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("find free udp port: %v", err) + } + defer conn.Close() + return conn.LocalAddr().(*net.UDPAddr).Port + default: + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("find free tcp port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port + } +} + +func startDNSServer(t *testing.T, domain, passphrase string, channels []string, messages map[int][]protocol.Message) (string, context.CancelFunc) { + t.Helper() + + qk, rk, err := protocol.DeriveKeys(passphrase) + if err != nil { + t.Fatalf("derive keys: %v", err) + } + + feed := server.NewFeed(channels) + for ch, msgs := range messages { + feed.UpdateChannel(ch, msgs) + } + + port := findFreePort(t, "udp") + addr := fmt.Sprintf("127.0.0.1:%d", port) + + dnsServer := server.NewDNSServer(addr, domain, feed, qk, rk, protocol.DefaultMaxPadding) + + ctx, cancel := context.WithCancel(context.Background()) + + ready := make(chan struct{}) + go func() { + close(ready) + if err := dnsServer.ListenAndServe(ctx); err != nil && ctx.Err() == nil { + t.Errorf("dns server error: %v", err) + } + }() + <-ready + time.Sleep(100 * time.Millisecond) + + return addr, cancel +} + +// --- Server E2E Tests --- + +func TestE2E_FetchMetadataThroughDNS(t *testing.T) { + domain := "feed.example.com" + passphrase := "test-secret-key-123" + channels := []string{"news", "tech"} + + msgs := map[int][]protocol.Message{ + 1: { + {ID: 100, Timestamp: 1700000000, Text: "Hello from news"}, + {ID: 101, Timestamp: 1700000001, Text: "Second news"}, + }, + 2: { + {ID: 200, Timestamp: 1700000010, Text: "Tech update"}, + }, + } + + resolver, cancel := startDNSServer(t, domain, passphrase, channels, msgs) + defer cancel() + + fetcher, err := client.NewFetcher(domain, passphrase, []string{resolver}) + if err != nil { + t.Fatalf("create fetcher: %v", err) + } + + meta, err := fetcher.FetchMetadata() + if err != nil { + t.Fatalf("fetch metadata: %v", err) + } + + if len(meta.Channels) != 2 { + t.Fatalf("expected 2 channels, got %d", len(meta.Channels)) + } + if meta.Channels[0].Name != "news" { + t.Errorf("channel 0 name = %q, want %q", meta.Channels[0].Name, "news") + } + if meta.Channels[1].Name != "tech" { + t.Errorf("channel 1 name = %q, want %q", meta.Channels[1].Name, "tech") + } + if meta.Channels[0].LastMsgID != 100 { + t.Errorf("channel 0 lastMsgID = %d, want 100", meta.Channels[0].LastMsgID) + } +} + +func TestE2E_FetchChannelMessages(t *testing.T) { + domain := "feed.example.com" + passphrase := "e2e-pass-456" + channels := []string{"updates"} + + msgs := map[int][]protocol.Message{ + 1: { + {ID: 1, Timestamp: 1700000000, Text: "First message"}, + {ID: 2, Timestamp: 1700000001, Text: "Second message"}, + {ID: 3, Timestamp: 1700000002, Text: "Third message"}, + }, + } + + resolver, cancel := startDNSServer(t, domain, passphrase, channels, msgs) + defer cancel() + + fetcher, err := client.NewFetcher(domain, passphrase, []string{resolver}) + if err != nil { + t.Fatalf("create fetcher: %v", err) + } + + meta, err := fetcher.FetchMetadata() + if err != nil { + t.Fatalf("fetch metadata: %v", err) + } + + blockCount := int(meta.Channels[0].Blocks) + if blockCount <= 0 { + t.Fatal("expected blocks > 0") + } + + fetchedMsgs, err := fetcher.FetchChannel(1, blockCount) + if err != nil { + t.Fatalf("fetch channel: %v", err) + } + + if len(fetchedMsgs) != 3 { + t.Fatalf("expected 3 messages, got %d", len(fetchedMsgs)) + } + + for i, want := range msgs[1] { + got := fetchedMsgs[i] + if got.ID != want.ID || got.Text != want.Text { + t.Errorf("message %d: got {ID:%d Text:%q}, want {ID:%d Text:%q}", + i, got.ID, got.Text, want.ID, want.Text) + } + } +} + +func TestE2E_FetchWithDoubleLabel(t *testing.T) { + domain := "feed.example.com" + passphrase := "double-label-test" + channels := []string{"channel1"} + + msgs := map[int][]protocol.Message{ + 1: {{ID: 10, Timestamp: 1700000000, Text: "Double label message"}}, + } + + resolver, cancel := startDNSServer(t, domain, passphrase, channels, msgs) + defer cancel() + + fetcher, err := client.NewFetcher(domain, passphrase, []string{resolver}) + if err != nil { + t.Fatalf("create fetcher: %v", err) + } + fetcher.SetQueryMode(protocol.QueryDoubleLabel) + + meta, err := fetcher.FetchMetadata() + if err != nil { + t.Fatalf("fetch metadata: %v", err) + } + + fetchedMsgs, err := fetcher.FetchChannel(1, int(meta.Channels[0].Blocks)) + if err != nil { + t.Fatalf("fetch channel: %v", err) + } + + if len(fetchedMsgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(fetchedMsgs)) + } + if fetchedMsgs[0].Text != "Double label message" { + t.Errorf("message text = %q, want %q", fetchedMsgs[0].Text, "Double label message") + } +} + +func TestE2E_WrongPassphrase(t *testing.T) { + domain := "feed.example.com" + channels := []string{"ch1"} + + msgs := map[int][]protocol.Message{ + 1: {{ID: 1, Timestamp: 1700000000, Text: "secret"}}, + } + + resolver, cancel := startDNSServer(t, domain, "server-key", channels, msgs) + defer cancel() + + fetcher, err := client.NewFetcher(domain, "wrong-key", []string{resolver}) + if err != nil { + t.Fatalf("create fetcher: %v", err) + } + + _, err = fetcher.FetchMetadata() + if err == nil { + t.Fatal("expected error with wrong passphrase, got nil") + } +} + +func TestE2E_LargeMessages(t *testing.T) { + domain := "feed.example.com" + passphrase := "large-msg-test" + channels := []string{"big"} + + longText := strings.Repeat("A", 500) + msgs := map[int][]protocol.Message{ + 1: { + {ID: 1, Timestamp: 1700000000, Text: longText}, + {ID: 2, Timestamp: 1700000001, Text: "Short"}, + }, + } + + resolver, cancel := startDNSServer(t, domain, passphrase, channels, msgs) + defer cancel() + + fetcher, err := client.NewFetcher(domain, passphrase, []string{resolver}) + if err != nil { + t.Fatalf("create fetcher: %v", err) + } + + meta, err := fetcher.FetchMetadata() + if err != nil { + t.Fatalf("fetch metadata: %v", err) + } + + fetchedMsgs, err := fetcher.FetchChannel(1, int(meta.Channels[0].Blocks)) + if err != nil { + t.Fatalf("fetch channel: %v", err) + } + + if len(fetchedMsgs) != 2 { + t.Fatalf("expected 2 messages, got %d", len(fetchedMsgs)) + } + if fetchedMsgs[0].Text != longText { + t.Errorf("long message length = %d, want %d", len(fetchedMsgs[0].Text), len(longText)) + } +} + +// --- Web UI E2E Tests --- + +func TestE2E_WebAPI_ConfigAndStatus(t *testing.T) { + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + // Status should show not configured + resp, err := http.Get(base + "/api/status") + if err != nil { + t.Fatalf("GET /api/status: %v", err) + } + defer resp.Body.Close() + + var status map[string]any + json.NewDecoder(resp.Body).Decode(&status) + if status["configured"] != false { + t.Errorf("expected configured=false, got %v", status["configured"]) + } + + // GET config when not configured + resp2, err := http.Get(base + "/api/config") + if err != nil { + t.Fatalf("GET /api/config: %v", err) + } + defer resp2.Body.Close() + var cfgResp map[string]any + json.NewDecoder(resp2.Body).Decode(&cfgResp) + if cfgResp["configured"] != false { + t.Errorf("expected configured=false on GET config, got %v", cfgResp["configured"]) + } + + // POST config + cfg := `{"domain":"test.example.com","key":"testpass","resolvers":["127.0.0.1:9999"],"queryMode":"single","rateLimit":10}` + resp3, err := http.Post(base+"/api/config", "application/json", strings.NewReader(cfg)) + if err != nil { + t.Fatalf("POST /api/config: %v", err) + } + defer resp3.Body.Close() + if resp3.StatusCode != 200 { + body, _ := io.ReadAll(resp3.Body) + t.Fatalf("POST /api/config status=%d body=%s", resp3.StatusCode, body) + } + + // Status should now show configured + resp4, err := http.Get(base + "/api/status") + if err != nil { + t.Fatalf("GET /api/status after config: %v", err) + } + defer resp4.Body.Close() + var status2 map[string]any + json.NewDecoder(resp4.Body).Decode(&status2) + if status2["configured"] != true { + t.Errorf("expected configured=true, got %v", status2["configured"]) + } + if status2["domain"] != "test.example.com" { + t.Errorf("domain = %v, want test.example.com", status2["domain"]) + } +} + +func TestE2E_WebAPI_InvalidConfig(t *testing.T) { + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + // Missing required fields + resp, err := http.Post(base+"/api/config", "application/json", strings.NewReader(`{"domain":"x"}`)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 400 { + t.Errorf("expected 400, got %d", resp.StatusCode) + } + + // Invalid JSON + resp2, err := http.Post(base+"/api/config", "application/json", strings.NewReader(`not json`)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 400 { + t.Errorf("expected 400 for invalid json, got %d", resp2.StatusCode) + } +} + +func TestE2E_WebAPI_Channels(t *testing.T) { + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + resp, err := http.Get(base + "/api/channels") + if err != nil { + t.Fatalf("GET /api/channels: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if string(body) != "null\n" && string(body) != "[]\n" { + t.Logf("channels response: %q (acceptable)", string(body)) + } +} + +func TestE2E_WebAPI_Messages(t *testing.T) { + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + resp, err := http.Get(base + "/api/messages/1") + if err != nil { + t.Fatalf("GET /api/messages/1: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + // Invalid channel number + resp2, err := http.Get(base + "/api/messages/abc") + if err != nil { + t.Fatalf("GET /api/messages/abc: %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 400 { + t.Errorf("expected 400 for invalid channel, got %d", resp2.StatusCode) + } +} + +func TestE2E_WebAPI_IndexPage(t *testing.T) { + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + resp, err := http.Get(base + "/") + if err != nil { + t.Fatalf("GET /: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Errorf("Content-Type = %q, want text/html", ct) + } +} + +func TestE2E_WebAPI_NotFound(t *testing.T) { + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + resp, err := http.Get(base + "/nonexistent") + if err != nil { + t.Fatalf("GET /nonexistent: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 404 { + t.Errorf("expected 404, got %d", resp.StatusCode) + } +} + +func TestE2E_WebAPI_MethodNotAllowed(t *testing.T) { + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + req, _ := http.NewRequest(http.MethodPut, base+"/api/config", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PUT /api/config: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != 405 { + t.Errorf("expected 405, got %d", resp.StatusCode) + } + + resp2, err := http.Get(base + "/api/refresh") + if err != nil { + t.Fatalf("GET /api/refresh: %v", err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 405 { + t.Errorf("expected 405 for GET /api/refresh, got %d", resp2.StatusCode) + } +} + +func TestE2E_WebAPI_ConfigPersistence(t *testing.T) { + dataDir := t.TempDir() + + port1 := findFreePort(t, "tcp") + srv1, err := web.New(dataDir, port1) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv1.Run() + time.Sleep(200 * time.Millisecond) + + base1 := fmt.Sprintf("http://127.0.0.1:%d", port1) + cfg := `{"domain":"persist.example.com","key":"persistkey","resolvers":["1.1.1.1"]}` + resp, err := http.Post(base1+"/api/config", "application/json", strings.NewReader(cfg)) + if err != nil { + t.Fatalf("POST config: %v", err) + } + resp.Body.Close() + + configPath := dataDir + "/config.json" + if _, err := os.Stat(configPath); os.IsNotExist(err) { + t.Fatal("config.json was not persisted to disk") + } + + port2 := findFreePort(t, "tcp") + srv2, err := web.New(dataDir, port2) + if err != nil { + t.Fatalf("create second web server: %v", err) + } + go srv2.Run() + time.Sleep(200 * time.Millisecond) + + base2 := fmt.Sprintf("http://127.0.0.1:%d", port2) + resp2, err := http.Get(base2 + "/api/status") + if err != nil { + t.Fatalf("GET /api/status on second instance: %v", err) + } + defer resp2.Body.Close() + + var status map[string]any + json.NewDecoder(resp2.Body).Decode(&status) + if status["configured"] != true { + t.Error("second instance should have loaded config, got configured=false") + } + if status["domain"] != "persist.example.com" { + t.Errorf("domain = %v, want persist.example.com", status["domain"]) + } +} + +// TestE2E_FullRoundTrip tests DNS server -> client fetcher -> web API end to end. +func TestE2E_FullRoundTrip(t *testing.T) { + domain := "roundtrip.example.com" + passphrase := "full-roundtrip-key" + channels := []string{"general", "alerts"} + + msgs := map[int][]protocol.Message{ + 1: { + {ID: 1, Timestamp: 1700000000, Text: "General message 1"}, + {ID: 2, Timestamp: 1700000001, Text: "General message 2"}, + }, + 2: { + {ID: 10, Timestamp: 1700000010, Text: "Alert!"}, + }, + } + + resolver, cancel := startDNSServer(t, domain, passphrase, channels, msgs) + defer cancel() + + dataDir := t.TempDir() + port := findFreePort(t, "tcp") + srv, err := web.New(dataDir, port) + if err != nil { + t.Fatalf("create web server: %v", err) + } + go srv.Run() + time.Sleep(200 * time.Millisecond) + + base := fmt.Sprintf("http://127.0.0.1:%d", port) + + cfgJSON := fmt.Sprintf(`{"domain":"%s","key":"%s","resolvers":["%s"],"queryMode":"single","rateLimit":0}`, + domain, passphrase, resolver) + resp, err := http.Post(base+"/api/config", "application/json", strings.NewReader(cfgJSON)) + if err != nil { + t.Fatalf("POST /api/config: %v", err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("config POST status=%d", resp.StatusCode) + } + + // Wait for auto-refresh to fetch data + time.Sleep(3 * time.Second) + + // Channels should be populated + resp2, err := http.Get(base + "/api/channels") + if err != nil { + t.Fatalf("GET /api/channels: %v", err) + } + defer resp2.Body.Close() + + var chList []protocol.ChannelInfo + json.NewDecoder(resp2.Body).Decode(&chList) + if len(chList) != 2 { + t.Fatalf("expected 2 channels, got %d", len(chList)) + } + if chList[0].Name != "general" || chList[1].Name != "alerts" { + t.Errorf("channels = %v, want [general, alerts]", chList) + } + + // Messages for channel 1 + resp3, err := http.Get(base + "/api/messages/1") + if err != nil { + t.Fatalf("GET /api/messages/1: %v", err) + } + defer resp3.Body.Close() + + var msgList []protocol.Message + json.NewDecoder(resp3.Body).Decode(&msgList) + if len(msgList) != 2 { + t.Fatalf("expected 2 messages for channel 1, got %d", len(msgList)) + } + if msgList[0].Text != "General message 1" { + t.Errorf("msg[0].Text = %q, want %q", msgList[0].Text, "General message 1") + } + + // Messages for channel 2 + resp4, err := http.Get(base + "/api/messages/2") + if err != nil { + t.Fatalf("GET /api/messages/2: %v", err) + } + defer resp4.Body.Close() + + var msgList2 []protocol.Message + json.NewDecoder(resp4.Body).Decode(&msgList2) + if len(msgList2) != 1 { + t.Fatalf("expected 1 message for channel 2, got %d", len(msgList2)) + } + if msgList2[0].Text != "Alert!" { + t.Errorf("msg[0].Text = %q, want %q", msgList2[0].Text, "Alert!") + } +} diff --git a/go.mod b/go.mod index 8573f11..912e728 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,6 @@ module github.com/sartoopjj/thefeed go 1.26.1 require ( - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 github.com/gotd/td v0.142.0 github.com/miekg/dns v1.1.72 golang.org/x/crypto v0.49.0 @@ -13,19 +10,10 @@ require ( ) require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/coder/websocket v1.8.14 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.18.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-faster/errors v0.7.1 // indirect @@ -36,19 +24,11 @@ require ( github.com/gotd/ige v0.2.2 // indirect github.com/gotd/neo v0.1.5 // indirect github.com/klauspost/compress v1.18.4 // indirect - github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/ogen-go/ogen v1.19.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect diff --git a/go.sum b/go.sum index 74ebc56..190b34d 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,13 @@ -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= @@ -65,38 +41,22 @@ github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= -github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ogen-go/ogen v1.19.0 h1:YvdNpeQJ8A8dLLpS6Vs4WxXL53BT6tBPxH0VSjfALhA= github.com/ogen-go/ogen v1.19.0/go.mod h1:DeShwO+TEpLYXNCuZliSAedphphXsJaTGGbmSomWUjE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= @@ -124,7 +84,6 @@ golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/tui/app.go b/internal/tui/app.go deleted file mode 100644 index 47e9380..0000000 --- a/internal/tui/app.go +++ /dev/null @@ -1,590 +0,0 @@ -package tui - -import ( - "fmt" - "strings" - "time" - "unicode/utf8" - - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - - "github.com/sartoopjj/thefeed/internal/client" - "github.com/sartoopjj/thefeed/internal/protocol" - "github.com/sartoopjj/thefeed/internal/version" -) - -var ( - channelListStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Padding(0, 1) - - messageViewStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("62")). - Padding(0, 1) - - logViewStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("240")). - Padding(0, 1) - - selectedStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Background(lipgloss.Color("57")). - Bold(true) - - normalStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("252")) - - statusStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")). - Background(lipgloss.Color("236")). - Padding(0, 1) - - titleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("229")). - Bold(true). - Padding(0, 1) - - timestampStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) - - msgIDStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("69")) - - logDimStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")) -) - -type focus int - -const ( - focusChannels focus = iota - focusMessages - focusLog -) - -const maxLogLines = 200 - -// logBuffer is a shared log buffer that survives bubbletea model copies. -type logBuffer struct { - lines []string -} - -func (lb *logBuffer) append(msg string) { - ts := time.Now().Format("15:04:05") - line := fmt.Sprintf("%s %s", logDimStyle.Render(ts), msg) - lb.lines = append(lb.lines, line) - if len(lb.lines) > maxLogLines { - lb.lines = lb.lines[len(lb.lines)-maxLogLines:] - } -} - -// Model is the TUI state. -type Model struct { - fetcher *client.Fetcher - cache *client.Cache - channels []protocol.ChannelInfo - messages map[int][]protocol.Message - - selectedChan int - focus focus - viewport viewport.Model - logViewport viewport.Model - - width, height int - status string - loading bool - forceRefresh bool - err error - lastUpdate time.Time - serverTimestamp uint32 - marker [protocol.MarkerSize]byte - resolverInfo string - logBuf *logBuffer - - autoRefreshInterval time.Duration -} - -type ( - metadataMsg struct { - meta *protocol.Metadata - err error - } - channelDataMsg struct { - channelNum int - msgs []protocol.Message - err error - } - tickMsg struct{} - logMsg string -) - -// New creates a new TUI model. -func New(fetcher *client.Fetcher, cache *client.Cache) Model { - vp := viewport.New(0, 0) - lv := viewport.New(0, 0) - lb := &logBuffer{} - m := Model{ - fetcher: fetcher, - cache: cache, - messages: make(map[int][]protocol.Message), - viewport: vp, - logViewport: lv, - logBuf: lb, - autoRefreshInterval: 30 * time.Second, - status: "Starting...", - resolverInfo: strings.Join(fetcher.Resolvers(), ", "), - } - - // Set up fetcher log callback — uses shared pointer so it works - // even after bubbletea copies the Model struct. - fetcher.SetLogFunc(func(msg string) { - lb.append(msg) - }) - - return m -} - -// Init starts the TUI. -func (m Model) Init() tea.Cmd { - return tea.Batch( - m.fetchMetadata(), - m.tickCmd(), - ) -} - -// Update handles messages. -func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - m.updateViewportSize() - - case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "tab", "left", "right": - // Cycle: channels → messages → log → channels - switch m.focus { - case focusChannels: - m.focus = focusMessages - case focusMessages: - m.focus = focusLog - case focusLog: - m.focus = focusChannels - } - case "up", "k": - if m.focus == focusChannels { - if m.selectedChan > 0 { - m.selectedChan-- - cmds = append(cmds, m.loadChannel(m.selectedChan+1)) - } - } else if m.focus == focusMessages { - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) - } else { - var cmd tea.Cmd - m.logViewport, cmd = m.logViewport.Update(msg) - cmds = append(cmds, cmd) - } - case "down", "j": - if m.focus == focusChannels { - if m.selectedChan < len(m.channels)-1 { - m.selectedChan++ - cmds = append(cmds, m.loadChannel(m.selectedChan+1)) - } - } else if m.focus == focusMessages { - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) - } else { - var cmd tea.Cmd - m.logViewport, cmd = m.logViewport.Update(msg) - cmds = append(cmds, cmd) - } - case "r": - m.status = "Refreshing..." - m.loading = true - m.forceRefresh = true - cmds = append(cmds, m.fetchMetadata()) - case "pgup", "pgdown", "home", "end": - if m.focus == focusLog { - var cmd tea.Cmd - m.logViewport, cmd = m.logViewport.Update(msg) - cmds = append(cmds, cmd) - } else { - var cmd tea.Cmd - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) - } - } - - case metadataMsg: - m.loading = false - if msg.err != nil { - m.err = msg.err - m.status = fmt.Sprintf("Error: %v", msg.err) - } else { - m.channels = msg.meta.Channels - m.serverTimestamp = msg.meta.Timestamp - m.marker = msg.meta.Marker - m.lastUpdate = time.Now() - m.err = nil - m.status = fmt.Sprintf("Updated %s | Server: %s", - time.Now().Format("15:04:05"), - time.Unix(int64(m.serverTimestamp), 0).Format("15:04:05")) - - _ = m.cache.PutMetadata(msg.meta) - - if len(m.channels) > 0 { - // Always fetch fresh data after metadata update - cmds = append(cmds, m.loadChannelFresh(m.selectedChan+1)) - } - m.forceRefresh = false - } - - case channelDataMsg: - if msg.err != nil { - m.status = fmt.Sprintf("Channel error: %v", msg.err) - } else { - m.messages[msg.channelNum] = msg.msgs - _ = m.cache.PutMessages(msg.channelNum, msg.msgs) - m.updateViewportContent() - } - - case tickMsg: - cmds = append(cmds, m.tickCmd()) - // Update log viewport content - m.updateLogContent() - if time.Since(m.lastUpdate) > m.autoRefreshInterval && !m.loading { - m.loading = true - m.status = "Auto-refreshing..." - cmds = append(cmds, m.fetchMetadata()) - } - - case logMsg: - m.logBuf.append(string(msg)) - m.updateLogContent() - } - - return m, tea.Batch(cmds...) -} - -// View renders the TUI. -func (m Model) View() string { - if m.width == 0 { - return "Loading..." - } - - channelWidth := m.width / 4 - if channelWidth < 20 { - channelWidth = 20 - } - if channelWidth > 40 { - channelWidth = 40 - } - messageWidth := m.width - channelWidth - 4 - - // Split height: messages get 80%, log panel gets 20% of content area - contentHeight := m.height - 3 - if contentHeight < 10 { - contentHeight = 10 - } - msgHeight := contentHeight * 8 / 10 - logHeight := contentHeight - msgHeight - if logHeight < 3 { - logHeight = 3 - msgHeight = contentHeight - logHeight - } - - channelContent := m.renderChannelList(channelWidth-4, msgHeight-2) - borderColor := "62" - if m.focus == focusChannels { - borderColor = "229" - } - channelPanel := channelListStyle. - BorderForeground(lipgloss.Color(borderColor)). - Width(channelWidth - 2). - Height(msgHeight). - Render(channelContent) - - m.updateViewportContent() - messageTitle := " Messages " - if m.selectedChan < len(m.channels) { - ch := m.channels[m.selectedChan] - messageTitle = fmt.Sprintf(" %s (%d blocks) ", ch.Name, ch.Blocks) - } - messageContent := titleStyle.Render(messageTitle) + "\n" + m.viewport.View() - msgBorderColor := "62" - if m.focus == focusMessages { - msgBorderColor = "229" - } - messagePanel := messageViewStyle. - BorderForeground(lipgloss.Color(msgBorderColor)). - Width(messageWidth - 2). - Height(msgHeight). - Render(messageContent) - - topContent := lipgloss.JoinHorizontal(lipgloss.Top, channelPanel, messagePanel) - - // Log panel (full width) - m.updateLogContent() - logBorderColor := "240" - if m.focus == focusLog { - logBorderColor = "229" - } - logTitle := titleStyle.Render(" Log ") - logContent := logTitle + "\n" + m.logViewport.View() - logPanel := logViewStyle. - BorderForeground(lipgloss.Color(logBorderColor)). - Width(m.width - 4). - Height(logHeight). - Render(logContent) - - // Status bar - statusLeft := m.status - if m.loading { - statusLeft = "... " + statusLeft - } - resolverStr := "" - if m.resolverInfo != "" { - resolverStr = " | DNS: " + truncateStr(m.resolverInfo, 30) - } - versionStr := "" - if version.Version != "" { - versionStr = " v" + version.Version - } - statusRight := fmt.Sprintf("Tab/←→:switch j/k:nav r:refresh q:quit%s%s", resolverStr, versionStr) - - gap := m.width - utf8.RuneCountInString(statusLeft) - utf8.RuneCountInString(statusRight) - 2 - if gap < 1 { - gap = 1 - } - statusBar := statusStyle.Width(m.width).Render( - statusLeft + strings.Repeat(" ", gap) + statusRight, - ) - - return topContent + "\n" + logPanel + "\n" + statusBar -} - -func (m Model) renderChannelList(width, height int) string { - title := titleStyle.Render(" Channels ") - var lines []string - lines = append(lines, title) - - if len(m.channels) == 0 { - lines = append(lines, " No channels") - return strings.Join(lines, "\n") - } - - for i, ch := range m.channels { - prefix := " " - style := normalStyle - if i == m.selectedChan { - prefix = "> " - if m.focus == focusChannels { - style = selectedStyle - } else { - style = lipgloss.NewStyle().Foreground(lipgloss.Color("229")).Bold(true) - } - } - name := truncateStr(ch.Name, width-4) - line := style.Render(fmt.Sprintf("%s%d. %s", prefix, i+1, name)) - lines = append(lines, line) - } - - for len(lines) < height { - lines = append(lines, "") - } - - return strings.Join(lines, "\n") -} - -// wrapText wraps a line to fit within maxWidth, returning multiple lines. -func wrapText(s string, maxWidth int) []string { - if maxWidth <= 0 { - return []string{s} - } - runes := []rune(s) - if len(runes) <= maxWidth { - return []string{s} - } - var lines []string - for len(runes) > maxWidth { - // Try to break at a space within the last portion - breakAt := maxWidth - for i := maxWidth; i > maxWidth/2; i-- { - if runes[i] == ' ' { - breakAt = i - break - } - } - lines = append(lines, string(runes[:breakAt])) - runes = runes[breakAt:] - // Skip leading space on next line - if len(runes) > 0 && runes[0] == ' ' { - runes = runes[1:] - } - } - if len(runes) > 0 { - lines = append(lines, string(runes)) - } - return lines -} - -func (m *Model) updateViewportContent() { - chNum := m.selectedChan + 1 - msgs, ok := m.messages[chNum] - if !ok { - cached := m.cache.GetMessages(chNum, 5*time.Minute) - if cached != nil { - m.messages[chNum] = cached - msgs = cached - } - } - - if len(msgs) == 0 { - m.viewport.SetContent(" No messages yet. Press r to refresh.") - return - } - - wrapWidth := m.viewport.Width - 4 - if wrapWidth < 20 { - wrapWidth = 20 - } - - var lines []string - for _, msg := range msgs { - ts := time.Unix(int64(msg.Timestamp), 0).Format("15:04 Jan 02") - header := fmt.Sprintf("%s %s", - timestampStyle.Render(ts), - msgIDStyle.Render(fmt.Sprintf("#%d", msg.ID))) - - lines = append(lines, header) - for _, textLine := range strings.Split(msg.Text, "\n") { - wrapped := wrapText(textLine, wrapWidth-2) - for _, wl := range wrapped { - lines = append(lines, " "+wl) - } - } - lines = append(lines, "") - } - - m.viewport.SetContent(strings.Join(lines, "\n")) -} - -func (m *Model) updateLogContent() { - if len(m.logBuf.lines) == 0 { - m.logViewport.SetContent(" Waiting for DNS queries...") - return - } - content := strings.Join(m.logBuf.lines, "\n") - m.logViewport.SetContent(content) - m.logViewport.GotoBottom() -} - -func (m *Model) updateViewportSize() { - channelWidth := m.width / 4 - if channelWidth < 20 { - channelWidth = 20 - } - if channelWidth > 40 { - channelWidth = 40 - } - messageWidth := m.width - channelWidth - 4 - - contentHeight := m.height - 3 - if contentHeight < 10 { - contentHeight = 10 - } - msgHeight := contentHeight * 8 / 10 - logHeight := contentHeight - msgHeight - if logHeight < 3 { - logHeight = 3 - msgHeight = contentHeight - logHeight - } - - m.viewport.Width = messageWidth - 4 - m.viewport.Height = msgHeight - 4 - if m.viewport.Height < 1 { - m.viewport.Height = 1 - } - - m.logViewport.Width = m.width - 8 - m.logViewport.Height = logHeight - 4 - if m.logViewport.Height < 1 { - m.logViewport.Height = 1 - } -} - -func (m *Model) fetchMetadata() tea.Cmd { - return func() tea.Msg { - meta, err := m.fetcher.FetchMetadata() - return metadataMsg{meta: meta, err: err} - } -} - -func (m *Model) loadChannel(channelNum int) tea.Cmd { - if !m.forceRefresh { - if msgs := m.cache.GetMessages(channelNum, 1*time.Minute); msgs != nil { - return func() tea.Msg { - return channelDataMsg{channelNum: channelNum, msgs: msgs} - } - } - } - - blockCount := 0 - idx := channelNum - 1 - if idx >= 0 && idx < len(m.channels) { - blockCount = int(m.channels[idx].Blocks) - } - - return func() tea.Msg { - msgs, err := m.fetcher.FetchChannel(channelNum, blockCount) - return channelDataMsg{channelNum: channelNum, msgs: msgs, err: err} - } -} - -func (m *Model) loadChannelFresh(channelNum int) tea.Cmd { - blockCount := 0 - idx := channelNum - 1 - if idx >= 0 && idx < len(m.channels) { - blockCount = int(m.channels[idx].Blocks) - } - - return func() tea.Msg { - msgs, err := m.fetcher.FetchChannel(channelNum, blockCount) - return channelDataMsg{channelNum: channelNum, msgs: msgs, err: err} - } -} - -func (m *Model) tickCmd() tea.Cmd { - return tea.Tick(5*time.Second, func(time.Time) tea.Msg { - return tickMsg{} - }) -} - -func truncateStr(s string, maxLen int) string { - if utf8.RuneCountInString(s) <= maxLen { - return s - } - runes := []rune(s) - return string(runes[:maxLen-1]) + "..." -} - -// Run starts the TUI application. -func Run(fetcher *client.Fetcher, cache *client.Cache) error { - m := New(fetcher, cache) - p := tea.NewProgram(m, tea.WithAltScreen()) - _, err := p.Run() - return err -} diff --git a/internal/web/static/Vazirmatn-Regular.woff2 b/internal/web/static/Vazirmatn-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..c9824c872b3b4f2eb13a102748f8ef1a6ef97da0 GIT binary patch literal 50684 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 JuV;L@umFHJgLMD^ literal 0 HcmV?d00001 diff --git a/internal/web/static/index.html b/internal/web/static/index.html new file mode 100644 index 0000000..b87d218 --- /dev/null +++ b/internal/web/static/index.html @@ -0,0 +1,474 @@ + + + + + + thefeed + + + +
+
+

thefeed

+ +
+
+ + +
+
+ +
+ + +
+
+
+

No messages yet

+ +
+
+
+
+
+ + + + + + diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..c024f95 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,415 @@ +package web + +import ( + "embed" + "encoding/json" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/sartoopjj/thefeed/internal/client" + "github.com/sartoopjj/thefeed/internal/protocol" + "github.com/sartoopjj/thefeed/internal/version" +) + +//go:embed static +var staticFS embed.FS + +// Config holds the client configuration saved in the data directory. +type Config struct { + Domain string `json:"domain"` + Key string `json:"key"` + Resolvers []string `json:"resolvers"` + QueryMode string `json:"queryMode"` + RateLimit float64 `json:"rateLimit"` +} + +// Server is the web UI server for thefeed client. +type Server struct { + dataDir string + port int + + mu sync.RWMutex + config *Config + fetcher *client.Fetcher + cache *client.Cache + channels []protocol.ChannelInfo + messages map[int][]protocol.Message + + logMu sync.RWMutex + logLines []string + + sseMu sync.Mutex + clients map[chan string]struct{} + + stopRefresh chan struct{} +} + +// New creates a new web server. +func New(dataDir string, port int) (*Server, error) { + if err := os.MkdirAll(dataDir, 0700); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + + s := &Server{ + dataDir: dataDir, + port: port, + messages: make(map[int][]protocol.Message), + clients: make(map[chan string]struct{}), + } + + cfg, err := s.loadConfig() + if err == nil { + s.config = cfg + if err := s.initFetcher(); err != nil { + log.Printf("Warning: could not initialize fetcher: %v", err) + } + } + + return s, nil +} + +// Run starts the web server. +func (s *Server) Run() error { + mux := http.NewServeMux() + + staticSub, _ := fs.Sub(staticFS, "static") + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) + + mux.HandleFunc("/api/status", s.handleStatus) + mux.HandleFunc("/api/config", s.handleConfig) + mux.HandleFunc("/api/channels", s.handleChannels) + mux.HandleFunc("/api/messages/", s.handleMessages) + mux.HandleFunc("/api/refresh", s.handleRefresh) + mux.HandleFunc("/api/events", s.handleSSE) + mux.HandleFunc("/", s.handleIndex) + + addr := fmt.Sprintf("127.0.0.1:%d", s.port) + log.Printf("thefeed client %s", version.Version) + fmt.Printf("\n Open in browser: http://%s\n\n", addr) + + if s.fetcher != nil { + s.startAutoRefresh() + } + + return http.ListenAndServe(addr, mux) +} + +func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + data, err := staticFS.ReadFile("static/index.html") + if err != nil { + http.Error(w, "internal error", 500) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write(data) +} + +func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + + status := map[string]any{ + "configured": s.config != nil, + "version": version.Version, + } + if s.config != nil { + status["domain"] = s.config.Domain + status["channels"] = s.channels + } + writeJSON(w, status) +} + +func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.mu.RLock() + defer s.mu.RUnlock() + if s.config == nil { + writeJSON(w, map[string]any{"configured": false}) + return + } + writeJSON(w, s.config) + + case http.MethodPost: + var cfg Config + if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { + http.Error(w, "invalid JSON", 400) + return + } + if cfg.Domain == "" || cfg.Key == "" || len(cfg.Resolvers) == 0 { + http.Error(w, "domain, key, and resolvers are required", 400) + return + } + if err := s.saveConfig(&cfg); err != nil { + http.Error(w, fmt.Sprintf("save config: %v", err), 500) + return + } + s.mu.Lock() + s.config = &cfg + s.mu.Unlock() + + if err := s.initFetcher(); err != nil { + http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500) + return + } + s.startAutoRefresh() + writeJSON(w, map[string]any{"ok": true}) + + default: + http.Error(w, "method not allowed", 405) + } +} + +func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) { + s.mu.RLock() + defer s.mu.RUnlock() + writeJSON(w, s.channels) +} + +func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(r.URL.Path, "/") + if len(parts) < 4 { + http.Error(w, "missing channel number", 400) + return + } + chNum, err := strconv.Atoi(parts[3]) + if err != nil || chNum < 1 { + http.Error(w, "invalid channel number", 400) + return + } + + s.mu.RLock() + msgs := s.messages[chNum] + s.mu.RUnlock() + + writeJSON(w, msgs) +} + +func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + go s.refresh() + writeJSON(w, map[string]any{"ok": true}) +} + +func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", 500) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + ch := make(chan string, 100) + s.sseMu.Lock() + s.clients[ch] = struct{}{} + s.sseMu.Unlock() + + defer func() { + s.sseMu.Lock() + delete(s.clients, ch) + s.sseMu.Unlock() + }() + + s.logMu.RLock() + for _, line := range s.logLines { + data, _ := json.Marshal(line) + fmt.Fprintf(w, "event: log\ndata: %s\n\n", data) + } + s.logMu.RUnlock() + flusher.Flush() + + ctx := r.Context() + for { + select { + case <-ctx.Done(): + return + case msg := <-ch: + fmt.Fprint(w, msg) + flusher.Flush() + } + } +} + +func (s *Server) broadcast(event string) { + s.sseMu.Lock() + defer s.sseMu.Unlock() + for ch := range s.clients { + select { + case ch <- event: + default: + } + } +} + +func (s *Server) addLog(msg string) { + ts := time.Now().Format("15:04:05") + line := fmt.Sprintf("%s %s", ts, msg) + + s.logMu.Lock() + s.logLines = append(s.logLines, line) + if len(s.logLines) > 200 { + s.logLines = s.logLines[len(s.logLines)-200:] + } + s.logMu.Unlock() + + data, _ := json.Marshal(line) + s.broadcast(fmt.Sprintf("event: log\ndata: %s\n\n", data)) +} + +func (s *Server) initFetcher() error { + s.mu.Lock() + defer s.mu.Unlock() + + cfg := s.config + if cfg == nil { + return fmt.Errorf("no config") + } + + cacheDir := filepath.Join(s.dataDir, "cache") + cache, err := client.NewCache(cacheDir) + if err != nil { + return fmt.Errorf("create cache: %w", err) + } + + fetcher, err := client.NewFetcher(cfg.Domain, cfg.Key, cfg.Resolvers) + if err != nil { + return fmt.Errorf("create fetcher: %w", err) + } + + if cfg.QueryMode == "double" { + fetcher.SetQueryMode(protocol.QueryDoubleLabel) + } + if cfg.RateLimit > 0 { + fetcher.SetRateLimit(cfg.RateLimit) + } + + fetcher.SetLogFunc(func(msg string) { + s.addLog(msg) + }) + + s.fetcher = fetcher + s.cache = cache + return nil +} + +func (s *Server) startAutoRefresh() { + if s.stopRefresh != nil { + close(s.stopRefresh) + } + s.stopRefresh = make(chan struct{}) + stop := s.stopRefresh + + go s.refresh() + + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + s.refresh() + } + } + }() +} + +func (s *Server) refresh() { + s.mu.RLock() + fetcher := s.fetcher + cache := s.cache + s.mu.RUnlock() + + if fetcher == nil { + return + } + + s.addLog("Fetching metadata...") + meta, err := fetcher.FetchMetadata() + if err != nil { + s.addLog(fmt.Sprintf("Error: %v", err)) + return + } + + s.mu.Lock() + s.channels = meta.Channels + s.mu.Unlock() + + if cache != nil { + _ = cache.PutMetadata(meta) + } + + s.broadcast("event: update\ndata: \"channels\"\n\n") + + for i, ch := range meta.Channels { + chNum := i + 1 + blockCount := int(ch.Blocks) + if blockCount <= 0 { + continue + } + + msgs, err := fetcher.FetchChannel(chNum, blockCount) + if err != nil { + s.addLog(fmt.Sprintf("Channel %s error: %v", ch.Name, err)) + continue + } + + s.mu.Lock() + s.messages[chNum] = msgs + s.mu.Unlock() + + if cache != nil { + _ = cache.PutMessages(chNum, msgs) + } + + s.addLog(fmt.Sprintf("Updated %s: %d messages", ch.Name, len(msgs))) + } + + s.broadcast("event: update\ndata: \"messages\"\n\n") +} + +func (s *Server) loadConfig() (*Config, error) { + path := filepath.Join(s.dataDir, "config.json") + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func (s *Server) saveConfig(cfg *Config) error { + path := filepath.Join(s.dataDir, "config.json") + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} diff --git a/scripts/install.sh b/scripts/install.sh index 5291a88..0a6c6c7 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -8,8 +8,7 @@ plain='\033[0m' GITHUB_REPO="sartoopjj/thefeed" INSTALL_DIR="/opt/thefeed" -CONFIG_DIR="/etc/thefeed" -SESSION_DIR="/var/lib/thefeed" +DATA_DIR="${INSTALL_DIR}/data" SERVICE_FILE="/etc/systemd/system/thefeed-server.service" # check root @@ -99,13 +98,13 @@ download_binary() { } setup_config() { - mkdir -p "$CONFIG_DIR" "$SESSION_DIR" + mkdir -p "$DATA_DIR" # Channels file - if [[ ! -f "$CONFIG_DIR/channels.txt" ]]; then + if [[ ! -f "$DATA_DIR/channels.txt" ]]; then echo -e "\n${green}Setting up channels...${plain}" - echo "# Telegram channel usernames (one per line)" > "$CONFIG_DIR/channels.txt" - echo "# Lines starting with # are comments" >> "$CONFIG_DIR/channels.txt" + echo "# Telegram channel usernames (one per line)" > "$DATA_DIR/channels.txt" + echo "# Lines starting with # are comments" >> "$DATA_DIR/channels.txt" echo "" echo -e "${yellow}Enter Telegram channel usernames (one per line, empty line to finish):${plain}" @@ -115,15 +114,15 @@ setup_config() { break fi channel="${channel#@}" - echo "@$channel" >> "$CONFIG_DIR/channels.txt" + echo "@$channel" >> "$DATA_DIR/channels.txt" echo -e " ${green}Added @${channel}${plain}" done else - echo -e "${yellow}Channels file already exists: ${CONFIG_DIR}/channels.txt${plain}" + echo -e "${yellow}Channels file already exists: ${DATA_DIR}/channels.txt${plain}" fi # Environment file - if [[ ! -f "$CONFIG_DIR/thefeed.env" ]]; then + if [[ ! -f "$DATA_DIR/thefeed.env" ]]; then echo -e "\n${green}═══════════════════════════════════════${plain}" echo -e "${green} Server Configuration${plain}" echo -e "${green}═══════════════════════════════════════${plain}" @@ -167,7 +166,7 @@ setup_config() { read -rp "DNS listen address [0.0.0.0:53]: " listen_addr listen_addr="${listen_addr:-0.0.0.0:53}" - cat > "$CONFIG_DIR/thefeed.env" < "$DATA_DIR/thefeed.env" < "$SERVICE_FILE" <