fix for preview
This commit is contained in:
parent
935876570b
commit
d70148aca3
5 changed files with 240 additions and 14 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue