fix: client perms, @everyone/@channel, docs, unit tests
- usePermissions ORs current user roles + @everyone only (not all server roles) - cache myRolesByServer; load on active server; refresh after self role edit - gate/notify @everyone and @channel; plain @username push; special mention UI - refresh FEATURE_PARITY (DMs exist; drop stale critical gaps) - README production deploy notes dumpster.service - unit tests for permission bits and broadcast mention tokens
This commit is contained in:
@@ -256,6 +256,19 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Gate @everyone / @channel on MENTION_EVERYONE (owner/admin always pass).
|
||||
if hasBroadcastToken(req.Content, "everyone") || hasBroadcastToken(req.Content, "channel") {
|
||||
allowed, permErr := h.checker.CheckPermission(r.Context(), serverID, userID, permissions.MENTION_EVERYONE)
|
||||
if permErr != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, `{"error":"missing permission: MENTION_EVERYONE"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous confessions: never store/broadcast the original /confess message.
|
||||
if h.confess != nil {
|
||||
if payload, handled := h.confess.TryConfess(r.Context(), serverID, userID, req.Content); handled {
|
||||
|
||||
@@ -6,13 +6,14 @@ import (
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
)
|
||||
|
||||
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
||||
var everyoneMention = "@everyone"
|
||||
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
||||
var plainUsernameMention = regexp.MustCompile(`@([a-zA-Z0-9_.-]+)`)
|
||||
|
||||
// MentionHandler dispatches push notifications for @mentions.
|
||||
type MentionHandler struct {
|
||||
@@ -29,20 +30,40 @@ func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logge
|
||||
}
|
||||
}
|
||||
|
||||
// hasBroadcastToken reports whether content contains @everyone / @channel as a whole token.
|
||||
func hasBroadcastToken(content, token string) bool {
|
||||
// token like "everyone" or "channel" (without @)
|
||||
needle := "@" + token
|
||||
idx := 0
|
||||
for {
|
||||
i := strings.Index(strings.ToLower(content[idx:]), needle)
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
i += idx
|
||||
end := i + len(needle)
|
||||
if end >= len(content) || !isUsernameChar(rune(content[end])) {
|
||||
return true
|
||||
}
|
||||
idx = end
|
||||
}
|
||||
}
|
||||
|
||||
func isUsernameChar(r rune) bool {
|
||||
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' || r == '-'
|
||||
}
|
||||
|
||||
// ParseAndNotify parses message content for mentions and sends push notifications.
|
||||
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
||||
// Find individual user mentions
|
||||
userMatches := mentionRegex.FindAllStringSubmatch(content, -1)
|
||||
mentionedUsers := make(map[string]bool)
|
||||
for _, match := range userMatches {
|
||||
|
||||
// Discord-style ID mentions
|
||||
for _, match := range mentionRegex.FindAllStringSubmatch(content, -1) {
|
||||
if len(match) > 1 {
|
||||
mentionedUsers[match[1]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for @everyone
|
||||
isEveryone := strings.Contains(content, everyoneMention)
|
||||
|
||||
// Get channel info for notification
|
||||
var serverID, channelName string
|
||||
err := m.db.QueryRowContext(ctx,
|
||||
@@ -75,8 +96,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
"url": "/channels/" + channelID,
|
||||
}
|
||||
|
||||
if isEveryone {
|
||||
// Send to all server members except those who muted this channel
|
||||
// @everyone / @channel — fan out to server members (permission gated at create).
|
||||
// ponytail: both use the same fanout; UI labels differ. Split if channel-private members matter.
|
||||
if hasBroadcastToken(content, "everyone") || hasBroadcastToken(content, "channel") {
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT m.user_id FROM members m
|
||||
LEFT JOIN notification_settings ns ON ns.user_id = m.user_id AND ns.channel_id = $3
|
||||
@@ -85,7 +107,7 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
serverID, authorID, channelID,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to query server members for @everyone", "error", err)
|
||||
m.logger.Error("failed to query server members for broadcast mention", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
@@ -100,13 +122,43 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
return
|
||||
}
|
||||
|
||||
// Check for role mentions
|
||||
// Plain @username mentions (what the frontend actually stores)
|
||||
usernames := make([]string, 0)
|
||||
seenUsernames := make(map[string]bool)
|
||||
for _, match := range plainUsernameMention.FindAllStringSubmatch(content, -1) {
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
u := strings.ToLower(match[1])
|
||||
if u == "everyone" || u == "channel" || u == "here" {
|
||||
continue
|
||||
}
|
||||
if !seenUsernames[u] {
|
||||
seenUsernames[u] = true
|
||||
usernames = append(usernames, match[1])
|
||||
}
|
||||
}
|
||||
if len(usernames) > 0 {
|
||||
// Resolve usernames that are members of this server.
|
||||
for _, uname := range usernames {
|
||||
var uid string
|
||||
err := m.db.QueryRowContext(ctx, `
|
||||
SELECT u.id FROM users u
|
||||
JOIN members m ON m.user_id = u.id
|
||||
WHERE m.server_id = $1 AND LOWER(u.username) = LOWER($2)
|
||||
`, serverID, uname).Scan(&uid)
|
||||
if err == nil {
|
||||
mentionedUsers[uid] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Role mentions
|
||||
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
||||
if len(roleMatches) > 0 {
|
||||
for _, match := range roleMatches {
|
||||
if len(match) > 1 {
|
||||
roleID := match[1]
|
||||
// Get users with this role
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
||||
roleID, authorID,
|
||||
@@ -127,12 +179,9 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the author from mentions
|
||||
delete(mentionedUsers, authorID)
|
||||
|
||||
// Send push to individually mentioned users
|
||||
for userID := range mentionedUsers {
|
||||
// Check if user is in DND status
|
||||
var status string
|
||||
err := m.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
||||
@@ -144,7 +193,6 @@ func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if user muted this channel
|
||||
var level string
|
||||
err = m.db.QueryRowContext(ctx,
|
||||
`SELECT level FROM notification_settings WHERE user_id = $1 AND channel_id = $2`,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package message
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasBroadcastToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
content string
|
||||
token string
|
||||
want bool
|
||||
}{
|
||||
{"hello @everyone", "everyone", true},
|
||||
{"@everyone hi", "everyone", true},
|
||||
{"@EVERYONE", "everyone", true},
|
||||
{"@everyone!", "everyone", true},
|
||||
{"@everyoneelse", "everyone", false},
|
||||
{"noteveryone", "everyone", false},
|
||||
{"@channel", "channel", true},
|
||||
{"ping @channel please", "channel", true},
|
||||
{"@channeling", "channel", false},
|
||||
{"", "everyone", false},
|
||||
{"@", "everyone", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := hasBroadcastToken(tc.content, tc.token)
|
||||
if got != tc.want {
|
||||
t.Errorf("hasBroadcastToken(%q, %q) = %v, want %v", tc.content, tc.token, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUsernameChar(t *testing.T) {
|
||||
if !isUsernameChar('a') || !isUsernameChar('9') || !isUsernameChar('_') {
|
||||
t.Fatal("expected alnum/_")
|
||||
}
|
||||
if isUsernameChar(' ') || isUsernameChar('!') || isUsernameChar('@') {
|
||||
t.Fatal("unexpected username chars")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user