feat: sync public links and phone change updates

This commit is contained in:
A 2026-07-10 22:01:44 +08:00
parent 41c7f1d018
commit da04c0fa6a
53 changed files with 3029 additions and 111 deletions

View file

@ -11,6 +11,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"go.uber.org/zap"
@ -22,6 +23,9 @@ type Config struct {
Addr string
PublicBaseURL string
Users UsernameResolver
Channels PublicChannelResolver
Privacy AnonymousPrivacyResolver
Photos ProfilePhotoResolver
}
type Resolver interface {
@ -32,6 +36,22 @@ 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, resolver Resolver, logger *zap.Logger) (*http.Server, error) {
addr := strings.TrimSpace(cfg.Addr)
if addr == "" {
@ -43,11 +63,15 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge
if logger == nil {
logger = zap.NewNop()
}
handler := NewHandlerWithUsers(resolver, cfg.Users, cfg.PublicBaseURL)
handler := newHandler(resolver, cfg.Users, cfg.Channels, cfg.Privacy, cfg.Photos, cfg.PublicBaseURL, logger)
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 {
@ -69,28 +93,64 @@ func Start(ctx context.Context, cfg Config, resolver Resolver, logger *zap.Logge
}
func NewHandler(resolver Resolver, publicBaseURL string) http.Handler {
return NewHandlerWithUsers(resolver, nil, publicBaseURL)
return newHandler(resolver, nil, nil, nil, nil, publicBaseURL, zap.NewNop())
}
func NewHandlerWithUsers(resolver Resolver, users UsernameResolver, publicBaseURL string) http.Handler {
return newHandler(resolver, users, nil, nil, nil, publicBaseURL, zap.NewNop())
}
func NewHandlerWithPublicPeers(
resolver Resolver,
users UsernameResolver,
channels PublicChannelResolver,
privacy AnonymousPrivacyResolver,
photos ProfilePhotoResolver,
publicBaseURL string,
) http.Handler {
return newHandler(resolver, users, channels, privacy, photos, publicBaseURL, zap.NewNop())
}
func newHandler(
resolver Resolver,
users UsernameResolver,
channels PublicChannelResolver,
privacy AnonymousPrivacyResolver,
photos ProfilePhotoResolver,
publicBaseURL string,
logger *zap.Logger,
) http.Handler {
if logger == nil {
logger = zap.NewNop()
}
h := &handler{
resolver: resolver,
users: users,
channels: channels,
privacy: privacy,
photos: photos,
publicBaseURL: normalizePublicBaseURL(publicBaseURL),
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)
return mux
mux.HandleFunc("GET /{username}/{$}", h.usernameLink)
return publicSecurityHeaders(mux)
}
type handler struct {
resolver Resolver
users UsernameResolver
channels PublicChannelResolver
privacy AnonymousPrivacyResolver
photos ProfilePhotoResolver
publicBaseURL string
logger *zap.Logger
}
func (h *handler) healthz(w http.ResponseWriter, _ *http.Request) {
@ -132,41 +192,127 @@ func (h *handler) addList(w http.ResponseWriter, r *http.Request) {
func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PathValue("username"))
if h.users == nil || !validUsernamePath(username) {
http.NotFound(w, r)
if !validUsernamePath(username) {
h.serveUsernameNotFound(w, username)
return
}
u, found, err := h.users.ByUsername(r.Context(), username)
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 || !u.Bot || strings.TrimSpace(u.Username) == "" {
http.NotFound(w, r)
if !found {
h.serveUsernameNotFound(w, username)
return
}
title := strings.TrimSpace(u.FirstName)
if title == "" {
title = u.Username
params.Set("domain", peer.username)
app := schemeURLValues("telesrv", "resolve", params)
legacy := schemeURLValues("tg", "resolve", params)
description := peer.about
if description == "" {
description = peer.fallbackDescription()
}
app := schemeURL("telesrv", "resolve", "domain", u.Username)
data := pageData{
Title: title,
KindLabel: "bot",
Subtitle: "@" + u.Username,
Description: "This page opens the app so you can start a chat with this bot.",
CanonicalURL: h.publicUsernameURL(u.Username),
data := usernamePageData{
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(schemeURL("tg", "resolve", "domain", u.Username)),
LegacyTgURL: template.URL(legacy),
WebURL: template.URL(publicWebAppURL(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")
if err := landingTemplate.Execute(w, data); err != nil {
http.Error(w, "render bot page failed", http.StatusInternalServerError)
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) {
@ -220,15 +366,364 @@ 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() string {
switch p.kind {
case publicPeerBot:
return "Open telesrv to start a chat with this bot."
case publicPeerChannel:
return "Open telesrv to view and join this channel."
case publicPeerSupergroup:
return "Open telesrv to view and join this group."
default:
return "Open telesrv 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
}
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(legacyURL string) string {
return "https://web.telesrv.net/#?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 normalizePublicBaseURL(raw string) string {
u, err := url.Parse(links.NormalizeBaseURL(raw))
if err != nil || u.Scheme == "" || u.Host == "" {
normalized, err := links.ValidateBaseURL(raw)
if err != nil {
return links.DefaultPublicBaseURL
}
u.Path = strings.TrimRight(u.Path, "/")
u.RawQuery = ""
u.Fragment = ""
return strings.TrimRight(u.String(), "/")
return normalized
}
func validShortNamePath(shortName string) bool {
@ -331,6 +826,130 @@ type pageData struct {
AppURLJS template.JS
}
type usernamePageData struct {
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
}{Username: username, HomeURL: h.publicBaseURL + "/"}); 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(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0e1621">
<title>{{.Title}} (@{{.Username}}) - telesrv</title>
<meta name="description" content="{{.Description}}">
<meta name="robots" content="index,follow,max-image-preview:large">
<link rel="canonical" href="{{.CanonicalURL}}">
<meta property="og:type" content="profile">
<meta property="og:site_name" content="telesrv">
<meta property="og:title" content="{{.Title}}">
<meta property="og:description" content="{{.Description}}">
<meta property="og:url" content="{{.CanonicalURL}}">
{{if .PhotoURL}}<meta property="og:image" content="{{.PhotoURL}}">{{end}}
<meta property="al:android:url" content="{{.AppURL}}">
<meta property="al:ios:url" content="{{.AppURL}}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.Title}}">
<meta name="twitter:description" content="{{.Description}}">
{{if .PhotoURL}}<meta name="twitter:image" content="{{.PhotoURL}}">{{end}}
<style>
:root { color-scheme: dark; font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; min-height: 100svh; color: #f5f8fb; background:
radial-gradient(circle at 50% -20%, rgba(50, 161, 255, .25), transparent 42%), #0e1621; }
.shell { min-height: 100svh; display: grid; grid-template-rows: auto 1fr auto; }
.brand { display: flex; align-items: center; gap: 10px; width: fit-content; margin: 28px auto 0; color: #dceeff;
font-size: 17px; font-weight: 700; letter-spacing: .01em; text-decoration: none; }
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; color: white;
background: linear-gradient(145deg, #52b8ff, #168de2); box-shadow: 0 8px 24px rgba(31, 151, 232, .3); }
main { display: grid; place-items: center; padding: 32px 18px; }
.card { width: min(100%, 420px); padding: 34px 30px 28px; text-align: center; border: 1px solid rgba(255,255,255,.08);
border-radius: 24px; background: rgba(23, 33, 43, .92); box-shadow: 0 28px 90px rgba(0,0,0,.34); backdrop-filter: blur(18px); }
.avatar { display: grid; place-items: center; width: 112px; height: 112px; margin: 0 auto 22px; overflow: hidden;
border-radius: 50%; background: linear-gradient(145deg, #47b7ff, #167bc1); box-shadow: 0 16px 44px rgba(10, 112, 183, .3); }
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
.initials { font-size: 38px; font-weight: 750; letter-spacing: -.04em; color: white; }
h1 { display: flex; align-items: center; justify-content: center; gap: 8px; margin: 0; font-size: clamp(25px, 7vw, 32px);
line-height: 1.18; letter-spacing: -.025em; overflow-wrap: anywhere; }
.verified { display: inline-grid; flex: 0 0 auto; place-items: center; width: 21px; height: 21px; border-radius: 50%;
color: #fff; background: #3aa8f7; font-size: 13px; font-weight: 900; }
.username { margin: 8px 0 0; color: #67bff9; font-size: 16px; overflow-wrap: anywhere; }
.extra { margin: 7px 0 0; color: #91a3b5; font-size: 14px; }
.description { margin: 22px auto 0; color: #c5d0da; font-size: 15px; line-height: 1.55; white-space: pre-line;
overflow-wrap: anywhere; }
.actions { display: grid; gap: 11px; margin-top: 28px; }
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 48px; padding: 0 20px; border-radius: 13px;
font-size: 15px; font-weight: 720; text-decoration: none; transition: transform .16s ease, background .16s ease; }
.button:hover { transform: translateY(-1px); }
.primary { color: #fff; background: linear-gradient(135deg, #31a9f5, #168de2); box-shadow: 0 10px 28px rgba(22,141,226,.25); }
.secondary { color: #a9dafa; background: rgba(72, 164, 226, .12); border: 1px solid rgba(89, 180, 241, .15); }
.legacy { margin: 18px 0 0; color: #718395; font-size: 12px; }
.legacy a { color: #83bddd; text-decoration: none; }
footer { padding: 0 18px 26px; color: #657789; font-size: 12px; text-align: center; }
@media (max-width: 480px) {
.brand { margin-top: 20px; }
main { padding: 24px 14px; align-items: start; }
.card { padding: 28px 22px 24px; border-radius: 20px; }
.avatar { width: 96px; height: 96px; }
}
@media (prefers-reduced-motion: reduce) { .button { transition: none; } }
</style>
</head>
<body>
<div class="shell">
<a class="brand" href="{{.HomeURL}}" aria-label="telesrv home"><span class="brand-mark">t</span><span>telesrv</span></a>
<main>
<article class="card">
<div class="avatar">{{if .PhotoURL}}<img src="{{.PhotoURL}}" alt="{{.Title}} profile photo" width="112" height="112">{{else}}<span class="initials" aria-hidden="true">{{.Initials}}</span>{{end}}</div>
<h1><span>{{.Title}}</span>{{if .Verified}}<span class="verified" title="Verified" aria-label="Verified"></span>{{end}}</h1>
<p class="username">@{{.Username}}</p>
{{if .Extra}}<p class="extra">{{.Extra}}</p>{{end}}
<p class="description">{{.Description}}</p>
<div class="actions">
<a class="button primary" href="{{.AppURL}}">{{.ButtonLabel}}</a>
<a class="button secondary" href="{{.WebURL}}">Open in Web</a>
</div>
<p class="legacy">Old test clients only: <a href="{{.LegacyTgURL}}">open with tg://</a></p>
</article>
</main>
<footer>If you have telesrv, this page can open the chat directly.</footer>
</div>
<script>window.setTimeout(function () { window.location.href = {{.AppURLJS}}; }, 250);</script>
</body>
</html>
`))
var usernameNotFoundTemplate = template.Must(template.New("username-not-found").Parse(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow"><title>Username not found - telesrv</title>
<style>:root{color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;background:#0e1621;color:#f5f8fb}.card{width:min(100%,420px);padding:34px 28px;border:1px solid rgba(255,255,255,.08);border-radius:22px;background:#17212b;text-align:center}h1{margin:0 0 12px;font-size:26px}p{margin:0;color:#9fb0bf;line-height:1.55;overflow-wrap:anywhere}a{display:inline-block;margin-top:24px;color:#67bff9;text-decoration:none}</style>
</head><body><main class="card"><h1>Username not found</h1><p>{{if .Username}}@{{.Username}} is not an active public telesrv username.{{else}}This is not a valid public telesrv username.{{end}}</p><a href="{{.HomeURL}}">Back to telesrv</a></main></body></html>`))
var landingTemplate = template.Must(template.New("landing").Parse(`<!doctype html>
<html lang="en">
<head>

View file

@ -119,7 +119,7 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/TetrisBot", nil)
NewHandlerWithUsers(fakeResolver{}, users, "http://127.0.0.1:2401").ServeHTTP(rr, req)
NewHandlerWithPublicPeers(fakeResolver{}, users, nil, nil, nil, "http://127.0.0.1:2401").ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
@ -132,7 +132,10 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) {
"http://127.0.0.1:2401/TetrisBot",
"telesrv://resolve?domain=TetrisBot",
"tg://resolve?domain=TetrisBot",
"start a chat with this bot",
"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"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing %q:\n%s", want, body)
@ -143,6 +146,266 @@ func TestHandlerServesBotUsernameLandingPage(t *testing.T) {
}
}
func TestHandlerServesUserChannelAndSupergroupLandingPages(t *testing.T) {
users := fakeUsers{
"alice": {
ID: 2001,
AccessHash: 987654321,
Phone: "+15551234567",
Username: "Alice",
FirstName: "Alice",
LastName: "Example",
About: "Public bio",
Verified: true,
LastSeenAt: 1700000000,
},
}
channels := fakeChannels{
"newsroom": {
ID: 3001,
Username: "NewsRoom",
Title: "News Room",
About: "Public channel description",
Broadcast: true,
ParticipantsCount: 12001,
Verified: true,
PhotoID: 301,
},
"studygroup": {
ID: 3002,
Username: "StudyGroup",
Title: "Study Group",
About: "A public supergroup",
Megagroup: true,
ParticipantsCount: 1,
},
}
photos := &fakePhotos{byID: map[int64]domain.Photo{
301: {ID: 301, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}}},
}}
handler := NewHandlerWithPublicPeers(fakeResolver{}, users, channels, nil, photos, "https://telesrv.net")
for _, tc := range []struct {
path string
wants []string
}{
{
path: "/aLiCe/",
wants: []string{
"Alice Example", "Public bio", "@Alice", "Send Message", "Verified",
"https://telesrv.net/Alice", "telesrv://resolve?domain=Alice",
},
},
{
path: "/NewsRoom",
wants: []string{
"News Room", "Public channel description", "12 001 subscribers", "View Channel",
"https://telesrv.net/NewsRoom", "telesrv://resolve?domain=NewsRoom",
"/_public/avatar/NewsRoom/301",
},
},
{
path: "/StudyGroup",
wants: []string{
"Study Group", "A public supergroup", "1 member", "View Group",
},
},
} {
t.Run(tc.path, func(t *testing.T) {
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, tc.path, nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
for _, want := range tc.wants {
if !strings.Contains(rr.Body.String(), want) {
t.Fatalf("body missing %q:\n%s", want, rr.Body.String())
}
}
if tc.path == "/aLiCe/" && strings.Count(rr.Body.String(), `<p class="username">@Alice</p>`) != 1 {
t.Fatalf("ordinary user username rendered more than once:\n%s", rr.Body.String())
}
if strings.Contains(rr.Body.String(), "+15551234567") || strings.Contains(rr.Body.String(), "987654321") || strings.Contains(rr.Body.String(), "1700000000") {
t.Fatalf("private protocol fields leaked into page:\n%s", rr.Body.String())
}
})
}
}
func TestHandlerPreservesBoundedResolveQueryAndOverridesDomain(t *testing.T) {
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
"tetrisbot": {ID: 2001, Username: "TetrisBot", FirstName: "Tetris", Bot: true},
}, nil, nil, nil, "https://telesrv.net")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/TetrisBot?start=hello&ref=campaign&domain=EvilBot", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
for _, want := range []string{
"telesrv://resolve?domain=TetrisBot&amp;ref=campaign&amp;start=hello",
"tg://resolve?domain=TetrisBot&amp;ref=campaign&amp;start=hello",
} {
if !strings.Contains(body, want) {
t.Fatalf("body missing sanitized query %q:\n%s", want, body)
}
}
if strings.Contains(body, "EvilBot") {
t.Fatalf("caller-controlled domain leaked into page:\n%s", body)
}
for _, target := range []string{
"/TetrisBot?bad-key=value",
"/TetrisBot?start=" + strings.Repeat("a", maxPublicLinkValueLen+1),
"/TetrisBot?" + strings.Repeat("a", maxPublicLinkRawQuery+1),
} {
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, target, nil))
if rr.Code != http.StatusRequestURITooLong {
t.Fatalf("%s status = %d, want 414", target, rr.Code)
}
}
}
func TestHandlerHonorsAnonymousAboutAndPhotoPrivacy(t *testing.T) {
const userID int64 = 2001
photos := &fakePhotos{
photos: map[photoLookupKey]domain.Photo{
{ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindProfile}: {
ID: 10, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}},
},
{ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindFallback}: {
ID: 11, Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}},
},
},
}
privacy := fakeAnonymousPrivacy{
domain.PrivacyKeyAbout: false,
domain.PrivacyKeyProfilePhoto: false,
}
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
"alice": {ID: userID, Username: "Alice", FirstName: "Alice", About: "private biography"},
}, nil, privacy, photos, "https://telesrv.net")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/Alice", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
if strings.Contains(body, "private biography") || strings.Contains(body, "/10") {
t.Fatalf("private about or main photo leaked:\n%s", body)
}
if !strings.Contains(body, "/_public/avatar/Alice/11") {
t.Fatalf("fallback public photo missing:\n%s", body)
}
}
func TestHandlerServesBoundedCurrentAvatarWithETag(t *testing.T) {
const userID int64 = 2001
jpeg := []byte{0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00, 0x01}
photos := &fakePhotos{
photos: map[photoLookupKey]domain.Photo{
{ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindProfile}: {
ID: 99, Date: 1700000000,
Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: len(jpeg)}},
},
},
files: map[string]domain.FileChunk{
"photo:99:c": {Bytes: jpeg, MimeType: "image/jpeg", Total: int64(len(jpeg))},
},
}
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
"alice": {ID: userID, Username: "Alice", FirstName: "Alice"},
}, nil, nil, photos, "https://telesrv.net")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/_public/avatar/Alice/99", nil))
if rr.Code != http.StatusOK || rr.Header().Get("Content-Type") != "image/jpeg" || rr.Body.String() != string(jpeg) {
t.Fatalf("avatar response status=%d type=%q body=%x", rr.Code, rr.Header().Get("Content-Type"), rr.Body.Bytes())
}
if rr.Header().Get("ETag") == "" || rr.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("avatar headers = %+v", rr.Header())
}
etag := rr.Header().Get("ETag")
req := httptest.NewRequest(http.MethodGet, "/_public/avatar/Alice/99", nil)
req.Header.Set("If-None-Match", etag)
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusNotModified || rr.Body.Len() != 0 {
t.Fatalf("conditional avatar status=%d body=%x", rr.Code, rr.Body.Bytes())
}
for _, path := range []string{"/_public/avatar/Alice/100", "/_public/avatar/Missing/99"} {
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("%s status = %d, want 404", path, rr.Code)
}
}
}
func TestHandlerFailsFastForAmbiguousUsernameOwner(t *testing.T) {
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
"sharedname": {ID: 2001, Username: "SharedName", FirstName: "User"},
}, fakeChannels{
"sharedname": {ID: 3001, Username: "SharedName", Title: "Channel", Broadcast: true},
}, nil, nil, "https://telesrv.net")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/SharedName", nil))
if rr.Code != http.StatusInternalServerError {
t.Fatalf("ambiguous owner status = %d, want 500", rr.Code)
}
}
func TestHandlerReturnsTrustedUsernameNotFoundPage(t *testing.T) {
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{}, fakeChannels{}, nil, nil, "https://telesrv.net")
for _, path := range []string{"/MissingName", "/bad-name", "/Nope"} {
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil))
if rr.Code != http.StatusNotFound || !strings.Contains(rr.Body.String(), "Username not found") || !strings.Contains(rr.Body.String(), "noindex,nofollow") {
t.Fatalf("%s status=%d body=%s", path, rr.Code, rr.Body.String())
}
if strings.Contains(rr.Body.String(), "telesrv://resolve") {
t.Fatalf("not-found page contains a fabricated app link: %s", rr.Body.String())
}
if rr.Header().Get("Content-Security-Policy") == "" || rr.Header().Get("X-Frame-Options") != "DENY" {
t.Fatalf("not-found security headers = %+v", rr.Header())
}
}
}
func TestPublicAvatarRejectsOversizedOrUnsafeBlob(t *testing.T) {
const userID int64 = 2001
photo := domain.Photo{
ID: 99,
Sizes: []domain.PhotoSize{{Kind: domain.PhotoSizeKindDefault, Type: "c", W: 640, H: 640, Size: 12}},
}
for _, tc := range []struct {
name string
chunk domain.FileChunk
}{
{name: "oversized", chunk: domain.FileChunk{Bytes: []byte("x"), MimeType: "image/jpeg", Total: maxPublicAvatarBytes + 1}},
{name: "unsafe mime", chunk: domain.FileChunk{Bytes: []byte("<svg></svg>"), MimeType: "image/svg+xml", Total: int64(len("<svg></svg>"))}},
} {
t.Run(tc.name, func(t *testing.T) {
photos := &fakePhotos{
photos: map[photoLookupKey]domain.Photo{
{ownerType: domain.PeerTypeUser, ownerID: userID, kind: domain.ProfilePhotoKindProfile}: photo,
},
files: map[string]domain.FileChunk{"photo:99:c": tc.chunk},
}
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
"alice": {ID: userID, Username: "Alice", FirstName: "Alice"},
}, nil, nil, photos, "https://telesrv.net")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/_public/avatar/Alice/99", nil))
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body=%s", rr.Code, rr.Body.String())
}
})
}
}
func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) {
resolver := fakeResolver{
"emoji_pack": {
@ -167,13 +430,13 @@ func TestHandlerRedirectsMismatchedKindToCanonicalURL(t *testing.T) {
}
func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) {
handler := NewHandlerWithUsers(fakeResolver{}, fakeUsers{
handler := NewHandlerWithPublicPeers(fakeResolver{}, fakeUsers{
"alice": {
ID: 2001,
Username: "Alice",
FirstName: "Alice",
},
}, "https://telesrv.net")
}, nil, nil, nil, "https://telesrv.net")
for _, path := range []string{
"/addstickers/missing_pack",
"/addstickers/bad-name",
@ -181,7 +444,6 @@ func TestHandlerNotFoundForMissingOrInvalidShortName(t *testing.T) {
"/addlist/bad!slug",
"/addlist/%E4%B8%AD%E6%96%87",
"/MissingBot",
"/Alice",
"/bad-name-bot",
"/1stBot",
} {
@ -228,3 +490,47 @@ func (f fakeUsers) ByUsername(_ context.Context, username string) (domain.User,
u, ok := f[strings.ToLower(strings.TrimPrefix(username, "@"))]
return u, ok, nil
}
type fakeChannels map[string]domain.Channel
func (f fakeChannels) ResolvePublicChannelUsername(_ context.Context, _ int64, username string) (domain.Channel, bool, error) {
ch, ok := f[strings.ToLower(strings.TrimPrefix(username, "@"))]
return ch, ok, nil
}
type fakeAnonymousPrivacy map[domain.PrivacyKey]bool
func (f fakeAnonymousPrivacy) CanSeeAnonymous(_ context.Context, _ int64, key domain.PrivacyKey) (bool, error) {
visible, ok := f[key]
if !ok {
return true, nil
}
return visible, nil
}
type photoLookupKey struct {
ownerType domain.PeerType
ownerID int64
kind domain.ProfilePhotoKind
}
type fakePhotos struct {
photos map[photoLookupKey]domain.Photo
byID map[int64]domain.Photo
files map[string]domain.FileChunk
}
func (f *fakePhotos) CurrentProfilePhotoKind(_ context.Context, ownerType domain.PeerType, ownerID int64, kind domain.ProfilePhotoKind) (domain.Photo, bool, error) {
photo, ok := f.photos[photoLookupKey{ownerType: ownerType, ownerID: ownerID, kind: kind}]
return photo, ok, nil
}
func (f *fakePhotos) GetPhoto(_ context.Context, id int64) (domain.Photo, bool, error) {
photo, ok := f.byID[id]
return photo, ok, nil
}
func (f *fakePhotos) GetFile(_ context.Context, req domain.FileDownloadRequest) (domain.FileChunk, bool, error) {
chunk, ok := f.files[req.LocationKey]
return chunk, ok, nil
}