mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-08 21:05:21 +00:00
WIP: Access cache
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"heckel.io/ntfy/v2/db"
|
||||
)
|
||||
|
||||
// aclEntry mirrors one user_access row in the in-memory snapshot.
|
||||
//
|
||||
// topic is the raw stored value: it may contain \_ escapes (for literal underscores)
|
||||
// and % wildcards (translated from user-supplied *). For exact-match entries (no %)
|
||||
// matcher is nil and the entry is keyed by topic in aclSnapshot.exact. For wildcard
|
||||
// entries (with %) matcher is the pre-compiled regex equivalent of the LIKE pattern.
|
||||
type aclEntry struct {
|
||||
topic string
|
||||
read bool
|
||||
write bool
|
||||
matcher *regexp.Regexp
|
||||
}
|
||||
|
||||
// aclSnapshot is an immutable indexed form of the entire user_access table.
|
||||
//
|
||||
// exact[userName][escapedTopic] returns the matching entry in O(1) for the common
|
||||
// case where the requested topic appears verbatim in some rule. The key is the
|
||||
// stored form of the topic (i.e. with \_ escapes), so callers must pass topics
|
||||
// through escapeUnderscore before probing.
|
||||
//
|
||||
// wildcards[userName] is the linear scan list of %-bearing rules for that user.
|
||||
// Walked per request; trivially small in practice. Wildcards are NOT u_everyone-
|
||||
// only -- any user can create them.
|
||||
type aclSnapshot struct {
|
||||
exact map[string]map[string]aclEntry
|
||||
wildcards map[string][]aclEntry
|
||||
}
|
||||
|
||||
// aclCache holds the current snapshot behind an atomic pointer so that the hot
|
||||
// path (Lookup) is lock-free. reload builds a fresh snapshot off the request
|
||||
// path and atomically swaps the pointer; the old snapshot is GC'd once in-flight
|
||||
// Lookups release their references.
|
||||
//
|
||||
// A nil receiver behaves as if no snapshot were loaded -- Lookup returns
|
||||
// found=false, which the caller then resolves via DefaultAccess. This keeps
|
||||
// tests and edge cases (e.g. early-startup) safe.
|
||||
type aclCache struct {
|
||||
snap atomic.Pointer[aclSnapshot]
|
||||
}
|
||||
|
||||
func newAccessCache() *aclCache {
|
||||
return &aclCache{}
|
||||
}
|
||||
|
||||
// reload runs the bulk-load query against the primary and atomically swaps in
|
||||
// a fresh snapshot. The primary is used (not ReadOnly) so a reload immediately
|
||||
// after an ACL mutation sees the freshly-written rows without replica lag.
|
||||
func (c *aclCache) reload(d *db.DB, query string) error {
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
snap := &aclSnapshot{
|
||||
exact: make(map[string]map[string]aclEntry),
|
||||
wildcards: make(map[string][]aclEntry),
|
||||
}
|
||||
for rows.Next() {
|
||||
var userName string
|
||||
var entry aclEntry
|
||||
if err := rows.Scan(&userName, &entry.topic, &entry.read, &entry.write); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.Contains(entry.topic, "%") {
|
||||
entry.matcher = compileLikeToRegex(entry.topic)
|
||||
snap.wildcards[userName] = append(snap.wildcards[userName], entry)
|
||||
} else {
|
||||
if snap.exact[userName] == nil {
|
||||
snap.exact[userName] = make(map[string]aclEntry)
|
||||
}
|
||||
snap.exact[userName][entry.topic] = entry
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
c.snap.Store(snap)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lookup returns the effective (read, write, found) permission for the given
|
||||
// (username, topic), preserving the priority ordering of the original SQL query:
|
||||
// 1. specific user beats Everyone
|
||||
// 2. longer pattern beats shorter (more specific wins)
|
||||
// 3. write beats read at equal length (write is "stronger")
|
||||
func (c *aclCache) Lookup(usernameOrEveryone, topic string) (read, write, found bool) {
|
||||
if c == nil {
|
||||
return false, false, false
|
||||
}
|
||||
snap := c.snap.Load()
|
||||
if snap == nil {
|
||||
return false, false, false
|
||||
}
|
||||
// Pre-compute the escaped form once: exact-match keys in the snapshot are
|
||||
// stored as toSQLWildcard would emit them (literal _ -> \_), so the
|
||||
// incoming topic must be escaped the same way before map lookup.
|
||||
escaped := escapeUnderscore(topic)
|
||||
|
||||
// Specific user takes priority over Everyone. Skip the first lookup when
|
||||
// the request is already anonymous to avoid scanning the same map twice.
|
||||
if usernameOrEveryone != Everyone {
|
||||
if e, ok := pickBest(snap, usernameOrEveryone, topic, escaped); ok {
|
||||
return e.read, e.write, true
|
||||
}
|
||||
}
|
||||
if e, ok := pickBest(snap, Everyone, topic, escaped); ok {
|
||||
return e.read, e.write, true
|
||||
}
|
||||
return false, false, false
|
||||
}
|
||||
|
||||
// pickBest returns the highest-priority entry for a single user, combining the
|
||||
// exact-match O(1) probe with a linear scan over the (usually empty or tiny)
|
||||
// wildcard list. Priority within a user: longer pattern wins; write wins ties.
|
||||
func pickBest(snap *aclSnapshot, userName, topic, escaped string) (aclEntry, bool) {
|
||||
var best aclEntry
|
||||
var found bool
|
||||
if m, ok := snap.exact[userName]; ok {
|
||||
if e, ok := m[escaped]; ok {
|
||||
best, found = e, true
|
||||
}
|
||||
}
|
||||
for _, w := range snap.wildcards[userName] {
|
||||
if !w.matcher.MatchString(topic) {
|
||||
continue
|
||||
}
|
||||
if !found || better(w, best) {
|
||||
best, found = w, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
// better implements the (length DESC, write DESC) tie-break used by the original
|
||||
// query's ORDER BY for entries owned by the same user.
|
||||
func better(a, b aclEntry) bool {
|
||||
if len(a.topic) != len(b.topic) {
|
||||
return len(a.topic) > len(b.topic)
|
||||
}
|
||||
if a.write != b.write {
|
||||
return a.write
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// compileLikeToRegex converts a stored ntfy LIKE pattern into an equivalent Go
|
||||
// regexp. In ntfy's stored form, % is the only wildcard (translated from *) and
|
||||
// \_ is a literal underscore; no other backslashes occur. Topics themselves are
|
||||
// restricted to [A-Za-z0-9_-] (see AllowedTopic), so neither % nor stray
|
||||
// backslashes appear in user-supplied input.
|
||||
func compileLikeToRegex(pattern string) *regexp.Regexp {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("^")
|
||||
i := 0
|
||||
for i < len(pattern) {
|
||||
switch {
|
||||
case pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '_':
|
||||
sb.WriteString(regexp.QuoteMeta("_"))
|
||||
i += 2
|
||||
case pattern[i] == '%':
|
||||
sb.WriteString(".*")
|
||||
i++
|
||||
default:
|
||||
sb.WriteString(regexp.QuoteMeta(string(pattern[i])))
|
||||
i++
|
||||
}
|
||||
}
|
||||
sb.WriteString("$")
|
||||
return regexp.MustCompile(sb.String())
|
||||
}
|
||||
Reference in New Issue
Block a user