// Package web serves telesrv's read-only public link landing pages. package web import ( "context" "errors" "fmt" "html/template" "net" "net/http" "net/url" "strconv" "strings" "time" "unicode/utf8" "go.uber.org/zap" "telesrv/internal/domain" "telesrv/internal/links" ) type Config struct { Addr string PublicBaseURL string AppScheme string WebBaseURL string AppName string StickerSets StickerSetResolver Users UsernameResolver Channels PublicChannelResolver Privacy AnonymousPrivacyResolver Photos ProfilePhotoResolver } type StickerSetResolver interface { ResolveStickerSet(ctx context.Context, ref domain.StickerSetRef) (domain.StickerSet, []domain.Document, bool, error) } type UsernameResolver interface { ByUsername(ctx context.Context, username string) (domain.User, bool, error) } // PublicChannelResolver exposes only the viewer-independent public username // projection. viewerUserID is always zero for this anonymous Web endpoint. type PublicChannelResolver interface { ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) } type AnonymousPrivacyResolver interface { CanSeeAnonymous(ctx context.Context, ownerUserID int64, key domain.PrivacyKey) (bool, error) } type ProfilePhotoResolver interface { CurrentProfilePhotoKind(ctx context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) GetPhoto(ctx context.Context, id int64) (domain.Photo, bool, error) GetFile(ctx context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) } func Start(ctx context.Context, cfg Config, logger *zap.Logger) (*http.Server, error) { addr := strings.TrimSpace(cfg.Addr) if addr == "" { return nil, nil } if logger == nil { logger = zap.NewNop() } handler, err := newHandler(cfg, logger) if err != nil { return nil, err } srv := &http.Server{ Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 16 << 10, } ln, err := net.Listen("tcp", addr) if err != nil { return nil, err } go func() { logger.Info("Public link Web endpoint enabled", zap.String("addr", addr), zap.String("public_base_url", cfg.PublicBaseURL), zap.String("app_scheme", cfg.AppScheme), 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)) } }() go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx) }() return srv, nil } func NewHandler(cfg Config) (http.Handler, error) { return newHandler(cfg, zap.NewNop()) } func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) { var err error if cfg.StickerSets == nil { return nil, fmt.Errorf("public Web sticker set resolver is nil") } if strings.TrimSpace(cfg.WebBaseURL) == "" { cfg.WebBaseURL = links.DefaultWebBaseURL } if strings.TrimSpace(cfg.AppName) == "" { cfg.AppName = links.DefaultAppName } 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) } if cfg.WebBaseURL, err = links.ValidateBaseURL(cfg.WebBaseURL); err != nil { return nil, fmt.Errorf("Web base URL: %w", err) } if cfg.AppName, err = links.ValidateAppName(cfg.AppName); err != nil { return nil, fmt.Errorf("app name: %w", err) } if logger == nil { logger = zap.NewNop() } h := &handler{ stickerSets: cfg.StickerSets, users: cfg.Users, channels: cfg.Channels, privacy: cfg.Privacy, photos: cfg.Photos, publicBaseURL: cfg.PublicBaseURL, appScheme: cfg.AppScheme, webBaseURL: cfg.WebBaseURL, appName: cfg.AppName, logger: logger, } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", h.healthz) mux.HandleFunc("GET /_public/avatar/{username}/{photoID}", h.publicAvatar) mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) mux.HandleFunc("GET /addlist/{slug}", h.addList) mux.HandleFunc("GET /{username}", h.usernameLink) mux.HandleFunc("GET /{username}/{$}", h.usernameLink) return publicSecurityHeaders(mux), nil } type handler struct { stickerSets StickerSetResolver users UsernameResolver channels PublicChannelResolver privacy AnonymousPrivacyResolver photos ProfilePhotoResolver publicBaseURL string appScheme string webBaseURL string appName string logger *zap.Logger } func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = w.Write([]byte("ok\n")) } func (h *handler) addStickers(w http.ResponseWriter, r *http.Request) { h.serveSet(w, r, "addstickers") } func (h *handler) addEmoji(w http.ResponseWriter, r *http.Request) { h.serveSet(w, r, "addemoji") } func (h *handler) addList(w http.ResponseWriter, r *http.Request) { slug := strings.TrimSpace(r.PathValue("slug")) if !validSlugPath(slug) { http.NotFound(w, r) return } app := h.appURL("addlist", "slug", slug) data := pageData{ AppName: h.appName, Title: "Shared Folder", KindLabel: "shared folder", Subtitle: slug, Description: "This page opens the app so you can preview and add this shared folder.", CanonicalURL: h.publicURL("addlist", slug), AppURL: template.URL(app), LegacyTgURL: template.URL(legacyTgURL("addlist", "slug", slug)), } data.AppURLJS = template.JS(strconv.Quote(app)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=60") if err := landingTemplate.Execute(w, data); err != nil { http.Error(w, "render shared folder page failed", http.StatusInternalServerError) } } func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PathValue("username")) if !validUsernamePath(username) { h.serveUsernameNotFound(w, username) return } params, ok := publicResolveQuery(r.URL.RawQuery) if !ok { http.Error(w, "public link query is too large or invalid", http.StatusRequestURITooLong) return } peer, found, err := h.resolvePublicPeer(r.Context(), username) if err != nil { h.logger.Error("Public username lookup failed", zap.String("username", username), zap.Error(err)) http.Error(w, "username lookup failed", http.StatusInternalServerError) return } if !found { h.serveUsernameNotFound(w, username) return } params.Set("domain", peer.username) app := schemeURLValues(h.appScheme, "resolve", params) legacy := schemeURLValues("tg", "resolve", params) description := peer.about if description == "" { description = peer.fallbackDescription(h.appName) } data := usernamePageData{ AppName: h.appName, AppInitial: appInitial(h.appName), Title: peer.title, Username: peer.username, Verified: peer.verified, Extra: peer.extra(), Description: description, CanonicalURL: h.publicUsernameURL(peer.username), HomeURL: h.publicBaseURL + "/", AppURL: template.URL(app), LegacyTgURL: template.URL(legacy), WebURL: template.URL(publicWebAppURL(h.webBaseURL, legacy)), ButtonLabel: peer.buttonLabel(), Initials: peer.initials(), } if peer.hasPhoto { data.PhotoURL = h.publicAvatarURL(peer.username, peer.photo.ID) } data.AppURLJS = template.JS(strconv.Quote(app)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=60, must-revalidate") if err := usernameLandingTemplate.Execute(w, data); err != nil { h.logger.Error("Render public username page failed", zap.String("username", peer.username), zap.Error(err)) } } const maxPublicAvatarBytes = 4 << 20 func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.PathValue("username")) photoID, err := strconv.ParseInt(r.PathValue("photoID"), 10, 64) if err != nil || photoID <= 0 || !validUsernamePath(username) || h.photos == nil { http.NotFound(w, r) return } peer, found, err := h.resolvePublicPeer(r.Context(), username) if err != nil { h.logger.Error("Public avatar peer lookup failed", zap.String("username", username), zap.Error(err)) http.Error(w, "avatar lookup failed", http.StatusInternalServerError) return } if !found || !peer.hasPhoto || peer.photo.ID != photoID { http.NotFound(w, r) return } size, inline, ok := bestPublicPhotoSize(peer.photo.Sizes) if !ok { http.NotFound(w, r) return } etag := fmt.Sprintf("\"public-avatar-%d-%s-%d\"", peer.photo.ID, size.Type, size.Size) w.Header().Set("ETag", etag) w.Header().Set("Cache-Control", "public, max-age=300, must-revalidate") if r.Header.Get("If-None-Match") == etag { w.WriteHeader(http.StatusNotModified) return } data := inline mimeType := "" if len(data) == 0 { chunk, found, err := h.photos.GetFile(r.Context(), domain.FileDownloadRequest{ LocationKey: fmt.Sprintf("photo:%d:%s", peer.photo.ID, size.Type), Limit: maxPublicAvatarBytes + 1, }) if err != nil { h.logger.Error("Read public avatar blob failed", zap.String("username", username), zap.Int64("photo_id", photoID), zap.Error(err)) http.Error(w, "avatar read failed", http.StatusInternalServerError) return } if !found || chunk.Total <= 0 || chunk.Total > maxPublicAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total { h.logger.Warn("Public avatar blob is missing or outside bounds", zap.String("username", username), zap.Int64("photo_id", photoID), zap.Int64("total", chunk.Total), zap.Int("bytes", len(chunk.Bytes))) http.NotFound(w, r) return } data = chunk.Bytes mimeType = chunk.MimeType } if len(data) == 0 || len(data) > maxPublicAvatarBytes { http.NotFound(w, r) return } detected := http.DetectContentType(data) if !safePublicImageType(detected) { h.logger.Warn("Public avatar blob is not a safe raster image", zap.String("username", username), zap.Int64("photo_id", photoID), zap.String("detected_type", detected), zap.String("stored_type", mimeType)) http.NotFound(w, r) return } w.Header().Set("Content-Type", detected) w.Header().Set("Content-Length", strconv.Itoa(len(data))) if peer.photo.Date > 0 { w.Header().Set("Last-Modified", time.Unix(int64(peer.photo.Date), 0).UTC().Format(http.TimeFormat)) } w.WriteHeader(http.StatusOK) _, _ = w.Write(data) } func (h *handler) serveSet(w http.ResponseWriter, r *http.Request, pathKind string) { shortName := strings.TrimSpace(r.PathValue("shortName")) if !validShortNamePath(shortName) { http.NotFound(w, r) return } set, docs, found, err := h.stickerSets.ResolveStickerSet(r.Context(), domain.StickerSetRef{ Kind: domain.StickerSetRefByShortName, ShortName: shortName, }) if err != nil { http.Error(w, "sticker set lookup failed", http.StatusInternalServerError) return } if !found || set.Deleted { http.NotFound(w, r) return } canonicalKind := linkKind(set) if canonicalKind != pathKind { http.Redirect(w, r, h.publicURL(canonicalKind, set.ShortName), http.StatusPermanentRedirect) return } count := set.Count if count == 0 { count = len(docs) } app := h.appURL(canonicalKind, "set", set.ShortName) data := pageData{ AppName: h.appName, Title: fallbackTitle(set), KindLabel: kindLabel(set), Subtitle: fmt.Sprintf("@%s · %d %s", set.ShortName, count, itemNoun(set, count)), Description: "This page opens the app so you can preview and install the set. Files are still fetched by the app through MTProto.", CanonicalURL: h.publicURL(canonicalKind, set.ShortName), AppURL: template.URL(app), LegacyTgURL: template.URL(legacyTgURL(canonicalKind, "set", set.ShortName)), } data.AppURLJS = template.JS(strconv.Quote(app)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=60") if err := landingTemplate.Execute(w, data); err != nil { http.Error(w, "render sticker set page failed", http.StatusInternalServerError) } } func (h *handler) publicURL(kind, value string) string { return h.publicBaseURL + "/" + kind + "/" + url.PathEscape(value) } func (h *handler) publicUsernameURL(username string) string { return h.publicBaseURL + "/" + url.PathEscape(username) } func (h *handler) publicAvatarURL(username string, photoID int64) string { return h.publicBaseURL + "/_public/avatar/" + url.PathEscape(username) + "/" + strconv.FormatInt(photoID, 10) } type publicPeerKind string const ( publicPeerUser publicPeerKind = "user" publicPeerBot publicPeerKind = "bot" publicPeerChannel publicPeerKind = "channel" publicPeerSupergroup publicPeerKind = "supergroup" ) type publicPeer struct { kind publicPeerKind username string title string about string verified bool memberCount int photo domain.Photo hasPhoto bool } func (h *handler) resolvePublicPeer(ctx context.Context, username string) (publicPeer, bool, error) { var ( u domain.User userOK bool ch domain.Channel chOK bool err error ) if h.users != nil { u, userOK, err = h.users.ByUsername(ctx, username) if err != nil { return publicPeer{}, false, err } } if h.channels != nil { ch, chOK, err = h.channels.ResolvePublicChannelUsername(ctx, 0, username) if err != nil { return publicPeer{}, false, err } } if userOK && chOK { return publicPeer{}, false, fmt.Errorf("public username %q has multiple owners", username) } if userOK { return h.publicUserPeer(ctx, username, u) } if chOK { return h.publicChannelPeer(ctx, username, ch) } return publicPeer{}, false, nil } func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain.User) (publicPeer, bool, error) { if u.ID == 0 || !strings.EqualFold(strings.TrimSpace(u.Username), requested) || !validUsernamePath(u.Username) { return publicPeer{}, false, fmt.Errorf("user username lookup returned invalid owner for %q", requested) } title := strings.TrimSpace(u.FirstName + " " + u.LastName) if title == "" { title = u.Username } if err := validatePublicPeerText(title, u.About); err != nil { return publicPeer{}, false, fmt.Errorf("invalid public user %q: %w", u.Username, err) } about := strings.TrimSpace(u.About) photoKind := domain.ProfilePhotoKindProfile if !u.Bot && h.privacy != nil { visible, err := h.privacy.CanSeeAnonymous(ctx, u.ID, domain.PrivacyKeyAbout) if err != nil { return publicPeer{}, false, fmt.Errorf("evaluate public about privacy: %w", err) } if !visible { about = "" } visible, err = h.privacy.CanSeeAnonymous(ctx, u.ID, domain.PrivacyKeyProfilePhoto) if err != nil { return publicPeer{}, false, fmt.Errorf("evaluate public profile photo privacy: %w", err) } if !visible { photoKind = domain.ProfilePhotoKindFallback } } peer := publicPeer{ kind: publicPeerUser, username: u.Username, title: title, about: about, verified: u.Verified, } if u.Bot { peer.kind = publicPeerBot } if h.photos != nil { photo, found, err := h.photos.CurrentProfilePhotoKind(ctx, domain.PeerTypeUser, u.ID, photoKind) if err != nil { return publicPeer{}, false, fmt.Errorf("load public user photo: %w", err) } if found && photo.ID != 0 { if _, _, renderable := bestPublicPhotoSize(photo.Sizes); renderable { peer.photo, peer.hasPhoto = photo, true } } } return peer, true, nil } func (h *handler) publicChannelPeer(ctx context.Context, requested string, ch domain.Channel) (publicPeer, bool, error) { if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) || !strings.EqualFold(strings.TrimSpace(ch.Username), requested) || !validUsernamePath(ch.Username) { return publicPeer{}, false, fmt.Errorf("channel username lookup returned invalid owner for %q", requested) } if err := validatePublicPeerText(ch.Title, ch.About); err != nil { return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", ch.Username, err) } peer := publicPeer{ kind: publicPeerChannel, username: ch.Username, title: strings.TrimSpace(ch.Title), about: strings.TrimSpace(ch.About), verified: ch.Verified, memberCount: ch.ParticipantsCount, } if ch.Megagroup { peer.kind = publicPeerSupergroup } if h.photos != nil && ch.PhotoID != 0 { photo, found, err := h.photos.GetPhoto(ctx, ch.PhotoID) if err != nil { return publicPeer{}, false, fmt.Errorf("load public channel photo: %w", err) } if !found { return publicPeer{}, false, fmt.Errorf("channel %q current photo %d is missing", ch.Username, ch.PhotoID) } if photo.ID == ch.PhotoID { if _, _, renderable := bestPublicPhotoSize(photo.Sizes); renderable { peer.photo, peer.hasPhoto = photo, true } } else { return publicPeer{}, false, fmt.Errorf("channel %q photo lookup returned id %d, want %d", ch.Username, photo.ID, ch.PhotoID) } } return peer, true, nil } func validatePublicPeerText(title, about string) error { if strings.TrimSpace(title) == "" || utf8.RuneCountInString(title) > 256 { return fmt.Errorf("title is empty or too long") } if utf8.RuneCountInString(about) > 4096 { return fmt.Errorf("about is too long") } return nil } func (p publicPeer) buttonLabel() string { switch p.kind { case publicPeerBot: return "Start Bot" case publicPeerChannel: return "View Channel" case publicPeerSupergroup: return "View Group" default: return "Send Message" } } func (p publicPeer) extra() string { switch p.kind { case publicPeerBot: return "bot" case publicPeerChannel: return groupedDecimal(p.memberCount) + " " + plural(p.memberCount, "subscriber", "subscribers") case publicPeerSupergroup: return groupedDecimal(p.memberCount) + " " + plural(p.memberCount, "member", "members") default: return "" } } func (p publicPeer) fallbackDescription(appName string) string { switch p.kind { case publicPeerBot: return "Open " + appName + " to start a chat with this bot." case publicPeerChannel: return "Open " + appName + " to view and join this channel." case publicPeerSupergroup: return "Open " + appName + " to view and join this group." default: return "Open " + appName + " to send a message to @" + p.username + "." } } func (p publicPeer) initials() string { words := strings.Fields(p.title) if len(words) == 0 { words = []string{p.username} } first := []rune(words[0]) if len(first) == 0 { return "T" } out := []rune{first[0]} if len(words) > 1 { last := []rune(words[len(words)-1]) if len(last) > 0 { out = append(out, last[0]) } } return strings.ToUpper(string(out)) } func groupedDecimal(n int) string { if n < 0 { n = 0 } s := strconv.Itoa(n) for i := len(s) - 3; i > 0; i -= 3 { s = s[:i] + " " + s[i:] } return s } func plural(n int, one, many string) string { if n == 1 { return one } return many } func appInitial(name string) string { for _, r := range name { return strings.ToUpper(string(r)) } return "T" } const ( maxPublicLinkRawQuery = 2048 maxPublicLinkParams = 16 maxPublicLinkValues = 2 maxPublicLinkValueLen = 512 ) func publicResolveQuery(raw string) (url.Values, bool) { if len(raw) > maxPublicLinkRawQuery { return nil, false } values, err := url.ParseQuery(raw) if err != nil || len(values) > maxPublicLinkParams { return nil, false } out := make(url.Values, len(values)+1) for key, items := range values { if strings.EqualFold(key, "domain") { continue } if !validPublicQueryKey(key) || len(items) > maxPublicLinkValues { return nil, false } for _, value := range items { if len(value) > maxPublicLinkValueLen || !utf8.ValidString(value) { return nil, false } out.Add(key, value) } } return out, true } func validPublicQueryKey(key string) bool { if key == "" || len(key) > 32 { return false } for _, r := range key { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { continue } return false } return true } func bestPublicPhotoSize(sizes []domain.PhotoSize) (domain.PhotoSize, []byte, bool) { var ( best domain.PhotoSize bestBytes []byte bestScore int64 = -1 ) for _, size := range sizes { if !validPhotoSizeType(size.Type) { continue } var inline []byte switch size.Kind { case domain.PhotoSizeKindCached: if len(size.Bytes) == 0 || len(size.Bytes) > maxPublicAvatarBytes { continue } inline = size.Bytes case domain.PhotoSizeKindDefault, domain.PhotoSizeKindProgressive: // Downloadable static raster size. default: continue } score := int64(size.W) * int64(size.H) if score <= 0 { score = int64(size.Size) } if score > bestScore { best, bestBytes, bestScore = size, inline, score } } return best, bestBytes, bestScore >= 0 } func validPhotoSizeType(value string) bool { if value == "" || len(value) > 8 { return false } for _, r := range value { if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' { continue } return false } return true } func safePublicImageType(value string) bool { switch value { case "image/jpeg", "image/png", "image/gif", "image/webp": return true default: return false } } func schemeURLValues(scheme, kind string, values url.Values) string { return (&url.URL{Scheme: scheme, Host: kind, RawQuery: values.Encode()}).String() } func publicWebAppURL(webBaseURL, legacyURL string) string { return strings.TrimRight(webBaseURL, "/") + "/#?tgaddr=" + url.QueryEscape(legacyURL) } func publicSecurityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'") w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()") w.Header().Set("Referrer-Policy", "no-referrer") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Frame-Options", "DENY") next.ServeHTTP(w, r) }) } func validShortNamePath(shortName string) bool { if shortName == "" || len(shortName) > 64 { return false } for _, r := range shortName { switch { case r >= 'a' && r <= 'z': case r >= 'A' && r <= 'Z': case r >= '0' && r <= '9': case r == '_' || r == '.': default: return false } } return true } func validSlugPath(slug string) bool { return links.ValidChatlistSlug(slug) } func validUsernamePath(username string) bool { if username == "" || len(username) < 5 || len(username) > 32 { return false } for i, r := range username { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': case r >= '0' && r <= '9', r == '_': if i == 0 { return false } default: return false } } return true } func linkKind(set domain.StickerSet) string { if set.Kind == domain.StickerSetKindEmoji || set.Emojis { return "addemoji" } return "addstickers" } func fallbackTitle(set domain.StickerSet) string { if title := strings.TrimSpace(set.Title); title != "" { return title } return set.ShortName } func kindLabel(set domain.StickerSet) string { switch { case set.Kind == domain.StickerSetKindEmoji || set.Emojis: return "custom emoji set" case set.Kind == domain.StickerSetKindMasks || set.Masks: return "mask set" default: return "sticker set" } } func itemNoun(set domain.StickerSet, count int) string { if set.Kind == domain.StickerSetKindEmoji || set.Emojis { if count == 1 { return "custom emoji" } return "custom emoji" } if count == 1 { return "sticker" } return "stickers" } func (h *handler) appURL(kind, key, value string) string { return schemeURL(h.appScheme, kind, key, value) } func legacyTgURL(kind, key, value string) string { return schemeURL("tg", kind, key, value) } func schemeURL(scheme, kind, key, value string) string { return scheme + "://" + kind + "?" + key + "=" + url.QueryEscape(value) } type pageData struct { AppName string Title string KindLabel string Subtitle string Description string CanonicalURL string AppURL template.URL LegacyTgURL template.URL AppURLJS template.JS } type usernamePageData struct { AppName string AppInitial string Title string Username string Verified bool Extra string Description string CanonicalURL string HomeURL string PhotoURL string Initials string ButtonLabel string AppURL template.URL LegacyTgURL template.URL WebURL template.URL AppURLJS template.JS } func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "public, max-age=30, must-revalidate") w.WriteHeader(http.StatusNotFound) if err := usernameNotFoundTemplate.Execute(w, struct { Username string HomeURL string AppName string }{Username: username, HomeURL: h.publicBaseURL + "/", AppName: h.appName}); err != nil { h.logger.Error("Render public username not-found page failed", zap.String("username", username), zap.Error(err)) } } var usernameLandingTemplate = template.Must(template.New("username-landing").Parse(` {{.Title}} (@{{.Username}}) - {{.AppName}} {{if .PhotoURL}}{{end}} {{if .PhotoURL}}{{end}}
{{.AppInitial}}{{.AppName}}
{{if .PhotoURL}}{{.Title}} profile photo{{else}}{{end}}

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

@{{.Username}}

{{if .Extra}}

{{.Extra}}

{{end}}

{{.Description}}

Old test clients only: open with tg://

`)) var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(` Username not found - {{.AppName}}

Username not found

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

Back to {{.AppName}}
`)) var landingTemplate = template.Must(template.New("landing").Parse(` {{.Title}} - {{.AppName}}

{{.KindLabel}}

{{.Title}}

{{.Subtitle}}

Open in {{.AppName}}

{{.Description}}

Old test clients only: open with tg://

{{.CanonicalURL}}

`))