feat: sync public links and phone change updates
This commit is contained in:
parent
41c7f1d018
commit
da04c0fa6a
53 changed files with 3029 additions and 111 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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&ref=campaign&start=hello",
|
||||
"tg://resolve?domain=TetrisBot&ref=campaign&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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue