diff --git a/internal/web/server.go b/internal/web/server.go index f353d53b..5c83d178 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -157,6 +157,14 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { if logger == nil { logger = zap.NewNop() } + // links.ValidateBaseURL above guarantees cfg.PublicBaseURL parses with a + // non-empty host; publicHost is the bare "host[:port]" used to build + // owpg:/// deep links (the client reinterprets those as + // https:///, see LaunchActivity's "owpg" scheme case). + publicHost := cfg.PublicBaseURL + if u, err := url.Parse(cfg.PublicBaseURL); err == nil { + publicHost = u.Host + } h := &handler{ stickerSets: cfg.StickerSets, users: cfg.Users, @@ -166,6 +174,7 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { uniqueGifts: cfg.UniqueGifts, giftWithdrawals: cfg.GiftWithdrawals, publicBaseURL: cfg.PublicBaseURL, + publicHost: publicHost, appScheme: cfg.AppScheme, webBaseURL: cfg.WebBaseURL, appName: cfg.AppName, @@ -199,6 +208,7 @@ type handler struct { uniqueGifts UniqueStarGiftResolver giftWithdrawals StarGiftWithdrawalResolver publicBaseURL string + publicHost string appScheme string webBaseURL string appName string @@ -284,7 +294,7 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } - app := h.appURL("addlist", "slug", slug) + app := h.appURL("addlist", slug) data := pageData{ AppName: h.appName, Title: "Shared Folder", @@ -340,7 +350,7 @@ func (h *handler) uniqueGift(w http.ResponseWriter, r *http.Request) { if unique.AvailabilityIssued > 0 && unique.AvailabilityTotal >= unique.AvailabilityIssued { subtitle += fmt.Sprintf(" · %s/%s issued", groupedDecimal(unique.AvailabilityIssued), groupedDecimal(unique.AvailabilityTotal)) } - app := h.appURL("nft", "slug", canonicalSlug) + app := h.appURL("nft", canonicalSlug) data := pageData{ AppName: h.appName, Title: title, @@ -385,8 +395,10 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { h.serveUsernameNotFound(w, username) return } + // owpg:/// — built from params BEFORE "domain" is added + // below, since owpg encodes the username in the path, not as a query key. + app := h.appUsernameURL(peer.username, params) params.Set("domain", peer.username) - app := schemeURLValues(h.appScheme, "resolve", params) description := peer.about if description == "" { description = peer.fallbackDescription(h.appName) @@ -455,7 +467,7 @@ func (h *handler) inviteLink(w http.ResponseWriter, r *http.Request, hash string if title == "" { title = strings.TrimSpace(invite.Title) } - app := h.appURL("join", "invite", hash) + app := h.appInviteURL(hash) description := peer.about if description == "" { description = peer.fallbackDescription(h.appName) @@ -618,7 +630,7 @@ func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind stri if count == 0 { count = len(docs) } - app := h.appURL(canonicalKind, "set", set.ShortName) + app := h.appURL(canonicalKind, set.ShortName) data := pageData{ AppName: h.appName, Title: fallbackTitle(set), @@ -1169,10 +1181,32 @@ func itemNoun(set domain.StickerSet, count int) string { return "stickers" } -func (h *handler) appURL(kind, key, value string) string { - return schemeURL(h.appScheme, kind, key, value) +// appURL/appInviteURL/appUsernameURL build owpg:/// deep links — +// mirrors publicURL/publicInviteURL/publicUsernameURL (https:///) +// but with the app scheme instead. The client's "owpg" URI handler reinterprets +// owpg:///? as https:///? and falls +// through to the normal t.me-style path router (LaunchActivity.java), which +// expects a path, not a tg://-style "resolve?domain=" query. Do NOT use +// schemeURL here — that format is only valid for the real tg:// scheme. +func (h *handler) appURL(kind, value string) string { + return h.appScheme + "://" + h.publicHost + "/" + kind + "/" + url.PathEscape(value) } +func (h *handler) appInviteURL(hash string) string { + return h.appScheme + "://" + h.publicHost + "/+" + url.PathEscape(hash) +} + +func (h *handler) appUsernameURL(username string, extraQuery url.Values) string { + u := h.appScheme + "://" + h.publicHost + "/" + url.PathEscape(username) + if len(extraQuery) > 0 { + u += "?" + extraQuery.Encode() + } + return u +} + +// schemeURL/schemeURLValues build the real tg:// deep-link format +// (tg://resolve?domain=X) — used only for the legacy-client fallback link +// (LegacyTgURL), never for the app's own owpg:// scheme. func schemeURL(scheme, kind, key, value string) string { return scheme + "://" + kind + "?" + key + "=" + url.QueryEscape(value) } diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 248990b5..93a4aa7f 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -88,7 +88,7 @@ func TestHandlerServesUniqueGiftLandingPage(t *testing.T) { for _, want := range []string{ "Official Gift", "Collectible #7", "7/1 000 issued", "http://127.0.0.1:2401/nft/" + slug, - "telesrv://nft?slug=" + slug, + "telesrv://127.0.0.1:2401/nft/" + slug, "tg://nft?slug=" + slug, "Open it in the app to view its current details.", } { @@ -219,7 +219,7 @@ func TestHandlerServesStickerSetLandingPage(t *testing.T) { for _, want := range []string{ "Fresh Pack", "https://telesrv.net/addstickers/fresh_pack", - "telesrv://addstickers?set=fresh_pack", + "telesrv://telesrv.net/addstickers/fresh_pack", "Files are still fetched by the app through MTProto.", } { if !strings.Contains(body, want) { @@ -257,7 +257,7 @@ func TestHandlerServesEmojiLandingPage(t *testing.T) { for _, want := range []string{ "custom emoji set", "https://example.test/base/addemoji/emoji_pack", - "telesrv://addemoji?set=emoji_pack", + "telesrv://example.test/addemoji/emoji_pack", } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) @@ -278,7 +278,7 @@ func TestHandlerServesChatlistLandingPage(t *testing.T) { for _, want := range []string{ "Shared Folder", "http://127.0.0.1:2401/addlist/zNhytIbwRwjaC2GH", - "telesrv://addlist?slug=zNhytIbwRwjaC2GH", + "telesrv://127.0.0.1:2401/addlist/zNhytIbwRwjaC2GH", "preview and add this shared folder", } { if !strings.Contains(body, want) { @@ -313,11 +313,11 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) { "bot", "@TetrisBot", "http://127.0.0.1:2401/TetrisBot", - "telesrv://resolve?domain=TetrisBot", + "telesrv://127.0.0.1:2401/TetrisBot", "Start Bot", "Open telesrv to start a chat with this bot.", `property="og:title" content="Tetris Bot"`, - `property="al:android:url" content="telesrv://resolve?domain=TetrisBot"`, + `property="al:android:url" content="telesrv://127.0.0.1:2401/TetrisBot"`, } { if !strings.Contains(body, want) { t.Fatalf("body missing %q:\n%s", want, body) @@ -353,7 +353,7 @@ func TestHandlerUsesConfiguredClientLinksAndBrand(t *testing.T) { } body := rr.Body.String() for _, want := range []string{ - "example-chat://resolve?domain=Alice&start=hello", + "example-chat://links.example.test/Alice?start=hello", "https://web.example.test/client/#?tgaddr=", "Example Chat", "Open Example Chat to send a message to @Alice.", @@ -369,10 +369,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: "example-chat://links.example.test/addstickers/stickers_pack"}, + {path: "/addemoji/emoji_pack", want: "example-chat://links.example.test/addemoji/emoji_pack"}, + {path: "/addlist/shared-folder", want: "example-chat://links.example.test/addlist/shared-folder"}, + {path: "/nft/gift-1", want: "example-chat://links.example.test/nft/gift-1"}, } { rr := httptest.NewRecorder() h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil)) @@ -447,14 +447,14 @@ func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) { path: "/aLiCe/", wants: []string{ "Alice Example", "Public bio", "@Alice", "Send Message", "Verified", - "https://telesrv.net/Alice", "telesrv://resolve?domain=Alice", + "https://telesrv.net/Alice", "telesrv://telesrv.net/Alice", }, }, { path: "/NewsRoom", wants: []string{ "News Room", "Public channel description", "12 001 subscribers", "View Channel", - "https://telesrv.net/NewsRoom", "telesrv://resolve?domain=NewsRoom", + "https://telesrv.net/NewsRoom", "telesrv://telesrv.net/NewsRoom", "/_public/avatar/NewsRoom/301", }, }, @@ -497,7 +497,7 @@ func TestHandlerPreservesBoundedResolveQueryAndOverridesDomain(t *testing.T) { } body := rr.Body.String() for _, want := range []string{ - "telesrv://resolve?domain=TetrisBot&ref=campaign&start=hello", + "telesrv://telesrv.net/TetrisBot?ref=campaign&start=hello", } { if !strings.Contains(body, want) { t.Fatalf("body missing sanitized query %q:\n%s", want, body)