diff --git a/.env.example b/.env.example
index 630f4c4..4b67b4e 100644
--- a/.env.example
+++ b/.env.example
@@ -31,7 +31,7 @@ THEFEED_KEY=your-secret-passphrase
#THEFEED_ALLOW_MANAGE=0
# Nitter RSS instances for X/Twitter (comma-separated)
-#THEFEED_X_RSS_INSTANCES=http://nitter.net,https://nitter.net
+#THEFEED_X_RSS_INSTANCES=https://nitter.net,http://nitter.net
# Max random padding bytes in DNS responses (anti-DPI, default: 32)
#THEFEED_PADDING=32
diff --git a/README-FA.md b/README-FA.md
index ee9d4cd..0423417 100644
--- a/README-FA.md
+++ b/README-FA.md
@@ -38,6 +38,8 @@ thefeed یک سیستم تونل DNS است که به شما اجازه می
- لاگ زنده درخواستهای DNS در مرورگر
- **جستجوی پیامها**: جستجو در پیامهای کانال فعلی با هایلایت نتایج و ناوبری قبلی/بعدی
- **خروجی پیامها**: کپی N پیام آخر یک کانال به کلیپبورد
+- **بانک ریزالور**: مدیریت مشترک ریزالورها برای تمام پروفایلها — بدون نیاز به تنظیم ریزالور جداگانه برای هر پروفایل. ریزالورها از طریق اسکنر، ایمپورت، یا ورود دستی اضافه میشوند و به صورت خودکار امتیازدهی میشوند
+- **پاکسازی ریزالور**: حذف ریزالورهای ضعیف از بانک بر اساس حداقل امتیاز دلخواه
- **نمایش ریزالورهای فعال**: مشاهده لیست ریزالورهای سالم و فعال از تنظیمات
- **تصویر پسزمینه**: تنظیم URL تصویر پسزمینه برای پنل پیامها (ذخیره محلی)
- **تایماوت DNS**: تنظیم تایماوت کوئری DNS برای هر پروفایل (پیشفرض ۱۵ ثانیه)
@@ -56,7 +58,7 @@ thefeed یک سیستم تونل DNS است که به شما اجازه می
- **مکث / ادامه / توقف**: کنترل کامل روی اسکنهای طولانی (مکث واقعاً ارسال درخواستهای جدید را متوقف میکند)
- **زمان پاسخ**: نتایج بر اساس تأخیر مرتب شدهاند تا سریعترینها اول نمایش داده شوند
- **انتخاب نتایج**: چکباکس برای انتخاب ریزالورهای مورد نظر
-- **اعمال نتایج**: افزودن یا جایگزینی لیست ریزالورهای پروفایل مستقیم از اسکنر
+- **اعمال نتایج**: افزودن یا جایگزینی بانک ریزالور مستقیم از اسکنر
- **کپی**: دکمه کپی برای هر آیپی، کپی انتخابشدهها، یا کپی همه
- **اسکن جدید**: بازنشانی رابط کاربری برای شروع اسکن جدید پس از اتمام
- **لاگ دیباگ**: در حالت دیباگ، کوئریها و پاسخهای هر probe ثبت میشوند
@@ -65,6 +67,7 @@ thefeed یک سیستم تونل DNS است که به شما اجازه می
- **اندازه متغیر پاسخ**: Padding تصادفی (۰-۳۲ بایت)
- **کوئری تکبرچسب**: رمزنگاری Base32 در یک برچسب DNS
- **شافل Resolver**: توزیع تصادفی کوئریها بین resolverها
+- **بانک ریزالور**: مخزن مشترک ریزالورها با امتیازدهی دائمی و ابزار پاکسازی
- **محدودیت نرخ**: قابل تنظیم برای ترکیب با ترافیک عادی DNS
- **Padding تصادفی کوئری**: ۴ بایت تصادفی در هر درخواست
- **اندازه بلاک متغیر**: بلاکهای ۴۰۰-۷۰۰ بایت
diff --git a/README.md b/README.md
index 642f8fb..2b2db0a 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,8 @@ DNS-based feed reader for Telegram channels and public X accounts. Designed for
**Client** (runs inside censored network):
- Browser-based web UI with RTL/Farsi support (VazirMatn font)
- Sends encrypted DNS TXT queries via available resolvers
-- **Resolver scoring**: tracks per-resolver success rate and latency; healthier resolvers are preferred automatically
+- **Resolver Bank**: shared pool of DNS resolvers used across all profiles — no more per-profile resolver lists. Resolvers are added via scanner, import, or manual entry and scored automatically
+- **Resolver scoring**: tracks per-resolver success rate and latency with persistent scores; healthier resolvers are preferred automatically. Users can clean up low-scoring resolvers from the bank
- **Scatter mode**: fans out the same DNS request to multiple resolvers simultaneously and uses the fastest response (default: 2 concurrent resolvers per request)
- Send messages to channels and private chats (requires server `--allow-manage` and login to telegram)
- Channel management (add/remove channels remotely via admin commands when `--allow-manage` is enabled)
@@ -41,6 +42,7 @@ DNS-based feed reader for Telegram channels and public X accounts. Designed for
- Variable response and query sizes to prevent fingerprinting
- Multiple query encoding modes for stealth
+- **Resolver Bank**: centralized resolver pool shared by all profiles with persistent scoring and cleanup tools
- **Resolver scoring**: per-resolver success-rate + latency scoreboard; high-scoring resolvers are picked more often via weighted-random selection
- **Scatter mode**: same block fetched from N resolvers simultaneously, first response wins — faster fetches and implicit failover
- Rate limiting and background noise traffic to blend in
@@ -247,7 +249,7 @@ Environment variables: `THEFEED_DOMAIN`, `THEFEED_KEY`, `THEFEED_MSG_LIMIT`, `TH
| `--key` | | Encryption passphrase (required) |
| `--channels` | `{data-dir}/channels.txt` | Path to channels file |
| `--x-accounts` | `{data-dir}/x_accounts.txt` | Path to X usernames file |
-| `--x-rss-instances` | `http://nitter.net,https://nitter.net` | Comma-separated X RSS base URLs |
+| `--x-rss-instances` | `https://nitter.net,http://nitter.net` | Comma-separated X RSS base URLs |
| `--api-id` | | Telegram API ID (required) |
| `--api-hash` | | Telegram API Hash (required) |
| `--phone` | | Telegram phone number (required) |
@@ -276,7 +278,7 @@ make build-client
./build/thefeed-client --password "your-secret"
```
-On first run, the client creates a `./thefeeddata/` directory next to where you run it. Open `http://127.0.0.1:8080` in your browser and configure your domain, passphrase, and resolvers through the Settings page.
+On first run, the client creates a `./thefeeddata/` directory next to where you run it. Open `http://127.0.0.1:8080` in your browser and configure your domain and passphrase through the Settings page. DNS resolvers are managed in the shared Resolver Bank (accessible from the sidebar), which is used by all profiles.
All configuration, cache, and data files are stored in the data directory.
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 05712fd..b717d8a 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -26,7 +26,7 @@ func main() {
key := flag.String("key", "", "Encryption passphrase")
channelsFile := flag.String("channels", "", "Path to channels file (default: {data-dir}/channels.txt)")
xAccountsFile := flag.String("x-accounts", "", "Path to X accounts file (default: {data-dir}/x_accounts.txt)")
- xRSSInstances := flag.String("x-rss-instances", "", "Comma-separated X RSS base URLs (e.g., http://nitter.net,https://nitter.net)")
+ xRSSInstances := flag.String("x-rss-instances", "", "Comma-separated X RSS base URLs (e.g., https://nitter.net,http://nitter.net)")
apiID := flag.String("api-id", "", "Telegram API ID (optional if --no-telegram)")
apiHash := flag.String("api-hash", "", "Telegram API Hash (optional if --no-telegram)")
phone := flag.String("phone", "", "Telegram phone number (optional if --no-telegram)")
diff --git a/configs/channels.txt b/configs/channels.txt
index 009e139..fe3db16 100644
--- a/configs/channels.txt
+++ b/configs/channels.txt
@@ -1,6 +1,6 @@
# Telegram channel usernames (one per line, with or without @)
# Channel numbers are assigned in order: first = channel 1, second = channel 2, etc.
# Lines starting with # are comments
-@networkt
+@networkti
@thefeedconfig
@VahidOnline
diff --git a/internal/client/fetcher.go b/internal/client/fetcher.go
index 1b28424..eced8f3 100644
--- a/internal/client/fetcher.go
+++ b/internal/client/fetcher.go
@@ -133,8 +133,8 @@ func (f *Fetcher) ScanConcurrency() int {
return 10
}
n := int(f.rateQPS)
- if n < 1 {
- n = 1
+ if n < 10 {
+ n = 10
}
return n
}
@@ -178,6 +178,29 @@ func (f *Fetcher) SetResolvers(resolvers []string) {
copy(f.activeResolvers, resolvers)
}
+// UpdateResolverPool replaces the full resolver list but keeps the existing
+// active pool intact (only pruning resolvers that are no longer in the bank).
+// New bank entries are added to allResolvers but NOT automatically activated.
+func (f *Fetcher) UpdateResolverPool(resolvers []string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ bankSet := make(map[string]bool, len(resolvers))
+ for _, r := range resolvers {
+ bankSet[r] = true
+ }
+ // Prune active resolvers that were removed from the bank.
+ filtered := make([]string, 0, len(f.activeResolvers))
+ for _, r := range f.activeResolvers {
+ if bankSet[r] {
+ filtered = append(filtered, r)
+ }
+ }
+ f.allResolvers = make([]string, len(resolvers))
+ copy(f.allResolvers, resolvers)
+ f.activeResolvers = filtered
+ f.log("resolver pool updated: %d total, %d active", len(f.allResolvers), len(f.activeResolvers))
+}
+
// RemoveActiveResolver removes a resolver from the active pool.
func (f *Fetcher) RemoveActiveResolver(addr string) {
f.mu.Lock()
@@ -653,6 +676,12 @@ func (f *Fetcher) FetchLatestVersion(ctx context.Context) (string, error) {
return protocol.DecodeVersionData(data)
}
+// ErrContentHashMismatch is returned when the fetched messages do not match
+// the expected content hash from metadata. This typically means the server
+// regenerated its blocks between the metadata fetch and the block fetch
+// (block-version race). The caller should re-fetch metadata and retry.
+var ErrContentHashMismatch = fmt.Errorf("content hash mismatch")
+
// FetchChannel fetches all blocks for a channel and returns the parsed messages.
// Cancelling ctx immediately aborts any queued or in-flight block fetches.
// Each block is retried individually via FetchBlock before the channel fetch fails.
@@ -660,6 +689,21 @@ func (f *Fetcher) FetchChannel(ctx context.Context, channelNum int, blockCount i
return f.fetchChannelBlocks(ctx, channelNum, blockCount, f.FetchBlock)
}
+// FetchChannelVerified works like FetchChannel but additionally verifies that
+// the parsed messages match the expected content hash from metadata.
+// Returns ErrContentHashMismatch when the hash does not match (block-version race).
+func (f *Fetcher) FetchChannelVerified(ctx context.Context, channelNum int, blockCount int, expectedHash uint32) ([]protocol.Message, error) {
+ msgs, err := f.fetchChannelBlocks(ctx, channelNum, blockCount, f.FetchBlock)
+ if err != nil {
+ return nil, err
+ }
+ if got := protocol.ContentHashOf(msgs); got != expectedHash {
+ f.log("Channel %d content hash mismatch: got %08x, want %08x (block-version race?)", channelNum, got, expectedHash)
+ return nil, ErrContentHashMismatch
+ }
+ return msgs, nil
+}
+
func (f *Fetcher) fetchChannelBlocks(ctx context.Context, channelNum int, blockCount int, fetchFn func(context.Context, uint16, uint16) ([]byte, error)) ([]protocol.Message, error) {
if blockCount <= 0 {
return nil, nil
@@ -725,7 +769,13 @@ func (f *Fetcher) fetchChannelBlocks(ctx context.Context, channelNum int, blockC
// Decompress if data has compression header
decompressed, err := protocol.DecompressMessages(allData)
if err != nil {
- // Fall back to raw parse for backward compatibility with uncompressed data
+ // If the data starts with a known compression header but decompression
+ // failed, the data is corrupt — do NOT raw-parse compressed bytes as
+ // messages (that produces binary garbage as message text).
+ if len(allData) > 0 && (allData[0] == 0x00 || allData[0] == 0x01) {
+ return nil, fmt.Errorf("decompress channel %d: %w", channelNum, err)
+ }
+ // Unknown header → pre-compression era data; try raw parse.
return protocol.ParseMessages(allData)
}
diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go
index e0604b8..093f7dc 100644
--- a/internal/protocol/protocol.go
+++ b/internal/protocol/protocol.go
@@ -9,6 +9,7 @@ import (
"hash/crc32"
"io"
"math/big"
+ "unicode/utf8"
)
const (
@@ -268,9 +269,16 @@ func ParseMessages(data []byte) ([]Message, error) {
if off+textLen > len(data) {
break // incomplete message text, stop
}
- text := string(data[off : off+textLen])
+ textBytes := data[off : off+textLen]
off += textLen
+ // Skip messages with invalid UTF-8 text — these are artifacts of
+ // corrupt/decompression-failed data, not real messages.
+ if !utf8.Valid(textBytes) {
+ continue
+ }
+ text := string(textBytes)
+
msgs = append(msgs, Message{
ID: id,
Timestamp: ts,
diff --git a/internal/server/xpublic.go b/internal/server/xpublic.go
index c37cea4..82469d0 100644
--- a/internal/server/xpublic.go
+++ b/internal/server/xpublic.go
@@ -82,7 +82,7 @@ func NewXPublicReader(accounts []string, feed *Feed, msgLimit int, baseCh int, i
}
func normalizeXRSSInstances(instancesCSV string) []string {
- defaults := []string{"http://nitter.net", "https://nitter.net"}
+ defaults := []string{"https://nitter.net", "http://nitter.net"}
if strings.TrimSpace(instancesCSV) == "" {
return defaults
}
diff --git a/internal/web/scanner.go b/internal/web/scanner.go
index 4a0299c..643f2b4 100644
--- a/internal/web/scanner.go
+++ b/internal/web/scanner.go
@@ -194,7 +194,7 @@ func (s *Server) handleScannerApply(w http.ResponseWriter, r *http.Request) {
}
}
- // Determine which profile to apply to.
+ // Determine which profile to apply to (for logging purposes / active check).
pl, _ := s.loadProfiles()
if pl == nil {
http.Error(w, "no profiles configured", 400)
@@ -218,41 +218,21 @@ func (s *Server) handleScannerApply(w http.ResponseWriter, r *http.Request) {
return
}
- var newResolvers []string
+ // Update the shared resolver bank instead of per-profile resolvers.
if req.Mode == "overwrite" {
- newResolvers = resolvers
+ pl.ResolverBank = resolvers
} else {
- // Append — deduplicate.
- seen := make(map[string]bool)
- for _, r := range pl.Profiles[targetIdx].Config.Resolvers {
- seen[r] = true
- newResolvers = append(newResolvers, r)
- }
- for _, r := range resolvers {
- if !seen[r] {
- newResolvers = append(newResolvers, r)
- }
- }
+ // Append — deduplicate against existing bank.
+ addToBank(pl, resolvers)
}
- pl.Profiles[targetIdx].Config.Resolvers = newResolvers
if err := s.saveProfiles(pl); err != nil {
http.Error(w, fmt.Sprintf("save profiles: %v", err), 500)
return
}
- // If this is the active profile, also update config + fetcher.
+ // If this is the active profile, re-init the fetcher with the updated bank.
if targetProfileID == pl.Active {
- s.mu.Lock()
- cfg := s.config
- s.mu.Unlock()
- if cfg != nil {
- cfg.Resolvers = newResolvers
- _ = s.saveConfig(cfg)
- s.mu.Lock()
- s.config = cfg
- s.mu.Unlock()
- }
// Cancel any in-progress checker scan before re-initializing so the
// old goroutine exits quickly and doesn't race with the new fetcher.
s.mu.RLock()
@@ -274,8 +254,8 @@ func (s *Server) handleScannerApply(w http.ResponseWriter, r *http.Request) {
ctx := s.fetcherCtx
s.mu.RUnlock()
if fetcher != nil {
- fetcher.SetActiveResolvers(newResolvers)
- s.saveLastScan(newResolvers)
+ fetcher.SetActiveResolvers(resolvers)
+ s.saveLastScan(resolvers)
}
if checker != nil && ctx != nil {
checker.StartPeriodic(ctx)
@@ -284,5 +264,5 @@ func (s *Server) handleScannerApply(w http.ResponseWriter, r *http.Request) {
}
s.addLog(fmt.Sprintf("Scanner resolvers applied: %d resolvers (%s) to profile %s", len(resolvers), req.Mode, pl.Profiles[targetIdx].Nickname))
- writeJSON(w, map[string]any{"ok": true, "count": len(newResolvers)})
+ writeJSON(w, map[string]any{"ok": true, "count": len(pl.ResolverBank)})
}
diff --git a/internal/web/static/index.html b/internal/web/static/index.html
index e2135b7..6b5e3e2 100644
--- a/internal/web/static/index.html
+++ b/internal/web/static/index.html
@@ -1443,11 +1443,11 @@
+▼
+
-
+
-
@@ -1629,8 +1629,10 @@
-
+
+ Resolvers are managed in the shared Resolver Bank.
+ Open Resolver Bank
+
-
@@ -1661,8 +1662,6 @@
-
@@ -1687,15 +1686,57 @@
-
+
-
-
Working Resolvers
-
+
+
Resolver Bank
+
+ Resolvers are DNS servers used to connect to thefeed and fetch data. Use the Scanner to find new resolvers, or add them manually below.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Remove Bad Resolvers
+
+
+
+ 0.10
+
+
+
+
+
+
+
Add Resolvers
+
+
+
+
+
+
+
+
+
+
-
+
+
+
@@ -1854,7 +1895,7 @@
channel_mgmt_inactive: 'برای مدیریت کانال\u200cها، ابتدا این پروفایل را فعال کنید.',
channel_placeholder: 'نام کاربری کانال',
version: 'نسخه',
- edit: 'ویرایش', share: 'اشتراک\u200cگذاری', delete: 'حذف', save: 'ذخیره', cancel: 'لغو',
+ edit: 'ویرایش', share: 'اشتراک\u200cگذاری', delete: 'حذف', save: 'ذخیره', cancel: 'لغو', yes: 'بله', no: 'خیر',
copied: 'کپی شد!', copy: 'کپی', active: 'فعال',
private: 'خصوصی', x_posts: 'پستهای X', x_label: 'X', no_config: 'ابتدا پروفایل را ذخیره کنید',
refreshing: 'در حال بروزرسانی...', fetching_channel: 'در حال دریافت کانال...',
@@ -1922,11 +1963,30 @@
export_no_messages: 'پیامی برای خروجی وجود ندارد',
show_resolvers: 'ریزالورهای فعال',
show_resolvers_btn: 'نمایش',
- resolvers_title: 'ریزالورهای فعال',
+ resolvers_title: 'بانک ریزالور',
no_active_resolvers: 'ریزالور فعالی وجود ندارد',
resolver_speed: 'سرعت',
resolver_score: 'امتیاز',
reset_scoreboard: 'ریست امتیازها',
+ resolver_bank_note: 'ریزالورها در بانک مشترک مدیریت میشوند.',
+ open_resolver_bank: 'باز کردن بانک ریزالور',
+ resolver_bank_info: 'ریزالورها سرورهای DNS هستند که برای اتصال به thefeed و دریافت اطلاعات استفاده میشوند. از اسکنر برای یافتن ریزالورهای جدید استفاده کنید یا آنها را دستی اضافه کنید.',
+ resolver_tab_active: 'فعال',
+ resolver_tab_bank: 'بانک',
+ cleanup_title: 'حذف ریزالورهای ضعیف',
+ min_score: 'حداقل امتیاز:',
+ remove_bad_resolvers: 'حذف ریزالورهای ضعیف',
+ would_be_removed: 'حذف خواهد شد',
+ would_remain: 'باقی میماند',
+ removed: 'حذف شد',
+ remaining: 'باقیمانده',
+ added: 'اضافه شد',
+ add_resolvers: 'افزودن ریزالور',
+ select_resolvers_export: 'ریزالورها برای اشتراکگذاری:',
+ select_all: 'همه',
+ select_none: 'هیچکدام',
+ import_add_resolvers: 'آیا {n} ریزالور وارد شده به بانک اضافه شود؟',
+ import_add_resolvers_large: 'بانک شما {c} ریزالور دارد. آیا {n} ریزالور جدید اضافه شود؟',
bg_image: 'تصویر پسزمینه',
apply: 'اعمال',
clear_bg: 'پاک کردن',
@@ -1966,7 +2026,7 @@
channel_mgmt_inactive: 'Switch to this profile first to manage its channels.',
channel_placeholder: 'channel_username',
version: 'Version',
- edit: 'Edit', share: 'Share', delete: 'Delete', save: 'Save', cancel: 'Cancel',
+ edit: 'Edit', share: 'Share', delete: 'Delete', save: 'Save', cancel: 'Cancel', yes: 'Yes', no: 'No',
copied: 'URI copied!', copy: 'Copy', active: 'Active',
private: 'Private', x_posts: 'X Posts', x_label: 'X', no_config: 'Save a profile first',
refreshing: 'Refreshing...', fetching_channel: 'Fetching channel...',
@@ -2034,11 +2094,30 @@
export_no_messages: 'No messages to export',
show_resolvers: 'Working Resolvers',
show_resolvers_btn: 'Show',
- resolvers_title: 'Working Resolvers',
+ resolvers_title: 'Resolver Bank',
no_active_resolvers: 'No active resolvers',
resolver_speed: 'Speed',
resolver_score: 'Score',
reset_scoreboard: 'Reset Scores',
+ resolver_bank_note: 'Resolvers are managed in the shared Resolver Bank.',
+ open_resolver_bank: 'Open Resolver Bank',
+ resolver_bank_info: 'Resolvers are DNS servers used to connect to thefeed and fetch data. Use the Scanner to find new resolvers, or add them manually below.',
+ resolver_tab_active: 'Active',
+ resolver_tab_bank: 'Bank',
+ cleanup_title: 'Remove Bad Resolvers',
+ min_score: 'Min score:',
+ remove_bad_resolvers: 'Remove Bad Resolvers',
+ would_be_removed: 'would be removed',
+ would_remain: 'would remain',
+ removed: 'Removed',
+ remaining: 'remaining',
+ added: 'Added',
+ add_resolvers: 'Add Resolvers',
+ select_resolvers_export: 'Resolvers to include in share link:',
+ select_all: 'All',
+ select_none: 'None',
+ import_add_resolvers: 'Add {n} imported resolvers to your bank?',
+ import_add_resolvers_large: 'Your bank has {c} resolvers. Add {n} new resolvers?',
bg_image: 'Background Image',
apply: 'Apply',
clear_bg: 'Clear',
@@ -2235,6 +2314,16 @@
document.getElementById('rescanPromptYes').onclick = function () { document.body.removeChild(overlay); resolve(false) };
});
}
+ function showConfirmDialog(msg, yesText, noText) {
+ return new Promise(function (resolve) {
+ var overlay = document.createElement('div');
+ overlay.className = 'modal-overlay active';
+ overlay.innerHTML = '
' + esc(msg) + '
';
+ document.body.appendChild(overlay);
+ document.getElementById('confirmNo').onclick = function () { document.body.removeChild(overlay); resolve(false) };
+ document.getElementById('confirmYes').onclick = function () { document.body.removeChild(overlay); resolve(true) };
+ });
+ }
// ===== SETTINGS =====
function openSettings() {
@@ -2324,6 +2413,7 @@
eventSource = new EventSource('/api/events');
eventSource.addEventListener('log', function (e) { addLogLine(JSON.parse(e.data)) });
eventSource.addEventListener('update', async function (e) {
+ refreshResolversBadge();
var data; try { data = JSON.parse(e.data) } catch (x) { data = e.data }
var wasEmpty = channels.length === 0;
var snapChannel = selectedChannel;
@@ -2372,11 +2462,12 @@
}
function closeProfiles() { document.getElementById('profilesModal').classList.remove('active') }
- function buildProfileUri(id) {
+ function buildProfileUri(id, selectedResolvers) {
if (!profiles || !profiles.profiles) return '';
var p = profiles.profiles.find(function (x) { return x.id === id });
if (!p || !p.config.domain) return '';
- return 'thefeed://' + encodeURIComponent(p.config.domain) + '/' + encodeURIComponent(p.config.key) + '?r=' + encodeURIComponent((p.config.resolvers || []).join(','));
+ var resolvers = selectedResolvers || [];
+ return 'thefeed://' + encodeURIComponent(p.config.domain) + '/' + encodeURIComponent(p.config.key) + '?r=' + encodeURIComponent(resolvers.join(','));
}
function renderProfilesModal() {
@@ -2397,35 +2488,74 @@
if (isActive) h += '' + t('active') + '';
h += '
' + esc(p.config.domain) + '
';
h += '
';
- if (isActive) h += '';
h += '';
h += '';
h += '
';
// Share panel (hidden by default)
h += '
';
h += '
';
+ h += '';
h += '';
+ h += '
';
h += '';
h += '';
- h += '
';
+ h += '
';
h += '';
}
el.innerHTML = h;
}
- function toggleSharePanel(id) {
- // Close all first
- document.querySelectorAll('.share-panel').forEach(function (sp) { sp.style.display = 'none' });
+ async function toggleSharePanel(id) {
var panel = document.getElementById('share-' + id);
if (!panel) return;
- var uri = buildProfileUri(id);
+ // If already visible, just close it
+ if (panel.style.display === 'block') {
+ panel.style.display = 'none';
+ return;
+ }
+ // Close all first
+ document.querySelectorAll('.share-panel').forEach(function (sp) { sp.style.display = 'none' });
+ panel.style.display = 'block';
+ // Populate resolver checkboxes from bank
+ var resolverEl = document.getElementById('share-resolvers-' + id);
+ resolverEl.innerHTML = '' + t('loading') + '';
+ try {
+ var r = await fetch('/api/resolvers/bank');
+ var data = r.ok ? await r.json() : { bank: [] };
+ var bank = data.bank || [];
+ if (bank.length === 0) {
+ resolverEl.innerHTML = '' + t('no_active_resolvers') + '';
+ } else {
+ var h = '';
+ h += '';
+ for (var i = 0; i < bank.length; i++) {
+ h += '';
+ }
+ resolverEl.innerHTML = h;
+ }
+ } catch (e) { resolverEl.innerHTML = '' }
+ updateShareUri(id);
+ }
+
+ function toggleAllShareResolvers(id, checked) {
+ document.querySelectorAll('.share-r-cb[data-profile="' + id + '"]').forEach(function (cb) { cb.checked = checked });
+ updateShareUri(id);
+ }
+
+ function updateShareUri(id) {
+ var cbs = document.querySelectorAll('.share-r-cb[data-profile="' + id + '"]');
+ var selected = [];
+ cbs.forEach(function (cb) { if (cb.checked) selected.push(cb.value) });
+ var uri = buildProfileUri(id, selected);
var input = document.getElementById('suri-' + id);
if (input) input.value = uri || t('no_config');
- panel.style.display = 'block';
}
function copyShareUri(id) {
- var uri = buildProfileUri(id);
+ var cbs = document.querySelectorAll('.share-r-cb[data-profile="' + id + '"]');
+ var selected = [];
+ cbs.forEach(function (cb) { if (cb.checked) selected.push(cb.value) });
+ var uri = buildProfileUri(id, selected);
if (!uri) { showToast(t('no_config')); return }
navigator.clipboard.writeText(uri).then(function () { showToast(t('copied')) }).catch(function () {
var input = document.getElementById('suri-' + id); if (input) { input.select(); input.setSelectionRange(0, 9999); }
@@ -2435,15 +2565,19 @@
async function activateProfile(id) {
if (id === activeProfileId) { closeProfiles(); return }
- // Check if we should skip resolver check
- var skipCheck = false;
+ // If there are active resolvers, ask user whether to rescan; otherwise skip.
+ var skipCheck = true;
try {
- var stRes = await fetch('/api/status');
- var st = await stRes.json();
- if (st.lastScan && st.lastScan.count > 0) {
- skipCheck = await showRescanPrompt(st.lastScan.count);
+ var bankR = await fetch('/api/resolvers/bank');
+ if (bankR.ok) {
+ var bankD = await bankR.json();
+ var activeN = 0;
+ (bankD.bank || []).forEach(function (b) { if (b.active) activeN++ });
+ if (activeN > 0) {
+ skipCheck = await showConfirmDialog(t('rescan_prompt_msg').replace('{n}', activeN), t('rescan_prompt_skip'), t('rescan_prompt_yes'));
+ }
}
- } catch (e) { }
+ } catch (e) { /* ignore — default to skipCheck=true */ }
try {
var r = await fetch('/api/profiles/switch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id, skipCheck: skipCheck }) });
if (!r.ok) return;
@@ -2471,12 +2605,29 @@
params.split('&').forEach(function (kv) { var p = kv.split('='); if (p[0] === 'r' && p[1]) resolvers = decodeURIComponent(p[1]).split(',').filter(Boolean) });
if (!domain || !key) { errEl.textContent = t('uri_missing'); errEl.style.display = 'block'; return }
if (!resolvers.length) resolvers = ['8.8.8.8', '1.1.1.1'];
- var profile = { id: '', nickname: domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: 'single', rateLimit: 6 } };
+ // Add resolvers to the shared bank.
+ var bankData = { count: 0 };
+ try {
+ var bankRes = await fetch('/api/resolvers/bank', { signal: AbortSignal.timeout(5000) });
+ if (bankRes.ok) bankData = await bankRes.json();
+ } catch (e2) { /* timeout or fetch error — treat as empty bank */ }
+ var shouldAdd = true;
+ if (bankData.count > 0 && bankData.count <= 200) {
+ shouldAdd = await showConfirmDialog(t('import_add_resolvers').replace('{n}', resolvers.length), t('yes'), t('no'));
+ } else if (bankData.count > 200) {
+ shouldAdd = await showConfirmDialog(t('import_add_resolvers_large').replace('{n}', resolvers.length).replace('{c}', bankData.count), t('yes'), t('no'));
+ }
+ if (shouldAdd) {
+ await fetch('/api/resolvers/bank', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolvers: resolvers }) });
+ }
+ // Create profile without resolvers (they're in the bank now).
+ var profile = { id: '', nickname: domain, config: { domain: domain, key: key, queryMode: 'single', rateLimit: 6 } };
var r = await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'create', profile: profile }) });
if (!r.ok) throw new Error('save failed');
okEl.textContent = t('import_success').replace('{d}', domain); okEl.style.display = 'block';
document.getElementById('importUriInput').value = '';
await loadProfiles(); renderProfilesModal();
+ refreshResolversBadge();
} catch (e) { errEl.textContent = t('import_error') + ': ' + e.message; errEl.style.display = 'block' }
}
@@ -2489,18 +2640,15 @@
if (id) {
document.getElementById('profileEditorTitle').textContent = t('edit_profile');
document.getElementById('peDeleteBtn').style.display = '';
- document.getElementById('peScannerBtn').style.display = '';
var p = profiles && profiles.profiles && profiles.profiles.find(function (x) { return x.id === id });
if (p) {
document.getElementById('peNick').value = p.nickname || '';
document.getElementById('peDomain').value = p.config.domain || '';
document.getElementById('peKey').value = p.config.key || '';
- document.getElementById('peResolvers').value = (p.config.resolvers || []).join('\n');
document.getElementById('peQueryMode').value = p.config.queryMode || 'single';
document.getElementById('peRateLimit').value = p.config.rateLimit || 6;
document.getElementById('peScatter').value = p.config.scatter || 4;
document.getElementById('peTimeout').value = p.config.timeout || 15;
- document.getElementById('peAutoScan').checked = p.config.autoScan !== false;
}
document.getElementById('peChannelSection').style.display = '';
var isActive = id === activeProfileId;
@@ -2516,16 +2664,13 @@
} else {
document.getElementById('profileEditorTitle').textContent = t('new_profile');
document.getElementById('peDeleteBtn').style.display = 'none';
- document.getElementById('peScannerBtn').style.display = 'none';
document.getElementById('peNick').value = '';
document.getElementById('peDomain').value = '';
document.getElementById('peKey').value = '';
- document.getElementById('peResolvers').value = '';
document.getElementById('peQueryMode').value = 'single';
document.getElementById('peRateLimit').value = '6';
document.getElementById('peScatter').value = '4';
document.getElementById('peTimeout').value = '15';
- document.getElementById('peAutoScan').checked = true;
document.getElementById('peChannelSection').style.display = 'none';
}
}
@@ -2572,21 +2717,30 @@
var nick = document.getElementById('peNick').value.trim();
var domain = document.getElementById('peDomain').value.trim();
var key = document.getElementById('peKey').value;
- var resolvers = document.getElementById('peResolvers').value.trim().split(/[\n,]+/).map(function (s) { return s.trim() }).filter(Boolean);
- if (!domain || !key || !resolvers.length) { errEl.textContent = t('resolvers') + ' / ' + t('domain') + ' / ' + t('passphrase'); errEl.style.display = 'block'; return }
- var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, resolvers: resolvers, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15, autoScan: document.getElementById('peAutoScan').checked ? undefined : false } };
+ if (!domain || !key) { errEl.textContent = t('domain') + ' / ' + t('passphrase'); errEl.style.display = 'block'; return }
+ var profile = { id: editingProfileId || '', nickname: nick || domain, config: { domain: domain, key: key, queryMode: document.getElementById('peQueryMode').value, rateLimit: parseFloat(document.getElementById('peRateLimit').value) || 6, scatter: parseInt(document.getElementById('peScatter').value) || 4, timeout: parseInt(document.getElementById('peTimeout').value) || 15 } };
+ // Preserve autoScan from existing profile
+ if (editingProfileId && profiles && profiles.profiles) {
+ var existing = profiles.profiles.find(function (x) { return x.id === editingProfileId });
+ if (existing && existing.config.autoScan === false) profile.config.autoScan = false;
+ }
var action = editingProfileId ? 'update' : 'create';
var wasFirst = !profiles || !profiles.profiles || profiles.profiles.length === 0;
- // Check if we should skip resolver check (existing healthy resolvers)
- var skipCheck = false;
- if (editingProfileId && editingProfileId === activeProfileId) {
+ // If there are active resolvers, ask user whether to rescan; otherwise skip.
+ var skipCheck = true;
+ var isActiveEdit = editingProfileId && editingProfileId === activeProfileId;
+ if (isActiveEdit || wasFirst) {
try {
- var stRes = await fetch('/api/status');
- var st = await stRes.json();
- if (st.lastScan && st.lastScan.count > 0) {
- skipCheck = await showRescanPrompt(st.lastScan.count);
+ var bankR = await fetch('/api/resolvers/bank');
+ if (bankR.ok) {
+ var bankD = await bankR.json();
+ var activeN = 0;
+ (bankD.bank || []).forEach(function (b) { if (b.active) activeN++ });
+ if (activeN > 0) {
+ skipCheck = await showConfirmDialog(t('rescan_prompt_msg').replace('{n}', activeN), t('rescan_prompt_skip'), t('rescan_prompt_yes'));
+ }
}
- } catch (e) { }
+ } catch (e) { /* ignore — default to skipCheck=true */ }
}
try {
var r = await fetch('/api/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, profile: profile, skipCheck: skipCheck }) });
@@ -2613,16 +2767,6 @@
} catch (e) { errEl.textContent = e.message; errEl.style.display = 'block' }
}
- // ===== RESCAN =====
- async function doRescanFromProfiles() {
- closeProfiles();
- showToast(t('rescan_started'));
- document.getElementById('progressPanel').innerHTML = '';
- showInitProgress();
- try { await fetch('/api/rescan', { method: 'POST' }) } catch (e) { }
- setTimeout(function () { loadChannels().then(function () { if (selectedChannel > 0) loadMessages(selectedChannel) }) }, 3000);
- }
-
async function deleteEditingProfile() {
if (!editingProfileId) return;
if (!confirm(t('delete') + '?')) return;
@@ -3156,17 +3300,6 @@
document.getElementById('scannerModal').classList.remove('active');
if (scanPollTimer) { clearInterval(scanPollTimer); scanPollTimer = null }
}
- function openScannerFromProfile() {
- var profileId = editingProfileId;
- closeProfileEditor();
- openScanner();
- if (profileId) {
- var sel = document.getElementById('scanProfile');
- for (var i = 0; i < sel.options.length; i++) {
- if (sel.options[i].value === profileId) { sel.selectedIndex = i; break }
- }
- }
- }
function populateScanProfileSelect() {
var sel = document.getElementById('scanProfile');
@@ -3557,86 +3690,175 @@
}).catch(function () { showToast('Copy failed') });
}
- // ===== WORKING RESOLVERS =====
- function updateResolversBadge(count) {
+ // ===== RESOLVER BANK =====
+ var currentResolverTab = 'active';
+ function updateResolversBadge(count, bankCount) {
var badge = document.getElementById('resolversBadge');
if (!badge) return;
- badge.textContent = count;
- badge.style.color = count > 0 ? 'var(--success, #27ae60)' : 'var(--error, #e74c3c)';
+ badge.textContent = count + ' / ' + (bankCount !== undefined ? bankCount : count);
+ var total = bankCount !== undefined ? bankCount : count;
+ if (total > 500) {
+ badge.style.color = 'var(--error, #e74c3c)';
+ } else {
+ badge.style.color = count > 0 ? 'var(--success, #27ae60)' : 'var(--error, #e74c3c)';
+ }
}
async function refreshResolversBadge() {
try {
- var r = await fetch('/api/resolvers/active');
+ var r = await fetch('/api/resolvers/bank');
if (!r.ok) return;
var data = await r.json();
- updateResolversBadge((data.resolvers || []).length);
+ var activeCount = 0;
+ (data.bank || []).forEach(function (b) { if (b.active) activeCount++ });
+ updateResolversBadge(activeCount, data.count || 0);
} catch (e) { }
}
var resolversRefreshTimer = null;
- async function _fetchResolversBoard(el) {
+ function _buildScoreboardTable(board, showRemove, removeFromBank) {
+ if (!board.length) return '
' + t('no_active_resolvers') + '
';
+ var h = '
';
+ h += '
';
+ h += '
Resolver
';
+ h += '
' + t('resolver_speed') + '
';
+ h += '
' + t('resolver_score') + '
';
+ h += '
\u2705
';
+ h += '
\u274C
';
+ if (showRemove) h += '
';
+ h += '
';
+ for (var i = 0; i < board.length; i++) {
+ var b = board[i];
+ var scoreColor = b.score >= 0.5 ? 'var(--success)' : b.score >= 0.15 ? 'var(--text)' : 'var(--error)';
+ h += '
';
+ h += '
' + esc(b.addr);
+ if (b.active !== undefined && b.active) h += ' \u25CF';
+ h += '
';
+ if (showRemove) {
+ var fn = removeFromBank ? 'removeResolverFromBank' : 'removeResolver';
+ h += '
';
+ }
+ h += '
';
+ }
+ h += '
';
+ return h;
+ }
+ async function _fetchActiveBoard() {
+ var el = document.getElementById('resolverPanelActive');
try {
var r = await fetch('/api/resolvers/active');
if (!r.ok) throw new Error(await r.text());
var data = await r.json();
var board = data.scoreboard || [];
- if (!board.length) { el.innerHTML = '
' + t('no_active_resolvers') + '
'; updateResolversBadge(0); return }
- updateResolversBadge(board.length);
- var h = '
';
- h += '
';
- h += '
Resolver
';
- h += '
' + t('resolver_speed') + '
';
- h += '
' + t('resolver_score') + '
';
- h += '
\u2705
';
- h += '
\u274C
';
- h += '
';
- h += '
';
- for (var i = 0; i < board.length; i++) {
- var b = board[i];
- var scoreColor = b.score >= 0.5 ? 'var(--success)' : b.score >= 0.15 ? 'var(--text)' : 'var(--error)';
- h += '