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 // viewerUserID may be zero for anonymous public-link projection; this lookup
// never returns viewer-specific membership or dialog state. // never returns viewer-specific membership or dialog state.
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) 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) 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) 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) 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 }, 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) { func (s *ChannelStore) ImportInvite(_ context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) {
if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" {
return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty 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 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) { func (s *ChannelStore) ImportInvite(ctx context.Context, req domain.ImportChannelInviteRequest) (domain.CreateChannelResult, error) {
if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" { if req.UserID == 0 || strings.TrimSpace(req.Hash) == "" {
return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty return domain.CreateChannelResult{}, domain.ErrInviteHashEmpty

View file

@ -46,6 +46,9 @@ type UsernameResolver interface {
// projection. viewerUserID is always zero for this anonymous Web endpoint. // projection. viewerUserID is always zero for this anonymous Web endpoint.
type PublicChannelResolver interface { type PublicChannelResolver interface {
ResolvePublicChannelUsername(ctx context.Context, viewerUserID int64, username string) (domain.Channel, bool, error) 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 { 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/logo.png", h.brandLogo)
mux.HandleFunc("GET /_public/assets/fonts/{file}", h.brandFont) mux.HandleFunc("GET /_public/assets/fonts/{file}", h.brandFont)
mux.HandleFunc("GET /_public/avatar/{username}/{photoID}", h.publicAvatar) 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 /addstickers/{shortName}", h.addStickers)
mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji) mux.HandleFunc("GET /addemoji/{shortName}", h.addEmoji)
mux.HandleFunc("GET /addlist/{slug}", h.addList) 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) { 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) { if !validUsernamePath(username) {
h.serveUsernameNotFound(w, username) h.serveUsernameNotFound(w, username)
return 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 const maxPublicAvatarBytes = 4 << 20
func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) { 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) http.NotFound(w, r)
return 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) size, inline, ok := bestPublicPhotoSize(peer.photo.Sizes)
if !ok { if !ok {
http.NotFound(w, r) http.NotFound(w, r)
@ -319,12 +425,12 @@ func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) {
Limit: maxPublicAvatarBytes + 1, Limit: maxPublicAvatarBytes + 1,
}) })
if err != nil { 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) http.Error(w, "avatar read failed", http.StatusInternalServerError)
return return
} }
if !found || chunk.Total <= 0 || chunk.Total > maxPublicAvatarBytes || int64(len(chunk.Bytes)) != chunk.Total { 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) http.NotFound(w, r)
return return
} }
@ -337,7 +443,7 @@ func (h *handler) publicAvatar(w http.ResponseWriter, r *http.Request) {
} }
detected := http.DetectContentType(data) detected := http.DetectContentType(data)
if !safePublicImageType(detected) { 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) http.NotFound(w, r)
return 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) 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 type publicPeerKind string
const ( 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) { 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 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 { if err := validatePublicPeerText(ch.Title, ch.About); err != nil {
return publicPeer{}, false, fmt.Errorf("invalid public channel %q: %w", ch.Username, err) 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 { func (p publicPeer) extra() string {
switch p.kind { switch p.kind {
case publicPeerBot: case publicPeerBot:
@ -821,6 +958,23 @@ func validUsernamePath(username string) bool {
return true 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 { func linkKind(set domain.StickerSet) string {
if set.Kind == domain.StickerSetKindEmoji || set.Emojis { if set.Kind == domain.StickerSetKindEmoji || set.Emojis {
return "addemoji" return "addemoji"
@ -900,16 +1054,28 @@ type usernamePageData struct {
} }
func (h *handler) serveUsernameNotFound(w http.ResponseWriter, username string) { 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("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=30, must-revalidate") w.Header().Set("Cache-Control", "public, max-age=30, must-revalidate")
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
if err := usernameNotFoundTemplate.Execute(w, struct { if err := usernameNotFoundTemplate.Execute(w, struct {
Username string Message string
HomeURL string HomeURL string
AppName string AppName string
DownloadURL string DownloadURL string
}{Username: username, HomeURL: h.publicBaseURL + "/", AppName: h.appName, DownloadURL: h.downloadURL}); err != nil { }{Message: message, 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)) 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 charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb"> <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="description" content="{{.Description}}">
<meta name="robots" content="index,follow,max-image-preview:large"> <meta name="robots" content="index,follow,max-image-preview:large">
<link rel="canonical" href="{{.CanonicalURL}}"> <link rel="canonical" href="{{.CanonicalURL}}">
@ -1183,7 +1349,7 @@ var usernameLandingTemplate = template.Must(template.New("username-landing").Par
<span>{{.Title}}</span> <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}} {{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>
<div class="sub">@{{.Username}}</div> {{if .Username}}<div class="sub">@{{.Username}}</div>{{end}}
{{if .Extra}}<div class="extra">{{.Extra}}</div>{{end}} {{if .Extra}}<div class="extra">{{.Extra}}</div>{{end}}
{{if .AboutText}}<div class="about">{{.AboutText}}</div>{{end}} {{if .AboutText}}<div class="about">{{.AboutText}}</div>{{end}}
<div class="actions"> <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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#2563eb"> <meta name="theme-color" content="#2563eb">
<meta name="robots" content="noindex,nofollow"> <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"> <link rel="icon" type="image/png" href="/_public/assets/logo.png">
<style>` + publicPageStyleSheet + `</style> <style>` + publicPageStyleSheet + `</style>
</head> </head>
@ -1219,8 +1385,7 @@ var usernameNotFoundTemplate = template.Must(template.New("username-not-found").
<div class="card"> <div class="card">
<div class="nf-emoji">🤔</div> <div class="nf-emoji">🤔</div>
<h2 style="font-size:22px">Nothing found</h2> <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="about">{{.Message}}</div>
<div class="actions"><a class="btn btn-primary" href="{{.HomeURL}}">Back to {{.AppName}}</a></div>
</div> </div>
</main>` + publicBgIconsScript + ` </main>` + publicBgIconsScript + `
</body> </body>

View file

@ -458,7 +458,7 @@ func TestHandlerReturnsTrustedUsernameNotFoundPage(t *testing.T) {
for _, path := range []string{"/MissingName", "/bad-name", "/Nope"} { for _, path := range []string{"/MissingName", "/bad-name", "/Nope"} {
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
handler.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) 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()) t.Fatalf("%s status=%d body=%s", path, rr.Code, rr.Body.String())
} }
if strings.Contains(rr.Body.String(), "telesrv://resolve") { if strings.Contains(rr.Body.String(), "telesrv://resolve") {
@ -594,6 +594,17 @@ func (f fakeChannels) ResolvePublicChannelUsername(_ context.Context, _ int64, u
return ch, ok, nil 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 type fakeAnonymousPrivacy map[domain.PrivacyKey]bool
func (f fakeAnonymousPrivacy) CanSeeAnonymous(_ context.Context, _ int64, key domain.PrivacyKey) (bool, error) { func (f fakeAnonymousPrivacy) CanSeeAnonymous(_ context.Context, _ int64, key domain.PrivacyKey) (bool, error) {