Skip to content

Commit 6453ea0

Browse files
committed
feat(core): proxy Telegram Mini App SDK through our origin
Add server-side caching for telegram-web-app.js so the subscription page serves it from our own origin instead of telegram.org (blocked in Russia). - Stale-while-revalidate cache with 12h TTL and singleflight guard - Cold/unreachable cache returns empty body immediately — never blocks page - Adds /tg.js route to serve cached SDK with appropriate Cache-Control
1 parent 21fe5ba commit 6453ea0

6 files changed

Lines changed: 376 additions & 1 deletion

File tree

internal/core/manager.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ type Manager struct {
9797
tmplMu sync.Mutex
9898
tmplCache map[string]routingTmpl // cached routing templates by URL
9999

100+
tgSDKMu sync.Mutex
101+
tgSDKBody []byte // cached telegram.org telegram-web-app.js (nil until first fetch)
102+
tgSDKAt time.Time // when tgSDKBody was fetched
103+
tgSDKFailAt time.Time // when the last fetch failed; suppresses inline retries for a cooldown
104+
tgSDKWait chan struct{} // non-nil while a fetch is in flight; closed when it lands (singleflight)
105+
100106
// userNotify pushes a message to a VPN user's Telegram chat (set by the user
101107
// bot; nil when off); adminNotify broadcasts to the admin chats (set by the
102108
// admin bot). Used e.g. to report payment start/completion. adminModerate asks
@@ -283,6 +289,8 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
283289
m.startWebhookWorkers() // drain the outbound-webhook delivery queue
284290
go m.prewarmRoutingTemplates() // warm the routing-template cache so the first
285291
// Happ/INCY sub pull after a restart doesn't block
292+
go m.refreshTelegramSDK() // fetch telegram-web-app.js so the sub page serves the
293+
// real SDK (not the shim) from the first Mini App open
286294
// NOTE: the initial proxy-pool load is done synchronously by main.go via
287295
// SeedProxies() before the first reconcile, so Xray starts once (with proxies)
288296
// rather than starting empty and restarting when a background fetch lands.

internal/core/manager_settings.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,3 +618,108 @@ func (m *Manager) prewarmRoutingTemplates() {
618618
}
619619
}
620620
}
621+
622+
const (
623+
// telegramSDKURL is Telegram's official Mini App JS wrapper. The subscription
624+
// page loads it from OUR origin (a cached copy of this) instead of directly:
625+
// telegram.org is blocked in Russia, so a direct <script> would hang the page
626+
// for the whole connection timeout before painting.
627+
telegramSDKURL = "https://telegram.org/js/telegram-web-app.js"
628+
telegramSDKTTL = 24 * time.Hour // how long a cached copy is served before a refresh
629+
telegramSDKBudget = 5 * time.Second // cap on a single upstream fetch, inline ones included
630+
telegramSDKRetryGap = time.Minute // after a failed fetch, don't stall a page again this soon
631+
telegramSDKMaxBytes = 1 << 20 // the wrapper is ~120 KB; 1 MiB is ample headroom
632+
)
633+
634+
// TelegramWebAppSDK returns a server-side cached copy of telegram.org's
635+
// telegram-web-app.js so the subscription page can serve it from our own
636+
// (reachable) origin. A fresh copy is returned as-is; a stale one is served
637+
// immediately while a refresh runs behind it (stale-while-revalidate), so a page
638+
// load never waits on a copy we already have.
639+
//
640+
// A COLD cache fetches inline, so the first visitor still gets the real SDK rather
641+
// than an empty file. That is the one place this can make a page wait, and it is
642+
// bounded on both axes: telegramSDKBudget caps the single fetch, and a failure arms
643+
// a telegramSDKRetryGap cooldown during which cold reads return immediately. So an
644+
// unreachable telegram.org costs one bounded wait per cooldown, not one per page
645+
// load — which is what keeps this from reintroducing the very hang the proxy exists
646+
// to remove. ok=false means "serve an empty body"; the page treats a missing SDK as
647+
// "not in Telegram" and renders normally.
648+
func (m *Manager) TelegramWebAppSDK() ([]byte, bool) {
649+
m.tgSDKMu.Lock()
650+
if body := m.tgSDKBody; body != nil {
651+
stale := time.Since(m.tgSDKAt) >= telegramSDKTTL
652+
m.tgSDKMu.Unlock()
653+
if stale {
654+
go m.refreshTelegramSDK() // serve what we have now, refresh behind it
655+
}
656+
return body, true
657+
}
658+
if time.Since(m.tgSDKFailAt) < telegramSDKRetryGap {
659+
m.tgSDKMu.Unlock() // upstream just failed us; don't stall this page too
660+
return nil, false
661+
}
662+
wait, lead := m.tgSDKWait, false
663+
if wait == nil { // nobody is fetching — this request does it
664+
wait = make(chan struct{})
665+
m.tgSDKWait, lead = wait, true
666+
}
667+
m.tgSDKMu.Unlock()
668+
669+
if lead {
670+
m.fetchTelegramSDK()
671+
} else {
672+
// A fetch is already in flight: ride along instead of starting a second one.
673+
select {
674+
case <-wait:
675+
case <-time.After(telegramSDKBudget):
676+
return nil, false
677+
}
678+
}
679+
680+
m.tgSDKMu.Lock()
681+
body := m.tgSDKBody
682+
m.tgSDKMu.Unlock()
683+
return body, body != nil
684+
}
685+
686+
// telegramSDKFetch performs the upstream GET. It's a var so tests can drive the
687+
// cache logic without a network (netguard rejects loopback, so httptest is out).
688+
var telegramSDKFetch = func(ctx context.Context) ([]byte, error) {
689+
return netguard.Get(ctx, telegramSDKURL, telegramSDKMaxBytes)
690+
}
691+
692+
// refreshTelegramSDK fetches in the background (startup warm-up and stale refresh).
693+
// It's a no-op while another fetch is in flight, so the per-request stale trigger
694+
// can't pile up goroutines.
695+
func (m *Manager) refreshTelegramSDK() {
696+
m.tgSDKMu.Lock()
697+
if m.tgSDKWait != nil {
698+
m.tgSDKMu.Unlock()
699+
return
700+
}
701+
m.tgSDKWait = make(chan struct{})
702+
m.tgSDKMu.Unlock()
703+
m.fetchTelegramSDK()
704+
}
705+
706+
// fetchTelegramSDK performs the upstream GET and publishes the result, then releases
707+
// everyone waiting on it. The caller must have claimed the fetch by setting
708+
// tgSDKWait. A failed fetch keeps any previous cached copy and stamps tgSDKFailAt so
709+
// cold readers stop blocking for a while.
710+
func (m *Manager) fetchTelegramSDK() {
711+
ctx, cancel := context.WithTimeout(context.Background(), telegramSDKBudget)
712+
defer cancel()
713+
b, err := telegramSDKFetch(ctx)
714+
715+
m.tgSDKMu.Lock()
716+
if err == nil && len(b) > 0 {
717+
m.tgSDKBody, m.tgSDKAt = b, time.Now()
718+
m.tgSDKFailAt = time.Time{}
719+
} else {
720+
m.tgSDKFailAt = time.Now()
721+
}
722+
close(m.tgSDKWait) // wake the riders; they re-read the cache
723+
m.tgSDKWait = nil
724+
m.tgSDKMu.Unlock()
725+
}
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package core
2+
3+
import (
4+
"context"
5+
"errors"
6+
"sync"
7+
"sync/atomic"
8+
"testing"
9+
"time"
10+
)
11+
12+
// stubTelegramSDKFetch swaps the upstream GET for the duration of a test and
13+
// reports how many times it was called.
14+
func stubTelegramSDKFetch(t *testing.T, fn func(ctx context.Context) ([]byte, error)) *atomic.Int32 {
15+
t.Helper()
16+
var calls atomic.Int32
17+
prev := telegramSDKFetch
18+
telegramSDKFetch = func(ctx context.Context) ([]byte, error) {
19+
calls.Add(1)
20+
return fn(ctx)
21+
}
22+
t.Cleanup(func() { telegramSDKFetch = prev })
23+
return &calls
24+
}
25+
26+
// waitTelegramSDKIdle blocks until no fetch is in flight. Background refreshes
27+
// outlive the call that spawned them, so a test must settle them before its cleanup
28+
// restores telegramSDKFetch — otherwise the in-flight goroutine reads the var as it
29+
// is reassigned (a test-only race; production never reassigns it).
30+
func waitTelegramSDKIdle(t *testing.T, m *Manager) {
31+
t.Helper()
32+
deadline := time.Now().Add(5 * time.Second)
33+
for {
34+
m.tgSDKMu.Lock()
35+
idle := m.tgSDKWait == nil
36+
m.tgSDKMu.Unlock()
37+
if idle {
38+
return
39+
}
40+
if time.Now().After(deadline) {
41+
t.Fatal("a telegram SDK fetch is still in flight after 5s")
42+
}
43+
time.Sleep(5 * time.Millisecond)
44+
}
45+
}
46+
47+
// A cold cache must fetch INLINE and hand the real body to the very first caller —
48+
// that's the whole point of the cold path (an empty file would silently disable the
49+
// Mini App bridge for whoever loads the page first).
50+
func TestTelegramSDKColdFetchesInline(t *testing.T) {
51+
calls := stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
52+
return []byte("// WebView"), nil
53+
})
54+
m := &Manager{}
55+
56+
body, ok := m.TelegramWebAppSDK()
57+
if !ok || string(body) != "// WebView" {
58+
t.Fatalf("cold read: got (%q, %v), want the fetched body", body, ok)
59+
}
60+
if got := calls.Load(); got != 1 {
61+
t.Fatalf("upstream called %d times, want 1", got)
62+
}
63+
64+
// Second read is served from cache — no new fetch.
65+
if body, ok = m.TelegramWebAppSDK(); !ok || string(body) != "// WebView" {
66+
t.Fatalf("warm read: got (%q, %v)", body, ok)
67+
}
68+
if got := calls.Load(); got != 1 {
69+
t.Fatalf("warm read refetched (%d calls), want still 1", got)
70+
}
71+
}
72+
73+
// An unreachable telegram.org must degrade to "empty body" AND arm the cooldown, so
74+
// only the first caller pays the timeout. Without this, every page load would block
75+
// for the full budget whenever upstream is down.
76+
func TestTelegramSDKUnreachableArmsCooldown(t *testing.T) {
77+
calls := stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
78+
return nil, errors.New("dial tcp: connection refused")
79+
})
80+
m := &Manager{}
81+
82+
if body, ok := m.TelegramWebAppSDK(); ok || body != nil {
83+
t.Fatalf("failed fetch: got (%q, %v), want (nil, false)", body, ok)
84+
}
85+
if got := calls.Load(); got != 1 {
86+
t.Fatalf("upstream called %d times, want 1", got)
87+
}
88+
89+
// Within the cooldown: return immediately, do NOT retry upstream.
90+
for i := 0; i < 5; i++ {
91+
if _, ok := m.TelegramWebAppSDK(); ok {
92+
t.Fatal("expected ok=false during cooldown")
93+
}
94+
}
95+
if got := calls.Load(); got != 1 {
96+
t.Fatalf("cooldown was not honoured: %d upstream calls, want 1", got)
97+
}
98+
99+
// Once the cooldown lapses, it tries again.
100+
m.tgSDKMu.Lock()
101+
m.tgSDKFailAt = time.Now().Add(-telegramSDKRetryGap - time.Second)
102+
m.tgSDKMu.Unlock()
103+
if _, ok := m.TelegramWebAppSDK(); ok {
104+
t.Fatal("still failing upstream, expected ok=false")
105+
}
106+
if got := calls.Load(); got != 2 {
107+
t.Fatalf("after cooldown: %d upstream calls, want 2", got)
108+
}
109+
}
110+
111+
// Concurrent cold readers must collapse onto ONE upstream fetch and all receive the
112+
// body — a thundering herd on a cold cache would otherwise hammer telegram.org once
113+
// per page load.
114+
func TestTelegramSDKConcurrentColdSingleflight(t *testing.T) {
115+
release := make(chan struct{})
116+
calls := stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
117+
<-release // hold the fetch open so every goroutine piles up behind it
118+
return []byte("// WebView"), nil
119+
})
120+
m := &Manager{}
121+
122+
const readers = 12
123+
var wg sync.WaitGroup
124+
got := make([]bool, readers)
125+
for i := range readers {
126+
wg.Add(1)
127+
go func() {
128+
defer wg.Done()
129+
body, ok := m.TelegramWebAppSDK()
130+
got[i] = ok && string(body) == "// WebView"
131+
}()
132+
}
133+
time.Sleep(50 * time.Millisecond) // let them all reach the cold path
134+
close(release)
135+
wg.Wait()
136+
137+
if n := calls.Load(); n != 1 {
138+
t.Fatalf("upstream called %d times, want exactly 1 (singleflight)", n)
139+
}
140+
for i, okd := range got {
141+
if !okd {
142+
t.Errorf("reader %d did not get the body", i)
143+
}
144+
}
145+
}
146+
147+
// A stale copy is served immediately (never blocking the page) while the refresh
148+
// happens behind it.
149+
func TestTelegramSDKStaleServedImmediately(t *testing.T) {
150+
block, started := make(chan struct{}), make(chan struct{})
151+
var once sync.Once
152+
stubTelegramSDKFetch(t, func(context.Context) ([]byte, error) {
153+
once.Do(func() { close(started) })
154+
<-block
155+
return []byte("fresh"), nil
156+
})
157+
m := &Manager{}
158+
m.tgSDKBody = []byte("old")
159+
m.tgSDKAt = time.Now().Add(-telegramSDKTTL - time.Minute) // stale
160+
161+
start := time.Now()
162+
body, ok := m.TelegramWebAppSDK()
163+
elapsed := time.Since(start)
164+
if !ok || string(body) != "old" {
165+
t.Fatalf("stale read: got (%q, %v), want the old body", body, ok)
166+
}
167+
if elapsed > time.Second {
168+
t.Fatalf("stale read blocked for %v — it must not wait on the refresh", elapsed)
169+
}
170+
171+
// Wait for the refresh to actually BEGIN before releasing it: the goroutine may
172+
// not have been scheduled yet, and "no fetch in flight" can't distinguish
173+
// "not started" from "finished".
174+
select {
175+
case <-started:
176+
case <-time.After(5 * time.Second):
177+
t.Fatal("background refresh never started")
178+
}
179+
close(block)
180+
waitTelegramSDKIdle(t, m) // let it land before cleanup restores the stub
181+
182+
// ...and the refresh it kicked off replaced the stale copy.
183+
if body, ok := m.TelegramWebAppSDK(); !ok || string(body) != "fresh" {
184+
t.Fatalf("after refresh: got (%q, %v), want the fresh body", body, ok)
185+
}
186+
}

