fix for preview

This commit is contained in:
onysd 2026-07-13 10:11:29 +03:00
parent 935876570b
commit d70148aca3
5 changed files with 240 additions and 14 deletions

View file

@ -42,6 +42,11 @@ type ChannelStore interface {
// viewerUserID may be zero for anonymous public-link projection; this lookup
// never returns viewer-specific membership or dialog state.
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error)
// ResolvePublicChannelInvite resolves an active (not revoked, not deleted,
// not expired) invite hash to its target channel/supergroup for the
// anonymous public invite-link preview page. It never returns viewer-specific
// membership state and does not require a userID.
ResolvePublicChannelInvite(ctx context.Context, hash string) (domain.Channel, domain.ChannelInvite, bool, error)
SearchPublicChannels(ctx context.Context, viewerUserID int64, query string, limit int) (domain.PublicChannelSearchResult, error)
SetSignatures(ctx context.Context, userID, channelID int64, enabled bool) (domain.Channel, error)
SetChannelPhoto(ctx context.Context, userID, channelID int64, photo *domain.Photo, date int) (domain.SetChannelPhotoResult, error)

View file

@ -273,6 +273,30 @@ func (s *ChannelStore) CheckInvite(_ context.Context, userID int64, hash string,
}, nil
}
// ResolvePublicChannelInvite is the anonymous, unauthenticated counterpart of
// CheckInvite: no userID/membership lookup, just "is this hash currently
// joinable" plus the public channel projection for the invite preview page.
func (s *ChannelStore) ResolvePublicChannelInvite(_ context.Context, hash string) (domain.Channel, domain.ChannelInvite, bool, error) {
hash = strings.TrimSpace(hash)
if hash == "" {
return domain.Channel{}, domain.ChannelInvite{}, false, nil
}
s.mu.RLock()
defer s.mu.RUnlock()
invite, ok := s.invites[hash]
if !ok || invite.Revoked {
return domain.Channel{}, domain.ChannelInvite{}, false, nil
}
if invite.ExpireDate > 0 && invite.ExpireDate < int(time.Now().Unix()) {
return domain.Channel{}, domain.ChannelInvite{}, false, nil
}
channel, ok := s.channels[invite.ChannelID]
if !ok || channel.Deleted {
return domain.Channel{}, domain.ChannelInvite{}, false, nil
}
return channel, invite, true, nil
}
func (s *ChannelStore) ImportInvite(_ context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) {
if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" {
return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty

View file

@ -36,6 +36,27 @@ func (s *ChannelStore) CheckInvite(ctx context.Context, userID int64, hash strin
return domain.CheckChannelInviteResult{Channel: channel, Invite: invite, Already: already, Self: member}, nil
}
// ResolvePublicChannelInvite is the anonymous, unauthenticated counterpart of
// CheckInvite: no userID/membership lookup, just "is this hash currently
// joinable" plus the public channel projection for the invite preview page.
func (s *ChannelStore) ResolvePublicChannelInvite(ctx context.Context, hash string) (domain.Channel, domain.ChannelInvite, bool, error) {
hash = strings.TrimSpace(hash)
if hash == "" {
return domain.Channel{}, domain.ChannelInvite{}, false, nil
}
channel, invite, err := s.getInviteByHash(ctx, s.db, hash)
if err != nil {
if errors.Is(err, domain.ErrInviteHashInvalid) {
return domain.Channel{}, domain.ChannelInvite{}, false, nil
}
return domain.Channel{}, domain.ChannelInvite{}, false, err
}
if invite.ExpireDate > 0 && invite.ExpireDate < nowUnix() {
return domain.Channel{}, domain.ChannelInvite{}, false, nil
}
return channel, invite, true, nil
}
func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) {
if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" {
return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty

View file

@ -46,6 +46,9 @@ type UsernameResolver interface {
// 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)
// ResolvePublicChannelInvite resolves a private invite-link hash (the
// "/+<hash>" landing page) to its target channel/supergroup.
ResolvePublicChannelInvite(ctx context.Context, hash string) (domain.Channel, domain.ChannelInvite, bool, error)
}
type AnonymousPrivacyResolver interface {
@ -161,6 +164,7 @@ func newHandler(cfg Config, logger *zap.Logger) (http.Handler, error) {
mux.HandleFunc("GET /_public/assets/logo.png", h.brandLogo)
mux.HandleFunc("GET /_public/assets/fonts/{file}", h.brandFont)
mux.HandleFunc("GET /_public/avatar/{username}/{photoID}", h.publicAvatar)
mux.HandleFunc("GET /_public/invite-avatar/{hash}/{photoID}", h.publicInviteAvatar)
mux.HandleFunc("GET /addstickers/{shortName}", h.addStickers)
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
mux.HandleFunc("GET /addlist/{slug}", h.addList)
@ -221,7 +225,12 @@ 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"))
raw := strings.TrimSpace(r.PathValue("username"))
if strings.HasPrefix(raw, "+") {
h.inviteLink(w, r, strings.TrimPrefix(raw, "+"))
return
}
username := raw
if !validUsernamePath(username) {
h.serveUsernameNotFound(w, username)
return
@ -280,6 +289,70 @@ func (h *handler) usernameLink(w http.ResponseWriter, r *http.Request) {
}
}
// inviteLink serves the "/+<hash>" private invite-link preview page: the
// channel/supergroup's photo, title, and member count, with a Join button
// that deep-links into the app. It never requires or reveals viewer-specific
// membership state.
func (h *handler) inviteLink(w http.ResponseWriter, r *http.Request, hash string) {
if !validInviteHashPath(hash) || h.channels == nil {
h.serveInviteNotFound(w)
return
}
ch, invite, found, err := h.channels.ResolvePublicChannelInvite(r.Context(), hash)
if err != nil {
h.logger.Error("Public invite lookup failed", zap.String("invite_hash", hash), zap.Error(err))
http.Error(w, "invite lookup failed", http.StatusInternalServerError)
return
}
if !found {
h.serveInviteNotFound(w)
return
}
peer, ok, err := h.publicInviteChannelPeer(r.Context(), ch)
if err != nil || !ok {
if err != nil {
h.logger.Error("Public invite channel projection failed", zap.String("invite_hash", hash), zap.Error(err))
}
h.serveInviteNotFound(w)
return
}
title := peer.title
if title == "" {
title = strings.TrimSpace(invite.Title)
}
app := h.appURL("join", "invite", hash)
description := peer.about
if description == "" {
description = peer.fallbackDescription(h.appName)
}
grad := avatarGradient(peer.id)
data := usernamePageData{
AppName: h.appName,
AppInitial: appInitial(h.appName),
Title: title,
Verified: peer.verified,
Extra: peer.extra(),
Description: description,
AboutText: peer.about,
CanonicalURL: h.publicInviteURL(hash),
HomeURL: h.publicBaseURL + "/",
DownloadURL: h.downloadURL,
AppURL: template.URL(app),
ButtonLabel: peer.inviteButtonLabel(),
Initials: peer.initials(),
GradFrom: grad[0],
GradTo: grad[1],
}
if peer.hasPhoto {
data.PhotoURL = h.publicInviteAvatarURL(hash, peer.photo.ID)
}
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 invite page failed", zap.String("invite_hash", hash), zap.Error(err))
}
}
const maxPublicAvatarBytes = 4 << 20
func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) {
@ -299,6 +372,39 @@ func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
h.servePeerAvatarPhoto(w, r, peer, photoID, zap.String("username", username))
}
func (h *handler) publicInviteAvatar(w http.ResponseWriter, r *http.Request) {
hash := strings.TrimSpace(r.PathValue("hash"))
photoID, err := strconv.ParseInt(r.PathValue("photoID"), 10, 64)
if err != nil || photoID <= 0 || !validInviteHashPath(hash) || h.photos == nil || h.channels == nil {
http.NotFound(w, r)
return
}
ch, _, found, err := h.channels.ResolvePublicChannelInvite(r.Context(), hash)
if err != nil {
h.logger.Error("Public invite avatar lookup failed", zap.String("invite_hash", hash), zap.Error(err))
http.Error(w, "avatar lookup failed", http.StatusInternalServerError)
return
}
if !found {
http.NotFound(w, r)
return
}
peer, ok, err := h.publicInviteChannelPeer(r.Context(), ch)
if err != nil || !ok || !peer.hasPhoto || peer.photo.ID != photoID {
http.NotFound(w, r)
return
}
h.servePeerAvatarPhoto(w, r, peer, photoID, zap.String("invite_hash", hash))
}
// servePeerAvatarPhoto streams an already-authorized public peer's profile
// photo. logField identifies the lookup key (username or invite hash) for
// diagnostics only; the caller has already verified peer.hasPhoto and that
// peer.photo.ID matches the requested photoID.
func (h *handler) servePeerAvatarPhoto(w http.ResponseWriter, r *http.Request, peer publicPeer, photoID int64, logField zap.Field) {
size, inline, ok := bestPublicPhotoSize(peer.photo.Sizes)
if !ok {
http.NotFound(w, r)
@ -319,12 +425,12 @@ func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) {
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))
h.logger.Error("Read public avatar blob failed", logField, 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)))
h.logger.Warn("Public avatar blob is missing or outside bounds", logField, zap.Int64("photo_id", photoID), zap.Int64("total", chunk.Total), zap.Int("bytes", len(chunk.Bytes)))
http.NotFound(w, r)
return
}
@ -337,7 +443,7 @@ func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) {
}
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))
h.logger.Warn("Public avatar blob is not a safe raster image", logField, zap.Int64("photo_id", photoID), zap.String("detected_type", detected), zap.String("stored_type", mimeType))
http.NotFound(w, r)
return
}
@ -407,6 +513,14 @@ func (h *handler) publicAvatarURL(username string, photoID int64) string {
return h.publicBaseURL + "/_public/avatar/" + url.PathEscape(username) + "/" + strconv.FormatInt(photoID, 10)
}
func (h *handler) publicInviteURL(hash string) string {
return h.publicBaseURL + "/+" + url.PathEscape(hash)
}
func (h *handler) publicInviteAvatarURL(hash string, photoID int64) string {
return h.publicBaseURL + "/_public/invite-avatar/" + url.PathEscape(hash) + "/" + strconv.FormatInt(photoID, 10)
}
type publicPeerKind string
const (
@ -515,9 +629,23 @@ func (h *handler) publicUserPeer(ctx context.Context, requested string, u domain
}
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) {
if !strings.EqualFold(strings.TrimSpace(ch.Username), requested) || !validUsernamePath(ch.Username) {
return publicPeer{}, false, fmt.Errorf("channel username lookup returned invalid owner for %q", requested)
}
return h.channelToPublicPeer(ctx, ch)
}
// publicInviteChannelPeer projects a channel resolved via an invite hash.
// Unlike publicChannelPeer it does not require (or use) a public username —
// invite links exist precisely for channels/groups that may not have one.
func (h *handler) publicInviteChannelPeer(ctx context.Context, ch domain.Channel) (publicPeer, bool, error) {
return h.channelToPublicPeer(ctx, ch)
}
func (h *handler) channelToPublicPeer(ctx context.Context, ch domain.Channel) (publicPeer, bool, error) {
if ch.ID == 0 || ch.Deleted || ch.ParticipantsCount < 0 || (!ch.Broadcast && !ch.Megagroup) {
return publicPeer{}, false, fmt.Errorf("channel %d is not eligible for a public projection", ch.ID)
}
if err := validatePublicPeerText(ch.Title, ch.About); err != nil {
return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", ch.Username, err)
}
@ -575,6 +703,15 @@ func (p publicPeer) buttonLabel() string {
}
}
// inviteButtonLabel is used on invite-link preview pages, where the viewer is
// not yet a member — "Join", not "View" like the public-username page.
func (p publicPeer) inviteButtonLabel() string {
if p.kind == publicPeerSupergroup {
return "Join Group"
}
return "Join Channel"
}
func (p publicPeer) extra() string {
switch p.kind {
case publicPeerBot:
@ -821,6 +958,23 @@ func validUsernamePath(username string) bool {
return true
}
func validInviteHashPath(hash string) bool {
if hash == "" || len(hash) > 64 {
return false
}
for _, r := range hash {
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 linkKind(set domain.StickerSet) string {
if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
return "addemoji"
@ -900,16 +1054,28 @@ type usernamePageData struct {
}
func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string) {
message := "This is not a valid public " + h.appName + " username."
if username != "" {
message = "@" + username + " is not an active public " + h.appName + " username."
}
h.serveNotFound(w, message)
}
func (h *handler) serveInviteNotFound(w http.ResponseWriter) {
h.serveNotFound(w, "This invite link is not active anymore.")
}
func (h *handler) serveNotFound(w http.ResponseWriter, message 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
Message string
HomeURL string
AppName string
DownloadURL string
}{Username: username, HomeURL: h.publicBaseURL + "/", AppName: h.appName, DownloadURL: h.downloadURL}); err != nil {
h.logger.Error("Render public username not-found page failed", zap.String("username", username), zap.Error(err))
}{Message: message, HomeURL: h.publicBaseURL + "/", AppName: h.appName, DownloadURL: h.downloadURL}); err != nil {
h.logger.Error("Render public not-found page failed", zap.Error(err))
}
}
@ -1137,7 +1303,7 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<title>{{.Title}} (@{{.Username}}) - {{.AppName}}</title>
<title>{{.Title}}{{if .Username}} (@{{.Username}}){{end}} - {{.AppName}}</title>
<meta name="description" content="{{.Description}}">
<meta name="robots" content="index,follow,max-image-preview:large">
<link rel="canonical" href="{{.CanonicalURL}}">
@ -1183,7 +1349,7 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par
<span>{{.Title}}</span>
{{if .Verified}}<svg class="badge" viewBox="0 0 24 24" fill="none" aria-label="Verified"><circle cx="12" cy="12" r="11" fill="#2563eb"/><path d="M7 12.5l3.2 3.2L17 9" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>{{end}}
</div>
<div class="sub">@{{.Username}}</div>
{{if .Username}}<div class="sub">@{{.Username}}</div>{{end}}
{{if .Extra}}<div class="extra">{{.Extra}}</div>{{end}}
{{if .AboutText}}<div class="about">{{.AboutText}}</div>{{end}}
<div class="actions">
@ -1203,7 +1369,7 @@ var usernameNotFoundTemplate = template.Must(template.New("username-not-found").
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb">
<meta name="robots" content="noindex,nofollow">
<title>Username not found - {{.AppName}}</title>
<title>Nothing found - {{.AppName}}</title>
<link rel="icon" type="image/png" href="/_public/assets/logo.png">
<style>` + publicPageStyleSheet + `</style>
</head>
@ -1219,8 +1385,7 @@ var usernameNotFoundTemplate = template.Must(template.New("username-not-found").
<div class="card">
<div class="nf-emoji">🤔</div>
<h2 style="font-size:22px">Nothing found</h2>
<div class="about">{{if .Username}}@{{.Username}} is not an active public {{.AppName}} username.{{else}}This is not a valid public {{.AppName}} username.{{end}}</div>
<div class="actions"><a class="btn btn-primary" href="{{.HomeURL}}">Back to {{.AppName}}</a></div>
<div class="about">{{.Message}}</div>
</div>
</main>` + publicBgIconsScript + `
</body>

View file

@ -458,7 +458,7 @@ func TestHandlerReturnsTrustedUsernameNotFoundPage(t *testing.T) {
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") {
if rr.Code != http.StatusNotFound || !strings.Contains(rr.Body.String(), "Nothing 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") {
@ -594,6 +594,17 @@ func (f fakeChannels) ResolvePublicChannelUsername(_ context.Context, _ int64, u
return ch, ok, nil
}
// ResolvePublicChannelInvite reuses the same map, keyed by whatever string
// the test registers as the invite "hash" (map keys are otherwise usernames,
// but nothing stops a test from also registering a hash-shaped key).
func (f fakeChannels) ResolvePublicChannelInvite(_ context.Context, hash string) (domain.Channel, domain.ChannelInvite, bool, error) {
ch, ok := f[hash]
if !ok {
return domain.Channel{}, domain.ChannelInvite{}, false, nil
}
return ch, domain.ChannelInvite{ChannelID: ch.ID, Hash: hash}, true, nil
}
type fakeAnonymousPrivacy map[domain.PrivacyKey]bool
func (f fakeAnonymousPrivacy) CanSeeAnonymous(_ context.Context, _ int64, key domain.PrivacyKey) (bool, error) {