Merge remote-tracking branch 'upstream/main' into merge-gramsrv-2965f5d

This commit is contained in:
onysd 2026-07-20 23:43:51 +03:00
commit ebb0be38d9
355 changed files with 44640 additions and 2320 deletions

View file

@ -21,17 +21,19 @@ import (
)
type Config struct {
Addr string
PublicBaseURL string
AppScheme string
WebBaseURL string
AppName string
DownloadURL string
StickerSets StickerSetResolver
Users UsernameResolver
Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver
Addr string
PublicBaseURL string
AppScheme string
WebBaseURL string
AppName string
DownloadURL string
StickerSets StickerSetResolver
Users UsernameResolver
Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver
UniqueGifts UniqueStarGiftResolver
GiftWithdrawals StarGiftWithdrawalResolver
}
type StickerSetResolver interface {
@ -61,6 +63,15 @@ type ProfilePhotoResolver interface {
GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error)
}
type UniqueStarGiftResolver interface {
UniqueBySlug(ctx context.Context, slug string) (domain.UniqueStarGift, bool, error)
}
type StarGiftWithdrawalResolver interface {
ResolveWithdrawal(ctx context.Context, providerRequestID string) (domain.StarGiftWithdrawal, bool, error)
CompleteWithdrawal(ctx context.Context, providerRequestID string, date int) (domain.StarGiftWithdrawal, error)
}
func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) {
addr := strings.TrimSpace(cfg.Addr)
if addr == "" {
@ -147,17 +158,19 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
logger = zap.NewNop()
}
h := &handler{
stickerSets: cfg.StickerSets,
users: cfg.Users,
channels: cfg.Channels,
privacy: cfg.Privacy,
photos: cfg.Photos,
publicBaseURL: cfg.PublicBaseURL,
appScheme: cfg.AppScheme,
webBaseURL: cfg.WebBaseURL,
appName: cfg.AppName,
downloadURL: cfg.DownloadURL,
logger: logger,
stickerSets: cfg.StickerSets,
users: cfg.Users,
channels: cfg.Channels,
privacy: cfg.Privacy,
photos: cfg.Photos,
uniqueGifts: cfg.UniqueGifts,
giftWithdrawals: cfg.GiftWithdrawals,
publicBaseURL: cfg.PublicBaseURL,
appScheme: cfg.AppScheme,
webBaseURL: cfg.WebBaseURL,
appName: cfg.AppName,
downloadURL: cfg.DownloadURL,
logger: logger,
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", h.healthz)
@ -168,23 +181,88 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers)
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
mux.HandleFunc("GET /addlist/{slug}", h.addList)
mux.HandleFunc("GET /nft/{slug}", h.uniqueGift)
mux.HandleFunc("GET /nft/{slug}/{$}", h.uniqueGift)
mux.HandleFunc("GET /gift-withdrawal/{requestID}", h.starGiftWithdrawal)
mux.HandleFunc("POST /gift-withdrawal/{requestID}", h.completeStarGiftWithdrawal)
mux.HandleFunc("GET /{username}", h.usernameLink)
mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
return publicSecurityHeaders(mux), nil
}
type handler struct {
stickerSets StickerSetResolver
users UsernameResolver
channels PublicChannelResolver
privacy AnonymousPrivacyResolver
photos ProfilePhotoResolver
publicBaseURL string
appScheme string
webBaseURL string
appName string
downloadURL string
logger *zap.Logger
stickerSets StickerSetResolver
users UsernameResolver
channels PublicChannelResolver
privacy AnonymousPrivacyResolver
photos ProfilePhotoResolver
uniqueGifts UniqueStarGiftResolver
giftWithdrawals StarGiftWithdrawalResolver
publicBaseURL string
appScheme string
webBaseURL string
appName string
downloadURL string
logger *zap.Logger
}
type starGiftWithdrawalPage struct {
AppName string
Title string
Slug string
Status string
OwnerAddress string
GiftAddress string
ExpiresAt string
CanComplete bool
}
var starGiftWithdrawalTemplate = template.Must(template.New("star-gift-withdrawal").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}} · {{.AppName}}</title><style>
body{font:16px/1.5 system-ui,sans-serif;background:#f4f6f8;color:#17212b;margin:0;padding:32px}.card{max-width:560px;margin:8vh auto;background:#fff;border-radius:16px;padding:28px;box-shadow:0 8px 32px #0002}h1{margin-top:0}.meta{overflow-wrap:anywhere;color:#53606d}button{border:0;border-radius:10px;padding:12px 18px;background:#2481cc;color:#fff;font-weight:600;cursor:pointer}.done{color:#18864b;font-weight:600}
</style></head><body><main class="card"><h1>{{.Title}}</h1><p class="meta">Collectible: {{.Slug}}</p>
{{if .CanComplete}}<p>This export is handled only by {{.AppName}}'s internal ledger. No external blockchain or wallet is contacted.</p><form method="post"><button type="submit">Complete local export</button></form><p class="meta">Expires: {{.ExpiresAt}}</p>{{else}}<p class="done">Status: {{.Status}}</p>{{if .OwnerAddress}}<p class="meta">Owner address: {{.OwnerAddress}}</p><p class="meta">Gift address: {{.GiftAddress}}</p>{{end}}{{end}}
</main></body></html>`))
func (h *handler) starGiftWithdrawal(w http.ResponseWriter, r *http.Request) {
h.renderStarGiftWithdrawal(w, r, false)
}
func (h *handler) completeStarGiftWithdrawal(w http.ResponseWriter, r *http.Request) {
h.renderStarGiftWithdrawal(w, r, true)
}
func (h *handler) renderStarGiftWithdrawal(w http.ResponseWriter, r *http.Request, complete bool) {
requestID := strings.TrimSpace(r.PathValue("requestID"))
if h.giftWithdrawals == nil || requestID == "" || len(requestID) > 256 {
http.NotFound(w, r)
return
}
var withdrawal domain.StarGiftWithdrawal
var found bool
var err error
if complete {
withdrawal, err = h.giftWithdrawals.CompleteWithdrawal(r.Context(), requestID, int(time.Now().Unix()))
found = err == nil
} else {
withdrawal, found, err = h.giftWithdrawals.ResolveWithdrawal(r.Context(), requestID)
}
if err != nil || !found {
http.NotFound(w, r)
return
}
page := starGiftWithdrawalPage{AppName: h.appName, Title: withdrawal.Gift.Title, Slug: withdrawal.Gift.Slug,
Status: withdrawal.Status, OwnerAddress: withdrawal.Gift.OwnerAddress, GiftAddress: withdrawal.Gift.GiftAddress,
ExpiresAt: time.Unix(int64(withdrawal.ExpiresAt), 0).UTC().Format(time.RFC3339),
CanComplete: withdrawal.Status == "pending" && withdrawal.ExpiresAt > int(time.Now().Unix())}
if page.Title == "" {
page.Title = "Collectible gift export"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := starGiftWithdrawalTemplate.Execute(w, page); err != nil {
h.logger.Warn("render star gift withdrawal", zap.Error(err))
}
}
func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) {
@ -224,6 +302,63 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) {
}
}
func (h *handler) uniqueGift(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if h.uniqueGifts == nil || !validStarGiftSlugPath(slug) {
http.NotFound(w, r)
return
}
unique, found, err := h.uniqueGifts.UniqueBySlug(r.Context(), slug)
if err != nil {
h.logger.Error("Public unique star gift lookup failed", zap.String("slug", slug), zap.Error(err))
http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
canonicalSlug := unique.Slug
if unique.ID <= 0 || unique.GiftID <= 0 || unique.Num <= 0 ||
!validStarGiftSlugPath(canonicalSlug) || !strings.EqualFold(slug, canonicalSlug) ||
!utf8.ValidString(unique.Title) || utf8.RuneCountInString(unique.Title) > domain.MaxStarGiftTitleRunes {
h.logger.Error("Public unique star gift resolver returned invalid aggregate",
zap.String("requested_slug", slug), zap.String("resolved_slug", canonicalSlug),
zap.Int64("unique_id", unique.ID), zap.Int64("gift_id", unique.GiftID), zap.Int("num", unique.Num))
http.Error(w, "collectible gift lookup failed", http.StatusInternalServerError)
return
}
if slug != canonicalSlug || strings.HasSuffix(r.URL.Path, "/") {
http.Redirect(w, r, h.publicURL("nft", canonicalSlug), http.StatusPermanentRedirect)
return
}
title := strings.TrimSpace(unique.Title)
if title == "" {
title = "Collectible gift"
}
subtitle := fmt.Sprintf("Collectible #%d", unique.Num)
if unique.AvailabilityIssued > 0 && unique.AvailabilityTotal >= unique.AvailabilityIssued {
subtitle += fmt.Sprintf(" · %s/%s issued", groupedDecimal(unique.AvailabilityIssued), groupedDecimal(unique.AvailabilityTotal))
}
app := h.appURL("nft", "slug", canonicalSlug)
data := pageData{
AppName: h.appName,
Title: title,
KindLabel: "collectible gift",
Subtitle: subtitle,
Description: "This collectible was created from a gift on " + h.appName + ". Open it in the app to view its current details.",
CanonicalURL: h.publicURL("nft", canonicalSlug),
AppURL: template.URL(app),
LegacyTgURL: template.URL(legacyTgURL("nft", "slug", canonicalSlug)),
}
data.AppURLJS = template.JS(strconv.Quote(app))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate")
if err := landingTemplate.Execute(w, data); err != nil {
h.logger.Error("Render public unique star gift page failed", zap.String("slug", canonicalSlug), zap.Error(err))
}
}
func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.PathValue("username"))
if strings.HasPrefix(raw, "+") {
@ -910,7 +1045,7 @@ func publicWebAppURL(webBaseURL, legacyURL string) string {
func publicSecurityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; font-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'")
w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; font-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
@ -940,6 +1075,23 @@ func validSlugPath(slug string) bool {
return links.ValidChatlistSlug(slug)
}
func validStarGiftSlugPath(slug string) bool {
if slug == "" || len(slug) > domain.MaxStarGiftSlugBytes {
return false
}
for _, r := range slug {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '.' || r == '_' || r == '-':
default:
return false
}
}
return true
}
func validUsernamePath(username string) bool {
if username == "" || len(username) < 5 || len(username) > 32 {
return false

View file

@ -7,6 +7,7 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"telesrv/internal/domain"
)
@ -44,6 +45,158 @@ func newTestHandlerWithPublicPeers(
return h
}
type fakeGiftWithdrawals struct {
value domain.StarGiftWithdrawal
found bool
completeCalls int
}
type fakeUniqueGifts struct {
bySlug map[string]domain.UniqueStarGift
err error
calls int
}
func (f *fakeUniqueGifts) UniqueBySlug(_ context.Context, slug string) (domain.UniqueStarGift, bool, error) {
f.calls++
if f.err != nil {
return domain.UniqueStarGift{}, false, f.err
}
value, ok := f.bySlug[strings.ToLower(slug)]
return value, ok, nil
}
func TestHandlerServesUniqueGiftLandingPage(t *testing.T) {
const slug = "official-5895603153683874485-7"
resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{
slug: {
ID: 7001, GiftID: 5895603153683874485, Title: "Official Gift", Slug: slug, Num: 7,
AvailabilityIssued: 7, AvailabilityTotal: 1000,
},
}}
handler, err := NewHandler(Config{
StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "http://127.0.0.1:2401",
})
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/"+slug, nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
for _, want := range []string{
"Official Gift", "Collectible #7", "7/1 000 issued",
"http://127.0.0.1:2401/nft/" + slug,
"telesrv://nft?slug=" + slug,
"tg://nft?slug=" + slug,
"Open it in the app to view its current details.",
} {
if !strings.Contains(rr.Body.String(), want) {
t.Fatalf("body missing %q:\n%s", want, rr.Body.String())
}
}
if strings.Contains(rr.Body.String(), `window.location.href = "tg://`) {
t.Fatalf("landing page must not auto-open tg:// and steal official Telegram:\n%s", rr.Body.String())
}
if got := rr.Header().Get("Cache-Control"); got != "public, max-age=60, must-revalidate" {
t.Fatalf("Cache-Control = %q", got)
}
}
func TestHandlerCanonicalizesUniqueGiftSlug(t *testing.T) {
const canonical = "Official-Gift-7"
resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{
strings.ToLower(canonical): {ID: 7, GiftID: 70, Slug: canonical, Num: 7},
}}
handler, err := NewHandler(Config{
StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net",
})
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
for _, path := range []string{"/nft/official-gift-7", "/nft/" + canonical + "/"} {
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
if rr.Code != http.StatusPermanentRedirect || rr.Header().Get("Location") != "https://telesrv.net/nft/"+canonical {
t.Fatalf("%s status=%d location=%q", path, rr.Code, rr.Header().Get("Location"))
}
}
}
func TestHandlerRejectsInvalidMissingAndBrokenUniqueGift(t *testing.T) {
resolver := &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{
"broken-1": {ID: 1, GiftID: 2, Slug: "other-1", Num: 1},
}}
handler, err := NewHandler(Config{
StickerSets: fakeResolver{}, UniqueGifts: resolver, PublicBaseURL: "https://telesrv.net",
})
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
for _, path := range []string{
"/nft/missing-1", "/nft/bad!slug", "/nft/%E4%B8%AD%E6%96%87", "/nft/" + strings.Repeat("x", domain.MaxStarGiftSlugBytes+1),
} {
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("%s status=%d, want 404", path, rr.Code)
}
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/broken-1", nil))
if rr.Code != http.StatusInternalServerError {
t.Fatalf("broken aggregate status=%d, want 500", rr.Code)
}
resolver.err = errors.New("lookup failed")
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/nft/error-1", nil))
if rr.Code != http.StatusInternalServerError {
t.Fatalf("lookup error status=%d, want 500", rr.Code)
}
}
func (f *fakeGiftWithdrawals) ResolveWithdrawal(context.Context, string) (domain.StarGiftWithdrawal, bool, error) {
return f.value, f.found, nil
}
func (f *fakeGiftWithdrawals) CompleteWithdrawal(_ context.Context, _ string, _ int) (domain.StarGiftWithdrawal, error) {
f.completeCalls++
f.value.Status = "completed"
f.value.Gift.OwnerAddress = "telesrv-owner:test"
f.value.Gift.GiftAddress = "telesrv-gift:test"
return f.value, nil
}
func TestHandlerCompletesLocalStarGiftWithdrawal(t *testing.T) {
resolver := &fakeGiftWithdrawals{found: true, value: domain.StarGiftWithdrawal{
ProviderRequestID: "safe-token", Status: "pending", ExpiresAt: int(time.Now().Add(time.Minute).Unix()),
Gift: domain.UniqueStarGift{Title: `<script>alert("x")</script>`, Slug: "gift-1"},
}}
handler, err := NewHandler(Config{StickerSets: fakeResolver{}, GiftWithdrawals: resolver,
PublicBaseURL: "https://telesrv.net", AppName: "telesrv"})
if err != nil {
t.Fatalf("NewHandler: %v", err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/gift-withdrawal/safe-token", nil))
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Complete local export") ||
strings.Contains(rr.Body.String(), `<script>alert("x")</script>`) {
t.Fatalf("withdrawal GET status=%d body=%s", rr.Code, rr.Body.String())
}
if csp := rr.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "form-action 'self'") {
t.Fatalf("withdrawal CSP does not allow its same-origin POST form: %q", csp)
}
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/gift-withdrawal/safe-token", strings.NewReader("")))
if rr.Code != http.StatusOK || resolver.completeCalls != 1 || !strings.Contains(rr.Body.String(), "Status: completed") ||
!strings.Contains(rr.Body.String(), "telesrv-owner:test") || !strings.Contains(rr.Body.String(), "telesrv-gift:test") {
t.Fatalf("withdrawal POST calls=%d status=%d body=%s", resolver.completeCalls, rr.Code, rr.Body.String())
}
}
func TestHandlerServesStickerSetLandingPage(t *testing.T) {
resolver := fakeResolver{
"fresh_pack": {
@ -181,7 +334,10 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) {
"stickers_pack": {ShortName: "stickers_pack", Title: "Stickers", Kind: domain.StickerSetKindStickers},
"emoji_pack": {ShortName: "emoji_pack", Title: "Emoji", Kind: domain.StickerSetKindEmoji, Emojis: true},
},
Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}},
Users: fakeUsers{"alice": {ID: 2001, Username: "Alice", FirstName: "Alice"}},
UniqueGifts: &fakeUniqueGifts{bySlug: map[string]domain.UniqueStarGift{
"gift-1": {ID: 1, GiftID: 10, Slug: "gift-1", Num: 1},
}},
PublicBaseURL: "https://links.example.test",
AppScheme: "example-chat",
WebBaseURL: "https://web.example.test/client/",
@ -216,6 +372,7 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) {
{path: "/addstickers/stickers_pack", want: "example-chat://addstickers?set=stickers_pack"},
{path: "/addemoji/emoji_pack", want: "example-chat://addemoji?set=emoji_pack"},
{path: "/addlist/shared-folder", want: "example-chat://addlist?slug=shared-folder"},
{path: "/nft/gift-1", want: "example-chat://nft?slug=gift-1"},
} {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil))