From 8cfb6f74c1b7bdfe4ecd64982ab2c9120e79008e Mon Sep 17 00:00:00 2001 From: A Date: Wed, 22 Jul 2026 21:43:56 +0800 Subject: [PATCH] feat: sync host-based public app links --- .env.example | 6 + cmd/telesrv/main.go | 5 +- docs/configuration.en.md | 2 + docs/configuration.zh-CN.md | 2 + internal/app/bots/stickersbot.go | 10 ++ internal/app/bots/stickersbot_test.go | 16 +++ internal/app/telegramlogin/service.go | 21 ++-- internal/app/telegramlogin/service_test.go | 26 ++++- internal/config/config.go | 9 ++ internal/config/config_test.go | 11 ++ internal/links/links.go | 128 +++++++++++++++++++++ internal/links/links_test.go | 73 ++++++++++++ internal/rpc/account_business.go | 2 +- internal/rpc/public_app_links_test.go | 32 ++++++ internal/rpc/public_links.go | 4 + internal/rpc/router.go | 12 +- internal/telegramloginhttp/handler_test.go | 14 ++- internal/web/server.go | 15 ++- internal/web/server_test.go | 13 ++- 19 files changed, 375 insertions(+), 26 deletions(-) create mode 100644 internal/rpc/public_app_links_test.go diff --git a/.env.example b/.env.example index 35abb6c8..d4211299 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,12 @@ TELESRV_PUBLIC_BASE_URL=https://telesrv.net # registered by every patched client build; tg/http/https are rejected. TELESRV_PUBLIC_APP_SCHEME=telesrv +# Optional host-based app-link root for multi-server clients. When set, public +# links use e.g. owpg://example.com/oauth and owpg://example.com/username while +# the scheme above remains accepted for existing/in-flight links. The value +# must be exactly ://, without port/path/query/fragment. +TELESRV_PUBLIC_APP_LINK_BASE= + # Web client target and display brand used by public landing pages. TELESRV_PUBLIC_WEB_BASE_URL=https://web.telesrv.net TELESRV_PUBLIC_APP_NAME=telesrv diff --git a/cmd/telesrv/main.go b/cmd/telesrv/main.go index ac50180e..2dd4e458 100644 --- a/cmd/telesrv/main.go +++ b/cmd/telesrv/main.go @@ -365,7 +365,7 @@ func run(logger *zap.Logger) error { return fmt.Errorf("load telegram login signing keys: %w", err) } telegramLoginService, err = telegramloginapp.NewService(postgres.NewTelegramLoginStore(pool), codeSealer, telegramloginapp.Config{ - Issuer: cfg.TelegramLoginIssuer, AppScheme: cfg.PublicAppScheme, + Issuer: cfg.TelegramLoginIssuer, AppScheme: cfg.PublicAppScheme, AppLinkBase: cfg.PublicAppLinkBase, AllowHTTP: cfg.TelegramLoginAllowHTTP, ClientSecretPepper: clientSecretPepper, SupportedSigningAlgorithms: signingKeys.ActiveAlgorithms(), @@ -838,6 +838,8 @@ func run(logger *zap.Logger) error { GroupCallMaxParticipants: cfg.GroupCallMaxParticipants, RtmpIngestURL: cfg.LiveStreamRtmpURL, PublicBaseURL: cfg.PublicBaseURL, + PublicAppScheme: cfg.PublicAppScheme, + PublicAppLinkBase: cfg.PublicAppLinkBase, // PFS temp→perm 解析缓存:显式撤销会清缓存并断开连接,re-bind 即时失效; // 配置 TTL 只承担跨进程/异常失效兜底,避免大连接数周期性打满 PG。 TempKeyResolveCacheTTL: cfg.TempKeyResolveCacheTTL, @@ -986,6 +988,7 @@ func run(logger *zap.Logger) error { Addr: cfg.PublicLinkWebAddr, PublicBaseURL: cfg.PublicBaseURL, AppScheme: cfg.PublicAppScheme, + AppLinkBase: cfg.PublicAppLinkBase, WebBaseURL: cfg.PublicWebBaseURL, AppName: cfg.PublicAppName, StickerSets: filesService, diff --git a/docs/configuration.en.md b/docs/configuration.en.md index 71de0b4b..911105e7 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -60,6 +60,7 @@ This document describes every setting loaded by `internal/config`. Defaults and | `TELESRV_ADMIN_SESSION_KEY` | secret string / empty | Encrypts/signs Admin UI session cookies. Production should use at least 32 random bytes; changing it invalidates sessions. | | `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | Client-visible canonical public-link root. Paths are allowed; credentials, query, and fragment are rejected. Local example: `http://127.0.0.1:2401`. | | `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | Automatic app-open scheme on landing pages. Must match patched client registration. `tg`, `http`, and `https` are rejected. | +| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / empty | Optional host-based root for multi-server clients, for example `owpg://example.com`. When set, links use `owpg://example.com/oauth`, `owpg://example.com/`, and equivalent route paths. Only exact `://` values are accepted; ports, paths, queries, and fragments are rejected. `TELESRV_PUBLIC_APP_SCHEME` remains an accepted legacy input. | | `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | Web-client root used by public username pages. Same URL validation as `TELESRV_PUBLIC_BASE_URL`. | | `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | Public landing-page product name; trimmed, non-empty, no control characters, maximum 64 Unicode characters. | | `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / empty | Read-only username/avatar/sticker/emoji/chatlist/collectible-gift landing-page listener. Empty disables it. Production should bind loopback behind exact nginx routes. `.env.example` enables `127.0.0.1:2401` for development. | @@ -237,6 +238,7 @@ that clients can actually reach. Bind `0.0.0.0:2401` for direct LAN/public acces TELESRV_PUBLIC_BASE_URL=http://192.0.2.25:2401 TELESRV_PUBLIC_LINK_WEB_ADDR=0.0.0.0:2401 TELESRV_PUBLIC_APP_SCHEME=telesrv +# Optional for multi-server clients: TELESRV_PUBLIC_APP_LINK_BASE=owpg://example.com TELESRV_TELEGRAM_LOGIN_ENABLE=true TELESRV_TELEGRAM_LOGIN_ISSUER=http://192.0.2.25:2401 diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 69433930..51085e06 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -60,6 +60,7 @@ | `TELESRV_ADMIN_SESSION_KEY` | secret string / 空 | 加密/签名 Admin UI session cookie;生产至少使用 32 字节随机值,修改会使已有会话失效。 | | `TELESRV_PUBLIC_BASE_URL` | HTTP(S) URL / `https://telesrv.net` | 客户端可见的公开链接根地址;允许 path,禁止 credentials、query、fragment。本地例:`http://127.0.0.1:2401`。 | | `TELESRV_PUBLIC_APP_SCHEME` | URL scheme / `telesrv` | 落地页自动唤起客户端的 scheme,必须与 patched 客户端注册值一致;禁止 `tg`、`http`、`https`。 | +| `TELESRV_PUBLIC_APP_LINK_BASE` | nullable custom URL base / 空 | 多服务客户端可选的 host-based 根,例如 `owpg://example.com`。配置后生成 `owpg://example.com/oauth`、`owpg://example.com/` 等;只允许精确 `://`,禁止端口、path、query、fragment。`TELESRV_PUBLIC_APP_SCHEME` 仍作为旧链接输入兼容。 | | `TELESRV_PUBLIC_WEB_BASE_URL` | HTTP(S) URL / `https://web.telesrv.net` | username 页面 Web 客户端入口,校验规则同 `TELESRV_PUBLIC_BASE_URL`。 | | `TELESRV_PUBLIC_APP_NAME` | string / `telesrv` | 公开落地页产品名;trim 后非空、无控制字符、最多 64 个 Unicode 字符。 | | `TELESRV_PUBLIC_LINK_WEB_ADDR` | nullable address / 空 | 只读 username/avatar/sticker/emoji/chatlist/collectible gift 落地页监听;空值关闭。生产应 loopback + nginx 精确反代;`.env.example` 为开发启用 `127.0.0.1:2401`。 | @@ -228,6 +229,7 @@ chmod 0600 data/telegram-login/* TELESRV_PUBLIC_BASE_URL=http://192.0.2.25:2401 TELESRV_PUBLIC_LINK_WEB_ADDR=0.0.0.0:2401 TELESRV_PUBLIC_APP_SCHEME=telesrv +# 多服务客户端可选:TELESRV_PUBLIC_APP_LINK_BASE=owpg://example.com TELESRV_TELEGRAM_LOGIN_ENABLE=true TELESRV_TELEGRAM_LOGIN_ISSUER=http://192.0.2.25:2401 diff --git a/internal/app/bots/stickersbot.go b/internal/app/bots/stickersbot.go index e951cc94..1cdb5ce1 100644 --- a/internal/app/bots/stickersbot.go +++ b/internal/app/bots/stickersbot.go @@ -754,6 +754,16 @@ func normalizeStickersBotShortName(raw string) string { raw = strings.TrimPrefix(raw, "tg://addemoji?set=") if strings.Contains(raw, "://") { if parsed, err := url.Parse(raw); err == nil { + query := parsed.Query() + route := strings.Trim(parsed.Path, "/") + if route == "" { + route = strings.ToLower(parsed.Host) + } + if route == "addstickers" || route == "addemoji" { + if shortName := query.Get("set"); shortName != "" { + raw = shortName + } + } parts := strings.Split(strings.Trim(parsed.Path, "/"), "/") for i, part := range parts { if (part == "addstickers" || part == "addemoji") && i+1 < len(parts) { diff --git a/internal/app/bots/stickersbot_test.go b/internal/app/bots/stickersbot_test.go index 96c43a5d..22fe26be 100644 --- a/internal/app/bots/stickersbot_test.go +++ b/internal/app/bots/stickersbot_test.go @@ -649,3 +649,19 @@ func (h *stickersBotHookRecorder) PushStickerSetsChanged(_ context.Context, user h.userID = userID h.kind = kind } + +func TestNormalizeStickersBotShortNameAcceptsHostBasedAppLinks(t *testing.T) { + for _, tc := range []struct { + raw string + want string + }{ + {raw: "telesrv://addstickers?set=Legacy_Pack", want: "legacy_pack"}, + {raw: "owpg://tenant.example.test/addstickers?set=Hosted_Pack", want: "hosted_pack"}, + {raw: "owpg://tenant.example.test/addemoji?set=Emoji_Pack", want: "emoji_pack"}, + {raw: "https://telesrv.net/addstickers/Web_Pack", want: "web_pack"}, + } { + if got := normalizeStickersBotShortName(tc.raw); got != tc.want { + t.Fatalf("normalizeStickersBotShortName(%q) = %q, want %q", tc.raw, got, tc.want) + } + } +} diff --git a/internal/app/telegramlogin/service.go b/internal/app/telegramlogin/service.go index dd725a03..70a60fdb 100644 --- a/internal/app/telegramlogin/service.go +++ b/internal/app/telegramlogin/service.go @@ -19,6 +19,7 @@ import ( "unicode/utf8" "telesrv/internal/domain" + "telesrv/internal/links" "telesrv/internal/store" ) @@ -36,6 +37,7 @@ var telegramLoginMatchCodePool = []string{ type Config struct { Issuer string AppScheme string + AppLinkBase string AllowHTTP bool ClientSecretPepper []byte SupportedSigningAlgorithms []domain.TelegramLoginSigningAlgorithm @@ -48,7 +50,7 @@ type Service struct { store store.TelegramLoginStore sealer *CodeSealer issuer string - appScheme string + appLinks links.AppLinkBuilder allowHTTP bool clientSecretPepper []byte signingAlgorithms []domain.TelegramLoginSigningAlgorithm @@ -66,8 +68,9 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con if err != nil { return nil, fmt.Errorf("telegram login issuer: %w", err) } - if !validAppScheme(cfg.AppScheme) { - return nil, fmt.Errorf("telegram login app scheme is invalid") + appLinks, err := links.NewAppLinkBuilder(cfg.AppScheme, cfg.AppLinkBase) + if err != nil { + return nil, fmt.Errorf("telegram login app links: %w", err) } if cfg.RequestTTL == 0 { cfg.RequestTTL = defaultRequestTTL @@ -92,7 +95,7 @@ func NewService(loginStore store.TelegramLoginStore, sealer *CodeSealer, cfg Con } } return &Service{ - store: loginStore, sealer: sealer, issuer: issuer, appScheme: strings.ToLower(cfg.AppScheme), + store: loginStore, sealer: sealer, issuer: issuer, appLinks: appLinks, allowHTTP: cfg.AllowHTTP, clientSecretPepper: append([]byte(nil), cfg.ClientSecretPepper...), signingAlgorithms: append([]domain.TelegramLoginSigningAlgorithm(nil), cfg.SupportedSigningAlgorithms...), @@ -669,7 +672,7 @@ func (s *Service) CreateAuthorization(ctx context.Context, params CreateAuthoriz if err != nil { return CreatedAuthorization{}, err } - deepLink := s.appScheme + "://oauth?token=" + url.QueryEscape(requestToken) + deepLink := s.appLinks.Build("oauth", url.Values{"token": []string{requestToken}}) return CreatedAuthorization{Request: request, RequestToken: requestToken, BrowserToken: browserToken, DeepLink: deepLink}, nil } @@ -885,12 +888,14 @@ func (s *Service) deepLinkToken(rawURL string) (string, error) { if err != nil { return "", domain.ErrTelegramLoginURLInvalid } - customOrCanonicalScheme := strings.EqualFold(u.Scheme, s.appScheme) || strings.EqualFold(u.Scheme, "tg") var token string switch { - case customOrCanonicalScheme && strings.EqualFold(u.Host, "oauth") && u.Path == "": + case s.appLinks.MatchesRoute(u, "oauth"): token, _ = singleQueryValue(query, "token") - case customOrCanonicalScheme && strings.EqualFold(u.Host, "resolve") && u.Path == "": + case strings.EqualFold(u.Scheme, "tg") && strings.EqualFold(u.Host, "oauth") && u.Path == "": + token, _ = singleQueryValue(query, "token") + case (s.appLinks.MatchesLegacyRoute(u, "resolve") || + (strings.EqualFold(u.Scheme, "tg") && strings.EqualFold(u.Host, "resolve") && u.Path == "")): domainValue, domainOK := singleQueryValue(query, "domain") startApp, startAppOK := singleQueryValue(query, "startapp") if domainOK && startAppOK && strings.EqualFold(domainValue, "oauth") { diff --git a/internal/app/telegramlogin/service_test.go b/internal/app/telegramlogin/service_test.go index dc98e407..86df38bb 100644 --- a/internal/app/telegramlogin/service_test.go +++ b/internal/app/telegramlogin/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/url" + "strings" "sync" "sync/atomic" "testing" @@ -78,10 +79,18 @@ func TestServiceClientCreationAndSecretRotationAreSingleWinner(t *testing.T) { } func newTelegramLoginTestService(t *testing.T, now *time.Time) (*Service, *memory.TelegramLoginStore) { - return newTelegramLoginTestServiceWithAlgorithms(t, now, nil) + return newTelegramLoginTestServiceWithConfig(t, now, nil, "") } func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm) (*Service, *memory.TelegramLoginStore) { + return newTelegramLoginTestServiceWithConfig(t, now, algorithms, "") +} + +func newTelegramLoginTestServiceWithAppLinkBase(t *testing.T, now *time.Time, appLinkBase string) (*Service, *memory.TelegramLoginStore) { + return newTelegramLoginTestServiceWithConfig(t, now, nil, appLinkBase) +} + +func newTelegramLoginTestServiceWithConfig(t *testing.T, now *time.Time, algorithms []domain.TelegramLoginSigningAlgorithm, appLinkBase string) (*Service, *memory.TelegramLoginStore) { t.Helper() key := make([]byte, 32) key[0] = 7 @@ -93,7 +102,7 @@ func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, alg pepper := make([]byte, 32) pepper[0] = 9 service, err := NewService(loginStore, sealer, Config{ - Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", + Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", AppLinkBase: appLinkBase, AllowHTTP: true, ClientSecretPepper: pepper, SupportedSigningAlgorithms: algorithms, Now: func() time.Time { return *now }, @@ -107,7 +116,7 @@ func newTelegramLoginTestServiceWithAlgorithms(t *testing.T, now *time.Time, alg func TestServiceAcceptsOfficialClientCanonicalOAuthDeepLinks(t *testing.T) { ctx := context.Background() now := time.Unix(1_780_000_000, 0).UTC() - service, _ := newTelegramLoginTestService(t, &now) + service, _ := newTelegramLoginTestServiceWithAppLinkBase(t, &now, "owpg://tenant.example.test") credentials, err := service.CreateClient(ctx, 9030, domain.TelegramLoginSigningRS256) if err != nil { t.Fatal(err) @@ -132,8 +141,13 @@ func TestServiceAcceptsOfficialClientCanonicalOAuthDeepLinks(t *testing.T) { t.Fatal(err) } token := parsed.Query().Get("token") + if got, want := parsed.Scheme+"://"+parsed.Host+parsed.Path, "owpg://tenant.example.test/oauth"; got != want { + t.Fatalf("generated deep link root = %q, want %q", got, want) + } valid := []string{ created.DeepLink, + "telesrv://oauth?token=" + url.QueryEscape(token), + "telesrv://resolve?domain=oauth&startapp=" + url.QueryEscape(token), "tg://oauth?token=" + url.QueryEscape(token), "tg://resolve?domain=oauth&startapp=" + url.QueryEscape(token), "https://t.me/oauth?startapp=" + url.QueryEscape(token), @@ -146,6 +160,9 @@ func TestServiceAcceptsOfficialClientCanonicalOAuthDeepLinks(t *testing.T) { } invalid := []string{ "telegram://oauth?token=" + url.QueryEscape(token), + "owpg://other.example.test/oauth?token=" + url.QueryEscape(token), + "owpg://tenant.example.test/resolve?domain=oauth&startapp=" + url.QueryEscape(token), + "owpg://tenant.example.test/oauth/extra?token=" + url.QueryEscape(token), "tg://oauth/path?token=" + url.QueryEscape(token), "tg://oauth?token=" + url.QueryEscape(token) + "&token=other", "tg://resolve?domain=oauth&domain=other&startapp=" + url.QueryEscape(token), @@ -228,6 +245,9 @@ func TestServiceAuthorizationCodeFlowAndRevocation(t *testing.T) { if created.DeepLink == "" || created.Request.ID == 0 || len(created.Request.MatchCodes) != 5 { t.Fatalf("created authorization = %#v", created) } + if !strings.HasPrefix(created.DeepLink, "telesrv://oauth?token=") { + t.Fatalf("default deep link = %q, want legacy telesrv:// OAuth form", created.DeepLink) + } if _, err := service.CheckMatchCode(ctx, created.DeepLink, created.Request.MatchCodes[0]); err == nil && created.Request.MatchCodes[0] != created.Request.MatchCode { t.Fatal("wrong match code unexpectedly accepted") } diff --git a/internal/config/config.go b/internal/config/config.go index 72fcbd47..fc7d4ee1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,6 +82,10 @@ type Config struct { // PublicAppScheme 是公开落地页自动唤起自建客户端时使用的 URL scheme。 // 必须与 TDesktop/Android 客户端构建时注册的 scheme 一致,且不能占用 tg/http/https。 PublicAppScheme string + // PublicAppLinkBase 是可选的 host-based 自建客户端链接根,例如 + // owpg://example.com。为空时继续生成 PublicAppScheme://;非空时 + // 生成 /,同时保留旧 scheme 作为服务端输入兼容。 + PublicAppLinkBase string // PublicWebBaseURL 是公开 username 页面“Open in Web”按钮指向的 Web 客户端根 URL。 PublicWebBaseURL string // PublicAppName 是公开落地页展示的产品名,不参与协议路由。 @@ -434,6 +438,10 @@ func Load() (Config, error) { if err != nil { return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_SCHEME: %w", err) } + publicAppLinkBase, err := links.ValidateAppLinkBase(envAllowEmptyOr("TELESRV_PUBLIC_APP_LINK_BASE", "")) + if err != nil { + return Config{}, fmt.Errorf("TELESRV_PUBLIC_APP_LINK_BASE: %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) @@ -487,6 +495,7 @@ func Load() (Config, error) { AdminAPIToken: envOr("TELESRV_ADMIN_API_TOKEN", ""), PublicBaseURL: publicBaseURL, PublicAppScheme: publicAppScheme, + PublicAppLinkBase: publicAppLinkBase, PublicWebBaseURL: publicWebBaseURL, PublicAppName: publicAppName, PublicLinkWebAddr: envAllowEmptyOr("TELESRV_PUBLIC_LINK_WEB_ADDR", ""), diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0339fb40..dd16f92f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -25,6 +25,9 @@ func TestLoadDefaultsAdvertiseIPToLoopback(t *testing.T) { if cfg.PublicAppScheme != "telesrv" { t.Fatalf("PublicAppScheme = %q, want telesrv", cfg.PublicAppScheme) } + if cfg.PublicAppLinkBase != "" { + t.Fatalf("PublicAppLinkBase = %q, want disabled", cfg.PublicAppLinkBase) + } if cfg.PublicWebBaseURL != "https://web.telesrv.net" { t.Fatalf("PublicWebBaseURL = %q, want https://web.telesrv.net", cfg.PublicWebBaseURL) } @@ -379,6 +382,7 @@ TELESRV_WEBSOCKET_ALLOWED_ORIGINS=https://one.example, https://two.example TELESRV_CALL_RING_TIMEOUT=2m TELESRV_PUBLIC_BASE_URL=links.example.test/root TELESRV_PUBLIC_APP_SCHEME=example-chat +TELESRV_PUBLIC_APP_LINK_BASE=OWPG://Tenant.Example.Test/ TELESRV_PUBLIC_WEB_BASE_URL=web.example.test/client TELESRV_PUBLIC_APP_NAME=Example Chat TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 @@ -410,6 +414,9 @@ TELESRV_PUBLIC_LINK_WEB_ADDR=127.0.0.1:2401 if cfg.PublicAppScheme != "example-chat" { t.Fatalf("PublicAppScheme = %q, want example-chat", cfg.PublicAppScheme) } + if cfg.PublicAppLinkBase != "owpg://tenant.example.test" { + t.Fatalf("PublicAppLinkBase = %q, want owpg://tenant.example.test", cfg.PublicAppLinkBase) + } if cfg.PublicWebBaseURL != "https://web.example.test/client" { t.Fatalf("PublicWebBaseURL = %q, want https://web.example.test/client", cfg.PublicWebBaseURL) } @@ -537,6 +544,10 @@ func TestLoadRejectsInvalidPublicLinkClientConfig(t *testing.T) { }{ {name: "official scheme", key: "TELESRV_PUBLIC_APP_SCHEME", value: "tg"}, {name: "malformed scheme", key: "TELESRV_PUBLIC_APP_SCHEME", value: "bad scheme"}, + {name: "app link base official scheme", key: "TELESRV_PUBLIC_APP_LINK_BASE", value: "tg://links.example.test"}, + {name: "app link base missing host", key: "TELESRV_PUBLIC_APP_LINK_BASE", value: "owpg://"}, + {name: "app link base path", key: "TELESRV_PUBLIC_APP_LINK_BASE", value: "owpg://links.example.test/root"}, + {name: "app link base query", key: "TELESRV_PUBLIC_APP_LINK_BASE", value: "owpg://links.example.test?tenant=one"}, {name: "invalid web base", key: "TELESRV_PUBLIC_WEB_BASE_URL", value: "file:///tmp/client"}, {name: "empty app name after trim", key: "TELESRV_PUBLIC_APP_NAME", value: " "}, {name: "control in app name", key: "TELESRV_PUBLIC_APP_NAME", value: "bad\nname"}, diff --git a/internal/links/links.go b/internal/links/links.go index 50ac7a2f..935e6e27 100644 --- a/internal/links/links.go +++ b/internal/links/links.go @@ -14,6 +14,17 @@ const ( ) const MaxChatlistSlugBytes = 128 +// AppLinkBuilder builds client-visible custom-scheme links. Without an +// explicit base it preserves Telegram's route-as-host shape, for example +// telesrv://oauth?token=... . A configured base uses an exact server host and +// moves the route into the path, for example owpg://example.test/oauth?token=... +// . The legacy scheme remains accepted so in-flight links survive a rollout. +type AppLinkBuilder struct { + legacyScheme string + baseScheme string + baseHost string +} + // ValidateAppScheme normalizes the client-visible custom URL scheme used by // public landing pages. Standard Web schemes and Telegram's official tg scheme // are deliberately rejected: the latter remains a manual compatibility link @@ -36,6 +47,123 @@ func ValidateAppScheme(raw string) (string, error) { return scheme, nil } +// ValidateAppLinkBase validates the optional host-based custom app-link root. +// The base is deliberately limited to ://: routes, query +// parameters, and fragments are owned by the individual link builders. +func ValidateAppLinkBase(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("parse URL: %w", err) + } + if parsed.Opaque != "" { + return "", fmt.Errorf("opaque URLs are not allowed") + } + if parsed.Scheme == "" { + return "", fmt.Errorf("scheme is required") + } + scheme, err := ValidateAppScheme(parsed.Scheme) + if err != nil { + return "", err + } + if parsed.Host == "" || parsed.Hostname() == "" { + return "", fmt.Errorf("host is required") + } + if parsed.User != nil { + return "", fmt.Errorf("credentials are not allowed") + } + if parsed.Port() != "" { + return "", fmt.Errorf("port is not allowed") + } + if (parsed.Path != "" && parsed.Path != "/") || parsed.RawPath != "" { + return "", fmt.Errorf("path is not allowed") + } + if parsed.RawQuery != "" || parsed.ForceQuery { + return "", fmt.Errorf("query parameters are not allowed") + } + if parsed.Fragment != "" { + return "", fmt.Errorf("fragment is not allowed") + } + parsed.Scheme = scheme + parsed.Host = strings.ToLower(parsed.Host) + parsed.Path = "" + return parsed.String(), nil +} + +func NewAppLinkBuilder(legacyScheme, rawBase string) (AppLinkBuilder, error) { + legacyScheme, err := ValidateAppScheme(legacyScheme) + if err != nil { + return AppLinkBuilder{}, fmt.Errorf("legacy scheme: %w", err) + } + base, err := ValidateAppLinkBase(rawBase) + if err != nil { + return AppLinkBuilder{}, fmt.Errorf("app link base: %w", err) + } + builder := AppLinkBuilder{legacyScheme: legacyScheme} + if base != "" { + parsed, _ := url.Parse(base) + builder.baseScheme = parsed.Scheme + builder.baseHost = parsed.Host + } + return builder, nil +} + +func (b AppLinkBuilder) Build(route string, query url.Values) string { + if b.baseHost != "" { + return (&url.URL{ + Scheme: b.baseScheme, + Host: b.baseHost, + Path: "/" + strings.Trim(route, "/"), + RawQuery: query.Encode(), + }).String() + } + return (&url.URL{Scheme: b.legacyScheme, Host: route, RawQuery: query.Encode()}).String() +} + +// BuildUsername preserves the official resolve query in legacy mode while a +// host-based multi-server client receives the public username as the path. +func (b AppLinkBuilder) BuildUsername(username string, query url.Values) string { + query = cloneValues(query) + if b.baseHost != "" { + query.Del("domain") + return b.Build(username, query) + } + query.Set("domain", username) + return b.Build("resolve", query) +} + +// MatchesRoute accepts the exact configured host-path form and the retained +// legacy route-as-host form. Query validation remains the caller's concern. +func (b AppLinkBuilder) MatchesRoute(parsed *url.URL, route string) bool { + if parsed == nil || parsed.Opaque != "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawPath != "" { + return false + } + if b.MatchesLegacyRoute(parsed, route) { + return true + } + return b.baseHost != "" && + strings.EqualFold(parsed.Scheme, b.baseScheme) && + strings.EqualFold(parsed.Host, b.baseHost) && + parsed.Path == "/"+route +} + +func (b AppLinkBuilder) MatchesLegacyRoute(parsed *url.URL, route string) bool { + return parsed != nil && parsed.Opaque == "" && parsed.User == nil && parsed.Fragment == "" && parsed.RawPath == "" && + strings.EqualFold(parsed.Scheme, b.legacyScheme) && + strings.EqualFold(parsed.Host, route) && parsed.Path == "" +} + +func cloneValues(values url.Values) url.Values { + cloned := make(url.Values, len(values)) + for key, entries := range values { + cloned[key] = append([]string(nil), entries...) + } + return cloned +} + func ValidateAppName(raw string) (string, error) { name := strings.TrimSpace(raw) if name == "" { diff --git a/internal/links/links_test.go b/internal/links/links_test.go index 14ca808e..3ba0df30 100644 --- a/internal/links/links_test.go +++ b/internal/links/links_test.go @@ -88,6 +88,79 @@ func TestValidateAppScheme(t *testing.T) { } } +func TestValidateAppLinkBase(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "disabled", raw: "", want: ""}, + {name: "normalized", raw: " OWPG://Example.Test/ ", want: "owpg://example.test"}, + {name: "missing host", raw: "owpg://", wantErr: true}, + {name: "reserved scheme", raw: "https://example.test", wantErr: true}, + {name: "credentials", raw: "owpg://user@example.test", wantErr: true}, + {name: "port", raw: "owpg://example.test:443", wantErr: true}, + {name: "path", raw: "owpg://example.test/root", wantErr: true}, + {name: "query", raw: "owpg://example.test?tenant=one", wantErr: true}, + {name: "fragment", raw: "owpg://example.test#root", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ValidateAppLinkBase(tc.raw) + if (err != nil) != tc.wantErr { + t.Fatalf("ValidateAppLinkBase(%q) error = %v, wantErr %v", tc.raw, err, tc.wantErr) + } + if got != tc.want { + t.Fatalf("ValidateAppLinkBase(%q) = %q, want %q", tc.raw, got, tc.want) + } + }) + } +} + +func TestAppLinkBuilderPreservesLegacyAndSupportsHostBase(t *testing.T) { + legacy, err := NewAppLinkBuilder("telesrv", "") + if err != nil { + t.Fatal(err) + } + if got, want := legacy.Build("oauth", url.Values{"token": {"a+b"}}), "telesrv://oauth?token=a%2Bb"; got != want { + t.Fatalf("legacy OAuth = %q, want %q", got, want) + } + if got, want := legacy.BuildUsername("Alice", url.Values{"start": {"hello"}}), "telesrv://resolve?domain=Alice&start=hello"; got != want { + t.Fatalf("legacy username = %q, want %q", got, want) + } + + hosted, err := NewAppLinkBuilder("telesrv", "owpg://links.example.test") + if err != nil { + t.Fatal(err) + } + if got, want := hosted.Build("oauth", url.Values{"token": {"a+b"}}), "owpg://links.example.test/oauth?token=a%2Bb"; got != want { + t.Fatalf("hosted OAuth = %q, want %q", got, want) + } + if got, want := hosted.BuildUsername("Alice", url.Values{"domain": {"spoofed"}, "start": {"hello"}}), "owpg://links.example.test/Alice?start=hello"; got != want { + t.Fatalf("hosted username = %q, want %q", got, want) + } + + for _, tc := range []struct { + raw string + want bool + }{ + {raw: "telesrv://oauth?token=x", want: true}, + {raw: "owpg://links.example.test/oauth?token=x", want: true}, + {raw: "owpg://other.example.test/oauth?token=x", want: false}, + {raw: "owpg://links.example.test/oauth/extra?token=x", want: false}, + {raw: "owpg://links.example.test/resolve?token=x", want: false}, + } { + parsed, err := url.Parse(tc.raw) + if err != nil { + t.Fatal(err) + } + if got := hosted.MatchesRoute(parsed, "oauth"); got != tc.want { + t.Fatalf("MatchesRoute(%q) = %v, want %v", tc.raw, got, tc.want) + } + } +} + func TestValidateAppName(t *testing.T) { if got, err := ValidateAppName(" Example Chat "); err != nil || got != "Example Chat" { t.Fatalf("ValidateAppName valid = %q, %v", got, err) diff --git a/internal/rpc/account_business.go b/internal/rpc/account_business.go index a5653f8c..629fd3f8 100644 --- a/internal/rpc/account_business.go +++ b/internal/rpc/account_business.go @@ -458,7 +458,7 @@ func (r *Router) connectedBusinessBotPeerSettings(ctx context.Context, ownerUser settings.BusinessBotManageURL = r.connectedBusinessBotManageURL(botUser) } if settings.BusinessBotManageURL == "" { - settings.BusinessBotManageURL = "telesrv://business-bot" + settings.BusinessBotManageURL = r.publicAppLink("business-bot") } return settings, nil } diff --git a/internal/rpc/public_app_links_test.go b/internal/rpc/public_app_links_test.go new file mode 100644 index 00000000..472e5285 --- /dev/null +++ b/internal/rpc/public_app_links_test.go @@ -0,0 +1,32 @@ +package rpc + +import ( + "testing" + + "github.com/iamxvbaba/td/clock" + "go.uber.org/zap" +) + +func TestRouterPublicAppLinkUsesConfiguredBaseAndLegacyDefault(t *testing.T) { + legacy := New(Config{}, Deps{}, zap.NewNop(), clock.System) + if got, want := legacy.publicAppLink("business-bot"), "telesrv://business-bot"; got != want { + t.Fatalf("legacy business bot link = %q, want %q", got, want) + } + + hosted := New(Config{ + PublicAppScheme: "telesrv", + PublicAppLinkBase: "owpg://tenant.example.test", + }, Deps{}, zap.NewNop(), clock.System) + if got, want := hosted.publicAppLink("business-bot"), "owpg://tenant.example.test/business-bot"; got != want { + t.Fatalf("hosted business bot link = %q, want %q", got, want) + } +} + +func TestRouterRejectsInvalidPublicAppLinkConfig(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("New did not fail fast for an invalid public app link base") + } + }() + _ = New(Config{PublicAppLinkBase: "owpg://tenant.example.test/root"}, Deps{}, zap.NewNop(), clock.System) +} diff --git a/internal/rpc/public_links.go b/internal/rpc/public_links.go index 6fb0e808..3207ddc1 100644 --- a/internal/rpc/public_links.go +++ b/internal/rpc/public_links.go @@ -22,6 +22,10 @@ func (r *Router) publicLinkHost() string { return links.Host(r.cfg.PublicBaseURL) } +func (r *Router) publicAppLink(route string) string { + return r.appLinks.Build(route, nil) +} + func publicLinkWithBaseURL(baseURL, path string) string { return links.Build(baseURL, path, nil) } diff --git a/internal/rpc/router.go b/internal/rpc/router.go index 212fc3b1..0575646f 100644 --- a/internal/rpc/router.go +++ b/internal/rpc/router.go @@ -20,6 +20,7 @@ import ( "github.com/iamxvbaba/td/tlprofile" compatandroid "telesrv/internal/compat/android" "telesrv/internal/domain" + "telesrv/internal/links" "telesrv/internal/observability/dbtrace" ) @@ -83,6 +84,10 @@ type Config struct { RtmpIngestURL string // PublicBaseURL 是所有客户端可见 telesrv 链接的公开根 URL。 PublicBaseURL string + // PublicAppScheme/PublicAppLinkBase 控制客户端 deep link;base 为空时 + // 保持 ://,非空时生成 /。 + PublicAppScheme string + PublicAppLinkBase string // TempKeyResolveCacheTTL 是 PFS temp→perm auth key 解析的进程内缓存有效期。>0 时同一 temp key // 在 TTL 内复用上次解析、跳过每帧 ResolveAuthKey 的 PG 查询;0(默认/测试)关闭=每帧重校验。 // 显式撤销会删除协议 auth key、清缓存并断开活跃连接;TTL 只影响自然过期或异常路径下的 @@ -99,6 +104,7 @@ type Config struct { // 剥离 invokeWithLayer / initConnection / invokeWithoutUpdates / invokeAfter*,并兜底未注册 RPC。 type Router struct { cfg Config + appLinks links.AppLinkBuilder log *zap.Logger clock clock.Clock deps Deps @@ -236,11 +242,15 @@ type authUserCacheEntry struct { // New 创建 Router,由各业务域自行注册其 RPC handler(registerHelp/Auth/Users/Updates)。 func New(cfg Config, deps Deps, log *zap.Logger, clk clock.Clock) *Router { assertNoTypedNilDeps(deps) + appLinks, err := links.NewAppLinkBuilder(cfg.PublicAppScheme, cfg.PublicAppLinkBase) + if err != nil { + panic(fmt.Sprintf("initialize public app links: %v", err)) + } instanceID := cfg.InstanceID if instanceID == "" { instanceID = fmt.Sprintf("%016x", randomNonZeroInt64()) } - r := &Router{cfg: cfg, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), accountFreezeWake: make(chan struct{}, 1), instanceID: instanceID} + r := &Router{cfg: cfg, appLinks: appLinks, log: log, clock: clk, deps: deps, exactProfiles: make(map[clientInfoSessionKey]exactSessionProfileEntry), authLayerEvidence: make(map[[8]byte]authLayerDefaultEvidence), presence: newPresenceTracker(), callbacks: newCallbackRegistry(deps.BotCallbacks), inlines: newInlineRegistry(botInlineQueryTTL, deps.Inline), webviews: newWebViewRegistry(webViewSessionTTL, deps.Inline), loginTokens: newLoginTokenRegistry(), botAPIUpdates: newBotAPIUpdateNotifier(), tempKeyResolveCache: newTempKeyResolveCache(cfg.TempKeyResolveCacheMaxEntries), storyProjectionCache: newStoryProjectionCache(clk.Now), storyPinnedCache: newStoryPinnedAvailableCache(clk.Now), storyPinnedListCache: newStoryPinnedStoriesCache(clk.Now), channelFullBotCache: newChannelFullBotInfoCache(clk.Now), userFullProjectionCache: newUserFullProjectionCache(clk.Now), peerSettingsProjectionCache: newPeerSettingsProjectionCache(clk.Now), channelFullProjectionCache: newChannelFullProjectionCache(clk.Now), emojiStickers: newEmojiStickerIndex(clk.Now), notifySettings: newNotifySettingsCache(clk.Now), stickerCatalog: newStickerCatalogCache(clk.Now), accountSettings: newAccountSettingsCache(clk.Now), accountFreezeWake: make(chan struct{}, 1), instanceID: instanceID} r.channelFanout = newChannelFanoutDispatcher(r, defaultChannelFanoutShards, defaultChannelFanoutBuffer) r.botAPIEnqueueQueue = newBotAPIEnqueueDispatcher(log, defaultBotAPIEnqueueBuffer) r.webPageResolveSem = make(chan struct{}, webPageResolveConcurrency) diff --git a/internal/telegramloginhttp/handler_test.go b/internal/telegramloginhttp/handler_test.go index 9b7abb16..e5414c1d 100644 --- a/internal/telegramloginhttp/handler_test.go +++ b/internal/telegramloginhttp/handler_test.go @@ -65,6 +65,10 @@ func (telegramLoginHTTPDenyLimiter) Allow(context.Context, string, int, time.Dur } func newTelegramLoginHTTPFixture(t *testing.T) telegramLoginHTTPFixture { + return newTelegramLoginHTTPFixtureWithAppLinkBase(t, "") +} + +func newTelegramLoginHTTPFixtureWithAppLinkBase(t *testing.T, appLinkBase string) telegramLoginHTTPFixture { t.Helper() now := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC) sealKey := make([]byte, 32) @@ -76,7 +80,7 @@ func newTelegramLoginHTTPFixture(t *testing.T) telegramLoginHTTPFixture { pepper := make([]byte, 32) pepper[0] = 2 service, err := loginapp.NewService(memory.NewTelegramLoginStore(nil), sealer, loginapp.Config{ - Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", ClientSecretPepper: pepper, + Issuer: "https://oauth.telesrv.test", AppScheme: "telesrv", AppLinkBase: appLinkBase, ClientSecretPepper: pepper, Now: func() time.Time { return now }, }) if err != nil { @@ -162,6 +166,14 @@ func (f telegramLoginHTTPFixture) authorize(t *testing.T) (browserToken, deepLin return browserToken, deepLink } +func TestAuthorizationPageUsesConfiguredHostBasedAppLink(t *testing.T) { + f := newTelegramLoginHTTPFixtureWithAppLinkBase(t, "owpg://tenant.example.test") + _, deepLink := f.authorize(t) + if !strings.HasPrefix(deepLink, "owpg://tenant.example.test/oauth?token=") { + t.Fatalf("authorization page deep link = %q, want configured host-based OAuth URL", deepLink) + } +} + func TestAuthorizationErrorsUseOnlyPreRegisteredTargets(t *testing.T) { f := newTelegramLoginHTTPFixture(t) base := url.Values{ diff --git a/internal/web/server.go b/internal/web/server.go index f762135f..f16a5f82 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -24,6 +24,7 @@ type Config struct { Addr string PublicBaseURL string AppScheme string + AppLinkBase string WebBaseURL string AppName string StickerSets StickerSetResolver @@ -102,6 +103,7 @@ 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("app_link_base", cfg.AppLinkBase), zap.String("web_base_url", cfg.WebBaseURL)) if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Warn("Public link Web endpoint exited", zap.Error(err)) @@ -134,8 +136,9 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { if cfg.PublicBaseURL, err = links.ValidateBaseURL(cfg.PublicBaseURL); err != nil { return nil, fmt.Errorf("public base URL: %w", err) } - if cfg.AppScheme, err = links.ValidateAppScheme(cfg.AppScheme); err != nil { - return nil, fmt.Errorf("app scheme: %w", err) + appLinks, err := links.NewAppLinkBuilder(cfg.AppScheme, cfg.AppLinkBase) + if err != nil { + return nil, fmt.Errorf("app links: %w", err) } if cfg.WebBaseURL, err = links.ValidateBaseURL(cfg.WebBaseURL); err != nil { return nil, fmt.Errorf("Web base URL: %w", err) @@ -155,7 +158,7 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { uniqueGifts: cfg.UniqueGifts, giftWithdrawals: cfg.GiftWithdrawals, publicBaseURL: cfg.PublicBaseURL, - appScheme: cfg.AppScheme, + appLinks: appLinks, webBaseURL: cfg.WebBaseURL, appName: cfg.AppName, logger: logger, @@ -195,7 +198,7 @@ type handler struct { uniqueGifts UniqueStarGiftResolver giftWithdrawals StarGiftWithdrawalResolver publicBaseURL string - appScheme string + appLinks links.AppLinkBuilder webBaseURL string appName string logger *zap.Logger @@ -376,8 +379,8 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { h.serveUsernameNotFound(w, username) return } + app := h.appLinks.BuildUsername(peer.username, params) params.Set("domain", peer.username) - app := schemeURLValues(h.appScheme, "resolve", params) legacy := schemeURLValues("tg", "resolve", params) description := peer.about if description == "" { @@ -988,7 +991,7 @@ func itemNoun(set domain.StickerSet, count int) string { } func (h *handler) appURL(kind, key, value string) string { - return schemeURL(h.appScheme, kind, key, value) + return h.appLinks.Build(kind, url.Values{key: []string{value}}) } func legacyTgURL(kind, key, value string) string { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index aca6a959..6e32f17b 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -344,6 +344,7 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { }}, PublicBaseURL: "https://links.example.test", AppScheme: "example-chat", + AppLinkBase: "owpg://tenant.example.test", WebBaseURL: "https://web.example.test/client/", AppName: "Example Chat", }) @@ -357,7 +358,7 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { } body := rr.Body.String() for _, want := range []string{ - "example-chat://resolve?domain=Alice&start=hello", + "owpg://tenant.example.test/Alice?start=hello", "https://web.example.test/client/#?tgaddr=", "Example Chat", "Open Example Chat to send a message to @Alice.", @@ -373,10 +374,10 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { path string want string }{ - {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"}, + {path: "/addstickers/stickers_pack", want: "owpg://tenant.example.test/addstickers?set=stickers_pack"}, + {path: "/addemoji/emoji_pack", want: "owpg://tenant.example.test/addemoji?set=emoji_pack"}, + {path: "/addlist/shared-folder", want: "owpg://tenant.example.test/addlist?slug=shared-folder"}, + {path: "/nft/gift-1", want: "owpg://tenant.example.test/nft?slug=gift-1"}, } { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil)) @@ -393,6 +394,8 @@ func TestNewHandlerRejectsInvalidClientLinkConfig(t *testing.T) { }{ {name: "missing sticker resolver", cfg: Config{}}, {name: "official scheme", cfg: Config{StickerSets: fakeResolver{}, AppScheme: "tg"}}, + {name: "official app link base", cfg: Config{StickerSets: fakeResolver{}, AppLinkBase: "tg://links.example.test"}}, + {name: "app link base path", cfg: Config{StickerSets: fakeResolver{}, AppLinkBase: "owpg://links.example.test/root"}}, {name: "invalid Web base URL", cfg: Config{StickerSets: fakeResolver{}, WebBaseURL: "file:///tmp/web"}}, {name: "invalid app name", cfg: Config{StickerSets: fakeResolver{}, AppName: "bad\nname"}}, } {