diff --git a/.env.example b/.env.example index d543def3..2486e0b8 100644 --- a/.env.example +++ b/.env.example @@ -65,6 +65,10 @@ TELESRV_PUBLIC_APP_SCHEME=telesrv TELESRV_PUBLIC_WEB_BASE_URL=https://web.telesrv.net TELESRV_PUBLIC_APP_NAME=telesrv +# Product site/download page linked from the "Download" button in the public +# landing pages' header. +TELESRV_PUBLIC_DOWNLOAD_URL=https://owpengram.org + # Admin API / Admin UI 配置 # # TELESRV_ADMIN_API_TOKEN 是主服务 (cmd/telesrv) 暴露 Admin REST API 的鉴权 token, diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index 0bb03121..6632746d 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -825,6 +825,7 @@ func run(logger *zap.Logger) error { AppScheme: cfg.PublicAppScheme, WebBaseURL: cfg.PublicWebBaseURL, AppName: cfg.PublicAppName, + DownloadURL: cfg.PublicDownloadURL, StickerSets: filesService, Users: userStore, Channels: channelStore, diff --git a/internal/config/config.go b/internal/config/config.go index bfc690e0..1b639084 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -74,6 +74,8 @@ type Config struct { PublicWebBaseURL string // PublicAppName 是公开落地页展示的产品名,不参与协议路由。 PublicAppName string + // PublicDownloadURL 是公开落地页头部“Download”按钮指向的产品官网/下载页 URL。 + PublicDownloadURL string // PublicLinkWebAddr 是公开链接落地页监听地址;为空关闭。 // 生产应只监听 loopback,并由 nginx 将 /、/addstickers/、/addemoji/ 与 /addlist/ 反代到该地址。 PublicLinkWebAddr string @@ -372,14 +374,23 @@ func Load() (Config, error) { if err != nil { return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_SCHEME: %w", err) } - publicWebBaseURL, err := links.ValidateBaseURL(envOr("TELESRV_PUBLIC_WEB_BASE_URL", links.DefaultWebBaseURL)) - if err != nil { - return Config{}, fmt.Errorf("TELESRV_PUBLIC_WEB_BASE_URL: %w", err) + // TELESRV_PUBLIC_WEB_BASE_URL is nullable: an explicitly empty value disables + // the "Open in Web" button on public landing pages instead of falling back + // to the default telesrv Web client URL. + publicWebBaseURL := envAllowEmptyOr("TELESRV_PUBLIC_WEB_BASE_URL", links.DefaultWebBaseURL) + if publicWebBaseURL != "" { + if publicWebBaseURL, err = links.ValidateBaseURL(publicWebBaseURL); err != nil { + return Config{}, fmt.Errorf("TELESRV_PUBLIC_WEB_BASE_URL: %w", err) + } } publicAppName, err := links.ValidateAppName(envOr("TELESRV_PUBLIC_APP_NAME", links.DefaultAppName)) if err != nil { return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_NAME: %w", err) } + publicDownloadURL, err := links.ValidateBaseURL(envOr("TELESRV_PUBLIC_DOWNLOAD_URL", links.DefaultDownloadURL)) + if err != nil { + return Config{}, fmt.Errorf("TELESRV_PUBLIC_DOWNLOAD_URL: %w", err) + } cfg := Config{ ListenAddr: envOr("TELESRV_LISTEN", "0.0.0.0:2398"), @@ -416,6 +427,7 @@ func Load() (Config, error) { PublicAppScheme: publicAppScheme, PublicWebBaseURL: publicWebBaseURL, PublicAppName: publicAppName, + PublicDownloadURL: publicDownloadURL, PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), AdminUIAddr: envOr("TELESRV_ADMIN_UI_ADDR", "127.0.0.1:2600"), AdminUIPassword: envOr("TELESRV_ADMIN_UI_PASSWORD", ""), diff --git a/internal/links/links.go b/internal/links/links.go index 50ac7a2f..b2ac34fa 100644 --- a/internal/links/links.go +++ b/internal/links/links.go @@ -11,6 +11,7 @@ const ( DefaultWebBaseURL = "https://web.telesrv.net" DefaultAppScheme = "telesrv" DefaultAppName = "telesrv" + DefaultDownloadURL = "https://owpengram.org" ) const MaxChatlistSlugBytes = 128 diff --git a/internal/rpc/sticker_link_smoke_test.go b/internal/rpc/sticker_link_smoke_test.go index 4c51b172..8c41cfa7 100644 --- a/internal/rpc/sticker_link_smoke_test.go +++ b/internal/rpc/sticker_link_smoke_test.go @@ -89,7 +89,7 @@ func TestCustomStickerPackLinkInstallAndSendSmoke(t *testing.T) { t.Fatalf("sticker link status = %d body=%q, want 200", rr.Code, rr.Body.String()) } body := rr.Body.String() - for _, want := range []string{"https://telesrv.net/addstickers/alice_fresh_pack", "telesrv://addstickers?set=alice_fresh_pack", "tg://addstickers?set=alice_fresh_pack"} { + for _, want := range []string{"https://telesrv.net/addstickers/alice_fresh_pack", "telesrv://addstickers?set=alice_fresh_pack"} { if !strings.Contains(body, want) { t.Fatalf("sticker link body missing %q:\n%s", want, body) } diff --git a/internal/web/assets.go b/internal/web/assets.go new file mode 100644 index 00000000..f23a9427 --- /dev/null +++ b/internal/web/assets.go @@ -0,0 +1,51 @@ +package web + +import ( + _ "embed" + "net/http" + "strings" +) + +//go:embed assets/logo.png +var brandLogoPNG []byte + +//go:embed assets/fonts/plus-jakarta-sans-400.woff2 +var fontJakarta400 []byte + +//go:embed assets/fonts/plus-jakarta-sans-500.woff2 +var fontJakarta500 []byte + +//go:embed assets/fonts/plus-jakarta-sans-600.woff2 +var fontJakarta600 []byte + +//go:embed assets/fonts/plus-jakarta-sans-700.woff2 +var fontJakarta700 []byte + +//go:embed assets/fonts/plus-jakarta-sans-800.woff2 +var fontJakarta800 []byte + +var publicFonts = map[string][]byte{ + "plus-jakarta-sans-400.woff2": fontJakarta400, + "plus-jakarta-sans-500.woff2": fontJakarta500, + "plus-jakarta-sans-600.woff2": fontJakarta600, + "plus-jakarta-sans-700.woff2": fontJakarta700, + "plus-jakarta-sans-800.woff2": fontJakarta800, +} + +func (h *handler) brandLogo(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + _, _ = w.Write(brandLogoPNG) +} + +func (h *handler) brandFont(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.PathValue("file")) + data, ok := publicFonts[name] + if !ok { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "font/woff2") + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + _, _ = w.Write(data) +} diff --git a/internal/web/assets/fonts/plus-jakarta-sans-400.woff2 b/internal/web/assets/fonts/plus-jakarta-sans-400.woff2 new file mode 100644 index 00000000..b3d51326 Binary files /dev/null and b/internal/web/assets/fonts/plus-jakarta-sans-400.woff2 differ diff --git a/internal/web/assets/fonts/plus-jakarta-sans-500.woff2 b/internal/web/assets/fonts/plus-jakarta-sans-500.woff2 new file mode 100644 index 00000000..eaf36240 Binary files /dev/null and b/internal/web/assets/fonts/plus-jakarta-sans-500.woff2 differ diff --git a/internal/web/assets/fonts/plus-jakarta-sans-600.woff2 b/internal/web/assets/fonts/plus-jakarta-sans-600.woff2 new file mode 100644 index 00000000..d71bab5c Binary files /dev/null and b/internal/web/assets/fonts/plus-jakarta-sans-600.woff2 differ diff --git a/internal/web/assets/fonts/plus-jakarta-sans-700.woff2 b/internal/web/assets/fonts/plus-jakarta-sans-700.woff2 new file mode 100644 index 00000000..18d8d152 Binary files /dev/null and b/internal/web/assets/fonts/plus-jakarta-sans-700.woff2 differ diff --git a/internal/web/assets/fonts/plus-jakarta-sans-800.woff2 b/internal/web/assets/fonts/plus-jakarta-sans-800.woff2 new file mode 100644 index 00000000..91be461a Binary files /dev/null and b/internal/web/assets/fonts/plus-jakarta-sans-800.woff2 differ diff --git a/internal/web/assets/logo.png b/internal/web/assets/logo.png new file mode 100644 index 00000000..32339056 Binary files /dev/null and b/internal/web/assets/logo.png differ diff --git a/internal/web/server.go b/internal/web/server.go index 9cbcad7f..7f47f8f5 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -26,6 +26,7 @@ type Config struct { AppScheme string WebBaseURL string AppName string + DownloadURL string StickerSets StickerSetResolver Users UsernameResolver Channels PublicChannelResolver @@ -87,7 +88,8 @@ func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, e zap.String("addr", addr), zap.String("public_base_url", cfg.PublicBaseURL), zap.String("app_scheme", cfg.AppScheme), - zap.String("web_base_url", cfg.WebBaseURL)) + zap.String("web_base_url", cfg.WebBaseURL), + zap.String("download_url", cfg.DownloadURL)) if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Warn("Public link Web endpoint exited", zap.Error(err)) } @@ -110,9 +112,6 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { if cfg.StickerSets == nil { return nil, fmt.Errorf("public Web sticker set resolver is nil") } - if strings.TrimSpace(cfg.WebBaseURL) == "" { - cfg.WebBaseURL = links.DefaultWebBaseURL - } if strings.TrimSpace(cfg.AppName) == "" { cfg.AppName = links.DefaultAppName } @@ -122,12 +121,25 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { if cfg.AppScheme, err = links.ValidateAppScheme(cfg.AppScheme); err != nil { return nil, fmt.Errorf("app scheme: %w", err) } - if cfg.WebBaseURL, err = links.ValidateBaseURL(cfg.WebBaseURL); err != nil { - return nil, fmt.Errorf("Web base URL: %w", err) + // WebBaseURL is optional: leaving it unset disables the "Open in Web" + // button on the username landing page instead of falling back to a + // default URL that would point at someone else's Web client. + if strings.TrimSpace(cfg.WebBaseURL) != "" { + if cfg.WebBaseURL, err = links.ValidateBaseURL(cfg.WebBaseURL); err != nil { + return nil, fmt.Errorf("Web base URL: %w", err) + } + } else { + cfg.WebBaseURL = "" } if cfg.AppName, err = links.ValidateAppName(cfg.AppName); err != nil { return nil, fmt.Errorf("app name: %w", err) } + if strings.TrimSpace(cfg.DownloadURL) == "" { + cfg.DownloadURL = links.DefaultDownloadURL + } + if cfg.DownloadURL, err = links.ValidateBaseURL(cfg.DownloadURL); err != nil { + return nil, fmt.Errorf("download URL: %w", err) + } if logger == nil { logger = zap.NewNop() } @@ -141,10 +153,13 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { appScheme: cfg.AppScheme, webBaseURL: cfg.WebBaseURL, appName: cfg.AppName, + downloadURL: cfg.DownloadURL, logger: logger, } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", h.healthz) + mux.HandleFunc("GET /_public/assets/logo.png", h.brandLogo) + mux.HandleFunc("GET /_public/assets/fonts/{file}", h.brandFont) mux.HandleFunc("GET /_public/avatar/{username}/{photoID}", h.publicAvatar) mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) @@ -164,6 +179,7 @@ type handler struct { appScheme string webBaseURL string appName string + downloadURL string logger *zap.Logger } @@ -195,9 +211,8 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) { Description: "This page opens the app so you can preview and add this shared folder.", CanonicalURL: h.publicURL("addlist", slug), AppURL: template.URL(app), - LegacyTgURL: template.URL(legacyTgURL("addlist", "slug", slug)), + DownloadURL: h.downloadURL, } - 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") if err := landingTemplate.Execute(w, data); err != nil { @@ -228,11 +243,11 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { } params.Set("domain", peer.username) app := schemeURLValues(h.appScheme, "resolve", params) - legacy := schemeURLValues("tg", "resolve", params) description := peer.about if description == "" { description = peer.fallbackDescription(h.appName) } + grad := avatarGradient(peer.id) data := usernamePageData{ AppName: h.appName, AppInitial: appInitial(h.appName), @@ -241,18 +256,23 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { Verified: peer.verified, Extra: peer.extra(), Description: description, + AboutText: peer.about, CanonicalURL: h.publicUsernameURL(peer.username), HomeURL: h.publicBaseURL + "/", + DownloadURL: h.downloadURL, AppURL: template.URL(app), - LegacyTgURL: template.URL(legacy), - WebURL: template.URL(publicWebAppURL(h.webBaseURL, legacy)), ButtonLabel: peer.buttonLabel(), Initials: peer.initials(), + GradFrom: grad[0], + GradTo: grad[1], + } + if h.webBaseURL != "" { + legacy := schemeURLValues("tg", "resolve", params) + data.WebURL = template.URL(publicWebAppURL(h.webBaseURL, legacy)) } if peer.hasPhoto { data.PhotoURL = h.publicAvatarURL(peer.username, peer.photo.ID) } - 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 := usernameLandingTemplate.Execute(w, data); err != nil { @@ -366,9 +386,8 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri Description: "This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.", CanonicalURL: h.publicURL(canonicalKind, set.ShortName), AppURL: template.URL(app), - LegacyTgURL: template.URL(legacyTgURL(canonicalKind, "set", set.ShortName)), + DownloadURL: h.downloadURL, } - 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") if err := landingTemplate.Execute(w, data); err != nil { @@ -398,6 +417,7 @@ const ( ) type publicPeer struct { + id int64 kind publicPeerKind username string title string @@ -470,6 +490,7 @@ func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain } } peer := publicPeer{ + id: u.ID, kind: publicPeerUser, username: u.Username, title: title, @@ -501,6 +522,7 @@ func (h *handler) publicChannelPeer(ctx context.Context, requested string, ch do return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", ch.Username, err) } peer := publicPeer{ + id: ch.ID, kind: publicPeerChannel, username: ch.Username, title: strings.TrimSpace(ch.Title), @@ -598,6 +620,23 @@ func (p publicPeer) initials() string { return strings.ToUpper(string(out)) } +var publicAvatarGradients = [...][2]string{ + {"#FF885E", "#FF516A"}, + {"#FFCD6A", "#FFA85C"}, + {"#82B1FF", "#665FFF"}, + {"#A0DE7E", "#54CB68"}, + {"#53EDD6", "#28C9B7"}, + {"#72D5FD", "#2A9EF1"}, + {"#E0A2F3", "#D669ED"}, +} + +func avatarGradient(id int64) [2]string { + if id < 0 { + id = -id + } + return publicAvatarGradients[id%int64(len(publicAvatarGradients))] +} + func groupedDecimal(n int) string { if n < 0 { n = 0 @@ -734,7 +773,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:; 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 'none'; 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") @@ -824,10 +863,6 @@ func (h *handler) appURL(kind, key, value string) string { return schemeURL(h.appScheme, kind, key, value) } -func legacyTgURL(kind, key, value string) string { - return schemeURL("tg", kind, key, value) -} - func schemeURL(scheme, kind, key, value string) string { return scheme + "://" + kind + "?" + key + "=" + url.QueryEscape(value) } @@ -839,9 +874,8 @@ type pageData struct { Subtitle string Description string CanonicalURL string + DownloadURL string AppURL template.URL - LegacyTgURL template.URL - AppURLJS template.JS } type usernamePageData struct { @@ -852,15 +886,17 @@ type usernamePageData struct { Verified bool Extra string Description string + AboutText string CanonicalURL string HomeURL string + DownloadURL string PhotoURL string Initials string + GradFrom string + GradTo string ButtonLabel string AppURL template.URL - LegacyTgURL template.URL WebURL template.URL - AppURLJS template.JS } func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string) { @@ -868,24 +904,244 @@ func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string) w.Header().Set("Cache-Control", "public, max-age=30, must-revalidate") w.WriteHeader(http.StatusNotFound) if err := usernameNotFoundTemplate.Execute(w, struct { - Username string - HomeURL string - AppName string - }{Username: username, HomeURL: h.publicBaseURL + "/", AppName: h.appName}); err != nil { + Username string + HomeURL string + AppName string + DownloadURL string + }{Username: username, HomeURL: h.publicBaseURL + "/", AppName: h.appName, DownloadURL: h.downloadURL}); err != nil { h.logger.Error("Render public username not-found page failed", zap.String("username", username), zap.Error(err)) } } +// publicPageStyleSheet is the shared visual system for every public landing +// page (username profile, not-found, sticker/addlist): CSS variables (light +// theme + dark-scheme override), the card/avatar/button component classes, +// and the floating background orbs. Fonts are self-hosted (no Google Fonts +// CDN call from visitors' browsers) via the /_public/assets/fonts/ route. +const publicPageStyleSheet = ` + @font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:400;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-400.woff2') format('woff2')} + @font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:500;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-500.woff2') format('woff2')} + @font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:600;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-600.woff2') format('woff2')} + @font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:700;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-700.woff2') format('woff2')} + @font-face{font-family:'Plus Jakarta Sans';font-style:normal;font-weight:800;font-display:swap;src:url('/_public/assets/fonts/plus-jakarta-sans-800.woff2') format('woff2')} + :root{ + --accent:#2563eb; --accent-bright:#38bdf8; + --grad:linear-gradient(135deg,#38bdf8 0%,#2563eb 55%,#1e40af 100%); + --bg:#f7f9fc; --surface:#ffffff; --surface-2:#f1f5f9; --line:#e2e8f0; --line-strong:#cbd5e1; + --ink:#050508; --body:#3f4b5c; --muted:#64748b; + --hero-glow:rgba(37,99,235,0.14); --hero-grid:rgba(5,5,8,0.04); + --radius:18px; --radius-lg:24px; + --shadow:0 28px 70px -36px rgba(5,5,8,0.35); + } + @media (prefers-color-scheme: dark){ + :root{ + --accent:#60a5fa; --accent-bright:#7dd3fc; + --grad:linear-gradient(135deg,#7dd3fc 0%,#3b82f6 50%,#2563eb 100%); + --bg:#08080e; --surface:#12121a; --surface-2:#181822; --line:#222228; --line-strong:#2e2e36; + --ink:#f8fafc; --body:#a8b4c4; --muted:#7b8798; + --hero-glow:rgba(59,130,246,0.25); --hero-grid:rgba(255,255,255,0.04); + --shadow:0 32px 80px -40px rgba(0,0,0,0.75); + } + } + *{box-sizing:border-box} + html,body{height:100%} + body{ + margin:0; color:var(--body); background:var(--bg); + font-family:'Plus Jakarta Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif; + -webkit-font-smoothing:antialiased; line-height:1.65; + min-height:100vh; display:flex; flex-direction:column; + background-image: + radial-gradient(900px 480px at 50% -8%, var(--hero-glow) 0%, rgba(0,0,0,0) 70%), + linear-gradient(var(--hero-grid) 1px, transparent 1px), + linear-gradient(90deg, var(--hero-grid) 1px, transparent 1px); + background-size:auto, 44px 44px, 44px 44px; + background-attachment:fixed; + } + h1,h2,h3{color:var(--ink); letter-spacing:-0.03em; font-weight:800; margin:0} + a{color:var(--accent); text-decoration:none} + header{ + display:flex; align-items:center; justify-content:space-between; + padding:18px 26px; max-width:1100px; width:100%; margin:0 auto; + } + .brand{display:flex; align-items:center; gap:11px; font-weight:800; font-size:19px; color:var(--ink); letter-spacing:-0.02em} + .brand img{width:34px; height:34px; border-radius:9px; display:block} + .btn{ + display:inline-flex; align-items:center; justify-content:center; gap:8px; + font-weight:700; font-size:0.92rem; padding:12px 20px; border-radius:11px; + border:none; cursor:pointer; text-decoration:none; line-height:1; transition:transform .16s ease, box-shadow .16s ease, background .16s ease, border-color .16s ease, color .16s ease; + font-family:inherit; + } + .btn:hover{transform:translateY(-1px)} + .btn-primary{background:var(--grad); color:#fff; box-shadow:0 12px 28px -14px rgba(37,99,235,0.65); width:100%} + .btn-primary:hover{box-shadow:0 16px 36px -14px rgba(37,99,235,0.75)} + .btn-ghost{background:var(--surface); color:var(--ink); border:1px solid var(--line)} + .btn-ghost:hover{border-color:var(--accent); color:var(--accent)} + main{flex:1; display:flex; align-items:center; justify-content:center; padding:24px} + .card{ + width:100%; max-width:430px; background:var(--surface); + border:1px solid var(--line); border-radius:var(--radius-lg); padding:38px 30px 30px; + text-align:center; box-shadow:var(--shadow); + } + .avatar-wrap{position:relative; width:124px; height:124px; margin:0 auto 20px} + .avatar{position:absolute; inset:0; width:124px; height:124px; border-radius:50%; display:block} + .avatar-photo{object-fit:cover; background:var(--surface-2)} + .name{font-size:25px; font-weight:800; line-height:1.2; color:var(--ink); word-break:break-word; display:inline-flex; align-items:center; gap:8px; justify-content:center} + .badge{width:20px; height:20px; flex:0 0 auto} + .sub{color:var(--muted); font-size:15px; margin-top:6px; font-weight:500} + .about{color:var(--body); font-size:15px; line-height:1.6; margin-top:18px; white-space:pre-wrap; word-break:break-word; text-align:center} + .actions{margin-top:26px; display:grid; gap:10px} + .scam{display:inline-block; margin-top:12px; color:#e5484d; border:1px solid rgba(229,72,77,0.25); background:rgba(229,72,77,0.08); font-size:12px; font-weight:700; letter-spacing:.4px; padding:4px 10px; border-radius:8px} + .nf-emoji{font-size:60px; line-height:1; margin-bottom:14px} + @media (max-width:480px){ .card{padding:30px 22px 24px} header{padding:16px 18px} } + + /* Floating background orbs. */ + .bg-orbs{position:fixed; inset:-60px; z-index:-1; pointer-events:none; transition:transform .3s ease-out; will-change:transform} + .bg-orb{position:absolute; border-radius:50%; filter:blur(100px); pointer-events:none} + .bg-orb--1{width:700px; height:700px; top:-15%; left:-10%; background:color-mix(in srgb,var(--accent-bright) 40%,transparent); animation:orbFloat1 20s ease-in-out infinite} + .bg-orb--2{width:600px; height:600px; top:25%; right:-15%; background:color-mix(in srgb,var(--accent) 38%,transparent); animation:orbFloat2 24s ease-in-out infinite} + .bg-orb--3{width:500px; height:500px; bottom:-15%; left:30%; background:color-mix(in srgb,var(--accent-bright) 30%,transparent); animation:orbFloat3 28s ease-in-out infinite} + @keyframes orbFloat1{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(60px,-40px) scale(1.08)}66%{transform:translate(-30px,30px) scale(.92)}} + @keyframes orbFloat2{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(-50px,-35px) scale(.93)}66%{transform:translate(45px,25px) scale(1.07)}} + @keyframes orbFloat3{0%,100%{transform:translate(0,0) scale(1)}33%{transform:translate(40px,45px) scale(1.06)}66%{transform:translate(-55px,-25px) scale(.94)}} + @media (max-width:720px){ .bg-orb{filter:blur(60px)} .bg-orb--1{width:350px;height:350px} .bg-orb--2{width:300px;height:300px} .bg-orb--3{width:250px;height:250px} } + @media (prefers-reduced-motion: reduce){ .bg-orb{animation:none} } + + /* Drifting line-icon particles. */ + .bg-icons{position:fixed; inset:0; z-index:-1; overflow:hidden; pointer-events:none; color:var(--accent)} + .bg-icon{position:absolute; left:0; top:0; will-change:transform} +` + +// publicBgOrbsMarkup is the floating-orb + particle container markup, placed +// right after on every public landing page. +const publicBgOrbsMarkup = ` + + ` + +// publicBgIconsScript drives the orb parallax and drifting line-icon +// particles. Self-contained vanilla JS, no external requests. +const publicBgIconsScript = ` + ` + var usernameLandingTemplate = template.Must(template.New("username-landing").Parse(` - + {{.Title}} (@{{.Username}}) - {{.AppName}} + @@ -898,123 +1154,113 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par {{if .PhotoURL}}{{end}} - - -
- {{.AppInitial}}{{.AppName}} -
-
-
{{if .PhotoURL}}{{.Title}} profile photo{{else}}{{end}}
-

