summaryrefslogtreecommitdiff
path: root/internal/store/scope.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/store/scope.go')
-rw-r--r--internal/store/scope.go116
1 files changed, 116 insertions, 0 deletions
diff --git a/internal/store/scope.go b/internal/store/scope.go
new file mode 100644
index 0000000..8c7c4a0
--- /dev/null
+++ b/internal/store/scope.go
@@ -0,0 +1,116 @@
+package store
+
+import (
+ "fmt"
+ "net"
+ "strings"
+)
+
+// ScopeEntry represents a single scope target.
+type ScopeEntry struct {
+ ID int64
+ Host string
+}
+
+// AllScopeEntries returns all scope entries.
+func (s *Store) AllScopeEntries() ([]ScopeEntry, error) {
+ rows, err := s.db.Query(`SELECT id, host FROM scope ORDER BY id ASC`)
+ if err != nil {
+ return nil, fmt.Errorf("store: query scope: %w", err)
+ }
+ defer rows.Close()
+
+ var entries []ScopeEntry
+ for rows.Next() {
+ var e ScopeEntry
+ if err := rows.Scan(&e.ID, &e.Host); err != nil {
+ return nil, fmt.Errorf("store: scan scope: %w", err)
+ }
+ entries = append(entries, e)
+ }
+ return entries, rows.Err()
+}
+
+// AddScopeEntry adds a new host to scope.
+func (s *Store) AddScopeEntry(host string) error {
+ err := s.write(func() error {
+ _, err := s.db.Exec(`INSERT INTO scope (host) VALUES (?)`, host)
+ if err != nil {
+ return fmt.Errorf("store: add scope entry: %w", err)
+ }
+ return nil
+ })
+ if err == nil {
+ s.invalidateScopeCache()
+ }
+ return err
+}
+
+// DeleteScopeEntry removes a scope entry by ID.
+func (s *Store) DeleteScopeEntry(id int64) error {
+ err := s.write(func() error {
+ _, err := s.db.Exec(`DELETE FROM scope WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("store: delete scope entry: %w", err)
+ }
+ return nil
+ })
+ if err == nil {
+ s.invalidateScopeCache()
+ }
+ return err
+}
+
+// InScope returns true if the given host matches any scope entry.
+// example.com matches example.com and all subdomains.
+// Uses an in-memory cache populated on first call and invalidated on scope writes.
+func (s *Store) InScope(host string) bool {
+ // Strip port if present; net.SplitHostPort handles IPv6 brackets correctly.
+ if h, _, err := net.SplitHostPort(host); err == nil {
+ host = h
+ }
+
+ s.scopeCacheMu.RLock()
+ if s.scopeCacheSet {
+ entries := s.scopeCache
+ s.scopeCacheMu.RUnlock()
+ return matchesScope(entries, host)
+ }
+ s.scopeCacheMu.RUnlock()
+
+ // Cache miss — populate under write lock.
+ s.scopeCacheMu.Lock()
+ // Re-check after acquiring write lock — another goroutine may have populated it.
+ if !s.scopeCacheSet {
+ entries, err := s.AllScopeEntries()
+ if err == nil {
+ s.scopeCache = entries
+ s.scopeCacheSet = true
+ }
+ }
+ entries := s.scopeCache
+ s.scopeCacheMu.Unlock()
+
+ return matchesScope(entries, host)
+}
+
+// invalidateScopeCache clears the scope cache so the next InScope call reloads from DB.
+func (s *Store) invalidateScopeCache() {
+ s.scopeCacheMu.Lock()
+ s.scopeCache = nil
+ s.scopeCacheSet = false
+ s.scopeCacheMu.Unlock()
+}
+
+// matchesScope returns true if host matches any entry in entries.
+func matchesScope(entries []ScopeEntry, host string) bool {
+ if len(entries) == 0 {
+ return true // no scope defined, everything is in scope
+ }
+ for _, e := range entries {
+ if host == e.Host || strings.HasSuffix(host, "."+e.Host) {
+ return true
+ }
+ }
+ return false
+}