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 0000000..c9824c8 Binary files /dev/null and b/internal/web/static/Vazirmatn-Regular.woff2 differ 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" <