package main import ( "bufio" "crypto/rand" "fmt" "html/template" "io" "log" "net" "net/http" "net/url" "regexp" "sort" "strconv" "strings" "sync" "time" ) const ( M2UsenetURL = "/compose" TorProxyAddr = "127.0.0.1:9050" PrimaryNNTP = "peannyjkqwqfynd24p6dszvtchkq7hfkwymi5by5y332wmosy5dwfaqd.onion:119" FallbackNNTP = "news.tcpreset.net:119" ) type NewsgroupInfo struct { Name string High int Low int Status string } type ArticleInfo struct { Number int Subject string From string Date string MessageID string References string // References header for threading ParentID string // Direct parent Message-ID ReplySubject string // For m2usenet reply link ReplyRef string // For m2usenet reply link Depth int // Threading depth (0 = root) Children []*ArticleInfo // Child posts (replies) } type NNTPClient struct { conn net.Conn reader *bufio.Reader mutex sync.Mutex isConnected bool connectedServer string lastUsed time.Time } type NewsServer struct { client *NNTPClient groups map[string]*NewsgroupInfo mutex sync.RWMutex session string downloadStatus struct { IsDownloading bool StartTime time.Time LastError string Completed bool GroupCount int mutex sync.RWMutex } } func NewNNTPClient() *NNTPClient { return &NNTPClient{} } func NewNewsServer() *NewsServer { sessionBytes := make([]byte, 8) rand.Read(sessionBytes) return &NewsServer{ client: NewNNTPClient(), groups: make(map[string]*NewsgroupInfo), session: fmt.Sprintf("%x", sessionBytes), } } // ============= SOCKS5 / Tor Connection ============= func (c *NNTPClient) connectViaSocks5(targetAddr string) (net.Conn, error) { proxyConn, err := net.DialTimeout("tcp", TorProxyAddr, 30*time.Second) if err != nil { return nil, fmt.Errorf("tor proxy unreachable: %v", err) } deadline := 60 * time.Second if strings.Contains(targetAddr, ".onion") { deadline = 180 * time.Second } proxyConn.SetDeadline(time.Now().Add(deadline)) defer proxyConn.SetDeadline(time.Time{}) if _, err := proxyConn.Write([]byte{0x05, 0x01, 0x00}); err != nil { proxyConn.Close() return nil, fmt.Errorf("socks5 auth failed: %v", err) } authResp := make([]byte, 2) if _, err := io.ReadFull(proxyConn, authResp); err != nil { proxyConn.Close() return nil, fmt.Errorf("socks5 auth response failed: %v", err) } if authResp[0] != 0x05 || authResp[1] != 0x00 { proxyConn.Close() return nil, fmt.Errorf("socks5 auth rejected") } parts := strings.Split(targetAddr, ":") host := parts[0] port, _ := strconv.Atoi(parts[1]) req := []byte{0x05, 0x01, 0x00, 0x03, byte(len(host))} req = append(req, []byte(host)...) req = append(req, byte(port>>8), byte(port&0xff)) if _, err := proxyConn.Write(req); err != nil { proxyConn.Close() return nil, fmt.Errorf("socks5 connect failed: %v", err) } resp := make([]byte, 4) if _, err := io.ReadFull(proxyConn, resp); err != nil { proxyConn.Close() return nil, fmt.Errorf("socks5 response failed: %v", err) } if resp[1] != 0x00 { proxyConn.Close() errCodes := map[byte]string{ 0x01: "general failure", 0x02: "not allowed", 0x03: "network unreachable", 0x04: "host unreachable", 0x05: "connection refused", 0x06: "TTL expired", } if msg, ok := errCodes[resp[1]]; ok { return nil, fmt.Errorf("socks5: %s", msg) } return nil, fmt.Errorf("socks5 error: %d", resp[1]) } switch resp[3] { case 0x01: io.ReadFull(proxyConn, make([]byte, 6)) case 0x03: lenByte := make([]byte, 1) io.ReadFull(proxyConn, lenByte) io.ReadFull(proxyConn, make([]byte, lenByte[0]+2)) case 0x04: io.ReadFull(proxyConn, make([]byte, 18)) } return proxyConn, nil } func (c *NNTPClient) connect() error { targets := []struct { addr string name string }{ {PrimaryNNTP, "Onion Service"}, {FallbackNNTP, "Fallback (via Tor)"}, } var lastErr error for _, target := range targets { log.Printf("๐Ÿ”— Connecting to %s...", target.name) conn, err := c.connectViaSocks5(target.addr) if err != nil { lastErr = err log.Printf("โŒ %s failed: %v", target.name, err) continue } c.conn = conn c.reader = bufio.NewReader(conn) conn.SetReadDeadline(time.Now().Add(60 * time.Second)) welcome, err := c.reader.ReadString('\n') conn.SetReadDeadline(time.Time{}) if err != nil { conn.Close() lastErr = err continue } log.Printf("๐Ÿ“ฅ Welcome: %s", strings.TrimSpace(welcome)) if _, err := conn.Write([]byte("MODE READER\r\n")); err != nil { conn.Close() lastErr = err continue } modeResp, err := c.reader.ReadString('\n') if err != nil { conn.Close() lastErr = err continue } if !strings.HasPrefix(modeResp, "200") && !strings.HasPrefix(modeResp, "201") { conn.Close() lastErr = fmt.Errorf("MODE READER failed: %s", modeResp) continue } c.isConnected = true c.connectedServer = target.name c.lastUsed = time.Now() log.Printf("โœ… Connected to %s", target.name) return nil } return fmt.Errorf("all servers failed: %v", lastErr) } func (c *NNTPClient) ensureConnected() error { c.mutex.Lock() defer c.mutex.Unlock() if !c.isConnected || c.conn == nil || time.Since(c.lastUsed) > 5*time.Minute { if c.conn != nil { c.conn.Close() } c.isConnected = false return c.connect() } c.lastUsed = time.Now() return nil } func (c *NNTPClient) ListGroups() (map[string]*NewsgroupInfo, error) { if err := c.ensureConnected(); err != nil { return nil, err } c.mutex.Lock() defer c.mutex.Unlock() // Set a long deadline for the entire LIST operation (10 minutes) c.conn.SetDeadline(time.Now().Add(10 * time.Minute)) defer c.conn.SetDeadline(time.Time{}) log.Printf("๐Ÿ“ค Sending LIST command...") if _, err := c.conn.Write([]byte("LIST\r\n")); err != nil { c.isConnected = false return nil, fmt.Errorf("failed to send LIST: %v", err) } resp, err := c.reader.ReadString('\n') if err != nil { c.isConnected = false return nil, fmt.Errorf("failed to read LIST response: %v", err) } if !strings.HasPrefix(resp, "215") { return nil, fmt.Errorf("LIST failed: %s", resp) } groups := make(map[string]*NewsgroupInfo) lineCount := 0 for { line, err := c.reader.ReadString('\n') if err != nil { c.isConnected = false return nil, fmt.Errorf("error reading groups at line %d: %v (try again)", lineCount, err) } line = strings.TrimSpace(line) if line == "." { break } lineCount++ if lineCount%5000 == 0 { log.Printf("๐Ÿ“Š Read %d groups...", lineCount) // Reset deadline every 5000 groups to keep connection alive c.conn.SetDeadline(time.Now().Add(10 * time.Minute)) } parts := strings.Fields(line) if len(parts) >= 4 { high, _ := strconv.Atoi(parts[1]) low, _ := strconv.Atoi(parts[2]) groups[parts[0]] = &NewsgroupInfo{ Name: parts[0], High: high, Low: low, Status: parts[3], } } } log.Printf("โœ… Loaded %d groups total", len(groups)) return groups, nil } func (c *NNTPClient) SelectGroup(name string) (int, int, int, error) { c.mutex.Lock() defer c.mutex.Unlock() if !c.isConnected { return 0, 0, 0, fmt.Errorf("not connected") } if _, err := c.conn.Write([]byte("GROUP " + name + "\r\n")); err != nil { return 0, 0, 0, err } resp, err := c.reader.ReadString('\n') if err != nil { return 0, 0, 0, err } if !strings.HasPrefix(resp, "211") { return 0, 0, 0, fmt.Errorf("GROUP failed: %s", resp) } parts := strings.Fields(resp) if len(parts) < 4 { return 0, 0, 0, fmt.Errorf("invalid response") } count, _ := strconv.Atoi(parts[1]) low, _ := strconv.Atoi(parts[2]) high, _ := strconv.Atoi(parts[3]) return count, low, high, nil } func (c *NNTPClient) GetRecentArticles(low, high, max int) ([]*ArticleInfo, error) { c.mutex.Lock() defer c.mutex.Unlock() if !c.isConnected { return nil, fmt.Errorf("not connected") } if high-low+1 > max { low = high - max + 1 } cmd := fmt.Sprintf("XOVER %d-%d\r\n", low, high) if _, err := c.conn.Write([]byte(cmd)); err != nil { return nil, err } resp, err := c.reader.ReadString('\n') if err != nil { return nil, err } if !strings.HasPrefix(resp, "224") { return nil, fmt.Errorf("XOVER failed: %s", resp) } var articles []*ArticleInfo for { line, err := c.reader.ReadString('\n') if err != nil { return nil, err } line = strings.TrimSpace(line) if line == "." { break } parts := strings.Split(line, "\t") if len(parts) >= 5 { num, _ := strconv.Atoi(parts[0]) date := "" if len(parts) > 3 { date = parts[3] } // XOVER format: num subject from date message-id references bytes lines refs := "" if len(parts) > 5 { refs = strings.TrimSpace(parts[5]) } // Extract parent ID (last message-id in references) parentID := "" if refs != "" { // References contains space-separated message-ids refParts := strings.Fields(refs) if len(refParts) > 0 { parentID = refParts[len(refParts)-1] } } articles = append(articles, &ArticleInfo{ Number: num, Subject: parts[1], From: parts[2], Date: date, MessageID: strings.TrimSpace(parts[4]), References: refs, ParentID: parentID, }) } } return articles, nil } func (c *NNTPClient) GetArticle(articleNum int) (map[string]string, string, error) { c.mutex.Lock() defer c.mutex.Unlock() if !c.isConnected { return nil, "", fmt.Errorf("not connected") } cmd := fmt.Sprintf("ARTICLE %d\r\n", articleNum) if _, err := c.conn.Write([]byte(cmd)); err != nil { return nil, "", err } resp, err := c.reader.ReadString('\n') if err != nil { return nil, "", err } if !strings.HasPrefix(resp, "220") { return nil, "", fmt.Errorf("ARTICLE failed: %s", resp) } headers := make(map[string]string) var bodyLines []string inHeaders := true currentHeader := "" for { line, err := c.reader.ReadString('\n') if err != nil { return nil, "", err } line = strings.TrimRight(line, "\r\n") if line == "." { break } // Handle dot-stuffing if strings.HasPrefix(line, "..") { line = line[1:] } if inHeaders { if line == "" { inHeaders = false continue } // Continuation of previous header if (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) && currentHeader != "" { headers[currentHeader] += " " + strings.TrimSpace(line) continue } // New header if idx := strings.Index(line, ":"); idx > 0 { currentHeader = line[:idx] headers[currentHeader] = strings.TrimSpace(line[idx+1:]) } } else { bodyLines = append(bodyLines, line) } } return headers, strings.Join(bodyLines, "\n"), nil } func (c *NNTPClient) Close() { c.mutex.Lock() defer c.mutex.Unlock() if c.conn != nil { c.conn.Write([]byte("QUIT\r\n")) c.conn.Close() } c.isConnected = false } // ============= Server Handlers ============= func (s *NewsServer) testTorProxy() error { conn, err := net.DialTimeout("tcp", TorProxyAddr, 10*time.Second) if err != nil { return err } defer conn.Close() conn.SetDeadline(time.Now().Add(10 * time.Second)) if _, err := conn.Write([]byte{0x05, 0x01, 0x00}); err != nil { return err } resp := make([]byte, 2) if _, err := io.ReadFull(conn, resp); err != nil { return err } if resp[0] != 0x05 || resp[1] != 0x00 { return fmt.Errorf("socks5 failed") } return nil } func (s *NewsServer) searchGroups(query string) []*NewsgroupInfo { s.mutex.RLock() defer s.mutex.RUnlock() if query == "" { return nil } pattern := strings.ToLower(query) pattern = strings.ReplaceAll(pattern, ".", "\\.") pattern = strings.ReplaceAll(pattern, "*", ".*") pattern = strings.ReplaceAll(pattern, "?", ".") if !strings.Contains(query, "*") && !strings.Contains(query, "?") { pattern = ".*" + pattern + ".*" } regex, err := regexp.Compile("^" + pattern + "$") if err != nil { var results []*NewsgroupInfo queryLower := strings.ToLower(query) for _, g := range s.groups { if strings.Contains(strings.ToLower(g.Name), queryLower) { results = append(results, g) } } return results } var results []*NewsgroupInfo for _, g := range s.groups { if regex.MatchString(strings.ToLower(g.Name)) { results = append(results, g) } } sort.Slice(results, func(i, j int) bool { return results[i].Name < results[j].Name }) return results } func (s *NewsServer) handleHome(w http.ResponseWriter, r *http.Request) { s.mutex.RLock() groupCount := len(s.groups) s.mutex.RUnlock() torOK := s.testTorProxy() == nil tmpl := template.Must(template.New("home").Parse(homeTemplate)) tmpl.Execute(w, map[string]interface{}{ "GroupCount": groupCount, "TorOK": torOK, "Session": s.session, "M2UsenetURL": M2UsenetURL, }) } func (s *NewsServer) handleDownload(w http.ResponseWriter, r *http.Request) { s.downloadStatus.mutex.Lock() s.downloadStatus.IsDownloading = true s.downloadStatus.StartTime = time.Now() s.downloadStatus.LastError = "" s.downloadStatus.Completed = false s.downloadStatus.GroupCount = 0 s.downloadStatus.mutex.Unlock() go func() { maxRetries := 3 var lastErr error for attempt := 1; attempt <= maxRetries; attempt++ { log.Printf("๐Ÿ“ฅ Download attempt %d/%d...", attempt, maxRetries) // Force reconnect on retry if attempt > 1 { s.client.Close() time.Sleep(5 * time.Second) // Wait before retry } groups, err := s.client.ListGroups() if err != nil { lastErr = err log.Printf("โŒ Attempt %d failed: %v", attempt, err) s.downloadStatus.mutex.Lock() s.downloadStatus.LastError = fmt.Sprintf("Attempt %d/%d: %v", attempt, maxRetries, err) s.downloadStatus.mutex.Unlock() continue // Retry } // Success! s.mutex.Lock() s.groups = groups s.mutex.Unlock() s.downloadStatus.mutex.Lock() s.downloadStatus.GroupCount = len(groups) s.downloadStatus.IsDownloading = false s.downloadStatus.Completed = true s.downloadStatus.LastError = "" s.downloadStatus.mutex.Unlock() log.Printf("โœ… Downloaded %d groups", len(groups)) return } // All retries failed s.downloadStatus.mutex.Lock() s.downloadStatus.IsDownloading = false s.downloadStatus.LastError = fmt.Sprintf("Failed after %d attempts: %v", maxRetries, lastErr) s.downloadStatus.mutex.Unlock() }() tmpl := template.Must(template.New("download").Parse(downloadingTemplate)) tmpl.Execute(w, nil) } func (s *NewsServer) handleDownloadStatus(w http.ResponseWriter, r *http.Request) { s.downloadStatus.mutex.RLock() defer s.downloadStatus.mutex.RUnlock() w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, `{"downloading":%t,"completed":%t,"groups":%d,"error":"%s","elapsed":%d}`, s.downloadStatus.IsDownloading, s.downloadStatus.Completed, s.downloadStatus.GroupCount, s.downloadStatus.LastError, int(time.Since(s.downloadStatus.StartTime).Seconds())) } func (s *NewsServer) handleSearch(w http.ResponseWriter, r *http.Request) { query := strings.TrimSpace(r.URL.Query().Get("q")) var results []*NewsgroupInfo if query != "" { results = s.searchGroups(query) } truncated := false if len(results) > 500 { results = results[:500] truncated = true } s.mutex.RLock() totalGroups := len(s.groups) s.mutex.RUnlock() tmpl := template.Must(template.New("search").Parse(searchTemplate)) tmpl.Execute(w, map[string]interface{}{ "Query": query, "Results": results, "ResultCount": len(results), "TotalGroups": totalGroups, "Truncated": truncated, "M2UsenetURL": M2UsenetURL, }) } // buildThreads organizes articles into a threaded structure func buildThreads(articles []*ArticleInfo) []*ArticleInfo { if len(articles) == 0 { return articles } // Create map of MessageID -> Article byMsgID := make(map[string]*ArticleInfo) for _, art := range articles { msgID := art.MessageID // Normalize message-id (ensure <> brackets) if !strings.HasPrefix(msgID, "<") { msgID = "<" + msgID } if !strings.HasSuffix(msgID, ">") { msgID = msgID + ">" } byMsgID[msgID] = art } // Build parent-child relationships var roots []*ArticleInfo for _, art := range articles { parentID := art.ParentID // Normalize parent ID if parentID != "" { if !strings.HasPrefix(parentID, "<") { parentID = "<" + parentID } if !strings.HasSuffix(parentID, ">") { parentID = parentID + ">" } } if parentID == "" { // No parent - this is a root thread roots = append(roots, art) } else if parent, ok := byMsgID[parentID]; ok { // Parent found in our set - add as child parent.Children = append(parent.Children, art) } else { // Parent not in our set - treat as root roots = append(roots, art) } } // Sort roots by article number (newest first) sort.Slice(roots, func(i, j int) bool { return roots[i].Number > roots[j].Number }) // Flatten the tree with depth information var result []*ArticleInfo var flatten func(art *ArticleInfo, depth int) flatten = func(art *ArticleInfo, depth int) { art.Depth = depth result = append(result, art) // Sort children by article number (oldest first within thread) sort.Slice(art.Children, func(i, j int) bool { return art.Children[i].Number < art.Children[j].Number }) for _, child := range art.Children { flatten(child, depth+1) } } for _, root := range roots { flatten(root, 0) } return result } func (s *NewsServer) handleGroup(w http.ResponseWriter, r *http.Request) { groupName := strings.TrimPrefix(r.URL.Path, "/group/") if groupName == "" { http.Redirect(w, r, "/search", http.StatusFound) return } var articles []*ArticleInfo var connErr string if err := s.client.ensureConnected(); err != nil { connErr = err.Error() } else { _, low, high, err := s.client.SelectGroup(groupName) if err != nil { connErr = err.Error() } else { articles, err = s.client.GetRecentArticles(low, high, 100) // Get more for threading if err != nil { connErr = err.Error() } } } // Compute ReplySubject and ReplyRef for each article for _, art := range articles { // Subject: add "Re: " only if not already present subj := art.Subject if !strings.HasPrefix(strings.ToLower(subj), "re:") { subj = "Re: " + subj } art.ReplySubject = subj // MessageID: ensure <> brackets msgID := art.MessageID if msgID != "" { if !strings.HasPrefix(msgID, "<") { msgID = "<" + msgID } if !strings.HasSuffix(msgID, ">") { msgID = msgID + ">" } } art.ReplyRef = msgID } // Build threaded view articles = buildThreads(articles) tmpl := template.Must(template.New("group").Parse(groupTemplate)) tmpl.Execute(w, map[string]interface{}{ "GroupName": groupName, "Articles": articles, "Error": connErr, "M2UsenetURL": M2UsenetURL, }) } func (s *NewsServer) handleArticle(w http.ResponseWriter, r *http.Request) { // URL format: /article/group.name/12345 path := strings.TrimPrefix(r.URL.Path, "/article/") parts := strings.Split(path, "/") if len(parts) < 2 { http.Redirect(w, r, "/search", http.StatusFound) return } groupName := strings.Join(parts[:len(parts)-1], "/") articleNum, err := strconv.Atoi(parts[len(parts)-1]) if err != nil { http.Error(w, "Invalid article number", http.StatusBadRequest) return } var headers map[string]string var body string var connErr string if err := s.client.ensureConnected(); err != nil { connErr = err.Error() } else { _, _, _, err := s.client.SelectGroup(groupName) if err != nil { connErr = err.Error() } else { headers, body, err = s.client.GetArticle(articleNum) if err != nil { connErr = err.Error() } } } // Build reply URL replyURL := "" if headers != nil { subject := headers["Subject"] if subject != "" && !strings.HasPrefix(strings.ToLower(subject), "re:") { subject = "Re: " + subject } msgID := headers["Message-ID"] if msgID != "" && !strings.HasPrefix(msgID, "<") { msgID = "<" + msgID + ">" } replyURL = fmt.Sprintf("%s?newsgroups=%s&subject=%s&references=%s", M2UsenetURL, url.QueryEscape(groupName), url.QueryEscape(subject), url.QueryEscape(msgID)) } tmpl := template.Must(template.New("article").Parse(articleTemplate)) tmpl.Execute(w, map[string]interface{}{ "GroupName": groupName, "ArticleNum": articleNum, "Headers": headers, "Body": body, "Error": connErr, "ReplyURL": replyURL, "M2UsenetURL": M2UsenetURL, }) } func (s *NewsServer) handleReply(w http.ResponseWriter, r *http.Request) { // URL format: /reply/group.name/12345 path := strings.TrimPrefix(r.URL.Path, "/reply/") parts := strings.Split(path, "/") if len(parts) < 2 { http.Redirect(w, r, "/search", http.StatusFound) return } groupName := strings.Join(parts[:len(parts)-1], "/") articleNum, err := strconv.Atoi(parts[len(parts)-1]) if err != nil { http.Error(w, "Invalid article number", http.StatusBadRequest) return } var headers map[string]string var body string if err := s.client.ensureConnected(); err != nil { http.Error(w, "Connection error: "+err.Error(), http.StatusServiceUnavailable) return } _, _, _, err = s.client.SelectGroup(groupName) if err != nil { http.Error(w, "Group error: "+err.Error(), http.StatusNotFound) return } headers, body, err = s.client.GetArticle(articleNum) if err != nil { http.Error(w, "Article error: "+err.Error(), http.StatusNotFound) return } // Build reply subject subject := headers["Subject"] if subject != "" && !strings.HasPrefix(strings.ToLower(subject), "re:") { subject = "Re: " + subject } // Build references msgID := headers["Message-ID"] if msgID != "" { if !strings.HasPrefix(msgID, "<") { msgID = "<" + msgID } if !strings.HasSuffix(msgID, ">") { msgID = msgID + ">" } } // Build quoted body author := headers["From"] date := headers["Date"] var quotedLines []string quotedLines = append(quotedLines, fmt.Sprintf("On %s, %s wrote:", date, author)) quotedLines = append(quotedLines, "") // Quote each line of the original body bodyLines := strings.Split(body, "\n") for _, line := range bodyLines { // Skip signature delimiter and everything after if strings.TrimSpace(line) == "--" || strings.TrimSpace(line) == "-- " { break } quotedLines = append(quotedLines, "> "+line) } quotedLines = append(quotedLines, "") quotedLines = append(quotedLines, "") quotedBody := strings.Join(quotedLines, "\n") // Redirect to m2usenet with all fields redirectURL := fmt.Sprintf("%s?newsgroups=%s&subject=%s&references=%s&body=%s", M2UsenetURL, url.QueryEscape(groupName), url.QueryEscape(subject), url.QueryEscape(msgID), url.QueryEscape(quotedBody)) http.Redirect(w, r, redirectURL, http.StatusFound) } func (s *NewsServer) handleHealth(w http.ResponseWriter, r *http.Request) { torOK := s.testTorProxy() == nil var nntpStatus, nntpServer string if err := s.client.ensureConnected(); err != nil { nntpStatus = "disconnected" } else { nntpStatus = "connected" nntpServer = s.client.connectedServer } s.mutex.RLock() groupCount := len(s.groups) s.mutex.RUnlock() tmpl := template.Must(template.New("health").Parse(healthTemplate)) tmpl.Execute(w, map[string]interface{}{ "TorOK": torOK, "NNTPStatus": nntpStatus, "NNTPServer": nntpServer, "GroupCount": groupCount, "Session": s.session, "Timestamp": time.Now().Format("2006-01-02 15:04:05 UTC"), }) } // ============= Templates ============= const homeTemplate = ` ๐Ÿง… Onion Newsreader

๐Ÿง… Onion Newsreader

Minimal NNTP client via Tor

๐Ÿ“Š Status

Tor Proxy: {{if .TorOK}}โœ… OK{{else}}โŒ Failed{{end}}

Cached Groups: {{.GroupCount}}

Session: {{.Session}}

๐Ÿ”ง Actions

๐Ÿ“ฅ Download Active File ๐Ÿ” Search Groups ๐Ÿ” Health Check

โœ๏ธ Post New Message

Create a new post (opens m2usenet):

๐Ÿ“ New Post

โ„น๏ธ Info

Primary: peannyjkqwqfynd24p6dszvtchkq7hfkwymi5by5y332wmosy5dwfaqd.onion

Fallback: news.tcpreset.net (via Tor)

Posting: {{.M2UsenetURL}}

` const downloadingTemplate = ` โณ Downloading...

๐Ÿ”„ Downloading Active File

This may take 2-10 minutes via Tor...

โณ Starting...

โ† Back to Home

` const searchTemplate = ` ๐Ÿ” Search Groups

๐Ÿ” Search Newsgroups

โ† Home | Total groups: {{.TotalGroups}}

{{if .Query}}

Found {{.ResultCount}} groups matching "{{.Query}}"{{if .Truncated}} (showing first 500){{end}}

{{end}} {{if .Results}}
{{range .Results}}
{{.Name}} {{.Low}}-{{.High}} ({{.Status}})
๐Ÿ“ Post
{{end}}
{{else if .Query}}

No groups found. Try different patterns like *keyword*

{{else}}

Enter a search pattern above. Use * for wildcards.

{{end}}
` const groupTemplate = ` ๐Ÿ“ฐ {{.GroupName}}

๐Ÿ“ฐ {{.GroupName}}

โ† Home | ๐Ÿ” Search

๐Ÿ“ New Post
{{if .Error}}
โŒ {{.Error}}
{{end}} {{if .Articles}}

Threaded View ({{len .Articles}} articles)

๐Ÿงต Posts are organized by thread. Replies are indented under their parent.

{{range .Articles}}
{{if gt .Depth 0}}โ†ณ{{end}} {{.Subject}}
{{.From}} | #{{.Number}} ๐Ÿ’ฌ Reply
{{end}} {{else if not .Error}}

No articles found in this group.

{{end}}
` const articleTemplate = ` ๐Ÿ“„ Article {{.ArticleNum}} - {{.GroupName}}

๐Ÿ“„ Article #{{.ArticleNum}}

โ† Home | โ† {{.GroupName}} | ๐Ÿ” Search

{{if .ArticleNum}} ๐Ÿ’ฌ Reply {{end}} ๐Ÿ“ New Post
{{if .Error}}
โŒ {{.Error}}
{{else}}

Headers

{{if .Headers}} {{range $key, $value := .Headers}}
{{$key}}: {{$value}}
{{end}} {{end}}

Message

{{.Body}}
{{end}}
` const healthTemplate = ` ๐Ÿ” Health Check

๐Ÿ” Health Check

โ† Home

Connectivity

Tor Proxy (127.0.0.1:9050): {{if .TorOK}}โœ… OK{{else}}โŒ Failed{{end}}

NNTP Status: {{.NNTPStatus}}

{{if .NNTPServer}}

Connected to: {{.NNTPServer}}

{{end}}

Cache

Groups cached: {{.GroupCount}}

Session: {{.Session}}

Timestamp: {{.Timestamp}}

Manual Tests

curl --socks5 127.0.0.1:9050 http://duckduckgogg42xjoc72x3sjasowoarfbgcmvfimaftt6twagswzczad.onion

` // ============= Main ============= func main() { log.Println("๐Ÿง… Onion Newsreader starting...") server := NewNewsServer() defer server.client.Close() http.HandleFunc("/", server.handleHome) http.HandleFunc("/download", server.handleDownload) http.HandleFunc("/download-status", server.handleDownloadStatus) http.HandleFunc("/search", server.handleSearch) http.HandleFunc("/group/", server.handleGroup) http.HandleFunc("/article/", server.handleArticle) http.HandleFunc("/reply/", server.handleReply) http.HandleFunc("/health", server.handleHealth) log.Println("๐Ÿš€ HTTP server on 127.0.0.1:8080") log.Printf("๐Ÿ“ก Primary: %s", PrimaryNNTP) log.Printf("๐Ÿ”„ Fallback: %s", FallbackNNTP) log.Printf("โœ๏ธ Posting: %s", M2UsenetURL) if err := http.ListenAndServe("127.0.0.1:8080", nil); err != nil { log.Fatal(err) } }