mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-18 06:34:36 +03:00
feat(telemirror): add optional backup feed for browsing Telegram channels
This commit is contained in:
@@ -5,14 +5,17 @@ go 1.26.1
|
||||
require (
|
||||
github.com/gotd/td v0.142.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/refraction-networking/utls v1.6.7
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/term v0.41.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudflare/circl v1.3.7 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU=
|
||||
github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -51,12 +55,16 @@ github.com/ogen-go/ogen v1.19.0 h1:YvdNpeQJ8A8dLLpS6Vs4WxXL53BT6tBPxH0VSjfALhA=
|
||||
github.com/ogen-go/ogen v1.19.0/go.mod h1:DeShwO+TEpLYXNCuZliSAedphphXsJaTGGbmSomWUjE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/refraction-networking/utls v1.6.7 h1:zVJ7sP1dJx/WtVuITug3qYUq034cDq9B2MR1K67ULZM=
|
||||
github.com/refraction-networking/utls v1.6.7/go.mod h1:BC3O4vQzye5hqpmDTWUqi4P5DDhzJfkV1tdqtawQIH0=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package telemirror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Cache TTLs.
|
||||
const (
|
||||
FreshTTL = 10 * time.Minute
|
||||
StaleTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Cache is a per-channel disk cache backed by a small in-memory map.
|
||||
type Cache struct {
|
||||
dir string
|
||||
|
||||
mu sync.Mutex
|
||||
mem map[string]*FetchResult
|
||||
}
|
||||
|
||||
func NewCache(dir string) *Cache {
|
||||
return &Cache{dir: dir, mem: make(map[string]*FetchResult)}
|
||||
}
|
||||
|
||||
func (c *Cache) path(username string) string {
|
||||
return filepath.Join(c.dir, strings.ToLower(SanitizeUsername(username))+".json")
|
||||
}
|
||||
|
||||
// Get returns (entry, fresh). fresh=false means the entry is older than
|
||||
// FreshTTL but still within StaleTTL, so the caller can serve it while
|
||||
// refreshing in the background.
|
||||
func (c *Cache) Get(username string) (*FetchResult, bool) {
|
||||
username = strings.ToLower(SanitizeUsername(username))
|
||||
if username == "" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if r, ok := c.mem[username]; ok && r != nil {
|
||||
age := time.Since(r.FetchedAt)
|
||||
if age <= StaleTTL {
|
||||
c.mu.Unlock()
|
||||
return r, age < FreshTTL
|
||||
}
|
||||
delete(c.mem, username)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
b, err := os.ReadFile(c.path(username))
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var r FetchResult
|
||||
if err := json.Unmarshal(b, &r); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if time.Since(r.FetchedAt) > StaleTTL {
|
||||
return nil, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.mem[username] = &r
|
||||
c.mu.Unlock()
|
||||
return &r, time.Since(r.FetchedAt) < FreshTTL
|
||||
}
|
||||
|
||||
func (c *Cache) Put(username string, r *FetchResult) error {
|
||||
username = strings.ToLower(SanitizeUsername(username))
|
||||
if username == "" || r == nil {
|
||||
return ErrEmptyUsername
|
||||
}
|
||||
if err := os.MkdirAll(c.dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
r.FetchedAt = time.Now()
|
||||
b, err := json.MarshalIndent(r, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(c.path(username), b, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.mem[username] = r
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear drops all in-memory and on-disk entries.
|
||||
func (c *Cache) Clear() {
|
||||
c.mu.Lock()
|
||||
c.mem = make(map[string]*FetchResult)
|
||||
c.mu.Unlock()
|
||||
entries, err := os.ReadDir(c.dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(filepath.Join(c.dir, e.Name()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package telemirror
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
mrand "math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
utls "github.com/refraction-networking/utls"
|
||||
)
|
||||
|
||||
const (
|
||||
proxyHost = "t-me.translate.goog"
|
||||
minRequestInterval = 1500 * time.Millisecond
|
||||
maxBodySize = 5 << 20
|
||||
dialTimeout = 8 * time.Second
|
||||
tlsTimeout = 8 * time.Second
|
||||
requestTimeout = 20 * time.Second
|
||||
)
|
||||
|
||||
// fingerprint identifies a TLS ClientHello shape. The Iranian DPI fails
|
||||
// to match Go's stock fingerprint; we have to mimic something it
|
||||
// already permits. We try multiple presets per request because we
|
||||
// can't know in advance which one is currently whitelisted.
|
||||
type fingerprint struct {
|
||||
name string
|
||||
id utls.ClientHelloID
|
||||
// nodeTLS12 == true → use the custom Node-mimic spec (TLS 1.2 only,
|
||||
// exact cipher list from the reference). HelloChrome_Auto sends a
|
||||
// TLS 1.3-capable ClientHello which the firewall may drop on sight.
|
||||
nodeTLS12 bool
|
||||
}
|
||||
|
||||
var fingerprints = []fingerprint{
|
||||
// First — TLS 1.2-only with the reference's exact cipher list.
|
||||
// This is the closest thing to the bytes Node's OpenSSL emits.
|
||||
{name: "node-tls12", nodeTLS12: true},
|
||||
// Real Chrome (TLS 1.3 capable). Most likely to match a generic
|
||||
// "browser" allow-list in the firewall.
|
||||
{name: "chrome", id: utls.HelloChrome_Auto},
|
||||
// Firefox is sometimes whitelisted separately.
|
||||
{name: "firefox", id: utls.HelloFirefox_Auto},
|
||||
// Older Chrome (TLS 1.2-only era).
|
||||
{name: "chrome-72", id: utls.HelloChrome_72},
|
||||
// iOS Safari — different fingerprint family entirely.
|
||||
{name: "ios", id: utls.HelloIOS_Auto},
|
||||
}
|
||||
|
||||
// proxyAttempt is one (IP, SNI, fingerprint, sl, tl) combination.
|
||||
// When sni differs from proxyHost it's domain fronting: TLS handshake
|
||||
// looks like a connection to the (whitelisted) front host while the
|
||||
// HTTP layer talks to the real one.
|
||||
type proxyAttempt struct {
|
||||
ip string
|
||||
sni string
|
||||
fp fingerprint
|
||||
sl string
|
||||
tl string
|
||||
}
|
||||
|
||||
// SNI host used for domain fronting. The user confirmed www.google.com
|
||||
// is whitelisted in Iran while translate.google.com / t-me.translate.goog
|
||||
// might not be — fronting via www.google.com is the only path that
|
||||
// passes TLS-SNI inspection.
|
||||
const frontSNI = "www.google.com"
|
||||
|
||||
var proxyAttempts = []proxyAttempt{
|
||||
// 1) www.google.com fronting on Google IPs — most likely to pass DPI.
|
||||
{ip: "216.239.38.120", sni: frontSNI, fp: fingerprints[0], sl: "auto", tl: "fa"}, // node-tls12 + front
|
||||
{ip: "216.239.38.120", sni: frontSNI, fp: fingerprints[1], sl: "auto", tl: "fa"}, // chrome + front
|
||||
{ip: "142.250.191.196", sni: frontSNI, fp: fingerprints[1], sl: "fa", tl: "en"}, // chrome + front
|
||||
{ip: "142.250.184.196", sni: frontSNI, fp: fingerprints[2], sl: "ru", tl: "en"}, // firefox + front
|
||||
{ip: "142.250.74.14", sni: frontSNI, fp: fingerprints[1], sl: "ar", tl: "en"}, // chrome + front
|
||||
|
||||
// 2) Pure (non-fronted) — for environments where t-me.translate.goog SNI is allowed.
|
||||
{ip: "216.239.38.120", sni: proxyHost, fp: fingerprints[0], sl: "auto", tl: "fa"},
|
||||
{ip: "216.239.38.120", sni: proxyHost, fp: fingerprints[1], sl: "auto", tl: "fa"},
|
||||
|
||||
// 3) Direct (DNS lookup) with Chrome — the path that's known to work
|
||||
// with VPN. Sits last because it depends on DNS.
|
||||
{ip: "", sni: proxyHost, fp: fingerprints[1], sl: "auto", tl: "fa"},
|
||||
}
|
||||
|
||||
// User-Agent list copied verbatim from the reference's `this.userAgents`.
|
||||
var userAgents = []string{
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.2592.81",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.2592.102",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.2478.67",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:126.0) Gecko/20100101 Firefox/126.0",
|
||||
"Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
|
||||
}
|
||||
|
||||
|
||||
// Client mirrors HttpClient from teleMirror's http-client.js.
|
||||
type Client struct {
|
||||
rateMu sync.Mutex
|
||||
lastRequest time.Time
|
||||
|
||||
// Index into proxyAttempts of the last attempt that succeeded.
|
||||
// Subsequent fetches try this first so image loads (parallel from
|
||||
// the browser) don't each pay the cost of walking through every
|
||||
// failed fronting variant.
|
||||
stickyMu sync.Mutex
|
||||
stickyAttempt int
|
||||
}
|
||||
|
||||
func NewClient() *Client { return &Client{stickyAttempt: -1} }
|
||||
|
||||
// orderedAttempts returns proxyAttempts with the last successful one
|
||||
// moved to the front. Used to short-circuit the fronting walk on
|
||||
// repeat fetches (e.g. parallel image loads after the channel HTML
|
||||
// already established a working path).
|
||||
func (c *Client) orderedAttempts() []proxyAttempt {
|
||||
c.stickyMu.Lock()
|
||||
idx := c.stickyAttempt
|
||||
c.stickyMu.Unlock()
|
||||
if idx < 0 || idx >= len(proxyAttempts) {
|
||||
return proxyAttempts
|
||||
}
|
||||
out := make([]proxyAttempt, 0, len(proxyAttempts))
|
||||
out = append(out, proxyAttempts[idx])
|
||||
for i, ap := range proxyAttempts {
|
||||
if i != idx {
|
||||
out = append(out, ap)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Client) markSuccess(idx int) {
|
||||
c.stickyMu.Lock()
|
||||
c.stickyAttempt = idx
|
||||
c.stickyMu.Unlock()
|
||||
}
|
||||
|
||||
// FetchHTML returns the rendered widget HTML for the username.
|
||||
func (c *Client) FetchHTML(ctx context.Context, username string) (string, error) {
|
||||
username = SanitizeUsername(username)
|
||||
if username == "" {
|
||||
return "", ErrEmptyUsername
|
||||
}
|
||||
var lastErr error
|
||||
for i, ap := range c.orderedAttempts() {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
}
|
||||
if err := c.waitForRate(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ua := userAgents[mrand.IntN(len(userAgents))]
|
||||
body, status, err := c.do(ctx, ap, username, ua)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("attempt %d (%s): %w", i+1, ap.label(), err)
|
||||
continue
|
||||
}
|
||||
if status == http.StatusOK && body != "" {
|
||||
c.markSuccess(attemptIndex(ap))
|
||||
return body, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("attempt %d (%s) status %d", i+1, ap.label(), status)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("telemirror: all attempts exhausted")
|
||||
}
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func attemptIndex(target proxyAttempt) int {
|
||||
for i, ap := range proxyAttempts {
|
||||
if ap == target {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (ap proxyAttempt) label() string {
|
||||
ip := ap.ip
|
||||
if ip == "" {
|
||||
ip = "direct"
|
||||
}
|
||||
suffix := ""
|
||||
if ap.sni != proxyHost {
|
||||
suffix = " front=" + ap.sni
|
||||
}
|
||||
return ip + "/" + ap.fp.name + suffix
|
||||
}
|
||||
|
||||
// stripH2 removes "h2" from any ALPN extension in spec. We can only
|
||||
// speak HTTP/1.1 over uTLS reliably (Go's net/http needs a *tls.Conn
|
||||
// to upgrade to HTTP/2, which uTLS doesn't provide), so we have to
|
||||
// stop the server from picking h2.
|
||||
func stripH2(spec *utls.ClientHelloSpec) {
|
||||
for _, ext := range spec.Extensions {
|
||||
if alpn, ok := ext.(*utls.ALPNExtension); ok {
|
||||
filtered := alpn.AlpnProtocols[:0]
|
||||
for _, p := range alpn.AlpnProtocols {
|
||||
if p != "h2" {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
filtered = []string{"http/1.1"}
|
||||
}
|
||||
alpn.AlpnProtocols = filtered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// presetSpec extracts a ClientHelloSpec from a uTLS preset and patches
|
||||
// out h2. We use HelloCustom + this spec everywhere so net/http
|
||||
// doesn't end up trying to read HTTP/2 frames as HTTP/1.1.
|
||||
func presetSpec(id utls.ClientHelloID) (*utls.ClientHelloSpec, error) {
|
||||
spec, err := utls.UTLSIdToSpec(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stripH2(&spec)
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
// nodeTLS12Spec is a ClientHelloSpec that mimics Node.js with
|
||||
// `secureProtocol: 'TLSv1_2_method'` plus the exact cipher list from
|
||||
// teleMirror's createCustomAgent. TLS 1.2 only — no supported_versions
|
||||
// extension, no TLS 1.3 hints. The Iranian DPI seems to allow this
|
||||
// shape because Node's OpenSSL produces it.
|
||||
func nodeTLS12Spec() *utls.ClientHelloSpec {
|
||||
return &utls.ClientHelloSpec{
|
||||
TLSVersMin: utls.VersionTLS12,
|
||||
TLSVersMax: utls.VersionTLS12,
|
||||
CipherSuites: []uint16{
|
||||
utls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
|
||||
utls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
|
||||
utls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
|
||||
utls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
|
||||
utls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
utls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
|
||||
utls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
|
||||
utls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
|
||||
utls.TLS_RSA_WITH_AES_128_GCM_SHA256,
|
||||
utls.TLS_RSA_WITH_AES_256_GCM_SHA384,
|
||||
utls.TLS_RSA_WITH_AES_128_CBC_SHA,
|
||||
utls.TLS_RSA_WITH_AES_256_CBC_SHA,
|
||||
utls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
|
||||
},
|
||||
CompressionMethods: []byte{0},
|
||||
Extensions: []utls.TLSExtension{
|
||||
&utls.SNIExtension{},
|
||||
&utls.ExtendedMasterSecretExtension{},
|
||||
&utls.RenegotiationInfoExtension{Renegotiation: utls.RenegotiateOnceAsClient},
|
||||
&utls.SupportedCurvesExtension{Curves: []utls.CurveID{
|
||||
utls.X25519, utls.CurveP256, utls.CurveP384, utls.CurveP521,
|
||||
}},
|
||||
&utls.SupportedPointsExtension{SupportedPoints: []byte{0}}, // uncompressed
|
||||
&utls.SessionTicketExtension{},
|
||||
&utls.StatusRequestExtension{},
|
||||
&utls.SignatureAlgorithmsExtension{SupportedSignatureAlgorithms: []utls.SignatureScheme{
|
||||
utls.ECDSAWithP256AndSHA256,
|
||||
utls.PSSWithSHA256,
|
||||
utls.PKCS1WithSHA256,
|
||||
utls.ECDSAWithP384AndSHA384,
|
||||
utls.PSSWithSHA384,
|
||||
utls.PKCS1WithSHA384,
|
||||
utls.PSSWithSHA512,
|
||||
utls.PKCS1WithSHA512,
|
||||
utls.PKCS1WithSHA1,
|
||||
utls.ECDSAWithSHA1,
|
||||
}},
|
||||
&utls.SCTExtension{},
|
||||
&utls.ALPNExtension{AlpnProtocols: []string{"http/1.1"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) waitForRate(ctx context.Context) error {
|
||||
c.rateMu.Lock()
|
||||
defer c.rateMu.Unlock()
|
||||
wait := minRequestInterval - time.Since(c.lastRequest)
|
||||
if wait > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
c.lastRequest = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
// dialTLSFor returns a DialTLSContext closure preconfigured for the given
|
||||
// proxy attempt. Connect → uTLS handshake with the chosen fingerprint and
|
||||
// SNI (which may differ from the request Host for domain fronting).
|
||||
func dialTLSFor(ap proxyAttempt) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
d := &net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second}
|
||||
target := addr
|
||||
if ap.ip != "" {
|
||||
target = net.JoinHostPort(ap.ip, "443")
|
||||
}
|
||||
rawConn, err := d.DialContext(ctx, network, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg := &utls.Config{
|
||||
ServerName: ap.sni,
|
||||
InsecureSkipVerify: true,
|
||||
NextProtos: []string{"http/1.1"},
|
||||
}
|
||||
var spec *utls.ClientHelloSpec
|
||||
if ap.fp.nodeTLS12 {
|
||||
spec = nodeTLS12Spec()
|
||||
} else {
|
||||
spec, err = presetSpec(ap.fp.id)
|
||||
if err != nil {
|
||||
_ = rawConn.Close()
|
||||
return nil, fmt.Errorf("load spec %s: %w", ap.fp.name, err)
|
||||
}
|
||||
}
|
||||
tlsConn := utls.UClient(rawConn, cfg, utls.HelloCustom)
|
||||
if err := tlsConn.ApplyPreset(spec); err != nil {
|
||||
_ = rawConn.Close()
|
||||
return nil, fmt.Errorf("apply spec %s: %w", ap.fp.name, err)
|
||||
}
|
||||
hsCtx, cancel := context.WithTimeout(ctx, tlsTimeout)
|
||||
defer cancel()
|
||||
if err := tlsConn.HandshakeContext(hsCtx); err != nil {
|
||||
_ = rawConn.Close()
|
||||
return nil, fmt.Errorf("tls handshake: %w", err)
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
}
|
||||
|
||||
// transportFor returns a one-shot http.Transport for an attempt.
|
||||
func transportFor(ap proxyAttempt) *http.Transport {
|
||||
return &http.Transport{
|
||||
DialTLSContext: dialTLSFor(ap),
|
||||
ResponseHeaderTimeout: requestTimeout,
|
||||
ForceAttemptHTTP2: false,
|
||||
MaxIdleConns: 1,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// setBrowserHeaders applies the verbatim header set from teleMirror's
|
||||
// baseHeaders (plus google-method overrides for the Translate path).
|
||||
func setBrowserHeaders(req *http.Request, ua string, fronted bool) {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8")
|
||||
req.Header.Set("Accept-Language", "en-US,en;q=0.9,fa;q=0.8")
|
||||
req.Header.Set("Sec-Fetch-Dest", "document")
|
||||
req.Header.Set("Sec-Fetch-Mode", "navigate")
|
||||
req.Header.Set("Sec-Fetch-Site", "none")
|
||||
req.Header.Set("Sec-Fetch-User", "?1")
|
||||
req.Header.Set("Upgrade-Insecure-Requests", "1")
|
||||
req.Header.Set("Pragma", "no-cache")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
if fronted {
|
||||
req.Header.Set("Referer", "https://translate.google.com/")
|
||||
req.Header.Set("Origin", "https://translate.google.com")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) do(ctx context.Context, ap proxyAttempt, username, ua string) (string, int, error) {
|
||||
url := fmt.Sprintf(
|
||||
"https://%s/s/%s?_x_tr_sl=%s&_x_tr_tl=%s&_x_tr_hl=en&_x_tr_pto=wapp",
|
||||
proxyHost, username, ap.sl, ap.tl,
|
||||
)
|
||||
|
||||
transport := transportFor(ap)
|
||||
defer transport.CloseIdleConnections()
|
||||
httpClient := &http.Client{Transport: transport, Timeout: requestTimeout}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Host = proxyHost
|
||||
setBrowserHeaders(req, ua, ap.ip != "")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1))
|
||||
if err != nil {
|
||||
return "", resp.StatusCode, err
|
||||
}
|
||||
if int64(len(body)) > maxBodySize {
|
||||
return "", resp.StatusCode, fmt.Errorf("response exceeds %d bytes", maxBodySize)
|
||||
}
|
||||
return string(body), resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// FetchURL fetches an arbitrary URL through the same fronting/uTLS
|
||||
// attempts used for the channel widget, but with the request Host set
|
||||
// to the URL's actual host (not proxyHost) so Google's edge routes it
|
||||
// to the right backend. Returns the bytes plus the upstream
|
||||
// Content-Type so the caller can stream it back to the browser.
|
||||
//
|
||||
// Used to proxy translate.goog-rewritten image URLs (channel avatars,
|
||||
// post photos/videos) without each one having to go through DNS.
|
||||
func (c *Client) FetchURL(ctx context.Context, rawURL string) ([]byte, string, error) {
|
||||
u, err := neturl.Parse(rawURL)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
return nil, "", fmt.Errorf("telemirror: bad url %q", rawURL)
|
||||
}
|
||||
hostHeader := u.Host
|
||||
|
||||
var lastErr error
|
||||
for i, ap := range c.orderedAttempts() {
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, "", ctx.Err()
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
body, ctype, status, err := c.fetchOnce(ctx, ap, rawURL, hostHeader)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("attempt %d (%s): %w", i+1, ap.label(), err)
|
||||
continue
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
c.markSuccess(attemptIndex(ap))
|
||||
return body, ctype, nil
|
||||
}
|
||||
lastErr = fmt.Errorf("attempt %d (%s) status %d", i+1, ap.label(), status)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("telemirror: all attempts exhausted")
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
func (c *Client) fetchOnce(ctx context.Context, ap proxyAttempt, rawURL, hostHeader string) ([]byte, string, int, error) {
|
||||
transport := transportFor(ap)
|
||||
defer transport.CloseIdleConnections()
|
||||
httpClient := &http.Client{Transport: transport, Timeout: requestTimeout}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
req.Host = hostHeader
|
||||
ua := userAgents[mrand.IntN(len(userAgents))]
|
||||
setBrowserHeaders(req, ua, ap.ip != "")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize+1))
|
||||
if err != nil {
|
||||
return nil, resp.Header.Get("Content-Type"), resp.StatusCode, err
|
||||
}
|
||||
if int64(len(body)) > maxBodySize {
|
||||
return nil, resp.Header.Get("Content-Type"), resp.StatusCode, fmt.Errorf("response exceeds %d bytes", maxBodySize)
|
||||
}
|
||||
return body, resp.Header.Get("Content-Type"), resp.StatusCode, nil
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package telemirror
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// ParseHTML extracts channel info and recent posts from a t.me/s/ widget.
|
||||
// The HTML is the rendered output of Google Translate's proxy, which keeps
|
||||
// the original Telegram class names but rewrites cross-domain URLs onto
|
||||
// translate.goog so the browser can load them through the same proxy.
|
||||
func ParseHTML(htmlBody string) (*Channel, []Post, error) {
|
||||
doc, err := html.Parse(strings.NewReader(htmlBody))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return parseChannelInfo(doc), parsePosts(doc), nil
|
||||
}
|
||||
|
||||
func parseChannelInfo(doc *html.Node) *Channel {
|
||||
ch := &Channel{}
|
||||
|
||||
if titleEl := findFirstByClass(doc, "tgme_channel_info_header_title"); titleEl != nil {
|
||||
if span := findFirstChildElement(titleEl, "span"); span != nil {
|
||||
ch.Title = textOf(span)
|
||||
} else {
|
||||
ch.Title = textOf(titleEl)
|
||||
}
|
||||
}
|
||||
if userEl := findFirstByClass(doc, "tgme_channel_info_header_username"); userEl != nil {
|
||||
if a := findFirstChildElement(userEl, "a"); a != nil {
|
||||
ch.Username = strings.TrimPrefix(textOf(a), "@")
|
||||
}
|
||||
}
|
||||
if descEl := findFirstByClass(doc, "tgme_channel_info_description"); descEl != nil {
|
||||
ch.Description = innerHTML(descEl)
|
||||
}
|
||||
if header := findFirstByClass(doc, "tgme_channel_info_header"); header != nil {
|
||||
if img := findFirstByTag(header, "img"); img != nil {
|
||||
ch.Photo = attrOf(img, "src")
|
||||
}
|
||||
}
|
||||
if cnt := findFirstByClass(doc, "tgme_channel_info_counter"); cnt != nil {
|
||||
ch.Subscribers = textOf(cnt)
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func parsePosts(doc *html.Node) []Post {
|
||||
var posts []Post
|
||||
visit(doc, func(n *html.Node) bool {
|
||||
if !hasClass(n, "tgme_widget_message_wrap") {
|
||||
return true
|
||||
}
|
||||
if p := parseSinglePost(n); p != nil {
|
||||
posts = append(posts, *p)
|
||||
}
|
||||
return false // posts don't nest
|
||||
})
|
||||
return posts
|
||||
}
|
||||
|
||||
func parseSinglePost(wrap *html.Node) *Post {
|
||||
msg := findFirstByClass(wrap, "tgme_widget_message")
|
||||
if msg == nil {
|
||||
msg = wrap
|
||||
}
|
||||
p := &Post{ID: attrOf(msg, "data-post")}
|
||||
|
||||
if owner := findFirstByClass(msg, "tgme_widget_message_owner_name"); owner != nil {
|
||||
p.Author = textOf(owner)
|
||||
}
|
||||
if textEl := findFirstByClass(msg, "tgme_widget_message_text"); textEl != nil {
|
||||
p.Text = innerHTML(textEl)
|
||||
}
|
||||
|
||||
visit(msg, func(n *html.Node) bool {
|
||||
switch {
|
||||
case hasClass(n, "tgme_widget_message_photo_wrap"):
|
||||
p.Media = append(p.Media, Media{
|
||||
Type: "photo",
|
||||
URL: attrOf(n, "href"),
|
||||
Thumb: extractBgImage(attrOf(n, "style")),
|
||||
})
|
||||
case hasClass(n, "tgme_widget_message_video_player"):
|
||||
m := Media{Type: "video", URL: attrOf(n, "href")}
|
||||
if t := findFirstByClass(n, "tgme_widget_message_video_thumb"); t != nil {
|
||||
m.Thumb = extractBgImage(attrOf(t, "style"))
|
||||
}
|
||||
if d := findFirstByClass(n, "message_video_duration"); d != nil {
|
||||
m.Duration = textOf(d)
|
||||
}
|
||||
p.Media = append(p.Media, m)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if dateEl := findFirstByClass(msg, "tgme_widget_message_date"); dateEl != nil {
|
||||
if tag := findFirstByTag(dateEl, "time"); tag != nil {
|
||||
if dt := attrOf(tag, "datetime"); dt != "" {
|
||||
if parsed, err := time.Parse(time.RFC3339, dt); err == nil {
|
||||
p.Time = parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := findFirstByClass(msg, "tgme_widget_message_views"); v != nil {
|
||||
p.Views = textOf(v)
|
||||
}
|
||||
if meta := findFirstByClass(msg, "tgme_widget_message_meta"); meta != nil {
|
||||
if strings.Contains(strings.ToLower(textOf(meta)), "edited") {
|
||||
p.Edited = true
|
||||
}
|
||||
}
|
||||
|
||||
if p.ID == "" && p.Text == "" && len(p.Media) == 0 {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ===== DOM helpers =====
|
||||
|
||||
func visit(n *html.Node, fn func(*html.Node) bool) {
|
||||
if !fn(n) {
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
visit(c, fn)
|
||||
}
|
||||
}
|
||||
|
||||
func hasClass(n *html.Node, class string) bool {
|
||||
if n == nil || n.Type != html.ElementNode {
|
||||
return false
|
||||
}
|
||||
for _, a := range n.Attr {
|
||||
if a.Key != "class" {
|
||||
continue
|
||||
}
|
||||
for _, c := range strings.Fields(a.Val) {
|
||||
if c == class {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func attrOf(n *html.Node, key string) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range n.Attr {
|
||||
if a.Key == key {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findFirstByClass(root *html.Node, class string) *html.Node {
|
||||
var found *html.Node
|
||||
visit(root, func(n *html.Node) bool {
|
||||
if found != nil {
|
||||
return false
|
||||
}
|
||||
if hasClass(n, class) {
|
||||
found = n
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func findFirstByTag(root *html.Node, tag string) *html.Node {
|
||||
var found *html.Node
|
||||
visit(root, func(n *html.Node) bool {
|
||||
if found != nil {
|
||||
return false
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == tag {
|
||||
found = n
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return found
|
||||
}
|
||||
|
||||
func findFirstChildElement(n *html.Node, tag string) *html.Node {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode && c.Data == tag {
|
||||
return c
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func textOf(n *html.Node) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
visit(n, func(x *html.Node) bool {
|
||||
if x.Type == html.TextNode {
|
||||
b.WriteString(x.Data)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// innerHTML serialises children only — drops the wrapping element so the
|
||||
// caller can splice the result into its own container.
|
||||
func innerHTML(n *html.Node) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if err := html.Render(&b, c); err != nil {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
var bgImageRe = regexp.MustCompile(`url\(['"]?([^'")]+)['"]?\)`)
|
||||
|
||||
func extractBgImage(style string) string {
|
||||
m := bgImageRe.FindStringSubmatch(style)
|
||||
if len(m) >= 2 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package telemirror
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleChannel = `<!DOCTYPE html><html><body>
|
||||
<div class="tgme_channel_info">
|
||||
<div class="tgme_channel_info_header">
|
||||
<i class="tgme_page_photo_image"><img src="https://cdn4-telegram-org.translate.goog/file/abc.jpg"/></i>
|
||||
<div class="tgme_channel_info_header_title"><span dir="auto">Sample Channel</span></div>
|
||||
<div class="tgme_channel_info_header_username"><a href="https://t-me.translate.goog/sample">@sample</a></div>
|
||||
</div>
|
||||
<div class="tgme_channel_info_description">channel <b>description</b> with <a href="https://example.com">link</a></div>
|
||||
<div class="tgme_channel_info_counter"><span class="counter_value">12.3K</span> <span class="counter_type">subscribers</span></div>
|
||||
</div>
|
||||
|
||||
<div class="tgme_widget_message_wrap">
|
||||
<div class="tgme_widget_message" data-post="sample/123">
|
||||
<a class="tgme_widget_message_owner_name" href="#"><span dir="auto">Sample Channel</span></a>
|
||||
<div class="tgme_widget_message_text">first <b>post</b> body</div>
|
||||
<a class="tgme_widget_message_photo_wrap" href="https://t-me.translate.goog/sample/123" style="background-image:url('https://cdn4.translate.goog/photo.jpg')"></a>
|
||||
<div class="tgme_widget_message_footer">
|
||||
<span class="tgme_widget_message_views">1.2K</span>
|
||||
<a class="tgme_widget_message_date" href="#"><time datetime="2026-04-30T12:34:56+00:00">Apr 30</time></a>
|
||||
<span class="tgme_widget_message_meta">edited</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tgme_widget_message_wrap">
|
||||
<div class="tgme_widget_message" data-post="sample/124">
|
||||
<div class="tgme_widget_message_text">second post</div>
|
||||
<a class="tgme_widget_message_video_player" href="https://t-me.translate.goog/sample/124">
|
||||
<i class="tgme_widget_message_video_thumb" style="background-image:url(https://cdn4.translate.goog/vid.jpg)"></i>
|
||||
<time class="message_video_duration">0:42</time>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body></html>`
|
||||
|
||||
func TestParseHTMLChannelHeader(t *testing.T) {
|
||||
ch, _, err := ParseHTML(sampleChannel)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseHTML: %v", err)
|
||||
}
|
||||
if ch.Title != "Sample Channel" {
|
||||
t.Errorf("title = %q, want %q", ch.Title, "Sample Channel")
|
||||
}
|
||||
if ch.Username != "sample" {
|
||||
t.Errorf("username = %q, want %q", ch.Username, "sample")
|
||||
}
|
||||
if !strings.Contains(ch.Description, "description") {
|
||||
t.Errorf("description = %q, missing 'description'", ch.Description)
|
||||
}
|
||||
if ch.Photo == "" {
|
||||
t.Errorf("photo url empty")
|
||||
}
|
||||
if !strings.Contains(ch.Subscribers, "12.3K") {
|
||||
t.Errorf("subscribers = %q, missing '12.3K'", ch.Subscribers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTMLPosts(t *testing.T) {
|
||||
_, posts, err := ParseHTML(sampleChannel)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseHTML: %v", err)
|
||||
}
|
||||
if len(posts) != 2 {
|
||||
t.Fatalf("posts = %d, want 2", len(posts))
|
||||
}
|
||||
|
||||
p1 := posts[0]
|
||||
if p1.ID != "sample/123" {
|
||||
t.Errorf("post 1 id = %q", p1.ID)
|
||||
}
|
||||
if !strings.Contains(p1.Text, "first") {
|
||||
t.Errorf("post 1 text = %q", p1.Text)
|
||||
}
|
||||
if len(p1.Media) != 1 || p1.Media[0].Type != "photo" {
|
||||
t.Errorf("post 1 media = %+v, want one photo", p1.Media)
|
||||
}
|
||||
if p1.Media[0].Thumb == "" {
|
||||
t.Errorf("post 1 photo thumb missing")
|
||||
}
|
||||
if p1.Views != "1.2K" {
|
||||
t.Errorf("post 1 views = %q", p1.Views)
|
||||
}
|
||||
if !p1.Edited {
|
||||
t.Errorf("post 1 should be marked edited")
|
||||
}
|
||||
if p1.Time.IsZero() {
|
||||
t.Errorf("post 1 time not parsed")
|
||||
}
|
||||
|
||||
p2 := posts[1]
|
||||
if p2.ID != "sample/124" {
|
||||
t.Errorf("post 2 id = %q", p2.ID)
|
||||
}
|
||||
if len(p2.Media) != 1 || p2.Media[0].Type != "video" {
|
||||
t.Errorf("post 2 media = %+v, want one video", p2.Media)
|
||||
}
|
||||
if p2.Media[0].Duration != "0:42" {
|
||||
t.Errorf("post 2 duration = %q", p2.Media[0].Duration)
|
||||
}
|
||||
if p2.Media[0].Thumb == "" {
|
||||
t.Errorf("post 2 video thumb missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHTMLEmpty(t *testing.T) {
|
||||
ch, posts, err := ParseHTML("<html></html>")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseHTML: %v", err)
|
||||
}
|
||||
if ch == nil {
|
||||
t.Fatal("channel nil")
|
||||
}
|
||||
if len(posts) != 0 {
|
||||
t.Errorf("posts = %d, want 0", len(posts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeUsername(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"@VahidOnline", "VahidOnline"},
|
||||
{" @VahidOnline ", "VahidOnline"},
|
||||
{"https://t.me/networkti", "networkti"},
|
||||
{"t.me/s/networkti", "networkti"},
|
||||
{"networkti?embed=1", "networkti"},
|
||||
{"bad name with spaces", "badnamewithspaces"},
|
||||
{"خبر", ""},
|
||||
{"", ""},
|
||||
{strings.Repeat("a", 64), strings.Repeat("a", 32)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := SanitizeUsername(c.in); got != c.want {
|
||||
t.Errorf("SanitizeUsername(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDefault(t *testing.T) {
|
||||
for _, want := range DefaultChannels {
|
||||
if !IsDefault(want) {
|
||||
t.Errorf("IsDefault(%q) = false", want)
|
||||
}
|
||||
if !IsDefault(strings.ToLower(want)) {
|
||||
t.Errorf("IsDefault(%q) case-sensitive miss", strings.ToLower(want))
|
||||
}
|
||||
}
|
||||
if IsDefault("not_a_default") {
|
||||
t.Errorf("IsDefault returned true for unknown channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreAddRemove(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewStore(dir)
|
||||
|
||||
if err := s.Add("@MyChan"); err != nil {
|
||||
t.Fatalf("Add: %v", err)
|
||||
}
|
||||
// Adding a default is a no-op (success, no duplicate).
|
||||
if err := s.Add(DefaultChannels[0]); err != nil {
|
||||
t.Errorf("Add default: %v", err)
|
||||
}
|
||||
list := s.List()
|
||||
if len(list) < len(DefaultChannels)+1 {
|
||||
t.Fatalf("list = %v, want defaults + MyChan", list)
|
||||
}
|
||||
// Defaults pinned at the front.
|
||||
for i, want := range DefaultChannels {
|
||||
if list[i] != want {
|
||||
t.Errorf("list[%d] = %q, want %q", i, list[i], want)
|
||||
}
|
||||
}
|
||||
// MyChan present and at the end.
|
||||
if list[len(list)-1] != "MyChan" {
|
||||
t.Errorf("last entry = %q, want MyChan", list[len(list)-1])
|
||||
}
|
||||
|
||||
// Removing a default is rejected.
|
||||
if err := s.Remove(DefaultChannels[0]); err != ErrPinnedChannel {
|
||||
t.Errorf("Remove default = %v, want ErrPinnedChannel", err)
|
||||
}
|
||||
if err := s.Remove("MyChan"); err != nil {
|
||||
t.Errorf("Remove: %v", err)
|
||||
}
|
||||
list = s.List()
|
||||
for _, u := range list {
|
||||
if strings.EqualFold(u, "MyChan") {
|
||||
t.Errorf("MyChan still present after Remove: %v", list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
c := NewCache(dir)
|
||||
|
||||
if r, _ := c.Get("nope"); r != nil {
|
||||
t.Errorf("Get on missing returned non-nil: %+v", r)
|
||||
}
|
||||
|
||||
res := &FetchResult{
|
||||
Channel: Channel{Username: "test", Title: "Test"},
|
||||
Posts: []Post{{ID: "test/1", Text: "hi"}},
|
||||
}
|
||||
if err := c.Put("test", res); err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
got, fresh := c.Get("test")
|
||||
if got == nil {
|
||||
t.Fatal("Get returned nil after Put")
|
||||
}
|
||||
if !fresh {
|
||||
t.Errorf("expected fresh entry right after Put")
|
||||
}
|
||||
if got.Channel.Title != "Test" || len(got.Posts) != 1 {
|
||||
t.Errorf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
c.Clear()
|
||||
if got, _ := c.Get("test"); got != nil {
|
||||
t.Errorf("Get after Clear returned %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package telemirror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Store persists the user-added channel list. Defaults are pinned and
|
||||
// always returned at the front of List() regardless of the file content.
|
||||
type Store struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewStore(dataDir string) *Store {
|
||||
return &Store{path: filepath.Join(dataDir, "telemirror_channels.json")}
|
||||
}
|
||||
|
||||
type subsFile struct {
|
||||
Channels []string `json:"channels"`
|
||||
}
|
||||
|
||||
func (s *Store) loadLocked() []string {
|
||||
b, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var f subsFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return nil
|
||||
}
|
||||
return f.Channels
|
||||
}
|
||||
|
||||
func (s *Store) saveLocked(chs []string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(subsFile{Channels: chs}, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.path, b, 0600)
|
||||
}
|
||||
|
||||
// List returns the full channel list with defaults pinned to the front.
|
||||
func (s *Store) List() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user := s.loadLocked()
|
||||
seen := make(map[string]bool, len(DefaultChannels)+len(user))
|
||||
out := make([]string, 0, len(DefaultChannels)+len(user))
|
||||
for _, d := range DefaultChannels {
|
||||
seen[strings.ToLower(d)] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
for _, u := range user {
|
||||
clean := SanitizeUsername(u)
|
||||
if clean == "" || seen[strings.ToLower(clean)] {
|
||||
continue
|
||||
}
|
||||
seen[strings.ToLower(clean)] = true
|
||||
out = append(out, clean)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Add(username string) error {
|
||||
username = SanitizeUsername(username)
|
||||
if username == "" {
|
||||
return ErrEmptyUsername
|
||||
}
|
||||
if IsDefault(username) {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user := s.loadLocked()
|
||||
for _, u := range user {
|
||||
if strings.EqualFold(u, username) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return s.saveLocked(append(user, username))
|
||||
}
|
||||
|
||||
func (s *Store) Remove(username string) error {
|
||||
username = SanitizeUsername(username)
|
||||
if username == "" {
|
||||
return ErrEmptyUsername
|
||||
}
|
||||
if IsDefault(username) {
|
||||
return ErrPinnedChannel
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
user := s.loadLocked()
|
||||
out := user[:0]
|
||||
for _, u := range user {
|
||||
if !strings.EqualFold(u, username) {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return s.saveLocked(out)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Package telemirror is an optional, removable backup feed.
|
||||
// It fetches public Telegram channel widgets through Google Translate's
|
||||
// public web-page proxy so users can browse public channels even when
|
||||
// Telegram itself is blocked. The implementation is intentionally
|
||||
// self-contained so the feature can be removed by deleting this package
|
||||
// and the matching handlers in internal/web/telemirror.go.
|
||||
package telemirror
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultChannels are pinned in the UI; users cannot remove them.
|
||||
var DefaultChannels = []string{"VahidOnline", "networkti", "thefeedconfig"}
|
||||
|
||||
// Channel describes the public channel header.
|
||||
type Channel struct {
|
||||
Username string `json:"username"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Photo string `json:"photo,omitempty"`
|
||||
Subscribers string `json:"subscribers,omitempty"`
|
||||
}
|
||||
|
||||
// Media is one attachment on a post.
|
||||
type Media struct {
|
||||
Type string `json:"type"` // "photo" | "video"
|
||||
URL string `json:"url,omitempty"`
|
||||
Thumb string `json:"thumb,omitempty"`
|
||||
Duration string `json:"duration,omitempty"`
|
||||
}
|
||||
|
||||
// Post is a single message from the channel feed.
|
||||
type Post struct {
|
||||
ID string `json:"id"` // "<channel>/<msgid>"
|
||||
Author string `json:"author,omitempty"`
|
||||
Text string `json:"text,omitempty"` // sanitised inner HTML
|
||||
Media []Media `json:"media,omitempty"`
|
||||
Time time.Time `json:"time,omitempty"`
|
||||
Views string `json:"views,omitempty"`
|
||||
Edited bool `json:"edited,omitempty"`
|
||||
}
|
||||
|
||||
// FetchResult is what we cache per channel.
|
||||
type FetchResult struct {
|
||||
Channel Channel `json:"channel"`
|
||||
Posts []Post `json:"posts"`
|
||||
FetchedAt time.Time `json:"fetchedAt"`
|
||||
}
|
||||
|
||||
// Sentinel errors returned by Store.
|
||||
var (
|
||||
ErrEmptyUsername = errors.New("empty username")
|
||||
ErrPinnedChannel = errors.New("pinned channel cannot be removed")
|
||||
)
|
||||
|
||||
// SanitizeUsername strips @ / t.me/ prefixes and rejects characters not
|
||||
// allowed by Telegram's username rules. Returns "" if the result is empty.
|
||||
func SanitizeUsername(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
for _, p := range []string{"https://t.me/", "http://t.me/", "t.me/", "s/", "@"} {
|
||||
s = strings.TrimPrefix(s, p)
|
||||
}
|
||||
if i := strings.IndexAny(s, "/?#"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
out := make([]rune, 0, len(s))
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '_':
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
if len(out) > 32 {
|
||||
out = out[:32]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// IsDefault reports whether username is one of the pinned defaults.
|
||||
func IsDefault(username string) bool {
|
||||
for _, d := range DefaultChannels {
|
||||
if strings.EqualFold(d, username) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -930,6 +930,227 @@
|
||||
background: rgba(255, 255, 255, .3)
|
||||
}
|
||||
|
||||
/* BEGIN telemirror */
|
||||
.tm-no-scroll { overflow: hidden }
|
||||
.tm-modal {
|
||||
position: fixed; inset: 0; z-index: 9000;
|
||||
background: var(--bg);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
color: var(--text)
|
||||
}
|
||||
.tm-modal.active { display: flex }
|
||||
|
||||
.tm-topbar {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 16px; min-height: 56px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: linear-gradient(180deg, var(--bg-elevated, var(--bg)), var(--bg));
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
flex-shrink: 0
|
||||
}
|
||||
.tm-back, .tm-menu {
|
||||
background: none; border: 1px solid transparent; color: var(--text);
|
||||
font-size: 16px; cursor: pointer; padding: 6px 10px; border-radius: 8px;
|
||||
transition: background .12s, border-color .12s
|
||||
}
|
||||
.tm-back:hover, .tm-menu:hover { background: var(--border); border-color: var(--border) }
|
||||
.tm-menu { display: none }
|
||||
@media (max-width: 768px) { .tm-menu { display: inline-flex } }
|
||||
|
||||
.tm-topbar-info { display: flex; align-items: center; gap: 12px; flex: 1; min-width: 0 }
|
||||
.tm-topbar-meta { min-width: 0; flex: 1 }
|
||||
.tm-topbar-name { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis }
|
||||
.tm-topbar-sub { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px }
|
||||
|
||||
.tm-avatar {
|
||||
border-radius: 50%;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-weight: 600; font-size: 16px;
|
||||
overflow: hidden; flex-shrink: 0;
|
||||
background: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08), 0 1px 2px rgba(0,0,0,.15);
|
||||
position: relative
|
||||
}
|
||||
.tm-avatar img {
|
||||
position: absolute; inset: 0;
|
||||
width: 100%; height: 100%; object-fit: cover; display: block
|
||||
}
|
||||
.tm-avatar-initial {
|
||||
pointer-events: none; user-select: none;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 100%; height: 100%
|
||||
}
|
||||
|
||||
.tm-body { flex: 1; min-height: 0; display: flex; position: relative }
|
||||
.tm-sidebar {
|
||||
width: 320px; flex-shrink: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
display: flex; flex-direction: column; min-height: 0
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.tm-sidebar {
|
||||
position: absolute; top: 0; bottom: 0; left: 0; width: 86%; max-width: 340px;
|
||||
z-index: 2; transform: translateX(-100%); transition: transform .22s ease;
|
||||
box-shadow: 4px 0 24px rgba(0,0,0,.0)
|
||||
}
|
||||
.tm-sidebar.open { transform: translateX(0); box-shadow: 4px 0 24px rgba(0,0,0,.32) }
|
||||
}
|
||||
.tm-disclaimer {
|
||||
padding: 12px 14px; margin: 10px;
|
||||
font-size: 11px; color: var(--text-dim); line-height: 1.6;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 10px;
|
||||
border-inline-start: 3px solid var(--accent)
|
||||
}
|
||||
.tm-add-row {
|
||||
display: flex; gap: 6px; padding: 0 10px 10px;
|
||||
border-bottom: 1px solid var(--border)
|
||||
}
|
||||
.tm-add-row input {
|
||||
flex: 1; padding: 8px 12px; font-size: 13px;
|
||||
background: var(--bg); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
font-family: inherit;
|
||||
transition: border-color .12s
|
||||
}
|
||||
.tm-add-row input:focus { outline: none; border-color: var(--accent) }
|
||||
.tm-add-row .btn { padding: 8px 14px; font-size: 13px; border-radius: 8px }
|
||||
|
||||
.tm-channels-list { flex: 1; overflow-y: auto; padding: 6px }
|
||||
.tm-channel-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 9px 10px; cursor: pointer;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
transition: background .12s, border-color .12s;
|
||||
margin-bottom: 2px
|
||||
}
|
||||
.tm-channel-item:hover { background: var(--bg); border-color: var(--border) }
|
||||
.tm-channel-item.active {
|
||||
background: color-mix(in oklab, var(--accent) 18%, transparent);
|
||||
border-color: var(--accent)
|
||||
}
|
||||
.tm-channel-item.active .tm-channel-item-name { color: var(--accent) }
|
||||
.tm-channel-item-meta { flex: 1; min-width: 0 }
|
||||
.tm-channel-item-name {
|
||||
font-size: 13px; font-weight: 600;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis
|
||||
}
|
||||
.tm-pin { font-size: 9px; opacity: .55; margin-inline-start: 4px }
|
||||
.tm-x {
|
||||
background: none; border: 1px solid transparent; color: inherit;
|
||||
font-size: 16px; cursor: pointer; opacity: .45;
|
||||
padding: 2px 7px; border-radius: 6px;
|
||||
transition: background .12s, opacity .12s
|
||||
}
|
||||
.tm-x:hover { opacity: 1; background: var(--border) }
|
||||
|
||||
.tm-content {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 18px 16px 40px;
|
||||
background:
|
||||
radial-gradient(circle at 25% 0%, color-mix(in oklab, var(--accent) 6%, transparent), transparent 40%),
|
||||
var(--bg);
|
||||
display: flex; flex-direction: column; gap: 12px
|
||||
}
|
||||
.tm-empty {
|
||||
margin: auto; padding: 32px 24px;
|
||||
text-align: center; color: var(--text-dim); font-size: 13px
|
||||
}
|
||||
|
||||
.tm-channel-desc {
|
||||
max-width: 720px; width: 100%; margin: 0 auto 4px;
|
||||
padding: 12px 16px; font-size: 13px; color: var(--text-dim);
|
||||
line-height: 1.7;
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.04)
|
||||
}
|
||||
.tm-channel-desc a { color: var(--accent) }
|
||||
|
||||
.tm-post {
|
||||
max-width: 720px; width: 100%; margin: 0 auto;
|
||||
background: var(--bg-elevated, var(--bg));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 12px 16px 10px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.05);
|
||||
transition: border-color .15s, transform .15s
|
||||
}
|
||||
.tm-post:hover { border-color: color-mix(in oklab, var(--accent) 35%, var(--border)) }
|
||||
.tm-post-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 11px; color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px dashed var(--border)
|
||||
}
|
||||
.tm-post-author { font-weight: 700; color: var(--accent) }
|
||||
.tm-post-time { opacity: .85 }
|
||||
.tm-post-edited {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 4px;
|
||||
background: var(--border); color: var(--text-dim)
|
||||
}
|
||||
.tm-post-text { font-size: 14px; line-height: 1.7; word-break: break-word }
|
||||
.tm-post-text a { color: var(--accent); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--accent) 50%, transparent) }
|
||||
.tm-post-text a:hover { text-decoration-color: currentColor }
|
||||
.tm-post-media {
|
||||
margin-top: 10px;
|
||||
display: grid; gap: 4px
|
||||
}
|
||||
/* Single photo: fill width, natural aspect, capped height. */
|
||||
.tm-album-1 { grid-template-columns: 1fr }
|
||||
.tm-album-1 .tm-photo img { max-height: 600px; object-fit: contain; background: #000 }
|
||||
/* 2 photos side by side. */
|
||||
.tm-album-2 { grid-template-columns: 1fr 1fr }
|
||||
.tm-album-2 .tm-photo img { aspect-ratio: 1 / 1; object-fit: cover }
|
||||
/* 3+ photos: 3-column album. */
|
||||
.tm-album-3 { grid-template-columns: 1fr 1fr 1fr }
|
||||
.tm-album-3 .tm-photo img { aspect-ratio: 1 / 1; object-fit: cover }
|
||||
|
||||
.tm-photo {
|
||||
display: block; line-height: 0;
|
||||
border-radius: 10px; overflow: hidden;
|
||||
background: #000;
|
||||
border: 1px solid var(--border)
|
||||
}
|
||||
.tm-photo img {
|
||||
width: 100%; height: auto; display: block
|
||||
}
|
||||
.tm-vid {
|
||||
position: relative; display: flex;
|
||||
align-items: center; justify-content: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-size: cover; background-position: center;
|
||||
border-radius: 10px; background-color: var(--border);
|
||||
color: #fff;
|
||||
text-shadow: 0 0 6px rgba(0,0,0,.6);
|
||||
border: 1px solid var(--border)
|
||||
}
|
||||
.tm-vid-play {
|
||||
width: 54px; height: 54px;
|
||||
border-radius: 50%; background: rgba(0,0,0,.5);
|
||||
backdrop-filter: blur(4px);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.4)
|
||||
}
|
||||
.tm-vid-dur {
|
||||
position: absolute; bottom: 8px; right: 10px;
|
||||
background: rgba(0,0,0,.65);
|
||||
padding: 2px 7px; border-radius: 5px;
|
||||
font-size: 11px
|
||||
}
|
||||
.tm-post-foot {
|
||||
display: flex; gap: 14px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px; color: var(--text-dim)
|
||||
}
|
||||
.tm-views { display: inline-flex; align-items: center; gap: 4px }
|
||||
/* END telemirror */
|
||||
|
||||
.media-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2465,6 +2686,9 @@
|
||||
<div class="sidebar-toolbar">
|
||||
<button class="stb" id="scannerIconBtn" onclick="openScanner()" data-i18n="scanner_find_resolvers">Find Resolvers</button>
|
||||
<button class="stb" id="resolversSidebarBtn" onclick="openResolversModal()"><span data-i18n="sidebar_resolvers">Resolvers</span> <span class="stb-badge" id="resolversBadge" style="color:var(--error)">0</span></button>
|
||||
<!-- BEGIN telemirror -->
|
||||
<button class="stb" id="telemirrorSidebarBtn" onclick="openTelemirror()" data-i18n-title="telemirror_btn_title"><span data-i18n="telemirror_btn">Browse channels</span></button>
|
||||
<!-- END telemirror -->
|
||||
</div>
|
||||
<input class="sidebar-search" id="channelSearch" type="text" data-i18n-ph="search" placeholder="Search..."
|
||||
oninput="filterChannels()">
|
||||
@@ -2539,6 +2763,35 @@
|
||||
<!-- TOAST -->
|
||||
<div id="toast"></div>
|
||||
|
||||
<!-- BEGIN telemirror -->
|
||||
<div id="telemirrorModal" class="tm-modal">
|
||||
<header class="tm-topbar">
|
||||
<button class="tm-back" onclick="closeTelemirror()" aria-label="Close">❮</button>
|
||||
<button class="tm-menu" onclick="toggleTmSidebar()" aria-label="Channels">☰</button>
|
||||
<div class="tm-topbar-info">
|
||||
<div id="tmTopbarAvatar"></div>
|
||||
<div class="tm-topbar-meta">
|
||||
<div class="tm-topbar-name" id="tmTopbarName" data-i18n="telemirror_title">Browse channels</div>
|
||||
<div class="tm-topbar-sub" id="tmTopbarSub"></div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="tm-body">
|
||||
<aside id="tmSidebar" class="tm-sidebar">
|
||||
<p class="tm-disclaimer" data-i18n="telemirror_disclaimer">Experimental backup feature for browsing public Telegram channels. Depends on third-party services and may stop working or be rate-limited. For reliable access, use the core thefeed feature.</p>
|
||||
<div class="tm-add-row">
|
||||
<input id="tmAddInput" type="text" data-i18n-ph="telemirror_add_ph" placeholder="@username" maxlength="40">
|
||||
<button class="btn btn-primary" onclick="telemirrorAdd()" data-i18n="add">Add</button>
|
||||
</div>
|
||||
<div id="tmChannelsList" class="tm-channels-list"></div>
|
||||
</aside>
|
||||
<main id="tmContent" class="tm-content">
|
||||
<div class="tm-empty" data-i18n="telemirror_pick_channel">Pick a channel</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END telemirror -->
|
||||
|
||||
<!-- ===== SAVED RESOLVERS POPUP ===== -->
|
||||
<div class="modal-overlay" id="savedResolversModal">
|
||||
<div class="modal" style="max-width:380px">
|
||||
@@ -3023,6 +3276,21 @@
|
||||
cancel_media_msg: 'دانلود این رسانه لغو شود؟', dismiss: 'بستن',
|
||||
write_message: 'پیام بنویسید...', configure_server: 'برای شروع یک سرور راهاندازی کنید',
|
||||
set_up: 'راهاندازی', switching: 'در حال تغییر پروفایل...', select_channel_hint: 'یک کانال را برای دیدن پیامها انتخاب کنید',
|
||||
// BEGIN telemirror
|
||||
telemirror_btn: 'دیدن کانالهای دلخواه',
|
||||
telemirror_btn_title: 'دیدن کانالهای دلخواه',
|
||||
telemirror_title: 'دیدن کانالهای دلخواه',
|
||||
telemirror_disclaimer: 'روش باز کردن کانالهای دلخواه در این بخش فقط تا زمانی که سرویسهای گوگل باز باشن کار میکنه. اگر براتون کار نکرد، از قابلیتهای اصلی thefeed استفاده کنید چون ضدفیلتر هستن.',
|
||||
telemirror_add_ph: 'مثلا @VahidOnline',
|
||||
telemirror_loading: 'در حال بارگذاری...',
|
||||
telemirror_load_failed: 'دریافت کانال ناموفق بود.',
|
||||
telemirror_no_posts: 'هیچ پستی پیدا نشد.',
|
||||
telemirror_pick_channel: 'یک کانال را انتخاب کن',
|
||||
telemirror_invalid_user: 'نام کاربری نامعتبر',
|
||||
telemirror_remove_pinned: 'این کانال پیشفرض قابل حذف نیست',
|
||||
telemirror_views: 'بازدید',
|
||||
telemirror_edited: 'ویرایش شده',
|
||||
// END telemirror
|
||||
font_size: 'اندازه قلم', debug_mode: 'حالت دیباگ', language: 'زبان',
|
||||
next_fetch_info: 'زمان باقیمانده تا دریافت بعدی محتوا توسط سرور',
|
||||
no_profiles: 'هنوز پروفایلی وجود ندارد', add_profile: '+ پروفایل جدید',
|
||||
@@ -3233,6 +3501,21 @@
|
||||
cancel_media_msg: 'Cancel this download?', dismiss: 'Dismiss',
|
||||
write_message: 'Write a message...', configure_server: 'Configure a server to start reading',
|
||||
set_up: 'Set Up', switching: 'Switching profile...', select_channel_hint: 'Pick a channel to view its messages',
|
||||
// BEGIN telemirror
|
||||
telemirror_btn: 'Browse channels',
|
||||
telemirror_btn_title: 'Browse channels',
|
||||
telemirror_title: 'Browse channels',
|
||||
telemirror_disclaimer: 'Browsing custom channels here only works while Google services are reachable. If it stops working for you, use thefeed\'s main features — those are filter-resistant.',
|
||||
telemirror_add_ph: 'e.g. @VahidOnline',
|
||||
telemirror_loading: 'Loading...',
|
||||
telemirror_load_failed: 'Failed to load channel.',
|
||||
telemirror_no_posts: 'No posts found.',
|
||||
telemirror_pick_channel: 'Pick a channel',
|
||||
telemirror_invalid_user: 'Invalid username',
|
||||
telemirror_remove_pinned: 'Pinned channels cannot be removed',
|
||||
telemirror_views: 'views',
|
||||
telemirror_edited: 'edited',
|
||||
// END telemirror
|
||||
font_size: 'Font Size', debug_mode: 'Debug mode', language: 'Language',
|
||||
next_fetch_info: 'Time until the server next fetches fresh channel content',
|
||||
no_profiles: 'No profiles yet', add_profile: '+ Add Profile',
|
||||
@@ -7901,7 +8184,7 @@
|
||||
if (e.key === 'Enter' && document.activeElement === document.getElementById('sendInput')) { e.preventDefault(); sendMessage() }
|
||||
if (e.key === 'Enter' && document.activeElement === document.getElementById('peAddChannelInput')) { e.preventDefault(); addChannelEditor() }
|
||||
if (e.key === 'Enter' && document.activeElement === document.getElementById('msgSearchInput')) { e.preventDefault(); msgSearchNext() }
|
||||
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor(); closeScanner(); closeMsgSearch(); closeExportModal(); closeResolversModal() }
|
||||
if (e.key === 'Escape') { closeSettings(); closeProfiles(); closeProfileEditor(); closeScanner(); closeMsgSearch(); closeExportModal(); closeResolversModal(); closeTelemirror() }
|
||||
});
|
||||
mobileQuery.addEventListener('change', function () {
|
||||
var app = document.getElementById('app');
|
||||
@@ -7979,6 +8262,9 @@
|
||||
|
||||
init();
|
||||
</script>
|
||||
<!-- BEGIN telemirror -->
|
||||
<script src="/static/telemirror.js"></script>
|
||||
<!-- END telemirror -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,268 @@
|
||||
// Optional, removable backup feed UI. All globals here are namespaced
|
||||
// with `tm` / `telemirror` so removing this file (plus the markup block
|
||||
// in index.html) drops the feature without touching anything else.
|
||||
(function () {
|
||||
var tmChannels = [];
|
||||
var tmActive = '';
|
||||
var tmAvatarCache = {}; // username (lower) -> photo URL once we've fetched it
|
||||
|
||||
function tmI18n(key, fallback) {
|
||||
try {
|
||||
var v = (typeof t === 'function') ? t(key) : '';
|
||||
return v && v !== key ? v : (fallback || '');
|
||||
} catch (e) { return fallback || ''; }
|
||||
}
|
||||
|
||||
function tmEsc(s) {
|
||||
return (typeof esc === 'function') ? esc(s) : String(s == null ? '' : s)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
function tmEscAttr(s) {
|
||||
return (typeof escAttr === 'function') ? escAttr(s) :
|
||||
tmEsc(s).replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function tmToast(msg) {
|
||||
if (typeof showToast === 'function') showToast(msg);
|
||||
}
|
||||
|
||||
function tmInitial(name) {
|
||||
if (!name) return '?';
|
||||
var ch = name.replace(/^@/, '').charAt(0);
|
||||
return ch ? ch.toUpperCase() : '?';
|
||||
}
|
||||
|
||||
// Deterministic colour-from-name so the placeholder avatars don't all
|
||||
// look identical. Mirrors what Telegram's web client does.
|
||||
function tmAvatarColor(name) {
|
||||
var palette = ['#e57373', '#f06292', '#ba68c8', '#9575cd', '#7986cb',
|
||||
'#64b5f6', '#4fc3f7', '#4dd0e1', '#4db6ac', '#81c784',
|
||||
'#aed581', '#dce775', '#ffd54f', '#ffb74d', '#ff8a65'];
|
||||
var h = 0;
|
||||
for (var i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return palette[h % palette.length];
|
||||
}
|
||||
|
||||
function tmAvatarHTML(username, name, size) {
|
||||
size = size || 40;
|
||||
var disp = name || username || '';
|
||||
var initial = '<span class="tm-avatar-initial">' + tmEsc(tmInitial(disp)) + '</span>';
|
||||
var bg = tmAvatarColor(disp || '?');
|
||||
var photo = tmAvatarCache[(username || '').toLowerCase()];
|
||||
var inner = initial;
|
||||
if (photo) {
|
||||
// Img falls back to the initial-letter span on load failure.
|
||||
inner = '<img src="' + tmEscAttr(photo) + '" loading="lazy" alt=""'
|
||||
+ ' onerror="this.parentNode.classList.add(\'tm-avatar-fallback\');this.remove()">'
|
||||
+ initial;
|
||||
}
|
||||
return '<div class="tm-avatar" style="width:' + size + 'px;height:' + size + 'px;background:' + bg + '">'
|
||||
+ inner + '</div>';
|
||||
}
|
||||
|
||||
// ===== open / close =====
|
||||
window.openTelemirror = function () {
|
||||
document.getElementById('telemirrorModal').classList.add('active');
|
||||
document.body.classList.add('tm-no-scroll');
|
||||
tmLoadChannels();
|
||||
};
|
||||
window.closeTelemirror = function () {
|
||||
document.getElementById('telemirrorModal').classList.remove('active');
|
||||
document.body.classList.remove('tm-no-scroll');
|
||||
};
|
||||
window.toggleTmSidebar = function () {
|
||||
var sb = document.getElementById('tmSidebar');
|
||||
if (sb) sb.classList.toggle('open');
|
||||
};
|
||||
|
||||
// ===== channel list =====
|
||||
async function tmLoadChannels() {
|
||||
try {
|
||||
var r = await fetch('/api/telemirror/channels');
|
||||
var d = await r.json();
|
||||
tmChannels = (d.channels || []).slice();
|
||||
} catch (e) { tmChannels = []; }
|
||||
tmRenderChannels();
|
||||
if (!tmActive && tmChannels.length > 0) {
|
||||
tmSelect(tmChannels[0].username);
|
||||
} else if (tmActive) {
|
||||
tmSelect(tmActive);
|
||||
} else {
|
||||
document.getElementById('tmContent').innerHTML =
|
||||
'<div class="tm-empty">' + tmEsc(tmI18n('telemirror_pick_channel', 'Pick a channel')) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function tmRenderChannels() {
|
||||
var box = document.getElementById('tmChannelsList');
|
||||
var html = '';
|
||||
for (var i = 0; i < tmChannels.length; i++) {
|
||||
var c = tmChannels[i];
|
||||
var active = (c.username.toLowerCase() === tmActive.toLowerCase()) ? ' active' : '';
|
||||
html += '<div class="tm-channel-item' + active + '" data-u="' + tmEscAttr(c.username) + '" onclick="tmSelectFromClick(this.dataset.u)">'
|
||||
+ tmAvatarHTML(c.username, c.username, 40)
|
||||
+ '<div class="tm-channel-item-meta">'
|
||||
+ '<div class="tm-channel-item-name">' + tmEsc(c.username) + (c.pinned ? ' <span class="tm-pin">📌</span>' : '') + '</div>'
|
||||
+ '</div>';
|
||||
if (!c.pinned) {
|
||||
html += '<button class="tm-x" data-u="' + tmEscAttr(c.username) + '" onclick="event.stopPropagation();tmRemove(this.dataset.u)">×</button>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
box.innerHTML = html || '<div class="tm-empty">' + tmEsc(tmI18n('telemirror_pick_channel', 'Pick a channel')) + '</div>';
|
||||
}
|
||||
|
||||
window.tmSelectFromClick = function (username) {
|
||||
tmSelect(username);
|
||||
// Mobile: collapse the sidebar drawer after picking.
|
||||
var sb = document.getElementById('tmSidebar');
|
||||
if (sb) sb.classList.remove('open');
|
||||
};
|
||||
|
||||
function tmShowError(msg) {
|
||||
var content = document.getElementById('tmContent');
|
||||
content.innerHTML =
|
||||
'<div class="tm-empty"><p>' + tmEsc(tmI18n('telemirror_load_failed', 'Failed to load')) + '</p>'
|
||||
+ '<pre style="white-space:pre-wrap;margin-top:10px;padding:10px;background:var(--bg-elevated,var(--bg));border:1px solid var(--border);border-radius:6px;color:var(--text-dim);font-size:11px;text-align:start;max-width:600px;direction:ltr">'
|
||||
+ tmEsc(String(msg).slice(0, 2000))
|
||||
+ '</pre></div>';
|
||||
}
|
||||
|
||||
async function tmSelect(username) {
|
||||
tmActive = username;
|
||||
tmRenderChannels();
|
||||
tmRenderTopbar(null, username);
|
||||
var content = document.getElementById('tmContent');
|
||||
content.innerHTML = '<div class="tm-empty">' + tmEsc(tmI18n('telemirror_loading', 'Loading...')) + '</div>';
|
||||
try {
|
||||
var r = await fetch('/api/telemirror/channel/' + encodeURIComponent(username));
|
||||
if (!r.ok) {
|
||||
var errBody = '';
|
||||
try { errBody = await r.text(); } catch (e2) { }
|
||||
tmShowError(errBody || ('HTTP ' + r.status));
|
||||
return;
|
||||
}
|
||||
var d = await r.json();
|
||||
if (d && d.channel && d.channel.photo) {
|
||||
tmAvatarCache[username.toLowerCase()] = d.channel.photo;
|
||||
tmRenderChannels();
|
||||
}
|
||||
tmRenderTopbar(d && d.channel, username);
|
||||
tmRenderPosts(d);
|
||||
} catch (e) {
|
||||
tmShowError((e && e.message) || String(e));
|
||||
}
|
||||
}
|
||||
window.tmSelect = tmSelect;
|
||||
|
||||
function tmRenderTopbar(channel, username) {
|
||||
var name = (channel && channel.title) || username;
|
||||
var sub = '';
|
||||
if (channel) {
|
||||
if (channel.subscribers) sub = channel.subscribers;
|
||||
else if (username) sub = '@' + username;
|
||||
} else if (username) {
|
||||
sub = '@' + username;
|
||||
}
|
||||
document.getElementById('tmTopbarAvatar').innerHTML = tmAvatarHTML(username, name, 38);
|
||||
document.getElementById('tmTopbarName').textContent = name || '';
|
||||
document.getElementById('tmTopbarSub').textContent = sub || '';
|
||||
}
|
||||
|
||||
function tmRenderPosts(data) {
|
||||
var content = document.getElementById('tmContent');
|
||||
var posts = (data && data.posts) || [];
|
||||
var ch = (data && data.channel) || {};
|
||||
if (!posts.length) {
|
||||
content.innerHTML = '<div class="tm-empty">' + tmEsc(tmI18n('telemirror_no_posts', 'No posts')) + '</div>';
|
||||
return;
|
||||
}
|
||||
// Telegram order: oldest first, newest at the bottom (chat-like).
|
||||
posts.sort(function (a, b) { return (a.time || '').localeCompare(b.time || ''); });
|
||||
|
||||
var html = '';
|
||||
if (ch.description) {
|
||||
html += '<div class="tm-channel-desc">' + ch.description + '</div>';
|
||||
}
|
||||
for (var i = 0; i < posts.length; i++) {
|
||||
var p = posts[i];
|
||||
var when = p.time ? new Date(p.time).toLocaleString() : '';
|
||||
html += '<div class="tm-post">';
|
||||
html += '<div class="tm-post-head">';
|
||||
if (ch.title) html += '<span class="tm-post-author">' + tmEsc(ch.title) + '</span>';
|
||||
html += '<span class="tm-post-time">' + tmEsc(when) + '</span>';
|
||||
if (p.edited) html += '<span class="tm-post-edited">' + tmEsc(tmI18n('telemirror_edited', 'edited')) + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
if (p.text) html += '<div class="tm-post-text">' + p.text + '</div>';
|
||||
|
||||
if (p.media && p.media.length) {
|
||||
// Album-aware grid: 1 photo → fullwidth, 2 → 2 cols, 3+ → 3 cols.
|
||||
var gridClass = 'tm-post-media tm-album-' + Math.min(p.media.length, 3);
|
||||
html += '<div class="' + gridClass + '">';
|
||||
for (var j = 0; j < p.media.length; j++) {
|
||||
var m = p.media[j];
|
||||
if (m.type === 'photo' && m.thumb) {
|
||||
// No link wrapping — clicking a Translate-proxied permalink
|
||||
// just returns useless bytes via /api/telemirror/img.
|
||||
html += '<div class="tm-photo">'
|
||||
+ '<img src="' + tmEscAttr(m.thumb) + '" loading="lazy" alt=""></div>';
|
||||
} else if (m.type === 'video') {
|
||||
var bg = m.thumb ? 'background-image:url(\'' + tmEscAttr(m.thumb) + '\')' : '';
|
||||
var dur = m.duration ? '<span class="tm-vid-dur">' + tmEsc(m.duration) + '</span>' : '';
|
||||
html += '<div class="tm-vid" style="' + bg + '">'
|
||||
+ '<span class="tm-vid-play">▶</span>' + dur + '</div>';
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '<div class="tm-post-foot">';
|
||||
if (p.views) html += '<span class="tm-views">👁 ' + tmEsc(p.views) + '</span>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
content.innerHTML = html;
|
||||
// Jump to the bottom (newest message), like Telegram does on load.
|
||||
requestAnimationFrame(function () {
|
||||
content.scrollTop = content.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// ===== add / remove =====
|
||||
window.telemirrorAdd = async function () {
|
||||
var input = document.getElementById('tmAddInput');
|
||||
var u = (input.value || '').trim();
|
||||
if (!u) return;
|
||||
try {
|
||||
var r = await fetch('/api/telemirror/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'add', username: u })
|
||||
});
|
||||
if (!r.ok) { tmToast(tmI18n('telemirror_invalid_user', 'Invalid username')); return; }
|
||||
input.value = '';
|
||||
await tmLoadChannels();
|
||||
} catch (e) { tmToast((e && e.message) || 'failed'); }
|
||||
};
|
||||
|
||||
window.tmRemove = async function (username) {
|
||||
try {
|
||||
var r = await fetch('/api/telemirror/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'remove', username: username })
|
||||
});
|
||||
if (!r.ok) { tmToast(tmI18n('telemirror_remove_pinned', 'Cannot remove pinned')); return; }
|
||||
if (tmActive.toLowerCase() === username.toLowerCase()) tmActive = '';
|
||||
await tmLoadChannels();
|
||||
} catch (e) { tmToast((e && e.message) || 'failed'); }
|
||||
};
|
||||
|
||||
// Allow Enter in the add input.
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var inp = document.getElementById('tmAddInput');
|
||||
if (inp) inp.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); window.telemirrorAdd(); }
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,255 @@
|
||||
// Optional, removable backup feed. Keep this file isolated from the rest
|
||||
// of the web package so deleting it (plus the routes / clear-cache wiring
|
||||
// in web.go and the BEGIN/END telemirror block in static/index.html) is
|
||||
// enough to drop the feature.
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sartoopjj/thefeed/internal/telemirror"
|
||||
)
|
||||
|
||||
type telemirrorHub struct {
|
||||
client *telemirror.Client
|
||||
cache *telemirror.Cache
|
||||
store *telemirror.Store
|
||||
|
||||
mu sync.Mutex
|
||||
refreshing map[string]chan struct{}
|
||||
}
|
||||
|
||||
func newTelemirrorHub(dataDir string) *telemirrorHub {
|
||||
return &telemirrorHub{
|
||||
client: telemirror.NewClient(),
|
||||
cache: telemirror.NewCache(filepath.Join(dataDir, "telemirror")),
|
||||
store: telemirror.NewStore(dataDir),
|
||||
refreshing: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *telemirrorHub) handleChannels(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
list := h.store.List()
|
||||
type entry struct {
|
||||
Username string `json:"username"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
out := make([]entry, 0, len(list))
|
||||
for _, u := range list {
|
||||
out = append(out, entry{Username: u, Pinned: telemirror.IsDefault(u)})
|
||||
}
|
||||
writeJSON(w, map[string]any{"channels": out})
|
||||
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1024)).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", 400)
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "add":
|
||||
if err := h.store.Add(req.Username); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
case "remove":
|
||||
if err := h.store.Remove(req.Username); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unknown action", 400)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true})
|
||||
|
||||
default:
|
||||
http.Error(w, "method not allowed", 405)
|
||||
}
|
||||
}
|
||||
|
||||
// handleChannel serves /api/telemirror/channel/<username>[?refresh=1].
|
||||
// Stale-while-revalidate: serve cached content immediately, refresh in
|
||||
// the background when it's older than FreshTTL.
|
||||
func (h *telemirrorHub) handleChannel(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
username := telemirror.SanitizeUsername(strings.TrimPrefix(r.URL.Path, "/api/telemirror/channel/"))
|
||||
if username == "" {
|
||||
http.Error(w, "missing username", 400)
|
||||
return
|
||||
}
|
||||
forceRefresh := r.URL.Query().Get("refresh") == "1"
|
||||
|
||||
cached, fresh := h.cache.Get(username)
|
||||
if cached != nil && fresh && !forceRefresh {
|
||||
writeJSON(w, rewriteImageURLs(cached))
|
||||
return
|
||||
}
|
||||
if cached != nil && !forceRefresh {
|
||||
go func() { _, _ = h.refresh(username) }()
|
||||
writeJSON(w, rewriteImageURLs(cached))
|
||||
return
|
||||
}
|
||||
res, err := h.refresh(username)
|
||||
if err != nil {
|
||||
if cached != nil {
|
||||
writeJSON(w, rewriteImageURLs(cached))
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
writeJSON(w, rewriteImageURLs(res))
|
||||
}
|
||||
|
||||
// rewriteImageURLs returns a copy of the result with image URLs pointed
|
||||
// at our /api/telemirror/img proxy. Only the bytes-bearing fields are
|
||||
// rewritten (Channel.Photo, Media.Thumb). Media.URL is the POST
|
||||
// permalink — rewriting it as an image was the "weird URL" bug:
|
||||
// clicking a photo fetched the post HTML through the image proxy.
|
||||
func rewriteImageURLs(in *telemirror.FetchResult) *telemirror.FetchResult {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *in
|
||||
cp.Channel.Photo = proxyImgURL(cp.Channel.Photo)
|
||||
cp.Posts = make([]telemirror.Post, len(in.Posts))
|
||||
for i, p := range in.Posts {
|
||||
p.Media = append([]telemirror.Media(nil), p.Media...)
|
||||
for j := range p.Media {
|
||||
// Leave Media.URL alone (it's a permalink, not bytes).
|
||||
p.Media[j].Thumb = proxyImgURL(p.Media[j].Thumb)
|
||||
}
|
||||
cp.Posts[i] = p
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
func proxyImgURL(raw string) string {
|
||||
if raw == "" || !strings.HasPrefix(raw, "https://") {
|
||||
return raw
|
||||
}
|
||||
// Only rewrite hosts we'll actually proxy — anything else passes
|
||||
// through so the browser can load it directly.
|
||||
if !isProxiableHost(raw) {
|
||||
return raw
|
||||
}
|
||||
return "/api/telemirror/img?u=" + url.QueryEscape(raw)
|
||||
}
|
||||
|
||||
// isProxiableHost — only translate.goog. Other CDNs (cdn*.telesco.pe,
|
||||
// cdn*.telegram.org) can't be proxied because Google's edge only routes
|
||||
// to its own backends, not arbitrary external hosts.
|
||||
func isProxiableHost(rawURL string) bool {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := strings.ToLower(u.Host)
|
||||
return strings.HasSuffix(host, ".translate.goog")
|
||||
}
|
||||
|
||||
// handleImg proxies a single image (or other binary) URL through the
|
||||
// same fronting path used for the channel widget.
|
||||
func (h *telemirrorHub) handleImg(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
raw := r.URL.Query().Get("u")
|
||||
if raw == "" {
|
||||
http.Error(w, "missing u", 400)
|
||||
return
|
||||
}
|
||||
if !isProxiableHost(raw) {
|
||||
http.Error(w, "host not allowed", 400)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
|
||||
defer cancel()
|
||||
body, ctype, err := h.client.FetchURL(ctx, raw)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 502)
|
||||
return
|
||||
}
|
||||
if ctype == "" {
|
||||
ctype = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", ctype)
|
||||
// Browser cache for an hour — these URLs are content-addressed so
|
||||
// any change shows up as a different URL anyway.
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// refresh fetches and parses a channel, coalescing concurrent calls for
|
||||
// the same username so we don't hit the upstream more than once at a time.
|
||||
func (h *telemirrorHub) refresh(username string) (*telemirror.FetchResult, error) {
|
||||
username = strings.ToLower(telemirror.SanitizeUsername(username))
|
||||
if username == "" {
|
||||
return nil, telemirror.ErrEmptyUsername
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if ch, ok := h.refreshing[username]; ok {
|
||||
h.mu.Unlock()
|
||||
<-ch
|
||||
if r, _ := h.cache.Get(username); r != nil {
|
||||
return r, nil
|
||||
}
|
||||
return nil, fmt.Errorf("telemirror: concurrent refresh did not produce a result")
|
||||
}
|
||||
done := make(chan struct{})
|
||||
h.refreshing[username] = done
|
||||
h.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
h.mu.Lock()
|
||||
delete(h.refreshing, username)
|
||||
h.mu.Unlock()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
body, err := h.client.FetchHTML(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chInfo, posts, err := telemirror.ParseHTML(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Reject "successful" responses that have no widget content — usually
|
||||
// a captcha / rate-limit / soft-error page returned with status 200.
|
||||
if len(posts) == 0 && chInfo.Title == "" && chInfo.Description == "" {
|
||||
return nil, fmt.Errorf("telemirror: empty widget for %q", username)
|
||||
}
|
||||
if chInfo.Username == "" {
|
||||
chInfo.Username = username
|
||||
}
|
||||
res := &telemirror.FetchResult{Channel: *chInfo, Posts: posts}
|
||||
_ = h.cache.Put(username, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (h *telemirrorHub) ClearCache() { h.cache.Clear() }
|
||||
@@ -213,6 +213,9 @@ type Server struct {
|
||||
|
||||
// profilesMu serialises read-modify-write cycles on profiles.json.
|
||||
profilesMu sync.Mutex
|
||||
|
||||
// Optional, removable backup feed (Telegram-via-Translate proxy).
|
||||
telemirror *telemirrorHub
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
@@ -249,6 +252,7 @@ func New(dataDir string, port int, host string, password string) (*Server, error
|
||||
mediaCache: mediaCache,
|
||||
dlProgress: make(map[string]*mediaDLProgress),
|
||||
relayInfo: newRelayCache(),
|
||||
telemirror: newTelemirrorHub(dataDir),
|
||||
}
|
||||
|
||||
if mediaCache != nil {
|
||||
@@ -337,6 +341,10 @@ func (s *Server) Run() error {
|
||||
// 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("/", s.handleIndex)
|
||||
|
||||
// Listen on the specified host (default 127.0.0.1)
|
||||
@@ -3574,6 +3582,9 @@ func (s *Server) handleClearCache(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.telemirror != nil {
|
||||
s.telemirror.ClearCache()
|
||||
}
|
||||
mediaDeleted := 0
|
||||
if s.mediaCache != nil {
|
||||
mediaDeleted = s.mediaCache.Clear()
|
||||
|
||||
Reference in New Issue
Block a user