feat: sync host-based public app links
This commit is contained in:
parent
10de462191
commit
8cfb6f74c1
19 changed files with 375 additions and 26 deletions
|
|
@ -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 <custom-scheme>://<host>, 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/<username>`, and equivalent route paths. Only exact `<custom-scheme>://<host>` 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
|
||||
|
|
|
|||
|
|
@ -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/<username>` 等;只允许精确 `<custom-scheme>://<host>`,禁止端口、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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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://<route>;非空时
|
||||
// 生成 <base>/<route>,同时保留旧 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", ""),
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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 <custom-scheme>://<host>: 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 == "" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
32
internal/rpc/public_app_links_test.go
Normal file
32
internal/rpc/public_app_links_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 为空时
|
||||
// 保持 <scheme>://<route>,非空时生成 <base>/<route>。
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
} {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue