package web import ( "context" "crypto/rand" "crypto/subtle" "embed" "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/fs" "log" mrand "math/rand/v2" "net" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/sartoopjj/thefeed/internal/client" "github.com/sartoopjj/thefeed/internal/protocol" "github.com/sartoopjj/thefeed/internal/update" "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"` // Timeout is the per-query DNS timeout in seconds (0 = default 15 s). // Also used as the resolver health-check probe timeout. Timeout float64 `json:"timeout,omitempty"` // Scatter is the number of resolvers queried simultaneously per DNS block request // (0 or 1 = sequential, 4 = default parallel pair). Scatter int `json:"scatter,omitempty"` // AutoScan enables hourly automatic resolver health-check scans. // nil means enabled (default); set to a false pointer to disable. AutoScan *bool `json:"autoScan,omitempty"` } // Profile wraps a Config with a user-chosen nickname and a unique ID. type Profile struct { ID string `json:"id"` Nickname string `json:"nickname"` Config Config `json:"config"` AutoUpdate []string `json:"autoUpdate,omitempty"` AutoUpdateInterval int `json:"autoUpdateInterval,omitempty"` } const ( // minAutoUpdateInterval is the floor — never tick faster than once per // minute, even if the user sets something silly. The DNS path is // expensive and the server's own fetch cycle is much longer. minAutoUpdateInterval = 60 * time.Second // serverFetchSettleDelay is how long after nextFetch we wait before // asking the server for fresh data — gives it time to process the // upstream Telegram fetch and have a coherent metadata snapshot. serverFetchSettleDelay = 30 * time.Second // autoUpdateStartupDelay defers the first tick so the initial metadata // + resolver checks have a chance to land before we start polling. autoUpdateStartupDelay = 30 * time.Second ) // SavedResolverScore stores persistent resolver performance data. type SavedResolverScore struct { Success int64 `json:"success"` Failure int64 `json:"failure"` TotalMs int64 `json:"totalMs"` } // ActiveList is a named subset of the resolver bank that the user can // switch between — e.g., "Home", "Office", "Mobile". The currently // selected list is what the fetcher uses; switching to a different list // hot-swaps the active resolvers without rescanning. type ActiveList struct { Name string `json:"name"` Resolvers []string `json:"resolvers"` LastUsed int64 `json:"lastUsed,omitempty"` } // ProfileList is the on-disk structure for profiles.json. type ProfileList struct { Active string `json:"active"` // ID of active profile Profiles []Profile `json:"profiles"` // FontSize stores user's preferred font size (0 = default 14). FontSize int `json:"fontSize,omitempty"` Debug bool `json:"debug,omitempty"` Theme string `json:"theme,omitempty"` Lang string `json:"lang,omitempty"` // ResolverBank is the shared pool of DNS resolvers used by all profiles. ResolverBank []string `json:"resolverBank,omitempty"` // ResolverScores stores accumulated performance data for bank resolvers. ResolverScores map[string]*SavedResolverScore `json:"resolverScores,omitempty"` // ActiveLists holds named resolver subsets so the user can keep one // list per situation (home, office, mobile data) and switch by name // instead of rescanning. The currently-selected list is named in // SelectedList; its resolvers are what the fetcher uses. // // Migration: legacy installs without ActiveLists are upgraded on // first load — their last_scan.json (or current bank) becomes a // single list named "Default". ActiveLists []ActiveList `json:"activeLists,omitempty"` SelectedList string `json:"selectedList,omitempty"` // ScanPromptOff suppresses the startup "scan resolvers?" prompt. // Persisted server-side so it survives Android's per-launch port // changes (each launch picks a fresh port → different localStorage // origin → flag was lost on every restart). ScanPromptOff bool `json:"scanPromptOff,omitempty"` // ProfilePicsEnabled enables fetching avatars over DNS when the // GitHub relay can't serve them. Off by default. ProfilePicsEnabled bool `json:"profilePicsEnabled,omitempty"` } // lastScanData is the on-disk structure for last_scan.json. type lastScanData struct { Resolvers []string `json:"resolvers"` ScannedAt int64 `json:"scannedAt"` } // channelsCacheEntry is one profile's startup snapshot. type channelsCacheEntry struct { Channels []protocol.ChannelInfo `json:"channels"` NextFetch uint32 `json:"nextFetch"` SavedAt int64 `json:"savedAt"` } // channelsCacheFile maps profile ID → snapshot. type channelsCacheFile map[string]*channelsCacheEntry // Server is the web UI server for thefeed client. type Server struct { dataDir string port int host string password string // admin password; empty means no auth mu sync.RWMutex config *Config fetcher *client.Fetcher cache *client.Cache channels []protocol.ChannelInfo messages map[int][]protocol.Message telegramLoggedIn bool nextFetch uint32 latestVersion string lastMsgIDs map[int]uint32 // last seen message IDs per channel lastHashes map[int]uint32 // last seen content hashes per channel // checker is the active resolver health-checker; set by initFetcher. checker *client.ResolverChecker // metaFetchedAt is when channels/nextFetch were last fetched from DNS. // refreshChannel reuses the in-memory metadata when it is younger than metaCacheTTL. metaFetchedAt time.Time metaCacheTTL time.Duration // fetcherCtx/fetcherCancel control the lifetime of the active fetcher's // background goroutines (rate limiter, noise, resolver checker). // They are cancelled and recreated each time the config changes. fetcherCtx context.Context fetcherCancel context.CancelFunc // refreshMu / refreshCancels allow a new refresh to cancel an in-progress one. // Each channel gets its own cancel func so concurrent channel refreshes are allowed. // Key 0 is reserved for metadata-only refreshes. refreshMu sync.Mutex refreshCancels map[int]context.CancelFunc logMu sync.RWMutex logLines []string sseMu sync.Mutex clients map[chan string]struct{} stopRefresh chan struct{} scanner *client.ResolverScanner // titlesMu guards the background title-fetch state. // Only one goroutine fetches titles at a time; errors impose a 5-minute backoff. titlesMu sync.Mutex titlesLoading bool titlesBackoffUntil time.Time // dlMu guards dlProgress. Active media downloads register their block // counter here so the frontend can poll /api/media/progress and show // per-block updates instead of waiting for byte chunks. dlMu sync.Mutex dlProgress map[string]*mediaDLProgress // relayInfo caches the latest answer from RelayInfoChannel so the fast // media path doesn't pay a DNS round trip per file. relayInfo *relayCache // mediaCache is a disk-backed store for downloaded media bytes so that // multiple devices on the same network share a single DNS-tunnelled // fetch. Entries expire after 7 days. mediaCache *mediaDiskCache // rescanReplaceList: set by handleRescan, consumed (and cleared) // by the next SetOnScanDone callback so an explicit user rescan // overwrites the selected list instead of just topping it up. rescanFlagMu sync.Mutex rescanReplaceList bool // profilesMu serialises read-modify-write cycles on profiles.json. profilesMu sync.Mutex // Optional, removable backup feed (Telegram-via-Translate proxy). telemirror *telemirrorHub // Optional per-channel profile pictures cache. profilePics *profilePicsHub } // New creates a new web server. func New(dataDir string, port int, host string, password string) (*Server, error) { if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("create data dir: %w", err) } // Remove stale cache files on every startup, even before a config is loaded. go func() { if c, err := client.NewCache(filepath.Join(dataDir, "cache")); err == nil { _ = c.Cleanup() } }() scanner := client.NewResolverScanner() mediaCache, mcErr := newMediaDiskCache(filepath.Join(dataDir, "media-cache"), 7*24*time.Hour) if mcErr != nil { log.Printf("Warning: media disk cache disabled: %v", mcErr) } s := &Server{ dataDir: dataDir, port: port, host: host, password: password, messages: make(map[int][]protocol.Message), clients: make(map[chan string]struct{}), refreshCancels: make(map[int]context.CancelFunc), lastMsgIDs: make(map[int]uint32), lastHashes: make(map[int]uint32), scanner: scanner, mediaCache: mediaCache, dlProgress: make(map[string]*mediaDLProgress), relayInfo: newRelayCache(), telemirror: newTelemirrorHub(dataDir), profilePics: newProfilePicsHub(dataDir), } if mediaCache != nil { go mediaCache.Cleanup() go s.runMediaCacheSweep() } // Migrate per-profile resolvers into the shared bank on first run. s.migrateResolverBank() 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) } } else { // config.json missing — try to bootstrap from the active profile if pl, plErr := s.loadProfiles(); plErr == nil && pl.Active != "" { for _, p := range pl.Profiles { if p.ID == pl.Active { _ = s.saveConfig(&p.Config) s.config = &p.Config if err := s.initFetcher(); err != nil { log.Printf("Warning: could not initialize fetcher from profile: %v", err) } break } } } } if cc := s.loadChannelsCache(); cc != nil { s.channels = cc.Channels s.nextFetch = cc.NextFetch } return s, nil } // Run starts the web server, binding to s.host:s.port. func (s *Server) Run() error { return s.serve(nil) } // Serve runs the web server on an already-bound listener. Used by the // mobile entry where the listener is opened first to discover the // kernel-assigned port. func (s *Server) Serve(ln net.Listener) error { return s.serve(ln) } func (s *Server) serve(ln net.Listener) 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/rescan", s.handleRescan) mux.HandleFunc("/api/send", s.handleSend) mux.HandleFunc("/api/admin", s.handleAdmin) mux.HandleFunc("/api/events", s.handleSSE) mux.HandleFunc("/api/profiles", s.handleProfiles) mux.HandleFunc("/api/profiles/switch", s.handleProfileSwitch) mux.HandleFunc("/api/auto-update", s.handleAutoUpdate) mux.HandleFunc("/api/auto-update/toggle", s.handleAutoUpdateToggle) mux.HandleFunc("/api/settings", s.handleSettings) mux.HandleFunc("/api/version-check", s.handleVersionCheck) mux.HandleFunc("/api/update/github", s.handleGitHubUpdateCheck) mux.HandleFunc("/api/cache/clear", s.handleClearCache) mux.HandleFunc("/api/bg-image", s.handleBgImage) mux.HandleFunc("/api/resolvers/apply-saved", s.handleApplySavedResolvers) mux.HandleFunc("/api/resolvers/active", s.handleActiveResolvers) mux.HandleFunc("/api/resolvers/remove", s.handleRemoveResolver) mux.HandleFunc("/api/resolvers/reset-stats", s.handleResetResolverStats) mux.HandleFunc("/api/resolvers/bank", s.handleResolverBank) mux.HandleFunc("/api/resolvers/bank/cleanup", s.handleResolverBankCleanup) mux.HandleFunc("/api/scanner/start", s.handleScannerStart) mux.HandleFunc("/api/scanner/stop", s.handleScannerStop) mux.HandleFunc("/api/scanner/pause", s.handleScannerPause) mux.HandleFunc("/api/scanner/resume", s.handleScannerResume) mux.HandleFunc("/api/scanner/progress", s.handleScannerProgress) mux.HandleFunc("/api/scanner/apply", s.handleScannerApply) mux.HandleFunc("/api/scanner/presets", s.handleScannerPresets) mux.HandleFunc("/api/resolvers/lists", s.handleResolverLists) mux.HandleFunc("/api/resolvers/lists/select", s.handleResolverListSelect) mux.HandleFunc("/api/resolvers/lists/save", s.handleResolverListSave) mux.HandleFunc("/api/resolvers/lists/rename", s.handleResolverListRename) mux.HandleFunc("/api/resolvers/lists/add", s.handleResolverListAdd) // Media (image/file) downloader: assembles a binary blob from a media // channel and streams it back. See internal/web/media.go for the param // contract. mux.HandleFunc("/api/media/get", s.handleMediaGet) mux.HandleFunc("/api/media/progress", s.handleMediaProgress) // Optional telemirror feature — see internal/telemirror/. mux.HandleFunc("/api/telemirror/channels", s.telemirror.handleChannels) mux.HandleFunc("/api/telemirror/channel/", s.telemirror.handleChannel) mux.HandleFunc("/api/telemirror/img", s.telemirror.handleImg) mux.HandleFunc("/api/telemirror/avatar/", s.telemirror.handleAvatar) // Profile-pics cache + control endpoints. mux.HandleFunc("/api/profile-pics/", s.profilePics.handleProfilePic) mux.HandleFunc("/api/profile-pics", s.handleProfilePicsList) mux.HandleFunc("/api/profile-pics/refresh", s.handleProfilePicsRefresh) mux.HandleFunc("/api/profile-pics/progress", s.handleProfilePicsProgress) mux.HandleFunc("/", s.handleIndex) // Listen on the specified host (default 127.0.0.1) addr := fmt.Sprintf("%s:%d", s.host, s.port) log.Printf("thefeed client %s", version.Version) fmt.Printf("\n Open in browser: http://%s:%d\n\n", s.host, s.port) if s.fetcher != nil { // Boot-time fast path for the resolver list: // 1. The user's currently-selected named list (instant, no // probing) — populated by the migration on first load. // 2. last_scan.json (legacy fast path for old installs). // 3. Full health-check scan. if applied := s.applySelectedList(); applied { s.checker.StartPeriodic(s.fetcherCtx) go s.refreshMetadataOnly() } else if ls := s.loadLastScan(); ls != nil && len(ls.Resolvers) > 0 { // Pin the runtime pool to the saved scan and propagate // those resolvers into the selected list and bank when // either is empty — without this, the user sees // counts of "0" in the UI even though the fetcher is // happily using the saved resolvers. s.fetcher.UpdateResolverPool(ls.Resolvers) s.fetcher.SetActiveResolvers(ls.Resolvers) s.persistLastScanToProfiles(ls.Resolvers) s.checker.StartPeriodic(s.fetcherCtx) go s.refreshMetadataOnly() } else { s.startCheckerThenRefresh() } } var handler http.Handler = mux if s.password != "" { pw := s.password handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, pass, ok := r.BasicAuth() if !ok || subtle.ConstantTimeCompare([]byte(pass), []byte(pw)) != 1 { w.Header().Set("WWW-Authenticate", `Basic realm="thefeed"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } mux.ServeHTTP(w, r) }) } srv := &http.Server{ Addr: addr, Handler: handler, // ReadHeaderTimeout protects against slow-loris on the request // header. The body itself can be large (Telegram send-message // uploads), and the response can be slow (DNS-tunneled media // streams take many minutes for multi-block files in censored // networks). So zero out ReadTimeout/WriteTimeout and bound the // idle period on the connection itself. ReadHeaderTimeout: 30 * time.Second, IdleTimeout: 30 * time.Minute, } if ln != nil { return srv.Serve(ln) } return srv.ListenAndServe() } 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, "hasPassword": s.password != "", } if s.config != nil { status["domain"] = s.config.Domain status["channels"] = s.channels status["telegramLoggedIn"] = s.telegramLoggedIn status["nextFetch"] = s.nextFetch status["latestVersion"] = s.latestVersion // Include last resolver scan if recent (<24 h) so the frontend can offer a quick-start. if ls := s.loadLastScan(); ls != nil { status["lastScan"] = map[string]any{ "resolvers": ls.Resolvers, "scannedAt": ls.ScannedAt, "count": len(ls.Resolvers), } } } writeJSON(w, status) } // handleConfig handles GET (read) and POST (write) of client configuration. // POST is authenticated when a global password is set (via the middleware). 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 } // Add config resolvers to the shared bank. if len(cfg.Resolvers) > 0 { pl, _ := s.loadProfiles() if pl == nil { pl = &ProfileList{} } addToBank(pl, cfg.Resolvers) _ = s.saveProfiles(pl) } 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.startCheckerThenRefresh() 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] chs := s.channels cache := s.cache s.mu.RUnlock() // Serve the persistent on-disk cache when available — // it contains the full merged history (up to 200 messages) keyed by channel name. if cache != nil && chNum >= 1 && chNum <= len(chs) { if result := cache.GetMessages(chs[chNum-1].Name); result != nil { writeJSON(w, result) return } } // Fall back to the in-memory fresh fetch (no accumulated history). writeJSON(w, client.NewMessagesResult(msgs)) } func (s *Server) handleRefresh(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } // Background (quiet) metadata-only refreshes skip silently if one is already running, // so the auto-refresh timer never cancels a slow in-progress fetch. // Channel refreshes are NOT skipped here — refreshChannel has its own duplicate guard. chParam := r.URL.Query().Get("channel") if r.URL.Query().Get("quiet") == "1" && chParam == "" { s.refreshMu.Lock() running := len(s.refreshCancels) > 0 s.refreshMu.Unlock() if running { writeJSON(w, map[string]any{"ok": true, "skipped": true}) return } } if chParam != "" { chNum, err := strconv.Atoi(chParam) if err != nil || chNum < 1 { http.Error(w, "invalid channel", 400) return } go s.refreshChannel(chNum) } else { go s.refreshMetadataOnly() } writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } s.mu.RLock() checker := s.checker fetcher := s.fetcher baseCtx := s.fetcherCtx s.mu.RUnlock() if checker == nil || baseCtx == nil { http.Error(w, "not configured", 400) return } // Widen the fetcher's pool to the full bank so we probe every // known resolver, not just the currently-active subset. // rescanReplaceList tells SetOnScanDone to overwrite the list. pl, _ := s.loadProfiles() if pl != nil && len(pl.ResolverBank) > 0 && fetcher != nil { fetcher.UpdateResolverPool(pl.ResolverBank) } s.rescanFlagMu.Lock() s.rescanReplaceList = true s.rescanFlagMu.Unlock() go func() { // Cancel any in-progress metadata refresh so it doesn't race with the // scan — we want fresh resolver data before we hit DNS again. s.refreshMu.Lock() for k, cancel := range s.refreshCancels { cancel() delete(s.refreshCancels, k) } s.refreshMu.Unlock() if checker.CheckNow(baseCtx) { // Cool-down: give resolvers time to recover from the scan's DNS // queries before we immediately hit them again with a fetch. sleep := 3*time.Second + time.Duration(mrand.IntN(13))*time.Second // 3–15 s select { case <-baseCtx.Done(): return case <-time.After(sleep): } s.refreshMetadataOnly() } }() writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleSend(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { Channel int `json:"channel"` Text string `json:"text"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } if req.Channel < 1 || req.Text == "" { http.Error(w, "channel and text are required", 400) return } if len(req.Text) > 4000 { http.Error(w, "message too long (max 4000 chars)", 400) return } s.mu.RLock() fetcher := s.fetcher basectx := s.fetcherCtx s.mu.RUnlock() if fetcher == nil || basectx == nil { http.Error(w, "not configured", 400) return } ctx, cancel := context.WithTimeout(basectx, 5*time.Minute) defer cancel() s.addLog(fmt.Sprintf("Sending message to channel %d (%d chars)...", req.Channel, len(req.Text))) if err := fetcher.SendMessage(ctx, req.Channel, req.Text); err != nil { log.Printf("[web] send error ch=%d: %v", req.Channel, err) s.addLog("Error: failed to send message") http.Error(w, "failed to send message", 500) return } s.addLog("Message sent successfully") writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { Command string `json:"command"` Arg string `json:"arg"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } if req.Command == "" { http.Error(w, "command is required", 400) return } s.mu.RLock() fetcher := s.fetcher basectx := s.fetcherCtx s.mu.RUnlock() if fetcher == nil || basectx == nil { http.Error(w, "not configured", 400) return } ctx, cancel := context.WithTimeout(basectx, 5*time.Minute) defer cancel() s.addLog(fmt.Sprintf("Admin command: %s %s", req.Command, req.Arg)) var cmd protocol.AdminCmd switch req.Command { case "add_channel": cmd = protocol.AdminCmdAddChannel case "remove_channel": cmd = protocol.AdminCmdRemoveChannel case "list_channels": cmd = protocol.AdminCmdListChannels case "refresh": cmd = protocol.AdminCmdRefresh default: http.Error(w, "unknown command", 400) return } result, err := fetcher.SendAdminCommand(ctx, cmd, req.Arg) if err != nil { log.Printf("[web] admin error: %v", err) s.addLog(fmt.Sprintf("Admin error: %v", err)) http.Error(w, "admin command failed", 500) return } s.addLog(fmt.Sprintf("Admin result: %s", result)) writeJSON(w, map[string]any{"ok": true, "result": result}) } 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 } // Disable the server-wide WriteTimeout for this long-lived SSE connection. rc := http.NewResponseController(w) _ = rc.SetWriteDeadline(time.Time{}) 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, 500) 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() ping := time.NewTicker(30 * time.Second) defer ping.Stop() for { select { case <-ctx.Done(): return case <-ping.C: // SSE comment line as heartbeat — keeps the connection alive and // lets us detect a dead client (write error). if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil { return } flusher.Flush() case msg := <-ch: if _, err := fmt.Fprint(w, msg); err != nil { return } 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() // Cancel goroutines from the previous fetcher configuration. // This also cancels any in-progress manual rescan (via the context chain). // Preserve resolver stats across fetcher re-creation (e.g. profile switch). var prevStats map[string][3]int64 if s.fetcher != nil { prevStats = s.fetcher.ExportStats() // Persist accumulated stats before destroying the old fetcher. s.persistResolverScores(prevStats) } if s.fetcherCancel != nil { s.fetcherCancel() } cfg := s.config if cfg == nil { return fmt.Errorf("no config") } // Load the shared resolver bank and preferences from profiles.json. var bankResolvers []string var debug bool var savedScores map[string]*SavedResolverScore if pl, plErr := s.loadProfiles(); plErr == nil { debug = pl.Debug if len(pl.ResolverBank) > 0 { bankResolvers = pl.ResolverBank } savedScores = pl.ResolverScores } // Use resolver bank; fall back to per-profile resolvers for backward compat. resolvers := cfg.Resolvers if len(bankResolvers) > 0 { resolvers = bankResolvers } 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, resolvers) if err != nil { return fmt.Errorf("create fetcher: %w", err) } // Restore resolver stats: prefer in-memory stats from the previous fetcher, // fall back to persisted scores for fresh starts. if prevStats != nil { fetcher.ImportStats(prevStats) } else if len(savedScores) > 0 { m := make(map[string][3]int64) for addr, ss := range savedScores { key := addr if !strings.Contains(key, ":") { key += ":53" } m[key] = [3]int64{ss.Success, ss.Failure, ss.TotalMs} } fetcher.ImportStats(m) } if cfg.QueryMode == "double" { fetcher.SetQueryMode(protocol.QueryMultiLabel) } fetcher.SetDebug(debug) s.scanner.SetDebug(debug) if cfg.RateLimit > 0 { fetcher.SetRateLimit(cfg.RateLimit) } if cfg.Scatter > 1 { fetcher.SetScatter(cfg.Scatter) } timeout := 15 * time.Second if cfg.Timeout > 0 { timeout = time.Duration(cfg.Timeout * float64(time.Second)) } fetcher.SetTimeout(timeout) fetcher.SetLogFunc(func(msg string) { s.addLog(msg) }) // Create a shared context for this fetcher's lifetime. ctx, cancel := context.WithCancel(context.Background()) s.fetcherCtx = ctx s.fetcherCancel = cancel // Start rate limiter and noise goroutines. fetcher.Start(ctx) // Initialise resolver health-checker; start it (with initial scan → then refresh) // via startCheckerThenRefresh, called by every initFetcher call site. checker := client.NewResolverChecker(fetcher, timeout) checker.SetLogFunc(func(msg string) { s.addLog(msg) }) checker.SetOnScanDone(func(healthy []string) { if len(healthy) > 0 { s.saveLastScan(healthy) } s.persistScanResultsToList(healthy) }) // nil means enabled (the default); only an explicit false pointer disables it. autoScan := cfg.AutoScan == nil || *cfg.AutoScan checker.SetAutoScan(autoScan) s.checker = checker s.fetcher = fetcher s.cache = cache go cache.Cleanup() // remove channel files not updated in 7 days // Goroutine dies with fetcherCtx, so a profile switch / config change // stops it cleanly. go s.runAutoUpdateLoop(ctx) return nil } // runAutoUpdateLoop refreshes the active profile's AutoUpdate channels on a // schedule that follows the server's own fetch cycle — there's no point // polling more often than the server actually pulls fresh data from // Telegram. User-set Profile.AutoUpdateInterval is honoured if it's >= the // 60s floor; otherwise we align with nextFetch + settle delay. func (s *Server) runAutoUpdateLoop(ctx context.Context) { select { case <-time.After(autoUpdateStartupDelay): case <-ctx.Done(): return } var lastTick time.Time for { wait := s.nextAutoUpdateWait(lastTick) select { case <-ctx.Done(): return case <-time.After(wait): } if !s.canAutoUpdate() { continue } s.tickAutoUpdate() lastTick = time.Now() } } // nextAutoUpdateWait returns how long to sleep before the next tick. Honours // user override when set sensibly; otherwise sleeps until just after the // server's next Telegram fetch so we always pull just-refreshed data. func (s *Server) nextAutoUpdateWait(lastTick time.Time) time.Duration { pl, _ := s.loadProfiles() if pl != nil && pl.Active != "" { for _, p := range pl.Profiles { if p.ID != pl.Active { continue } if p.AutoUpdateInterval > 0 { user := time.Duration(p.AutoUpdateInterval) * time.Second if user < minAutoUpdateInterval { user = minAutoUpdateInterval } return user } break } } s.mu.RLock() nf := s.nextFetch s.mu.RUnlock() if nf == 0 { return minAutoUpdateInterval } target := time.Unix(int64(nf), 0).Add(serverFetchSettleDelay) delay := time.Until(target) if delay < minAutoUpdateInterval { delay = minAutoUpdateInterval } if !lastTick.IsZero() { if since := time.Since(lastTick); since < minAutoUpdateInterval { if rem := minAutoUpdateInterval - since; rem > delay { delay = rem } } } return delay } // canAutoUpdate returns false when we should skip a tick: server hasn't // produced metadata yet (channel list empty), or the resolver scanner is // busy (it'd race with our DNS fetches), or there's no fetcher. func (s *Server) canAutoUpdate() bool { s.mu.RLock() channels := s.channels fetcher := s.fetcher scanner := s.scanner s.mu.RUnlock() if fetcher == nil || len(channels) == 0 { return false } if scanner != nil { switch scanner.State() { case client.ScannerRunning, client.ScannerPaused: return false } } return true } func (s *Server) tickAutoUpdate() { pl, err := s.loadProfiles() if err != nil || pl == nil || pl.Active == "" { return } var watch []string for _, p := range pl.Profiles { if p.ID == pl.Active { watch = p.AutoUpdate break } } if len(watch) == 0 { return } s.mu.RLock() channels := s.channels s.mu.RUnlock() if len(channels) == 0 { return } wantSet := make(map[string]bool, len(watch)) for _, name := range watch { wantSet[strings.TrimPrefix(strings.TrimSpace(name), "@")] = true } for i, ch := range channels { if !wantSet[ch.Name] { continue } go s.refreshChannel(i + 1) // 1-indexed } } func (s *Server) checkLatestVersion(ctx context.Context) (string, error) { s.mu.RLock() cfg := s.config s.mu.RUnlock() if cfg == nil { return "", fmt.Errorf("no config") } // Use resolver bank; fall back to per-profile resolvers. resolvers := cfg.Resolvers var debug bool if pl, plErr := s.loadProfiles(); plErr == nil { debug = pl.Debug if len(pl.ResolverBank) > 0 { resolvers = pl.ResolverBank } } fetcher, err := client.NewFetcher(cfg.Domain, cfg.Key, resolvers) if err != nil { return "", fmt.Errorf("create fetcher: %w", err) } if cfg.QueryMode == "double" { fetcher.SetQueryMode(protocol.QueryMultiLabel) } fetcher.SetDebug(debug) s.scanner.SetDebug(debug) if cfg.RateLimit > 0 { fetcher.SetRateLimit(cfg.RateLimit) } if cfg.Scatter > 1 { fetcher.SetScatter(cfg.Scatter) } timeout := 15 * time.Second if cfg.Timeout > 0 { timeout = time.Duration(cfg.Timeout * float64(time.Second)) } fetcher.SetTimeout(timeout) fetcher.SetLogFunc(func(msg string) { s.addLog(msg) }) fetcher.SetActiveResolvers(resolvers) checkCtx, cancel := context.WithTimeout(ctx, timeout*3) defer cancel() fetcher.Start(checkCtx) v, err := fetcher.FetchLatestVersion(checkCtx) if err != nil { return "", err } s.mu.Lock() s.latestVersion = v s.mu.Unlock() return v, nil } // startCheckerThenRefresh runs the resolver health-check pass synchronously // (in a new goroutine), then starts the periodic checker and fetches metadata. // This ensures fresh resolver data is used for the very first metadata query. func (s *Server) startCheckerThenRefresh() { s.mu.RLock() checker := s.checker ctx := s.fetcherCtx s.mu.RUnlock() if checker == nil { return } checker.StartAndNotify(ctx, func() { s.refreshMetadataOnly() }) } // skipCheckerUseSaved uses saved resolvers from the last scan and starts // periodic health checks without an initial scan pass. // If no saved resolvers are available, falls back to a full scan with // retry-every-minute until at least one resolver is found. func (s *Server) skipCheckerUseSaved() { s.mu.RLock() checker := s.checker ctx := s.fetcherCtx fetcher := s.fetcher s.mu.RUnlock() if checker == nil || fetcher == nil { return } if ls := s.loadLastScan(); ls != nil && len(ls.Resolvers) > 0 { fetcher.SetActiveResolvers(ls.Resolvers) checker.StartPeriodic(ctx) go s.refreshMetadataOnly() } else { // No saved resolvers — do a full scan (with retry-every-minute). checker.StartAndNotify(ctx, func() { s.refreshMetadataOnly() }) } } // nextFetchDeadline returns the Time when the server will next fetch from Telegram. // Returns zero value if nextFetch is not set or has already passed. func (s *Server) nextFetchDeadline() time.Time { s.mu.RLock() nf := s.nextFetch s.mu.RUnlock() if nf == 0 { return time.Time{} } t := time.Unix(int64(nf), 0) if time.Until(t) <= 0 { return time.Time{} // already passed } return t } // waitForServerFetch blocks until the server's Telegram fetch is likely complete // (nextFetch + 30 s), emitting a countdown progress event each second so the UI // can render a live progress bar. Returns true on completion, false if ctx cancelled. func (s *Server) waitForServerFetch(ctx context.Context, nf uint32) bool { const serverFetchDuration = 30 * time.Second deadline := time.Unix(int64(nf), 0).Add(serverFetchDuration) totalWait := time.Until(deadline) if totalWait <= 0 { totalWait = serverFetchDuration } totalSec := int(totalWait.Seconds()) + 1 s.addLog(fmt.Sprintf("SERVER_FETCH_WAIT start %d", totalSec)) timer := time.NewTimer(totalWait) defer timer.Stop() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() start := time.Now() for { select { case <-ctx.Done(): s.addLog("SERVER_FETCH_WAIT done") return false case <-timer.C: s.addLog("SERVER_FETCH_WAIT done") return true case <-ticker.C: remaining := int((totalWait - time.Since(start)).Seconds()) if remaining < 0 { remaining = 0 } s.addLog(fmt.Sprintf("SERVER_FETCH_WAIT tick %d/%d", remaining, totalSec)) } } } func (s *Server) refreshMetadataOnly() { // Don't fetch before resolver scanning has found at least one healthy resolver. // The onFirstDone callback in startCheckerThenRefresh is the canonical first trigger. s.mu.RLock() fetcherEarly := s.fetcher s.mu.RUnlock() if fetcherEarly != nil && len(fetcherEarly.Resolvers()) == 0 { s.addLog("Waiting for resolver scan to complete...") return } // Cancel any in-progress metadata refresh and start a new cancellable one. const metaKey = 0 s.refreshMu.Lock() if prev, ok := s.refreshCancels[metaKey]; ok { prev() } s.mu.RLock() basectx := s.fetcherCtx fetcher := s.fetcher cache := s.cache s.mu.RUnlock() if fetcher == nil || basectx == nil { delete(s.refreshCancels, metaKey) s.refreshMu.Unlock() return } // Child context: cancelled either by the next refresh call or by a config change. ctx, cancel := context.WithCancel(basectx) s.refreshCancels[metaKey] = cancel s.refreshMu.Unlock() defer func() { cancel() s.refreshMu.Lock() delete(s.refreshCancels, metaKey) s.refreshMu.Unlock() }() s.addLog(fmt.Sprintf("Fetching metadata... (%d active resolvers)", len(fetcher.Resolvers()))) // If the server's next Telegram fetch is imminent (within 15 s), wait for it first. if dl := s.nextFetchDeadline(); !dl.IsZero() && time.Until(dl) < 15*time.Second { s.mu.RLock() nf := s.nextFetch s.mu.RUnlock() if !s.waitForServerFetch(ctx, nf) { return } } meta, err := fetcher.FetchMetadata(ctx) if err != nil { if ctx.Err() != nil { s.addLog("Refresh cancelled") return } // Detect invalid passphrase from crypto errors errStr := err.Error() if strings.Contains(errStr, "integrity check failed") || strings.Contains(errStr, "message authentication failed") || strings.Contains(errStr, "cipher") { s.addLog("Error: Invalid passphrase — check your encryption key in Settings") } else { s.addLog(fmt.Sprintf("Error: %v", err)) } return } channels := meta.Channels if cache != nil { if cached := cache.GetAllTitles(); len(cached) > 0 { for i := range channels { if t := cached[channels[i].Name]; t != "" { channels[i].DisplayName = t } } } } s.mu.Lock() s.channels = channels s.telegramLoggedIn = meta.TelegramLoggedIn s.nextFetch = meta.NextFetch s.metaFetchedAt = time.Now() s.mu.Unlock() if cache != nil { _ = cache.PutMetadata(meta) } s.saveChannelsCache(channels, meta.NextFetch) s.broadcast("event: update\ndata: \"channels\"\n\n") needsFetch := false for _, ch := range channels { if ch.DisplayName == "" { needsFetch = true break } } if needsFetch { go s.ensureTitlesFetched(basectx) } go s.maybeRefreshProfilePics(basectx) } // maybeRefreshProfilePics fires a refresh when GitHub relay is up or // the user has opted into the DNS path. No-op otherwise; hub coalesces. func (s *Server) maybeRefreshProfilePics(parentCtx context.Context) { s.mu.RLock() hub := s.profilePics fetcher := s.fetcher rc := s.relayInfo s.mu.RUnlock() if hub == nil || fetcher == nil { return } dnsAllowed := s.profilePicsEnabled() githubLikelyUp := false if rc != nil { ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second) info, err := rc.get(ctx, fetcher) cancel() if err == nil && info.GitHubRepo != "" { githubLikelyUp = true } } if !dnsAllowed && !githubLikelyUp { return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() onStored := func(string) { s.broadcast("event: update\ndata: \"profile-pics\"\n\n") } if err := hub.refresh(ctx, fetcher, dnsAllowed, s.fetchFromGitHubRelayBytes, onStored); err == nil { s.broadcast("event: update\ndata: \"profile-pics\"\n\n") } } // ensureTitlesFetched fetches channel display names from TitlesChannel in the background. // At most one fetch runs at a time; errors impose a 5-minute backoff so that an // outdated server does not cause endless retries. func (s *Server) ensureTitlesFetched(ctx context.Context) { s.titlesMu.Lock() if s.titlesLoading || time.Now().Before(s.titlesBackoffUntil) { s.titlesMu.Unlock() return } s.titlesLoading = true s.titlesMu.Unlock() defer func() { s.titlesMu.Lock() s.titlesLoading = false s.titlesMu.Unlock() }() s.mu.RLock() fetcher := s.fetcher cache := s.cache s.mu.RUnlock() if fetcher == nil { return } titles, err := fetcher.FetchTitles(ctx) if err != nil && ctx.Err() == nil { s.titlesMu.Lock() s.titlesBackoffUntil = time.Now().Add(5 * time.Minute) s.titlesMu.Unlock() return } if len(titles) == 0 { // Server doesn't support TitlesChannel or has no titles yet; back off. s.titlesMu.Lock() s.titlesBackoffUntil = time.Now().Add(5 * time.Minute) s.titlesMu.Unlock() return } if cache != nil { for name, title := range titles { _ = cache.PutTitle(name, title) } } s.mu.Lock() channels := s.channels updated := false for i := range channels { if t, ok := titles[channels[i].Name]; ok && t != "" && channels[i].DisplayName != t { channels[i].DisplayName = t updated = true } } s.channels = channels nextFetch := s.nextFetch s.mu.Unlock() if updated { s.saveChannelsCache(channels, nextFetch) s.broadcast("event: update\ndata: \"channels\"\n\n") } } func (s *Server) refreshChannel(channelNum int) { // Prevent duplicate fetches for the same channel s.refreshMu.Lock() if _, running := s.refreshCancels[channelNum]; running { s.refreshMu.Unlock() return } s.mu.RLock() basectx := s.fetcherCtx fetcher := s.fetcher cache := s.cache s.mu.RUnlock() if fetcher == nil || basectx == nil { s.refreshMu.Unlock() return } ctx, cancel := context.WithCancel(basectx) s.refreshCancels[channelNum] = cancel s.refreshMu.Unlock() defer func() { cancel() s.refreshMu.Lock() delete(s.refreshCancels, channelNum) s.refreshMu.Unlock() }() // Use the cached in-memory metadata if it is fresh enough (< metaCacheTTL, default 30 sec). // This avoids a redundant metadata DNS fetch for every channel refresh. // If the metadata is stale (or was never fetched), fetch it from DNS now. s.mu.RLock() ttl := s.metaCacheTTL if ttl <= 0 { ttl = 30 * time.Second } // Cap TTL at the time remaining until the server's next Telegram fetch. // If nextFetch is sooner than our TTL the cached metadata may already be stale. if nf := s.nextFetch; nf > 0 { if rem := time.Until(time.Unix(int64(nf), 0)); rem > 0 && rem < ttl { ttl = rem } } cachedChannels := s.channels cachedAge := time.Since(s.metaFetchedAt) s.mu.RUnlock() var meta *protocol.Metadata if len(cachedChannels) > 0 && cachedAge < ttl { // Build a lightweight Metadata from the cached fields to keep the rest of the // function unchanged. s.mu.RLock() meta = &protocol.Metadata{ Channels: s.channels, TelegramLoggedIn: s.telegramLoggedIn, NextFetch: s.nextFetch, } s.mu.RUnlock() } else { var err error meta, err = fetcher.FetchMetadata(ctx) if err != nil { if ctx.Err() != nil { s.addLog("Refresh cancelled") return } errStr := err.Error() if strings.Contains(errStr, "integrity check failed") || strings.Contains(errStr, "message authentication failed") || strings.Contains(errStr, "cipher") { s.addLog("Error: Invalid passphrase — check your encryption key in Settings") } else { s.addLog(fmt.Sprintf("Error: %v", err)) } return } channels := meta.Channels if cache != nil { if cached := cache.GetAllTitles(); len(cached) > 0 { for i := range channels { if t := cached[channels[i].Name]; t != "" { channels[i].DisplayName = t } } } } meta.Channels = channels s.mu.Lock() s.channels = channels s.telegramLoggedIn = meta.TelegramLoggedIn s.nextFetch = meta.NextFetch s.metaFetchedAt = time.Now() s.mu.Unlock() if cache != nil { _ = cache.PutMetadata(meta) } s.saveChannelsCache(channels, meta.NextFetch) s.broadcast("event: update\ndata: \"channels\"\n\n") needsFetch := false for _, ch := range channels { if ch.DisplayName == "" { needsFetch = true break } } if needsFetch { go s.ensureTitlesFetched(basectx) } } channels := meta.Channels if channelNum < 1 || channelNum > len(channels) { s.addLog(fmt.Sprintf("Warning: channel %d is not available", channelNum)) return } ch := channels[channelNum-1] // Skip refresh if the last message ID and content hash haven't changed // AND we already have messages stored for this channel. s.mu.RLock() prevID := s.lastMsgIDs[channelNum] prevHash := s.lastHashes[channelNum] prevMsgs := s.messages[channelNum] s.mu.RUnlock() if prevID > 0 && ch.LastMsgID == prevID && ch.ContentHash == prevHash && len(prevMsgs) > 0 { s.addLog(fmt.Sprintf("Channel %s: no changes (last ID: %d)", ch.Name, prevID)) s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"no_changes\",\"channel\":%d}\n\n", channelNum)) return } blockCount := int(ch.Blocks) if blockCount <= 0 { s.mu.Lock() s.messages[channelNum] = nil s.lastMsgIDs[channelNum] = ch.LastMsgID s.lastHashes[channelNum] = ch.ContentHash s.mu.Unlock() s.addLog(fmt.Sprintf("Updated %s: 0 messages", ch.Name)) s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum)) return } // Wrap the context with a deadline at the server's next Telegram fetch. // If the server starts fetching during our block download we cancel early, // wait for the fresh data to land, then restart this channel fetch. fetchCtx := ctx var fetchCancel context.CancelFunc var fetchNF uint32 if dl := s.nextFetchDeadline(); !dl.IsZero() { s.mu.RLock() fetchNF = s.nextFetch s.mu.RUnlock() // If the server's next refresh is within 15 seconds, wait for it rather // than risking a block-version race (metadata says N blocks but the // server regenerates them mid-download). if time.Until(dl) < 15*time.Second { s.addLog("Server refresh imminent — waiting before fetching blocks") if !s.waitForServerFetch(ctx, fetchNF) { return } // Re-fetch metadata after the server refresh to get fresh block counts. freshMeta, freshErr := fetcher.FetchMetadata(ctx) if freshErr != nil { if ctx.Err() != nil { s.addLog("Refresh cancelled") return } s.addLog(fmt.Sprintf("Channel %s error refreshing metadata: %v", ch.Name, freshErr)) return } s.mu.Lock() s.channels = freshMeta.Channels s.telegramLoggedIn = freshMeta.TelegramLoggedIn s.nextFetch = freshMeta.NextFetch s.metaFetchedAt = time.Now() s.mu.Unlock() if cache != nil { _ = cache.PutMetadata(freshMeta) } s.saveChannelsCache(freshMeta.Channels, freshMeta.NextFetch) if channelNum < 1 || channelNum > len(freshMeta.Channels) { return } ch = freshMeta.Channels[channelNum-1] blockCount = int(ch.Blocks) if blockCount <= 0 { return } } // Refresh the deadline after potential wait. dl = s.nextFetchDeadline() if !dl.IsZero() { fetchCtx, fetchCancel = context.WithDeadline(ctx, dl) defer fetchCancel() } } // Fetch blocks with content-hash verification. On hash mismatch (the // server regenerated blocks between our metadata fetch and block fetch) // re-fetch metadata and retry up to 2 times. const maxHashRetries = 2 var msgs []protocol.Message var err error for attempt := 0; ; attempt++ { msgs, err = fetcher.FetchChannelVerified(fetchCtx, channelNum, blockCount, ch.ContentHash) if err == nil { break } if errors.Is(err, client.ErrContentHashMismatch) && attempt < maxHashRetries { s.addLog(fmt.Sprintf("Channel %s: block-version race detected, re-fetching metadata (attempt %d/%d)", ch.Name, attempt+1, maxHashRetries)) freshMeta, freshErr := fetcher.FetchMetadata(ctx) if freshErr != nil { s.addLog(fmt.Sprintf("Channel %s error refreshing metadata: %v", ch.Name, freshErr)) return } s.mu.Lock() s.channels = freshMeta.Channels s.telegramLoggedIn = freshMeta.TelegramLoggedIn s.nextFetch = freshMeta.NextFetch s.metaFetchedAt = time.Now() s.mu.Unlock() if cache != nil { _ = cache.PutMetadata(freshMeta) } s.saveChannelsCache(freshMeta.Channels, freshMeta.NextFetch) if channelNum < 1 || channelNum > len(freshMeta.Channels) { return } ch = freshMeta.Channels[channelNum-1] blockCount = int(ch.Blocks) if blockCount <= 0 { return } continue // retry with fresh metadata } if fetchCancel != nil && fetchCtx.Err() == context.DeadlineExceeded { // nextFetch fired mid-download — wait for the server, then re-fetch. fetchCancel() if s.waitForServerFetch(ctx, fetchNF) { go s.refreshChannel(channelNum) } return } if ctx.Err() != nil { s.addLog("Refresh cancelled") return } s.addLog(fmt.Sprintf("Channel %s error: %v", ch.Name, err)) return } s.mu.Lock() s.messages[channelNum] = msgs // Only store the metadata IDs when we actually received messages. // If the fetch returned 0 messages but the channel has content (LastMsgID > 0), // keep the old IDs so the next refresh will try a full fetch instead of skipping. if len(msgs) > 0 || ch.LastMsgID == 0 { s.lastMsgIDs[channelNum] = ch.LastMsgID s.lastHashes[channelNum] = ch.ContentHash } s.mu.Unlock() if cache != nil { if result, mergeErr := cache.MergeAndPut(ch.Name, msgs); mergeErr == nil { // Replace the in-memory store with the full merged history. s.mu.Lock() s.messages[channelNum] = result.Messages s.mu.Unlock() } } s.addLog(fmt.Sprintf("Updated %s: %d messages", ch.Name, len(msgs))) s.broadcast(fmt.Sprintf("event: update\ndata: {\"type\":\"messages\",\"channel\":%d}\n\n", channelNum)) } 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 } // saveLastScan persists the healthy resolver list from the most recent scan. func (s *Server) saveLastScan(resolvers []string) { d := lastScanData{Resolvers: resolvers, ScannedAt: time.Now().Unix()} b, err := json.MarshalIndent(d, "", " ") if err != nil { return } _ = os.WriteFile(filepath.Join(s.dataDir, "last_scan.json"), b, 0600) } func (s *Server) activeProfileID() string { pl, err := s.loadProfiles() if err != nil || pl == nil { return "" } return pl.Active } func (s *Server) channelsCachePath() string { return filepath.Join(s.dataDir, "channels_cache.json") } func (s *Server) readChannelsCacheFile() channelsCacheFile { b, err := os.ReadFile(s.channelsCachePath()) if err != nil { return channelsCacheFile{} } var f channelsCacheFile if err := json.Unmarshal(b, &f); err != nil || f == nil { return channelsCacheFile{} } return f } func (s *Server) saveChannelsCache(channels []protocol.ChannelInfo, nextFetch uint32) { id := s.activeProfileID() if id == "" || len(channels) == 0 { return } f := s.readChannelsCacheFile() f[id] = &channelsCacheEntry{ Channels: channels, NextFetch: nextFetch, SavedAt: time.Now().Unix(), } b, err := json.MarshalIndent(f, "", " ") if err != nil { return } _ = os.WriteFile(s.channelsCachePath(), b, 0600) } func (s *Server) loadChannelsCache() *channelsCacheEntry { id := s.activeProfileID() if id == "" { return nil } return s.readChannelsCacheFile()[id] } func (s *Server) dropChannelsCacheEntry(profileID string) { if profileID == "" { return } f := s.readChannelsCacheFile() if _, ok := f[profileID]; !ok { return } delete(f, profileID) if len(f) == 0 { _ = os.Remove(s.channelsCachePath()) return } if b, err := json.MarshalIndent(f, "", " "); err == nil { _ = os.WriteFile(s.channelsCachePath(), b, 0600) } } // loadLastScan reads the most recent resolver scan result. // Returns nil when the file doesn't exist or is older than 24 hours. func (s *Server) loadLastScan() *lastScanData { b, err := os.ReadFile(filepath.Join(s.dataDir, "last_scan.json")) if err != nil { return nil } var d lastScanData if err := json.Unmarshal(b, &d); err != nil { return nil } if len(d.Resolvers) == 0 || time.Since(time.Unix(d.ScannedAt, 0)) > 24*time.Hour { return nil } return &d } // handleApplySavedResolvers immediately activates the resolvers from the last // scan file, letting the UI skip the current scan and start fetching channels. func (s *Server) handleApplySavedResolvers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } ls := s.loadLastScan() if ls == nil { http.Error(w, "no saved scan", 400) return } s.mu.RLock() fetcher := s.fetcher s.mu.RUnlock() if fetcher == nil { http.Error(w, "not configured", 400) return } fetcher.SetActiveResolvers(ls.Resolvers) go s.refreshMetadataOnly() writeJSON(w, map[string]any{"ok": true, "count": len(ls.Resolvers)}) } func (s *Server) handleActiveResolvers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", 405) return } s.mu.RLock() fetcher := s.fetcher s.mu.RUnlock() if fetcher == nil { writeJSON(w, map[string]any{"resolvers": []string{}, "scoreboard": []client.ResolverInfo{}}) return } writeJSON(w, map[string]any{ "resolvers": fetcher.Resolvers(), "all": fetcher.AllResolvers(), "scoreboard": fetcher.ResolverScoreboard(), }) } func (s *Server) handleRemoveResolver(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { Addr string `json:"addr"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Addr == "" { http.Error(w, "addr required", 400) return } s.mu.RLock() fetcher := s.fetcher s.mu.RUnlock() if fetcher == nil { http.Error(w, "no active fetcher", 400) return } fetcher.RemoveActiveResolver(req.Addr) // Persist the removal in the currently-selected named list. Without // this, the resolver pops back on next start because applySelectedList // re-applies the on-disk list verbatim. Scope is intentionally narrow: // only the active list is touched — the bank and other named lists // keep the resolver until the user removes it from the bank. listChanged := false if pl, err := s.loadProfiles(); err == nil && pl != nil { if list := findList(pl, pl.SelectedList); list != nil { out := list.Resolvers[:0] for _, r := range list.Resolvers { if r == req.Addr { listChanged = true continue } out = append(out, r) } if listChanged { list.Resolvers = out _ = s.saveProfiles(pl) } } } if listChanged { // Push tab counts to any open Resolver Bank modal — without // this, the badge stays at the old count until the user // switches lists. s.broadcast("event: update\ndata: \"resolver-lists\"\n\n") } writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleResetResolverStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } s.mu.RLock() fetcher := s.fetcher s.mu.RUnlock() if fetcher == nil { http.Error(w, "no active fetcher", 400) return } fetcher.ResetStats() writeJSON(w, map[string]any{"ok": true}) } // handleResolverBank manages the shared resolver bank. // GET: returns all bank resolvers with scores. // POST: adds resolvers to the bank. // DELETE: removes specific resolvers from the bank. func (s *Server) handleResolverBank(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: pl, _ := s.loadProfiles() if pl == nil { pl = &ProfileList{} } // Get live stats from the current fetcher. var liveStats map[string][3]int64 var activeSet map[string]bool s.mu.RLock() if s.fetcher != nil { liveStats = s.fetcher.ExportStats() activeSet = make(map[string]bool) for _, r := range s.fetcher.Resolvers() { k := r if !strings.Contains(k, ":") { k += ":53" } activeSet[k] = true } } s.mu.RUnlock() if activeSet == nil { activeSet = make(map[string]bool) } type bankResolver struct { Addr string `json:"addr"` Score float64 `json:"score"` Success int64 `json:"success"` Failure int64 `json:"failure"` AvgMs float64 `json:"avgMs"` Active bool `json:"active"` } var bank []bankResolver for _, addr := range pl.ResolverBank { key := addr if !strings.Contains(key, ":") { key += ":53" } br := bankResolver{Addr: addr, Active: activeSet[key]} // Prefer live stats, fall back to saved scores. if liveStats != nil { if st, ok := liveStats[key]; ok { br.Success = st[0] br.Failure = st[1] if st[0] > 0 { br.AvgMs = float64(st[2]) / float64(st[0]) } br.Score = computeResolverScore(st[0], st[1], st[2]) } else if ss, ok := pl.ResolverScores[addr]; ok { br.Success = ss.Success br.Failure = ss.Failure if ss.Success > 0 { br.AvgMs = float64(ss.TotalMs) / float64(ss.Success) } br.Score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs) } else { br.Score = 0.2 } } else if ss, ok := pl.ResolverScores[addr]; ok { br.Success = ss.Success br.Failure = ss.Failure if ss.Success > 0 { br.AvgMs = float64(ss.TotalMs) / float64(ss.Success) } br.Score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs) } else { br.Score = 0.2 } bank = append(bank, br) } sort.Slice(bank, func(i, j int) bool { return bank[i].Score > bank[j].Score }) writeJSON(w, map[string]any{"bank": bank, "count": len(pl.ResolverBank)}) case http.MethodPost: var req struct { Resolvers []string `json:"resolvers"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } pl, _ := s.loadProfiles() if pl == nil { pl = &ProfileList{} } added := addToBank(pl, req.Resolvers) if err := s.saveProfiles(pl); err != nil { http.Error(w, "save failed", 500) return } // Update the fetcher's resolver pool. s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { f.UpdateResolverPool(pl.ResolverBank) } writeJSON(w, map[string]any{"ok": true, "added": added, "total": len(pl.ResolverBank)}) case http.MethodDelete: var req struct { Addrs []string `json:"addrs"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || len(req.Addrs) == 0 { http.Error(w, "addrs required", 400) return } pl, _ := s.loadProfiles() if pl == nil { writeJSON(w, map[string]any{"ok": true, "removed": 0, "remaining": 0}) return } removeSet := make(map[string]bool) for _, a := range req.Addrs { removeSet[a] = true } filtered := make([]string, 0, len(pl.ResolverBank)) for _, r := range pl.ResolverBank { if !removeSet[r] { filtered = append(filtered, r) } } removed := len(pl.ResolverBank) - len(filtered) pl.ResolverBank = filtered for _, a := range req.Addrs { delete(pl.ResolverScores, a) } // Strip these resolvers from every saved active list — keeping // a list pointing at a resolver no longer in the bank would // surface as a "ghost" resolver the fetcher would try to use. listsTouched := pruneResolversFromLists(pl, removeSet) _ = s.saveProfiles(pl) s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { f.UpdateResolverPool(pl.ResolverBank) // If the selected list lost members, reapply it so the // fetcher's active set matches the trimmed list. if listsTouched { if list := findList(pl, pl.SelectedList); list != nil && len(list.Resolvers) > 0 { f.SetActiveResolvers(list.Resolvers) } } } s.broadcast("event: update\ndata: \"resolver-lists\"\n\n") writeJSON(w, map[string]any{"ok": true, "removed": removed, "remaining": len(pl.ResolverBank)}) default: http.Error(w, "method not allowed", 405) } } // handleResolverBankCleanup removes resolvers with score below a threshold. func (s *Server) handleResolverBankCleanup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { MinScore float64 `json:"minScore"` DryRun bool `json:"dryRun"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } if req.MinScore <= 0 { http.Error(w, "minScore must be > 0", 400) return } pl, _ := s.loadProfiles() if pl == nil { writeJSON(w, map[string]any{"ok": true, "removed": 0, "remaining": 0}) return } // Get live stats for score computation. var liveStats map[string][3]int64 s.mu.RLock() if s.fetcher != nil { liveStats = s.fetcher.ExportStats() } s.mu.RUnlock() var filtered []string removed := 0 for _, addr := range pl.ResolverBank { key := addr if !strings.Contains(key, ":") { key += ":53" } var score float64 if liveStats != nil { if st, ok := liveStats[key]; ok { score = computeResolverScore(st[0], st[1], st[2]) } else if ss, ok := pl.ResolverScores[addr]; ok { score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs) } else { score = 0.2 } } else if ss, ok := pl.ResolverScores[addr]; ok { score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs) } else { score = 0.2 } if score >= req.MinScore { filtered = append(filtered, addr) } else { removed++ } } if req.DryRun { writeJSON(w, map[string]any{"ok": true, "removed": removed, "remaining": len(filtered)}) return } // Apply the cleanup. for _, addr := range pl.ResolverBank { key := addr if !strings.Contains(key, ":") { key += ":53" } var score float64 if liveStats != nil { if st, ok := liveStats[key]; ok { score = computeResolverScore(st[0], st[1], st[2]) } else if ss, ok := pl.ResolverScores[addr]; ok { score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs) } else { score = 0.2 } } else if ss, ok := pl.ResolverScores[addr]; ok { score = computeResolverScore(ss.Success, ss.Failure, ss.TotalMs) } else { score = 0.2 } if score < req.MinScore { delete(pl.ResolverScores, addr) } } // Build the removed-set for list pruning (addresses that didn't // make the score cut). removedSet := make(map[string]bool) keep := make(map[string]bool, len(filtered)) for _, k := range filtered { keep[k] = true } for _, addr := range pl.ResolverBank { if !keep[addr] { removedSet[addr] = true } } pl.ResolverBank = filtered listsTouched := pruneResolversFromLists(pl, removedSet) _ = s.saveProfiles(pl) s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { f.UpdateResolverPool(pl.ResolverBank) if listsTouched { if list := findList(pl, pl.SelectedList); list != nil && len(list.Resolvers) > 0 { f.SetActiveResolvers(list.Resolvers) } } } s.broadcast("event: update\ndata: \"resolver-lists\"\n\n") writeJSON(w, map[string]any{"ok": true, "removed": removed, "remaining": len(filtered)}) } 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) } func (s *Server) loadProfiles() (*ProfileList, error) { path := filepath.Join(s.dataDir, "profiles.json") data, err := os.ReadFile(path) if err != nil { return nil, err } var pl ProfileList if err := json.Unmarshal(data, &pl); err != nil { return nil, err } return &pl, nil } // loadProfilesExisting returns (nil, nil) only when the file truly // doesn't exist; other errors are surfaced so callers don't overwrite // with an empty struct. func (s *Server) loadProfilesExisting() (*ProfileList, error) { path := filepath.Join(s.dataDir, "profiles.json") data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var pl ProfileList if err := json.Unmarshal(data, &pl); err != nil { return nil, err } return &pl, nil } func (s *Server) saveProfiles(pl *ProfileList) error { if err := os.MkdirAll(s.dataDir, 0700); err != nil { return err } path := filepath.Join(s.dataDir, "profiles.json") data, err := json.MarshalIndent(pl, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, data, 0600); err != nil { return err } return os.Rename(tmp, path) } func generateID() string { b := make([]byte, 8) rand.Read(b) return hex.EncodeToString(b) } // migrateResolverBank merges per-profile resolvers into the shared bank on first run. func (s *Server) migrateResolverBank() { pl, err := s.loadProfiles() if err != nil || pl == nil { return } if len(pl.ResolverBank) > 0 { return // already migrated } seen := make(map[string]bool) for _, p := range pl.Profiles { for _, r := range p.Config.Resolvers { addr := r if !strings.Contains(addr, ":") { addr += ":53" } if !seen[addr] { seen[addr] = true pl.ResolverBank = append(pl.ResolverBank, addr) } } } if len(pl.ResolverBank) > 0 { for i := range pl.Profiles { pl.Profiles[i].Config.Resolvers = nil } _ = s.saveProfiles(pl) } } // addToBank adds resolvers to the shared bank (deduplicated, normalized with :53). func addToBank(pl *ProfileList, resolvers []string) int { seen := make(map[string]bool) for _, r := range pl.ResolverBank { seen[r] = true } added := 0 for _, r := range resolvers { addr := r if !strings.Contains(addr, ":") { addr += ":53" } if !seen[addr] { seen[addr] = true pl.ResolverBank = append(pl.ResolverBank, addr) added++ } } return added } // persistResolverScores saves the current fetcher stats to profiles.json. // Not serialised by profilesMu — initFetcher holds s.mu while calling this, // and grabbing profilesMu here would risk AB-BA with handlers that take // profilesMu first. The score map-merge is benign under last-writer-wins. func (s *Server) persistResolverScores(stats map[string][3]int64) { if len(stats) == 0 { return } pl, err := s.loadProfiles() if err != nil || pl == nil { return } if pl.ResolverScores == nil { pl.ResolverScores = make(map[string]*SavedResolverScore) } for addr, st := range stats { pl.ResolverScores[addr] = &SavedResolverScore{ Success: st[0], Failure: st[1], TotalMs: st[2], } } _ = s.saveProfiles(pl) } // computeResolverScore mirrors the scoring formula from fetcher.go. func computeResolverScore(success, failure, totalMs int64) float64 { total := success + failure if total == 0 { return 0.2 } successRate := float64(success) / float64(total) var avgMs float64 if success > 0 { avgMs = float64(totalMs) / float64(success) } else { avgMs = 30000 } score := successRate * successRate / (avgMs/5000.0 + 1.0) if score < 0.001 { score = 0.001 } return score } // handleProfiles manages CRUD for config profiles. // GET: returns profile list. POST: create/update/delete profiles. func (s *Server) handleProfiles(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.profilesMu.Lock() pl, err := s.loadProfilesExisting() if err != nil { s.profilesMu.Unlock() http.Error(w, fmt.Sprintf("load: %v", err), 500) return } if pl == nil { // First-run migration from config.json. pl = &ProfileList{} if s.config != nil { p := Profile{ ID: generateID(), Nickname: s.config.Domain, Config: *s.config, } pl.Profiles = []Profile{p} pl.Active = p.ID _ = s.saveProfiles(pl) } } s.profilesMu.Unlock() writeJSON(w, pl) case http.MethodPost: var req struct { Action string `json:"action"` // "create", "update", "delete", "reorder" Profile Profile `json:"profile"` Order []string `json:"order"` // for reorder SkipCheck bool `json:"skipCheck"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } s.profilesMu.Lock() pl, err := s.loadProfilesExisting() if err != nil { s.profilesMu.Unlock() http.Error(w, fmt.Sprintf("load: %v", err), 500) return } if pl == nil { pl = &ProfileList{} } needsReinit := false switch req.Action { case "create": req.Profile.ID = generateID() if req.Profile.Nickname == "" { req.Profile.Nickname = req.Profile.Config.Domain } // Move resolvers to the shared bank. if len(req.Profile.Config.Resolvers) > 0 { addToBank(pl, req.Profile.Config.Resolvers) req.Profile.Config.Resolvers = nil } pl.Profiles = append(pl.Profiles, req.Profile) if len(pl.Profiles) == 1 { pl.Active = req.Profile.ID needsReinit = true } case "update": for i, p := range pl.Profiles { if p.ID == req.Profile.ID { // Move resolvers to the shared bank. if len(req.Profile.Config.Resolvers) > 0 { addToBank(pl, req.Profile.Config.Resolvers) req.Profile.Config.Resolvers = nil } // Carry over fields the edit-profile UI doesn't manage so // they don't get wiped on save (auto-update list etc.). req.Profile.AutoUpdate = p.AutoUpdate req.Profile.AutoUpdateInterval = p.AutoUpdateInterval pl.Profiles[i] = req.Profile if p.ID == pl.Active { needsReinit = true } break } } case "delete": for i, p := range pl.Profiles { if p.ID == req.Profile.ID { pl.Profiles = append(pl.Profiles[:i], pl.Profiles[i+1:]...) if pl.Active == req.Profile.ID { pl.Active = "" if len(pl.Profiles) > 0 { pl.Active = pl.Profiles[0].ID needsReinit = true } } s.dropChannelsCacheEntry(req.Profile.ID) break } } case "reorder": if len(req.Order) > 0 { ordered := make([]Profile, 0, len(pl.Profiles)) byID := make(map[string]Profile) for _, p := range pl.Profiles { byID[p.ID] = p } for _, id := range req.Order { if p, ok := byID[id]; ok { ordered = append(ordered, p) } } pl.Profiles = ordered } default: s.profilesMu.Unlock() http.Error(w, "unknown action", 400) return } saveErr := s.saveProfiles(pl) var activeConfig *Config if needsReinit && pl.Active != "" { for _, p := range pl.Profiles { if p.ID == pl.Active { cfg := p.Config activeConfig = &cfg break } } } s.profilesMu.Unlock() if saveErr != nil { http.Error(w, fmt.Sprintf("save profiles: %v", saveErr), 500) return } // initFetcher takes s.mu — call it OUTSIDE profilesMu so handlers // that need both don't AB-BA against it. if activeConfig != nil { _ = s.saveConfig(activeConfig) s.mu.Lock() s.config = activeConfig s.mu.Unlock() if err := s.initFetcher(); err != nil { log.Printf("[web] re-init fetcher after profile change: %v", err) } else if req.SkipCheck { s.skipCheckerUseSaved() } else { s.startCheckerThenRefresh() } } s.broadcast("event: update\ndata: \"profiles\"\n\n") writeJSON(w, map[string]any{"ok": true, "profiles": pl}) default: http.Error(w, "method not allowed", 405) } } // handleProfileSwitch switches the active profile and re-initializes the fetcher. func (s *Server) handleProfileSwitch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { ID string `json:"id"` SkipCheck bool `json:"skipCheck"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } s.profilesMu.Lock() pl, err := s.loadProfiles() if err != nil || pl == nil { s.profilesMu.Unlock() http.Error(w, "no profiles", 400) return } var found *Profile for i, p := range pl.Profiles { if p.ID == req.ID { found = &pl.Profiles[i] break } } if found == nil { s.profilesMu.Unlock() http.Error(w, "profile not found", 404) return } pl.Active = found.ID saveErr := s.saveProfiles(pl) s.profilesMu.Unlock() if err := saveErr; err != nil { http.Error(w, fmt.Sprintf("save: %v", err), 500) return } if err := s.saveConfig(&found.Config); err != nil { http.Error(w, fmt.Sprintf("save config: %v", err), 500) return } // Reset state and seed channels from the new profile's cache (if any). cc := s.loadChannelsCache() s.mu.Lock() s.config = &found.Config if cc != nil { s.channels = cc.Channels s.nextFetch = cc.NextFetch } else { s.channels = nil } s.messages = make(map[int][]protocol.Message) if s.relayInfo != nil { s.relayInfo.invalidate() } s.lastMsgIDs = make(map[int]uint32) s.lastHashes = make(map[int]uint32) s.mu.Unlock() // Tell every connected client (other tabs / devices) that the active // profile changed so they refresh their UI instead of pointing at the // old one. s.broadcast("event: update\ndata: \"profiles\"\n\n") if cc != nil { s.broadcast("event: update\ndata: \"channels\"\n\n") } if err := s.initFetcher(); err != nil { http.Error(w, fmt.Sprintf("init fetcher: %v", err), 500) return } // Same fallback chain as boot: selected list → last_scan → full scan. if req.SkipCheck && s.applySelectedList() { s.mu.RLock() checker := s.checker ctx := s.fetcherCtx s.mu.RUnlock() if checker != nil && ctx != nil { checker.StartPeriodic(ctx) } go s.refreshMetadataOnly() } else if req.SkipCheck { s.skipCheckerUseSaved() } else { s.startCheckerThenRefresh() } writeJSON(w, map[string]any{"ok": true}) } // handleAutoUpdate exposes the active profile's auto-update channel list. // GET → {channels, intervalSeconds, defaultIntervalSeconds}. // POST {channels, intervalSeconds?} replaces both. Names are stripped and // dedup'd before saving. func (s *Server) handleAutoUpdate(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: pl, _ := s.loadProfiles() channels := []string{} interval := 0 if pl != nil && pl.Active != "" { for _, p := range pl.Profiles { if p.ID == pl.Active { if p.AutoUpdate != nil { channels = p.AutoUpdate } interval = p.AutoUpdateInterval break } } } writeJSON(w, map[string]any{ "channels": channels, "intervalSeconds": interval, "defaultIntervalSeconds": int(minAutoUpdateInterval / time.Second), }) case http.MethodPost: var req struct { Channels []string `json:"channels"` IntervalSeconds *int `json:"intervalSeconds,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } pl, err := s.loadProfiles() if err != nil || pl == nil || pl.Active == "" { http.Error(w, "no active profile", 400) return } idx := -1 for i, p := range pl.Profiles { if p.ID == pl.Active { idx = i break } } if idx < 0 { http.Error(w, "active profile not found", 400) return } pl.Profiles[idx].AutoUpdate = normaliseAutoUpdateList(req.Channels) if req.IntervalSeconds != nil { v := *req.IntervalSeconds if v < 0 { v = 0 } minSec := int(minAutoUpdateInterval / time.Second) if v > 0 && v < minSec { v = minSec // floor: never poll faster than the server fetches } pl.Profiles[idx].AutoUpdateInterval = v } if err := s.saveProfiles(pl); err != nil { http.Error(w, fmt.Sprintf("save: %v", err), 500) return } writeJSON(w, map[string]any{ "ok": true, "channels": pl.Profiles[idx].AutoUpdate, "intervalSeconds": pl.Profiles[idx].AutoUpdateInterval, }) default: http.Error(w, "method not allowed", 405) } } // handleAutoUpdateToggle flips one channel's membership. Body {channel}. // Returns {enabled, channels}. func (s *Server) handleAutoUpdateToggle(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var req struct { Channel string `json:"channel"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } name := strings.TrimPrefix(strings.TrimSpace(req.Channel), "@") if name == "" { http.Error(w, "channel required", 400) return } pl, err := s.loadProfiles() if err != nil || pl == nil || pl.Active == "" { http.Error(w, "no active profile", 400) return } idx := -1 for i, p := range pl.Profiles { if p.ID == pl.Active { idx = i break } } if idx < 0 { http.Error(w, "active profile not found", 400) return } current := pl.Profiles[idx].AutoUpdate on := false hit := -1 for i, n := range current { if strings.TrimPrefix(strings.TrimSpace(n), "@") == name { hit = i break } } if hit >= 0 { current = append(current[:hit], current[hit+1:]...) } else { current = append(current, name) on = true } pl.Profiles[idx].AutoUpdate = normaliseAutoUpdateList(current) if err := s.saveProfiles(pl); err != nil { http.Error(w, fmt.Sprintf("save: %v", err), 500) return } writeJSON(w, map[string]any{ "ok": true, "channel": name, "enabled": on, "channels": pl.Profiles[idx].AutoUpdate, }) } // normaliseAutoUpdateList strips @ + whitespace, drops empties, dedupes // while preserving order. func normaliseAutoUpdateList(in []string) []string { seen := make(map[string]bool, len(in)) out := make([]string, 0, len(in)) for _, raw := range in { name := strings.TrimPrefix(strings.TrimSpace(raw), "@") if name == "" || seen[name] { continue } seen[name] = true out = append(out, name) } return out } // handleSettings manages user preferences (font size etc.). func (s *Server) handleSettings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: pl, _ := s.loadProfiles() if pl == nil { pl = &ProfileList{} } writeJSON(w, map[string]any{ "fontSize": pl.FontSize, "debug": pl.Debug, "theme": pl.Theme, "lang": pl.Lang, "scanPromptOff": pl.ScanPromptOff, "profilePicsEnabled": pl.ProfilePicsEnabled, "version": version.Version, "commit": version.Commit, }) case http.MethodPost: // Optional pointers so partial requests don't reset other fields. var req struct { FontSize *int `json:"fontSize"` Debug *bool `json:"debug"` Theme *string `json:"theme"` Lang *string `json:"lang"` ScanPromptOff *bool `json:"scanPromptOff"` ProfilePicsEnabled *bool `json:"profilePicsEnabled"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", 400) return } s.profilesMu.Lock() defer s.profilesMu.Unlock() pl, err := s.loadProfilesExisting() if err != nil { http.Error(w, fmt.Sprintf("load: %v", err), 500) return } if pl == nil { pl = &ProfileList{} } if req.FontSize != nil { fs := *req.FontSize if fs < 10 { fs = 0 } if fs > 24 { fs = 24 } pl.FontSize = fs } if req.Debug != nil { pl.Debug = *req.Debug } if req.Theme != nil && (*req.Theme == "dark" || *req.Theme == "light") { pl.Theme = *req.Theme } if req.Lang != nil && (*req.Lang == "fa" || *req.Lang == "en") { pl.Lang = *req.Lang } if req.ScanPromptOff != nil { pl.ScanPromptOff = *req.ScanPromptOff } if req.ProfilePicsEnabled != nil { pl.ProfilePicsEnabled = *req.ProfilePicsEnabled } if err := s.saveProfiles(pl); err != nil { http.Error(w, fmt.Sprintf("save: %v", err), 500) return } // Apply debug to the current fetcher session immediately. if req.Debug != nil { s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { f.SetDebug(*req.Debug) } s.scanner.SetDebug(*req.Debug) } writeJSON(w, map[string]any{"ok": true}) default: http.Error(w, "method not allowed", 405) } } func (s *Server) handleBgImage(w http.ResponseWriter, r *http.Request) { bgPath := filepath.Join(s.dataDir, "bg_image") switch r.Method { case http.MethodGet: data, err := os.ReadFile(bgPath) if err != nil { w.Header().Set("Content-Type", "application/octet-stream") w.WriteHeader(204) return } // Detect content type from file data. ct := http.DetectContentType(data) w.Header().Set("Content-Type", ct) w.Header().Set("Cache-Control", "no-cache") w.Write(data) case http.MethodPost: // Limit upload to 10 MB. r.Body = http.MaxBytesReader(w, r.Body, 10<<20) data, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "file too large (max 10MB)", 413) return } ct := http.DetectContentType(data) if !strings.HasPrefix(ct, "image/") { http.Error(w, "not an image", 400) return } if err := os.WriteFile(bgPath, data, 0600); err != nil { http.Error(w, fmt.Sprintf("save: %v", err), 500) return } writeJSON(w, map[string]any{"ok": true}) case http.MethodDelete: os.Remove(bgPath) writeJSON(w, map[string]any{"ok": true}) default: http.Error(w, "method not allowed", 405) } } func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } v, err := s.checkLatestVersion(r.Context()) if err != nil { if strings.Contains(err.Error(), "no config") { http.Error(w, "no config", 400) return } errStr := err.Error() if strings.Contains(errStr, "integrity check failed") || strings.Contains(errStr, "message authentication failed") || strings.Contains(errStr, "cipher") { http.Error(w, "invalid passphrase", 400) return } http.Error(w, fmt.Sprintf("version check failed: %v", err), 502) return } writeJSON(w, map[string]any{"ok": true, "latestVersion": v}) } // handleGitHubUpdateCheck queries the public thefeed-files repo for the // latest published client version and returns a download URL tailored // to this binary's platform. Independent of the DNS-protocol version // check above — works without a configured profile. func (s *Server) handleGitHubUpdateCheck(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second) defer cancel() st, err := update.Check(ctx) if err != nil { http.Error(w, fmt.Sprintf("update check failed: %v", err), 502) return } writeJSON(w, st) } // ===================================================================== // Named active resolver lists // // Users keep one ResolverBank (the master pool) but many named subsets // of it — typically one per network situation (home, office, mobile). // Switching the selected list hot-swaps the fetcher's active resolvers // without rescanning. When a resolver is removed from the bank it's // removed from every list too, so a list never references a resolver // that no longer exists in the master pool. // ===================================================================== const defaultListName = "Default" // persistScanResultsToList commits checker healthy results to the // selected list when (a) the list is empty (bank-scan fallback) or // (b) handleRescan asked for an overwrite. Re-pins the runtime pool // and broadcasts so the open modal repopulates. func (s *Server) persistScanResultsToList(healthy []string) { if len(healthy) == 0 { return } s.rescanFlagMu.Lock() overwrite := s.rescanReplaceList s.rescanReplaceList = false s.rescanFlagMu.Unlock() pl, err := s.loadProfiles() if err != nil || pl == nil { return } list := findList(pl, pl.SelectedList) if list == nil { // First scan with no lists yet — seed a Default list so the // UI doesn't show empty after the very first scan completes. pl.ActiveLists = append(pl.ActiveLists, ActiveList{Name: defaultListName}) list = &pl.ActiveLists[len(pl.ActiveLists)-1] pl.SelectedList = defaultListName } // Don't shrink a populated list on routine periodic checks. if !overwrite && len(list.Resolvers) > 0 { return } list.Resolvers = append([]string(nil), healthy...) list.LastUsed = time.Now().Unix() if err := s.saveProfiles(pl); err != nil { s.addLog(fmt.Sprintf("save profiles after scan: %v", err)) return } s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { f.UpdateResolverPool(list.Resolvers) } s.addLog(fmt.Sprintf("resolvers: list %q populated with %d healthy resolvers", list.Name, len(healthy))) s.broadcast("event: update\ndata: \"resolver-lists\"\n\n") } // persistLastScanToProfiles seeds an empty selected list and/or empty // bank from boot-time last_scan.json so the UI counts match the // runtime fetcher's active set. func (s *Server) persistLastScanToProfiles(resolvers []string) { if len(resolvers) == 0 { return } pl, err := s.loadProfiles() if err != nil || pl == nil { return } changed := false if list := findList(pl, pl.SelectedList); list != nil && len(list.Resolvers) == 0 { list.Resolvers = append([]string(nil), resolvers...) list.LastUsed = time.Now().Unix() changed = true } if len(pl.ResolverBank) == 0 { pl.ResolverBank = append([]string(nil), resolvers...) changed = true } if !changed { return } if err := s.saveProfiles(pl); err != nil { s.addLog(fmt.Sprintf("save profiles after last_scan boot: %v", err)) } } // applySelectedList is the boot-time short-circuit. Returns true when // a populated saved list was applied; false routes the caller to the // last_scan.json / full-scan fallback chain. func (s *Server) applySelectedList() bool { pl, err := s.loadProfiles() if err != nil || pl == nil { return false } migrated := s.migrateActiveLists(pl) if list := findList(pl, pl.SelectedList); list != nil && len(list.Resolvers) > 0 { s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { f.UpdateResolverPool(list.Resolvers) f.SetActiveResolvers(list.Resolvers) list.LastUsed = time.Now().Unix() if err := s.saveProfiles(pl); err != nil { s.addLog(fmt.Sprintf("save profiles after select: %v", err)) } s.addLog(fmt.Sprintf("resolvers: applied list %q (%d resolvers)", list.Name, len(list.Resolvers))) return true } } if migrated { _ = s.saveProfiles(pl) } return false } // migrateActiveLists fills in ActiveLists for installs that pre-date // this feature. The current last_scan.json (or, failing that, the // resolver bank) becomes a single list named "Default" so users keep // their current setup. Returns true if a write is needed. func (s *Server) migrateActiveLists(pl *ProfileList) bool { if pl == nil || len(pl.ActiveLists) > 0 { // Even if lists exist, make sure SelectedList points at one. if findList(pl, pl.SelectedList) == nil && len(pl.ActiveLists) > 0 { pl.SelectedList = pl.ActiveLists[0].Name return true } return false } var seed []string if ls := s.loadLastScan(); ls != nil && len(ls.Resolvers) > 0 { seed = ls.Resolvers } else if len(pl.ResolverBank) > 0 { seed = pl.ResolverBank } if len(seed) == 0 { return false } pl.ActiveLists = []ActiveList{{ Name: defaultListName, Resolvers: append([]string(nil), seed...), LastUsed: time.Now().Unix(), }} pl.SelectedList = defaultListName return true } // findList returns a pointer into pl.ActiveLists so callers can mutate // the entry directly. Match is case-insensitive on the trimmed name. func findList(pl *ProfileList, name string) *ActiveList { if pl == nil || name == "" { return nil } for i := range pl.ActiveLists { if strings.EqualFold(strings.TrimSpace(pl.ActiveLists[i].Name), strings.TrimSpace(name)) { return &pl.ActiveLists[i] } } return nil } // pruneResolverFromLists removes resolver from every named list. Called // after the user removes a resolver from the bank so dangling // references don't outlive their entry. Returns true if any list was // modified. func pruneResolverFromLists(pl *ProfileList, resolver string) bool { if pl == nil || resolver == "" { return false } changed := false for i := range pl.ActiveLists { out := pl.ActiveLists[i].Resolvers[:0] for _, r := range pl.ActiveLists[i].Resolvers { if r == resolver { changed = true continue } out = append(out, r) } pl.ActiveLists[i].Resolvers = out } return changed } // pruneResolversFromLists removes a set of resolvers from every list in // one pass — used by the bank cleanup path that drops many at once. func pruneResolversFromLists(pl *ProfileList, removed map[string]bool) bool { if pl == nil || len(removed) == 0 { return false } changed := false for i := range pl.ActiveLists { out := pl.ActiveLists[i].Resolvers[:0] for _, r := range pl.ActiveLists[i].Resolvers { if removed[r] { changed = true continue } out = append(out, r) } pl.ActiveLists[i].Resolvers = out } return changed } // sanitizeListName normalises and length-caps user-supplied names. // Returns "" if the cleaned name is empty. func sanitizeListName(raw string) string { name := strings.TrimSpace(raw) if name == "" { return "" } if len(name) > 32 { name = name[:32] } return name } type resolverListOut struct { Name string `json:"name"` Count int `json:"count"` Resolvers []string `json:"resolvers,omitempty"` LastUsed int64 `json:"lastUsed,omitempty"` Selected bool `json:"selected,omitempty"` } // handleResolverLists supports GET (enumerate), POST (create), DELETE // (remove). Body for POST: {name, resolvers?}; if resolvers omitted, // snapshot the currently-active resolvers under the new name. // // GET ?include=resolvers also serialises each list's full resolver // address list — used by the share panel's "source = list X" picker. func (s *Server) handleResolverLists(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.writeListsInfo(w, r.URL.Query().Get("include") == "resolvers") case http.MethodPost: var body struct { Name string `json:"name"` Resolvers []string `json:"resolvers,omitempty"` } if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&body); err != nil { http.Error(w, "bad json", 400) return } name := sanitizeListName(body.Name) if name == "" { http.Error(w, "name required", 400) return } pl, err := s.loadProfiles() if err != nil { http.Error(w, err.Error(), 500) return } s.migrateActiveLists(pl) if findList(pl, name) != nil { http.Error(w, "list already exists", 409) return } resolvers := body.Resolvers if len(resolvers) == 0 { // Default: copy the currently-active resolvers so the user // can label whatever's working right now. s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { resolvers = append([]string(nil), f.Resolvers()...) } } pl.ActiveLists = append(pl.ActiveLists, ActiveList{ Name: name, Resolvers: append([]string(nil), resolvers...), LastUsed: time.Now().Unix(), }) if err := s.saveProfiles(pl); err != nil { http.Error(w, err.Error(), 500) return } s.writeListsInfo(w) case http.MethodDelete: var body struct { Name string `json:"name"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil { http.Error(w, "bad json", 400) return } name := sanitizeListName(body.Name) if name == "" { http.Error(w, "name required", 400) return } pl, err := s.loadProfiles() if err != nil { http.Error(w, err.Error(), 500) return } idx := -1 for i := range pl.ActiveLists { if strings.EqualFold(strings.TrimSpace(pl.ActiveLists[i].Name), name) { idx = i break } } if idx < 0 { http.Error(w, "no such list", 404) return } if len(pl.ActiveLists) == 1 { http.Error(w, "cannot delete the only list", 400) return } removed := pl.ActiveLists[idx].Name pl.ActiveLists = append(pl.ActiveLists[:idx], pl.ActiveLists[idx+1:]...) // If the deleted list was selected, pick the most recently used // remaining list as the new selection and reapply. if strings.EqualFold(strings.TrimSpace(pl.SelectedList), removed) { best := 0 for i := 1; i < len(pl.ActiveLists); i++ { if pl.ActiveLists[i].LastUsed > pl.ActiveLists[best].LastUsed { best = i } } pl.SelectedList = pl.ActiveLists[best].Name s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { // Match handleResolverListSelect — pin both the pool // and active to the new list so the checker doesn't // re-broaden after the swap. f.UpdateResolverPool(pl.ActiveLists[best].Resolvers) f.SetActiveResolvers(pl.ActiveLists[best].Resolvers) } } if err := s.saveProfiles(pl); err != nil { http.Error(w, err.Error(), 500) return } s.writeListsInfo(w) default: http.Error(w, "method not allowed", 405) } } // handleResolverListSelect switches the active list and hot-swaps the // fetcher's resolver pool. No probing — the user is choosing a list // they already trust to work in this situation. NoScan disables the // empty-list bank-scan fallback so the user can deliberately switch // to an empty list without kicking off a probe. func (s *Server) handleResolverListSelect(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var body struct { Name string `json:"name"` NoScan bool `json:"noScan"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil { http.Error(w, "bad json", 400) return } name := sanitizeListName(body.Name) if name == "" { http.Error(w, "name required", 400) return } pl, err := s.loadProfiles() if err != nil { http.Error(w, err.Error(), 500) return } list := findList(pl, name) if list == nil { http.Error(w, "no such list", 404) return } pl.SelectedList = list.Name list.LastUsed = time.Now().Unix() if err := s.saveProfiles(pl); err != nil { http.Error(w, err.Error(), 500) return } s.mu.RLock() f := s.fetcher checker := s.checker s.mu.RUnlock() // Cancel any in-progress bank scan first — the user is asking for // a different pool, so probing the previous one is wasted work. if checker != nil { checker.CancelCurrentScan() } if f != nil { switch { case len(list.Resolvers) > 0: f.UpdateResolverPool(list.Resolvers) f.SetActiveResolvers(list.Resolvers) s.addLog(fmt.Sprintf("resolvers: switched to list %q (%d resolvers)", list.Name, len(list.Resolvers))) go s.refreshMetadataOnly() case !body.NoScan && len(pl.ResolverBank) > 0: // Empty list, scan opted in: probe the bank as fallback. // CheckNow rather than StartAndNotify because the latter // has a one-shot guard that's already tripped post-boot. f.UpdateResolverPool(pl.ResolverBank) f.SetActiveResolvers(nil) s.addLog(fmt.Sprintf("resolvers: list %q is empty — scanning bank (%d) as fallback", list.Name, len(pl.ResolverBank))) s.mu.RLock() ctx := s.fetcherCtx s.mu.RUnlock() if checker != nil && ctx != nil { go func() { if checker.CheckNow(ctx) { s.refreshMetadataOnly() } }() } case body.NoScan: f.UpdateResolverPool(nil) f.SetActiveResolvers(nil) s.addLog(fmt.Sprintf("resolvers: switched to empty list %q (scan declined)", list.Name)) default: f.UpdateResolverPool(nil) f.SetActiveResolvers(nil) s.addLog(fmt.Sprintf("resolvers: list %q is empty and bank is empty — run scanner first", list.Name)) } } s.writeListsInfo(w) } // handleResolverListSave snapshots the currently-active fetcher // resolvers into a list. Mode "create" fails if the name already // exists; mode "overwrite" replaces an existing list's resolvers. func (s *Server) handleResolverListSave(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var body struct { Name string `json:"name"` Mode string `json:"mode"` // "create" (default) or "overwrite" } if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil { http.Error(w, "bad json", 400) return } name := sanitizeListName(body.Name) if name == "" { http.Error(w, "name required", 400) return } s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f == nil { http.Error(w, "not configured", 400) return } resolvers := append([]string(nil), f.Resolvers()...) if len(resolvers) == 0 { http.Error(w, "no active resolvers to save", 400) return } pl, err := s.loadProfiles() if err != nil { http.Error(w, err.Error(), 500) return } s.migrateActiveLists(pl) existing := findList(pl, name) if existing != nil { if body.Mode != "overwrite" { http.Error(w, "list already exists", 409) return } existing.Resolvers = resolvers existing.LastUsed = time.Now().Unix() } else { pl.ActiveLists = append(pl.ActiveLists, ActiveList{ Name: name, Resolvers: resolvers, LastUsed: time.Now().Unix(), }) } if err := s.saveProfiles(pl); err != nil { http.Error(w, err.Error(), 500) return } s.writeListsInfo(w) } // handleResolverListAdd appends resolvers (typically picked from the // Bank tab) to a named list. Deduped; hot-swaps the fetcher pool when // the list is currently selected. func (s *Server) handleResolverListAdd(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var body struct { Name string `json:"name"` Resolvers []string `json:"resolvers"` } if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&body); err != nil { http.Error(w, "bad json", 400) return } name := sanitizeListName(body.Name) if name == "" || len(body.Resolvers) == 0 { http.Error(w, "name and resolvers required", 400) return } pl, err := s.loadProfiles() if err != nil { http.Error(w, err.Error(), 500) return } list := findList(pl, name) if list == nil { http.Error(w, "no such list", 404) return } seen := map[string]bool{} for _, r := range list.Resolvers { seen[r] = true } added := 0 for _, r := range body.Resolvers { r = strings.TrimSpace(r) if r == "" || seen[r] { continue } list.Resolvers = append(list.Resolvers, r) seen[r] = true added++ } list.LastUsed = time.Now().Unix() if err := s.saveProfiles(pl); err != nil { http.Error(w, err.Error(), 500) return } if added > 0 && strings.EqualFold(strings.TrimSpace(pl.SelectedList), name) { s.mu.RLock() f := s.fetcher s.mu.RUnlock() if f != nil { // Pool widens AND active gains the new entries — // UpdateResolverPool alone only prunes active to the new // pool, never adds, so the freshly-added resolver would // stay invisible in the Active panel until a checker tick. f.UpdateResolverPool(list.Resolvers) f.SetActiveResolvers(list.Resolvers) } } s.broadcast("event: update\ndata: \"resolver-lists\"\n\n") writeJSON(w, map[string]any{"ok": true, "added": added, "count": len(list.Resolvers)}) } // handleResolverListRename changes a list's display name. Body: // {name, newName}. The selection pointer follows the rename. func (s *Server) handleResolverListRename(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } var body struct { Name string `json:"name"` NewName string `json:"newName"` } if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&body); err != nil { http.Error(w, "bad json", 400) return } from := sanitizeListName(body.Name) to := sanitizeListName(body.NewName) if from == "" || to == "" { http.Error(w, "name and newName required", 400) return } if strings.EqualFold(from, to) { writeJSON(w, map[string]any{"ok": true}) return } pl, err := s.loadProfiles() if err != nil { http.Error(w, err.Error(), 500) return } if findList(pl, to) != nil { http.Error(w, "name already used", 409) return } list := findList(pl, from) if list == nil { http.Error(w, "no such list", 404) return } list.Name = to if strings.EqualFold(strings.TrimSpace(pl.SelectedList), from) { pl.SelectedList = to } if err := s.saveProfiles(pl); err != nil { http.Error(w, err.Error(), 500) return } s.writeListsInfo(w) } // writeListsInfo serialises the named lists. includeResolvers=true // expands each entry with its full address list (used by share panel; // regular polls skip it to keep responses small). func (s *Server) writeListsInfo(w http.ResponseWriter, includeResolvers ...bool) { pl, _ := s.loadProfiles() out := struct { Selected string `json:"selected"` Lists []resolverListOut `json:"lists"` }{} if pl == nil { writeJSON(w, out) return } withAddrs := len(includeResolvers) > 0 && includeResolvers[0] out.Selected = pl.SelectedList for _, l := range pl.ActiveLists { entry := resolverListOut{ Name: l.Name, Count: len(l.Resolvers), LastUsed: l.LastUsed, Selected: strings.EqualFold(strings.TrimSpace(l.Name), strings.TrimSpace(pl.SelectedList)), } if withAddrs { entry.Resolvers = append([]string(nil), l.Resolvers...) } out.Lists = append(out.Lists, entry) } writeJSON(w, out) } // runMediaCacheSweep evicts expired media-cache entries every hour for the // lifetime of the process. func (s *Server) runMediaCacheSweep() { ticker := time.NewTicker(1 * time.Hour) defer ticker.Stop() for range ticker.C { if s.mediaCache == nil { return } s.mediaCache.Cleanup() } } // handleClearCache wipes both the per-channel message cache and the // downloaded-media disk cache. func (s *Server) handleClearCache(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } deleted := 0 cacheDir := filepath.Join(s.dataDir, "cache") if entries, err := os.ReadDir(cacheDir); err == nil { for _, e := range entries { if e.IsDir() { continue } if os.Remove(filepath.Join(cacheDir, e.Name())) == nil { deleted++ } } } if s.telemirror != nil { s.telemirror.ClearCache() } if s.profilePics != nil { s.profilePics.Clear() } mediaDeleted := 0 if s.mediaCache != nil { mediaDeleted = s.mediaCache.Clear() } _ = os.Remove(s.channelsCachePath()) // Reset in-memory message state too so refreshChannel's "no changes" // guard doesn't skip the next fetch (prev IDs match what's on the // server, but our cache is gone). s.mu.Lock() s.messages = make(map[int][]protocol.Message) s.lastMsgIDs = make(map[int]uint32) s.lastHashes = make(map[int]uint32) s.mu.Unlock() s.addLog(fmt.Sprintf("Cache cleared: %d message files, %d media files", deleted, mediaDeleted)) writeJSON(w, map[string]any{"ok": true, "deleted": deleted, "mediaDeleted": mediaDeleted}) }