{{.Title}}{{if .Verified}}{{end}}

-

@{{.Username}}

- {{if .Extra}}

{{.Extra}}

{{end}} -

{{.Description}}

- -

Old test clients only: open with tg://

-
-
-
If you have {{.AppName}}, this page can open the chat directly.
-
- +` + publicBgOrbsMarkup + ` +
+ + {{.AppName}} + {{.AppName}} + + Download +
+
+
+
+ + {{if .PhotoURL}}{{end}} +
+
+ {{.Title}} + {{if .Verified}}{{end}} +
+
@{{.Username}}
+ {{if .Extra}}
{{.Extra}}
{{end}} + {{if .AboutText}}
{{.AboutText}}
{{end}} +
+ {{.ButtonLabel}} + {{if .WebURL}}Open in Web{{end}} +
+
+
` + publicBgIconsScript + ` `)) var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(` - -Username not found - {{.AppName}} - -

Username not found

{{if .Username}}@{{.Username}} is not an active public {{.AppName}} username.{{else}}This is not a valid public {{.AppName}} username.{{end}}

Back to {{.AppName}}
`)) + + + + + + + Username not found - {{.AppName}} + + + +` + publicBgOrbsMarkup + ` +
+ + {{.AppName}} + {{.AppName}} + + Download +
+
+
+
🤔
+

Nothing found

+
{{if .Username}}@{{.Username}} is not an active public {{.AppName}} username.{{else}}This is not a valid public {{.AppName}} username.{{end}}
+ +
+
` + publicBgIconsScript + ` + + +`)) var landingTemplate = template.Must(template.New("landing").Parse(` - + + {{.Title}} - {{.AppName}} + - - +` + publicBgOrbsMarkup + ` +
+ + {{.AppName}} + {{.AppName}} + + Download +
-

{{.KindLabel}}

-

{{.Title}}

-

{{.Subtitle}}

-

Open in {{.AppName}}

-

{{.Description}}

-

Old test clients only: open with tg://

-

{{.CanonicalURL}}

-
- +
+
{{.KindLabel}}
+

{{.Title}}

+ {{if .Subtitle}}
{{.Subtitle}}
{{end}} + {{if .Description}}
{{.Description}}
{{end}} + +
+ ` + publicBgIconsScript + ` `)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 055fca32..31ce7d01 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -67,7 +67,6 @@ func TestHandlerServesStickerSetLandingPage(t *testing.T) { "Fresh Pack", "https://telesrv.net/addstickers/fresh_pack", "telesrv://addstickers?set=fresh_pack", - "tg://addstickers?set=fresh_pack", "Files are still fetched by the app through MTProto.", } { if !strings.Contains(body, want) { @@ -106,7 +105,6 @@ func TestHandlerServesEmojiLandingPage(t *testing.T) { "custom emoji set", "https://example.test/base/addemoji/emoji_pack", "telesrv://addemoji?set=emoji_pack", - "tg://addemoji?set=emoji_pack", } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) @@ -128,7 +126,6 @@ func TestHandlerServesChatlistLandingPage(t *testing.T) { "Shared Folder", "http://127.0.0.1:2401/addlist/zNhytIbwRwjaC2GH", "telesrv://addlist?slug=zNhytIbwRwjaC2GH", - "tg://addlist?slug=zNhytIbwRwjaC2GH", "preview and add this shared folder", } { if !strings.Contains(body, want) { @@ -164,7 +161,6 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) { "@TetrisBot", "http://127.0.0.1:2401/TetrisBot", "telesrv://resolve?domain=TetrisBot", - "tg://resolve?domain=TetrisBot", "Start Bot", "Open telesrv to start a chat with this bot.", `property="og:title" content="Tetris Bot"`, @@ -323,7 +319,7 @@ func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) { t.Fatalf("body missing %q:\n%s", want, rr.Body.String()) } } - if tc.path == "/aLiCe/" && strings.Count(rr.Body.String(), `

@Alice

`) != 1 { + if tc.path == "/aLiCe/" && strings.Count(rr.Body.String(), `
@Alice
`) != 1 { t.Fatalf("ordinary user username rendered more than once:\n%s", rr.Body.String()) } if strings.Contains(rr.Body.String(), "+15551234567") || strings.Contains(rr.Body.String(), "987654321") || strings.Contains(rr.Body.String(), "1700000000") { @@ -345,7 +341,6 @@ func TestHandlerPreservesBoundedResolveQueryAndOverridesDomain(t *testing.T) { body := rr.Body.String() for _, want := range []string{ "telesrv://resolve?domain=TetrisBot&ref=campaign&start=hello", - "tg://resolve?domain=TetrisBot&ref=campaign&start=hello", } { if !strings.Contains(body, want) { t.Fatalf("body missing sanitized query %q:\n%s", want, body)