internal/server/subscription.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,27 @@ func handleSub(rt *Router, w http.ResponseWriter, r *http.Request, rest string)
116116
w.Header().Set("Cache-Control", "public, max-age=300")
117117
_, _ = w.Write(b)
118118

119+
case "tg.js":
120+
// The Telegram Mini App SDK, proxied through us: the panel fetches it from
121+
// telegram.org server-side and serves the cached copy from our own origin, so
122+
// the page never loads it straight from telegram.org (blocked in Russia — a
123+
// direct <script> there hangs the page until the connection times out).
124+
//
125+
// A cold/unreachable cache serves an EMPTY body rather than blocking on an
126+
// inline fetch — never hanging the page is the whole point of this route. The
127+
// page degrades cleanly: window.Telegram stays undefined, so it treats itself
128+
// as a plain browser (INTG=false) exactly as it does today when telegram.org
129+
// is blocked. no-store on that empty reply so the client picks up the real SDK
130+
// as soon as the background fetch lands.
131+
js, ok := rt.mgr.TelegramWebAppSDK()
132+
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
133+
if ok {
134+
w.Header().Set("Cache-Control", "public, max-age=3600")
135+
} else {
136+
w.Header().Set("Cache-Control", "no-store")
137+
}
138+
_, _ = w.Write(js)
139+
119140
case "qr.png":
120141
png, err := qrcode.Encode(sub.URL(set, u.SubToken), qrcode.Medium, 512)
121142
if err != nil {

internal/sub/page.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
href="https://fonts.googleapis.com/css2?family=Mulish:wght@400;600;700;800&display=swap"
1212
rel="stylesheet"
1313
/>
14-
<script src="https://telegram.org/js/telegram-web-app.js"></script>
14+
<script src="{{.SubURL}}/tg.js"></script>
1515
<style>
1616
:root {
1717
--brand: {{.Brand}};

0 commit comments

Comments
 (0)