···3344import (
55 "net/http"
66+ "strings"
6778 "git.fogtype.com/nebel/apprize/internal/notify"
88- "git.fogtype.com/nebel/apprize/internal/store"
99)
10101111// Deps holds everything a server instance needs. Tests construct it directly
1212-// with a fake Notifier and an in-memory Store.
1212+// with a fake Notifier.
1313type Deps struct {
1414 Notifier notify.Notifier
1515- Store store.Store
16151716 // Behaviour toggles (mirrors the relevant apprise-api env vars).
1818- StatelessURLs []string // APPRIZE_STATELESS_URLS
1919- ConfigLock bool // APPRIZE_CONFIG_LOCK
2020- Admin bool // APPRIZE_ADMIN
2121- RecursionMax int // APPRIZE_RECURSION_MAX
2222- DenyServices []string // APPRIZE_DENY_SERVICES
2323- AllowServices []string // APPRIZE_ALLOW_SERVICES
2424- APIKey string // optional simple API secret (empty = no auth)
2525- MaxBodyBytes int64 // request body limit; 0 = sensible default
2626- DefaultConfigID string // APPRIZE_DEFAULT_CONFIG_ID
1717+ StatelessURLs []string // APPRIZE_STATELESS_URLS
1818+ RecursionMax int // APPRIZE_RECURSION_MAX
1919+ DenyServices []string // APPRIZE_DENY_SERVICES
2020+ AllowServices []string // APPRIZE_ALLOW_SERVICES
2121+ APIKey string // optional simple API secret (empty = no auth)
2222+ MaxBodyBytes int64 // request body limit; 0 = sensible default; test-only
27232824 Version string
2925}
···3127// server is the concrete handler holding dependencies.
3228type server struct {
3329 Deps
3030+ allowSet map[string]struct{}
3131+ denySet map[string]struct{}
3232+ schemas []string
3433}
35343635// New returns the HTTP handler implementing the Apprise API.
3736func New(d Deps) http.Handler {
3838- s := &server{Deps: d}
3737+ s := &server{
3838+ Deps: d,
3939+ allowSet: buildServiceSet(d.AllowServices),
4040+ denySet: buildServiceSet(d.DenyServices),
4141+ schemas: notify.SupportedSchemas(),
4242+ }
3943 mux := http.NewServeMux()
40444141- // Meta — P1
4245 mux.HandleFunc("GET /status", s.handleStatus)
4346 mux.HandleFunc("GET /details", s.handleDetails)
4444-4545- // Stateless — P2+
4647 mux.HandleFunc("POST /notify", s.handleNotify)
47484848- // Persistent — P4-P5
4949- mux.HandleFunc("GET /cfg", s.handleListConfigs)
5050- mux.HandleFunc("POST /cfg", s.handleGetConfig)
5151- mux.HandleFunc("POST /add", s.handleAddConfig)
5252- mux.HandleFunc("POST /del", s.handleDeleteConfig)
5353- mux.HandleFunc("POST /get", s.handleGetConfig)
5454- mux.HandleFunc("POST /add/{key}", s.handleAddConfig)
5555- mux.HandleFunc("POST /del/{key}", s.handleDeleteConfig)
5656- mux.HandleFunc("POST /get/{key}", s.handleGetConfig)
5757- mux.HandleFunc("POST /cfg/{key}", s.handleGetConfig)
5858- mux.HandleFunc("POST /notify/{key}", s.handleNotifyByKey)
5959- mux.HandleFunc("GET /json/urls/{key}", s.handleJSONURLs)
6060-6161- return s.withMiddleware(mux)
4949+ return withMiddlewareChain(s, mux)
6250}
63516464-// withMiddleware composes the cross-cutting middleware chain.
6565-// P1 wires the full stack: requestLog → recover → apiKey → recursion → bodyLimit → handler.
6666-func (s *server) withMiddleware(h http.Handler) http.Handler {
6767- return withMiddlewareChain(s, h)
5252+func buildServiceSet(services []string) map[string]struct{} {
5353+ if len(services) == 0 {
5454+ return nil
5555+ }
5656+ m := make(map[string]struct{}, len(services))
5757+ for _, sc := range services {
5858+ m[strings.ToLower(strings.TrimSpace(sc))] = struct{}{}
5959+ }
6060+ return m
6861}
-53
internal/store/memory.go
···11-package store
22-33-import (
44- "context"
55- "sort"
66- "sync"
77-)
88-99-// Memory is an in-memory Store used for tests and when DBPath is :memory:.
1010-type Memory struct {
1111- mu sync.RWMutex
1212- entries map[string]Entry
1313-}
1414-1515-// NewMemory returns an empty in-memory store.
1616-func NewMemory() *Memory {
1717- return &Memory{entries: make(map[string]Entry)}
1818-}
1919-2020-func (m *Memory) Get(_ context.Context, key string) (Entry, bool, error) {
2121- m.mu.RLock()
2222- defer m.mu.RUnlock()
2323- e, ok := m.entries[key]
2424- return e, ok, nil
2525-}
2626-2727-func (m *Memory) Put(_ context.Context, e Entry) error {
2828- m.mu.Lock()
2929- defer m.mu.Unlock()
3030- m.entries[e.Key] = e
3131- return nil
3232-}
3333-3434-func (m *Memory) Delete(_ context.Context, key string) (bool, error) {
3535- m.mu.Lock()
3636- defer m.mu.Unlock()
3737- _, ok := m.entries[key]
3838- delete(m.entries, key)
3939- return ok, nil
4040-}
4141-4242-func (m *Memory) List(_ context.Context) ([]string, error) {
4343- m.mu.RLock()
4444- defer m.mu.RUnlock()
4545- keys := make([]string, 0, len(m.entries))
4646- for k := range m.entries {
4747- keys = append(keys, k)
4848- }
4949- sort.Strings(keys)
5050- return keys, nil
5151-}
5252-5353-func (m *Memory) Close() error { return nil }
-114
internal/store/sqlite.go
···11-package store
22-33-import (
44- "context"
55- "database/sql"
66- "fmt"
77-88- _ "modernc.org/sqlite"
99-)
1010-1111-// SQLite is a Store backed by modernc.org/sqlite (pure Go, no cgo).
1212-type SQLite struct {
1313- db *sql.DB
1414-}
1515-1616-// OpenSQLite opens (creating if needed) a sqlite database at path and runs
1717-// migrations. Pass ":memory:" for an ephemeral database.
1818-func OpenSQLite(path string) (*SQLite, error) {
1919- memory := path == ":memory:"
2020- var dsn string
2121- if memory {
2222- // A bare ":memory:" gives every pooled connection its own private
2323- // database, so the table created during migration is invisible to the
2424- // next connection. A shared cache makes one in-memory database visible
2525- // across the pool.
2626- dsn = "file::memory:?cache=shared"
2727- } else {
2828- // Enable WAL and a busy timeout for concurrent access.
2929- dsn = fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)", path)
3030- }
3131- db, err := sql.Open("sqlite", dsn)
3232- if err != nil {
3333- return nil, err
3434- }
3535- if memory {
3636- // Keep at least one connection alive; the shared in-memory database is
3737- // dropped once the last connection closes.
3838- db.SetMaxOpenConns(1)
3939- }
4040- s := &SQLite{db: db}
4141- if err := s.migrate(context.Background()); err != nil {
4242- _ = db.Close()
4343- return nil, err
4444- }
4545- return s, nil
4646-}
4747-4848-func (s *SQLite) migrate(ctx context.Context) error {
4949- _, err := s.db.ExecContext(ctx, `
5050-CREATE TABLE IF NOT EXISTS configs (
5151- key TEXT PRIMARY KEY,
5252- format TEXT NOT NULL,
5353- config TEXT NOT NULL,
5454- updated_at INTEGER NOT NULL
5555-);`)
5656- return err
5757-}
5858-5959-func (s *SQLite) Get(ctx context.Context, key string) (Entry, bool, error) {
6060- var e Entry
6161- err := s.db.QueryRowContext(ctx,
6262- `SELECT key, format, config, updated_at FROM configs WHERE key = ?`, key).
6363- Scan(&e.Key, &e.Format, &e.Config, &e.UpdatedAt)
6464- if err == sql.ErrNoRows {
6565- return Entry{}, false, nil
6666- }
6767- if err != nil {
6868- return Entry{}, false, err
6969- }
7070- return e, true, nil
7171-}
7272-7373-func (s *SQLite) Put(ctx context.Context, e Entry) error {
7474- _, err := s.db.ExecContext(ctx, `
7575-INSERT INTO configs (key, format, config, updated_at)
7676-VALUES (?, ?, ?, ?)
7777-ON CONFLICT(key) DO UPDATE SET
7878- format = excluded.format,
7979- config = excluded.config,
8080- updated_at = excluded.updated_at;`,
8181- e.Key, e.Format, e.Config, e.UpdatedAt)
8282- return err
8383-}
8484-8585-func (s *SQLite) Delete(ctx context.Context, key string) (bool, error) {
8686- res, err := s.db.ExecContext(ctx, `DELETE FROM configs WHERE key = ?`, key)
8787- if err != nil {
8888- return false, err
8989- }
9090- n, err := res.RowsAffected()
9191- if err != nil {
9292- return false, err
9393- }
9494- return n > 0, nil
9595-}
9696-9797-func (s *SQLite) List(ctx context.Context) ([]string, error) {
9898- rows, err := s.db.QueryContext(ctx, `SELECT key FROM configs ORDER BY key`)
9999- if err != nil {
100100- return nil, err
101101- }
102102- defer rows.Close()
103103- var keys []string
104104- for rows.Next() {
105105- var k string
106106- if err := rows.Scan(&k); err != nil {
107107- return nil, err
108108- }
109109- keys = append(keys, k)
110110- }
111111- return keys, rows.Err()
112112-}
113113-114114-func (s *SQLite) Close() error { return s.db.Close() }
-28
internal/store/store.go
···11-// Package store persists named Apprise configurations.
22-package store
33-44-import "context"
55-66-// Entry is a stored configuration identified by Key.
77-type Entry struct {
88- Key string
99- Format string // "text" | "yaml"
1010- Config string // raw configuration body
1111- UpdatedAt int64 // unix seconds
1212-}
1313-1414-// Store is the persistence interface used by the persistent endpoints.
1515-//
1616-// Implementations must be safe for concurrent use.
1717-type Store interface {
1818- // Get returns the entry for key. The bool is false when no entry exists.
1919- Get(ctx context.Context, key string) (Entry, bool, error)
2020- // Put inserts or replaces the entry.
2121- Put(ctx context.Context, e Entry) error
2222- // Delete removes key, returning whether it existed.
2323- Delete(ctx context.Context, key string) (bool, error)
2424- // List returns all stored keys.
2525- List(ctx context.Context) ([]string, error)
2626- // Close releases any underlying resources.
2727- Close() error
2828-}
-105
internal/store/store_test.go
···11-package store_test
22-33-import (
44- "context"
55- "fmt"
66- "path/filepath"
77- "sync"
88- "testing"
99-1010- "git.fogtype.com/nebel/apprize/internal/store"
1111-)
1212-1313-// newStores returns one instance of every Store implementation so the shared
1414-// suite runs identically against memory and sqlite (file and :memory:).
1515-func newStores(t *testing.T) map[string]store.Store {
1616- t.Helper()
1717- file, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
1818- if err != nil {
1919- t.Fatalf("open sqlite file: %v", err)
2020- }
2121- mem, err := store.OpenSQLite(":memory:")
2222- if err != nil {
2323- t.Fatalf("open sqlite memory: %v", err)
2424- }
2525- stores := map[string]store.Store{
2626- "memory": store.NewMemory(),
2727- "sqlite-file": file,
2828- "sqlite-memory": mem,
2929- }
3030- t.Cleanup(func() {
3131- for _, s := range stores {
3232- _ = s.Close()
3333- }
3434- })
3535- return stores
3636-}
3737-3838-func TestStoreCRUD(t *testing.T) {
3939- ctx := context.Background()
4040- for name, s := range newStores(t) {
4141- t.Run(name, func(t *testing.T) {
4242- if _, ok, err := s.Get(ctx, "k"); err != nil || ok {
4343- t.Fatalf("empty Get: ok=%v err=%v", ok, err)
4444- }
4545-4646- seed := store.Entry{Key: "k", Format: "text", Config: "gotify://h/t", UpdatedAt: 1}
4747- if err := s.Put(ctx, seed); err != nil {
4848- t.Fatalf("Put: %v", err)
4949- }
5050- got, ok, err := s.Get(ctx, "k")
5151- if err != nil || !ok || got != seed {
5252- t.Fatalf("Get after Put = %+v ok=%v err=%v", got, ok, err)
5353- }
5454-5555- // Put upserts in place rather than duplicating the key.
5656- if err := s.Put(ctx, store.Entry{Key: "k", Format: "yaml", Config: "urls: []", UpdatedAt: 2}); err != nil {
5757- t.Fatalf("upsert: %v", err)
5858- }
5959- keys, err := s.List(ctx)
6060- if err != nil || len(keys) != 1 || keys[0] != "k" {
6161- t.Fatalf("List after upsert = %v err=%v", keys, err)
6262- }
6363-6464- deleted, err := s.Delete(ctx, "k")
6565- if err != nil || !deleted {
6666- t.Fatalf("Delete existing: deleted=%v err=%v", deleted, err)
6767- }
6868- if again, err := s.Delete(ctx, "k"); err != nil || again {
6969- t.Fatalf("Delete missing: deleted=%v err=%v", again, err)
7070- }
7171- })
7272- }
7373-}
7474-7575-// TestSQLiteMemoryConcurrent guards the shared-cache fix: a bare ":memory:" DSN
7676-// would give each pooled connection its own database, so concurrent reads would
7777-// hit "no such table".
7878-func TestSQLiteMemoryConcurrent(t *testing.T) {
7979- ctx := context.Background()
8080- s, err := store.OpenSQLite(":memory:")
8181- if err != nil {
8282- t.Fatalf("open: %v", err)
8383- }
8484- defer s.Close()
8585- if err := s.Put(ctx, store.Entry{Key: "k", Format: "text", Config: "gotify://h/t", UpdatedAt: 1}); err != nil {
8686- t.Fatalf("Put: %v", err)
8787- }
8888-8989- var wg sync.WaitGroup
9090- fails := make(chan error, 64)
9191- for i := 0; i < 64; i++ {
9292- wg.Add(1)
9393- go func() {
9494- defer wg.Done()
9595- if _, ok, err := s.Get(ctx, "k"); err != nil || !ok {
9696- fails <- fmt.Errorf("ok=%v err=%v", ok, err)
9797- }
9898- }()
9999- }
100100- wg.Wait()
101101- close(fails)
102102- if err := <-fails; err != nil {
103103- t.Fatalf("concurrent Get: %v", err)
104104- }
105105-}