mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-12 23:05:20 +00:00
Merge branch 'main' of github.com:binwiederhier/ntfy into 1511-ampersand
This commit is contained in:
+24
-14
@@ -4,10 +4,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,12 +22,14 @@ const (
|
||||
actionView = "view"
|
||||
actionBroadcast = "broadcast"
|
||||
actionHTTP = "http"
|
||||
actionCopy = "copy"
|
||||
)
|
||||
|
||||
var (
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP}
|
||||
actionsWithURL = []string{actionView, actionHTTP}
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
actionsAll = []string{actionView, actionBroadcast, actionHTTP, actionCopy}
|
||||
actionsWithURL = []string{actionView, actionHTTP} // Must be distinct from actionsWithValue, see populateAction()
|
||||
actionsWithValue = []string{actionCopy} // Must be distinct from actionsWithURL, see populateAction()
|
||||
actionsKeyRegex = regexp.MustCompile(`^([-.\w]+)\s*=\s*`)
|
||||
)
|
||||
|
||||
type actionParser struct {
|
||||
@@ -36,7 +40,7 @@ type actionParser struct {
|
||||
// parseActions parses the actions string as described in https://ntfy.sh/docs/publish/#action-buttons.
|
||||
// It supports both a JSON representation (if the string begins with "[", see parseActionsFromJSON),
|
||||
// and the "simple" format, which is more human-readable, but harder to parse (see parseActionsFromSimple).
|
||||
func parseActions(s string) (actions []*action, err error) {
|
||||
func parseActions(s string) (actions []*model.Action, err error) {
|
||||
// Parse JSON or simple format
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "[") {
|
||||
@@ -61,11 +65,13 @@ func parseActions(s string) (actions []*action, err error) {
|
||||
}
|
||||
for _, action := range actions {
|
||||
if !util.Contains(actionsAll, action.Action) {
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
|
||||
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast', 'http' and 'copy'", action.Action)
|
||||
} else if action.Label == "" {
|
||||
return nil, fmt.Errorf("parameter 'label' is required")
|
||||
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
|
||||
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
|
||||
} else if util.Contains(actionsWithValue, action.Action) && action.Value == "" {
|
||||
return nil, fmt.Errorf("parameter 'value' is required for action '%s'", action.Action)
|
||||
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
|
||||
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
|
||||
}
|
||||
@@ -75,8 +81,8 @@ func parseActions(s string) (actions []*action, err error) {
|
||||
}
|
||||
|
||||
// parseActionsFromJSON converts a JSON array into an array of actions
|
||||
func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
func parseActionsFromJSON(s string) ([]*model.Action, error) {
|
||||
actions := make([]*model.Action, 0)
|
||||
if err := json.Unmarshal([]byte(s), &actions); err != nil {
|
||||
return nil, fmt.Errorf("JSON error: %w", err)
|
||||
}
|
||||
@@ -102,7 +108,7 @@ func parseActionsFromJSON(s string) ([]*action, error) {
|
||||
// https://github.com/adampresley/sample-ini-parser/blob/master/services/lexer/lexer/Lexer.go
|
||||
// https://github.com/benbjohnson/sql-parser/blob/master/scanner.go
|
||||
// https://blog.gopheracademy.com/advent-2014/parsers-lexers/
|
||||
func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
func parseActionsFromSimple(s string) ([]*model.Action, error) {
|
||||
if !utf8.ValidString(s) {
|
||||
return nil, errors.New("invalid utf-8 string")
|
||||
}
|
||||
@@ -114,8 +120,8 @@ func parseActionsFromSimple(s string) ([]*action, error) {
|
||||
}
|
||||
|
||||
// Parse loops trough parseAction() until the end of the string is reached
|
||||
func (p *actionParser) Parse() ([]*action, error) {
|
||||
actions := make([]*action, 0)
|
||||
func (p *actionParser) Parse() ([]*model.Action, error) {
|
||||
actions := make([]*model.Action, 0)
|
||||
for !p.eof() {
|
||||
a, err := p.parseAction()
|
||||
if err != nil {
|
||||
@@ -129,8 +135,8 @@ func (p *actionParser) Parse() ([]*action, error) {
|
||||
// parseAction parses the individual sections of an action using parseSection into key/value pairs,
|
||||
// and then uses populateAction to interpret the keys/values. The function terminates
|
||||
// when EOF or ";" is reached.
|
||||
func (p *actionParser) parseAction() (*action, error) {
|
||||
a := newAction()
|
||||
func (p *actionParser) parseAction() (*model.Action, error) {
|
||||
a := model.NewAction()
|
||||
section := 0
|
||||
for {
|
||||
key, value, last, err := p.parseSection()
|
||||
@@ -150,7 +156,7 @@ func (p *actionParser) parseAction() (*action, error) {
|
||||
|
||||
// populateAction is the "business logic" of the parser. It applies the key/value
|
||||
// pair to the action instance.
|
||||
func populateAction(newAction *action, section int, key, value string) error {
|
||||
func populateAction(newAction *model.Action, section int, key, value string) error {
|
||||
// Auto-expand keys based on their index
|
||||
if key == "" && section == 0 {
|
||||
key = "action"
|
||||
@@ -158,6 +164,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
key = "label"
|
||||
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
|
||||
key = "url"
|
||||
} else if key == "" && section == 2 && util.Contains(actionsWithValue, newAction.Action) {
|
||||
key = "value"
|
||||
}
|
||||
|
||||
// Validate
|
||||
@@ -188,6 +196,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
case "value":
|
||||
newAction.Value = value
|
||||
case "intent":
|
||||
newAction.Intent = value
|
||||
default:
|
||||
|
||||
+42
-3
@@ -1,8 +1,9 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseActions(t *testing.T) {
|
||||
@@ -132,6 +133,44 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, `https://x.org`, actions[1].URL)
|
||||
require.Equal(t, true, actions[1].Clear)
|
||||
|
||||
// Copy action (simple format)
|
||||
actions, err = parseActions("copy, Copy code, 1234")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy code", actions[0].Label)
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
|
||||
// Copy action (JSON)
|
||||
actions, err = parseActions(`[{"action":"copy","label":"Copy OTP","value":"567890"}]`)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy OTP", actions[0].Label)
|
||||
require.Equal(t, "567890", actions[0].Value)
|
||||
|
||||
// Copy action with clear
|
||||
actions, err = parseActions("copy, Copy code, 1234, clear=true")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy code", actions[0].Label)
|
||||
require.Equal(t, "1234", actions[0].Value)
|
||||
require.Equal(t, true, actions[0].Clear)
|
||||
|
||||
// Copy action with explicit value key
|
||||
actions, err = parseActions("action=copy, label=Copy token, clear=true, value=abc-123-def")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "copy", actions[0].Action)
|
||||
require.Equal(t, "Copy token", actions[0].Label)
|
||||
require.Equal(t, "abc-123-def", actions[0].Value)
|
||||
require.True(t, actions[0].Clear)
|
||||
|
||||
// Copy action without value (error)
|
||||
_, err = parseActions("copy, Copy code")
|
||||
require.EqualError(t, err, "parameter 'value' is required for action 'copy'")
|
||||
|
||||
// Invalid syntax
|
||||
_, err = parseActions(`label="Out of order!" x, action="http", url=http://example.com`)
|
||||
require.EqualError(t, err, "unexpected character 'x' at position 22")
|
||||
@@ -146,7 +185,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.EqualError(t, err, "term 'what is this anyway' unknown")
|
||||
|
||||
_, err = parseActions(`fdsfdsf`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast' and 'http'")
|
||||
require.EqualError(t, err, "parameter 'action' cannot be 'fdsfdsf', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions(`aaa=a, "bbb, 'ccc, ddd, eee "`)
|
||||
require.EqualError(t, err, "key 'aaa' unknown")
|
||||
@@ -173,7 +212,7 @@ func TestParseActions(t *testing.T) {
|
||||
require.EqualError(t, err, "JSON error: invalid character 'i' looking for beginning of value")
|
||||
|
||||
_, err = parseActions(`[ { "some": "object" } ]`)
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast' and 'http'")
|
||||
require.EqualError(t, err, "parameter 'action' cannot be '', valid values are 'view', 'broadcast', 'http' and 'copy'")
|
||||
|
||||
_, err = parseActions("\x00\x01\xFFx\xFE")
|
||||
require.EqualError(t, err, "invalid utf-8 string")
|
||||
|
||||
+65
-10
@@ -1,8 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/user"
|
||||
@@ -11,12 +16,11 @@ import (
|
||||
// Defines default config settings (excluding limits, see below)
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultCacheBatchTimeout = time.Duration(0)
|
||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||
DefaultManagerInterval = time.Minute
|
||||
DefaultManagerBatchSize = 30000
|
||||
DefaultDelayedSenderInterval = 10 * time.Second
|
||||
DefaultMessageDelayMin = 10 * time.Second
|
||||
DefaultMessageDelayMax = 3 * 24 * time.Hour
|
||||
@@ -26,6 +30,12 @@ const (
|
||||
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
||||
)
|
||||
|
||||
// Platform-specific default paths (set in config_unix.go or config_windows.go)
|
||||
var (
|
||||
DefaultConfigFile string
|
||||
DefaultTemplateDir string
|
||||
)
|
||||
|
||||
// Defines default Web Push settings
|
||||
const (
|
||||
DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour
|
||||
@@ -37,11 +47,13 @@ const (
|
||||
// - total topic limit: max number of topics overall
|
||||
// - various attachment limits
|
||||
const (
|
||||
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
|
||||
DefaultTotalTopicLimit = 15000
|
||||
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
||||
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
||||
DefaultAttachmentExpiryDuration = 3 * time.Hour
|
||||
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
|
||||
DefaultTotalTopicLimit = 15000
|
||||
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
|
||||
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
|
||||
DefaultAttachmentExpiryDuration = 3 * time.Hour
|
||||
DefaultAttachmentOrphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads
|
||||
|
||||
)
|
||||
|
||||
// Defines all per-visitor limits
|
||||
@@ -57,7 +69,9 @@ const (
|
||||
DefaultVisitorMessageDailyLimit = 0
|
||||
DefaultVisitorEmailLimitBurst = 16
|
||||
DefaultVisitorEmailLimitReplenish = time.Hour
|
||||
DefaultVisitorAccountCreationLimitBurst = 3
|
||||
DefaultVisitorTopicCreationLimitBurst = 100
|
||||
DefaultVisitorTopicCreationLimitReplenish = time.Minute
|
||||
DefaultVisitorAccountCreationLimitBurst = 6 // Shared by signup and password-reset requests (same per-visitor bucket)
|
||||
DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour
|
||||
DefaultVisitorAuthFailureLimitBurst = 30
|
||||
DefaultVisitorAuthFailureLimitReplenish = time.Minute
|
||||
@@ -86,6 +100,8 @@ type Config struct {
|
||||
ListenUnixMode fs.FileMode
|
||||
KeyFile string
|
||||
CertFile string
|
||||
DatabaseURL string // PostgreSQL connection string (e.g. "postgres://user:pass@host:5432/ntfy")
|
||||
DatabaseReplicaURLs []string // PostgreSQL read replica connection strings
|
||||
FirebaseKeyFile string
|
||||
CacheFile string
|
||||
CacheDuration time.Duration
|
||||
@@ -100,13 +116,17 @@ type Config struct {
|
||||
AuthTokens map[string][]*user.Token
|
||||
AuthBcryptCost int
|
||||
AuthStatsQueueWriterInterval time.Duration
|
||||
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
|
||||
AuthAccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
|
||||
AttachmentCacheDir string
|
||||
AttachmentTotalSizeLimit int64
|
||||
AttachmentFileSizeLimit int64
|
||||
AttachmentExpiryDuration time.Duration
|
||||
AttachmentOrphanGracePeriod time.Duration
|
||||
TemplateDir string // Directory to load named templates from
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
ManagerBatchSize int
|
||||
DisallowedTopics []string
|
||||
WebRoot string // empty to disable
|
||||
DelayedSenderInterval time.Duration
|
||||
@@ -119,6 +139,7 @@ type Config struct {
|
||||
SMTPSenderUser string
|
||||
SMTPSenderPass string
|
||||
SMTPSenderFrom string
|
||||
SMTPSenderVerify bool
|
||||
SMTPServerListen string
|
||||
SMTPServerDomain string
|
||||
SMTPServerAddrPrefix string
|
||||
@@ -128,6 +149,7 @@ type Config struct {
|
||||
TwilioCallsBaseURL string
|
||||
TwilioVerifyBaseURL string
|
||||
TwilioVerifyService string
|
||||
TwilioCallFormat *template.Template
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
@@ -145,6 +167,8 @@ type Config struct {
|
||||
VisitorMessageDailyLimit int
|
||||
VisitorEmailLimitBurst int
|
||||
VisitorEmailLimitReplenish time.Duration
|
||||
VisitorTopicCreationLimitBurst int // Burst of new topic creations per visitor
|
||||
VisitorTopicCreationLimitReplenish time.Duration // Interval at which topic-creation tokens are refilled
|
||||
VisitorAccountCreationLimitBurst int
|
||||
VisitorAccountCreationLimitReplenish time.Duration
|
||||
VisitorAuthFailureLimitBurst int
|
||||
@@ -173,7 +197,9 @@ type Config struct {
|
||||
WebPushStartupQueries string
|
||||
WebPushExpiryDuration time.Duration
|
||||
WebPushExpiryWarningDuration time.Duration
|
||||
Version string // injected by App
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
@@ -187,6 +213,7 @@ func NewConfig() *Config {
|
||||
ListenUnixMode: 0,
|
||||
KeyFile: "",
|
||||
CertFile: "",
|
||||
DatabaseURL: "",
|
||||
FirebaseKeyFile: "",
|
||||
CacheFile: "",
|
||||
CacheDuration: DefaultCacheDuration,
|
||||
@@ -198,13 +225,17 @@ func NewConfig() *Config {
|
||||
AuthDefault: user.PermissionReadWrite,
|
||||
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
|
||||
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
|
||||
AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled,
|
||||
AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval,
|
||||
AttachmentCacheDir: "",
|
||||
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
|
||||
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
|
||||
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
|
||||
AttachmentOrphanGracePeriod: DefaultAttachmentOrphanGracePeriod,
|
||||
TemplateDir: DefaultTemplateDir,
|
||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||
ManagerInterval: DefaultManagerInterval,
|
||||
ManagerBatchSize: DefaultManagerBatchSize,
|
||||
DisallowedTopics: DefaultDisallowedTopics,
|
||||
WebRoot: "/",
|
||||
DelayedSenderInterval: DefaultDelayedSenderInterval,
|
||||
@@ -217,6 +248,7 @@ func NewConfig() *Config {
|
||||
SMTPSenderUser: "",
|
||||
SMTPSenderPass: "",
|
||||
SMTPSenderFrom: "",
|
||||
SMTPSenderVerify: false,
|
||||
SMTPServerListen: "",
|
||||
SMTPServerDomain: "",
|
||||
SMTPServerAddrPrefix: "",
|
||||
@@ -226,6 +258,7 @@ func NewConfig() *Config {
|
||||
TwilioPhoneNumber: "",
|
||||
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
||||
TwilioVerifyService: "",
|
||||
TwilioCallFormat: nil,
|
||||
MessageSizeLimit: DefaultMessageSizeLimit,
|
||||
MessageDelayMin: DefaultMessageDelayMin,
|
||||
MessageDelayMax: DefaultMessageDelayMax,
|
||||
@@ -241,6 +274,8 @@ func NewConfig() *Config {
|
||||
VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit,
|
||||
VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst,
|
||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||
VisitorTopicCreationLimitBurst: DefaultVisitorTopicCreationLimitBurst,
|
||||
VisitorTopicCreationLimitReplenish: DefaultVisitorTopicCreationLimitReplenish,
|
||||
VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst,
|
||||
VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish,
|
||||
VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst,
|
||||
@@ -259,12 +294,32 @@ func NewConfig() *Config {
|
||||
EnableReservations: false,
|
||||
RequireLogin: false,
|
||||
AccessControlAllowOrigin: "*",
|
||||
Version: "",
|
||||
WebPushPrivateKey: "",
|
||||
WebPushPublicKey: "",
|
||||
WebPushFile: "",
|
||||
WebPushEmailAddress: "",
|
||||
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||
BuildVersion: "",
|
||||
BuildDate: "",
|
||||
BuildCommit: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Hash computes an SHA-256 hash of the configuration. This is used to detect
|
||||
// configuration changes for the web app version check feature. It uses reflection
|
||||
// to include all JSON-serializable fields automatically.
|
||||
func (c *Config) Hash() string {
|
||||
v := reflect.ValueOf(*c)
|
||||
t := v.Type()
|
||||
var result string
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldName := t.Field(i).Name
|
||||
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
|
||||
if b, err := json.Marshal(field.Interface()); err == nil {
|
||||
result += fmt.Sprintf("%s:%s|", fieldName, string(b))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
func init() {
|
||||
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
programData := os.Getenv("ProgramData")
|
||||
if programData == "" {
|
||||
programData = `C:\ProgramData`
|
||||
}
|
||||
DefaultConfigFile = filepath.Join(programData, "ntfy", "server.yml")
|
||||
DefaultTemplateDir = filepath.Join(programData, "ntfy", "templates")
|
||||
}
|
||||
+27
-2
@@ -3,8 +3,9 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
||||
@@ -77,6 +78,21 @@ func (e errHTTP) clone() errHTTP {
|
||||
}
|
||||
}
|
||||
|
||||
// errWebSocketPostUpgrade is a wrapper error indicating an error occurred after the WebSocket
|
||||
// upgrade completed (i.e., the connection was hijacked). This is used to avoid calling
|
||||
// WriteHeader on hijacked connections, which causes log spam.
|
||||
type errWebSocketPostUpgrade struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errWebSocketPostUpgrade) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *errWebSocketPostUpgrade) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
var (
|
||||
errHTTPBadRequest = &errHTTP{40000, http.StatusBadRequest, "invalid request", "", nil}
|
||||
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications", nil}
|
||||
@@ -125,6 +141,12 @@ var (
|
||||
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
||||
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
|
||||
errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestEmailVerificationLinkInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification link invalid or expired", "", nil}
|
||||
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
|
||||
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
@@ -134,6 +156,8 @@ var (
|
||||
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
|
||||
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil}
|
||||
errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
|
||||
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
|
||||
errHTTPConflictEmailPrimaryElsewhere = &errHTTP{40908, http.StatusConflict, "conflict: email address is the primary email on another account", "", nil}
|
||||
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil}
|
||||
errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
|
||||
@@ -143,11 +167,12 @@ var (
|
||||
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAttachmentBandwidth = &errHTTP{42905, http.StatusTooManyRequests, "limit reached: daily bandwidth reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAccountCreation = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many accounts created", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitAccountActions = &errHTTP{42906, http.StatusTooManyRequests, "limit reached: too many account requests", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitReservations = &errHTTP{42907, http.StatusTooManyRequests, "limit reached: too many topic reservations for this user", "", nil}
|
||||
errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit
|
||||
errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPTooManyRequestsLimitTopicCreation = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: too many new topics, please wait", "https://ntfy.sh/docs/publish/#limitations", nil}
|
||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil}
|
||||
errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil}
|
||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, messageIDLength))
|
||||
errInvalidFileID = errors.New("invalid file ID")
|
||||
errFileExists = errors.New("file exists")
|
||||
)
|
||||
|
||||
type fileCache struct {
|
||||
dir string
|
||||
totalSizeCurrent int64
|
||||
totalSizeLimit int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newFileCache(dir string, totalSizeLimit int64) (*fileCache, error) {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size, err := dirSize(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fileCache{
|
||||
dir: dir,
|
||||
totalSizeCurrent: size,
|
||||
totalSizeLimit: totalSizeLimit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) {
|
||||
if !fileIDRegex.MatchString(id) {
|
||||
return 0, errInvalidFileID
|
||||
}
|
||||
log.Tag(tagFileCache).Field("message_id", id).Debug("Writing attachment")
|
||||
file := filepath.Join(c.dir, id)
|
||||
if _, err := os.Stat(file); err == nil {
|
||||
return 0, errFileExists
|
||||
}
|
||||
f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
limiters = append(limiters, util.NewFixedLimiter(c.Remaining()))
|
||||
limitWriter := util.NewLimitWriter(f, limiters...)
|
||||
size, err := io.Copy(limitWriter, in)
|
||||
if err != nil {
|
||||
os.Remove(file)
|
||||
return 0, err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(file)
|
||||
return 0, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.totalSizeCurrent += size
|
||||
mset(metricAttachmentsTotalSize, c.totalSizeCurrent)
|
||||
c.mu.Unlock()
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (c *fileCache) Remove(ids ...string) error {
|
||||
for _, id := range ids {
|
||||
if !fileIDRegex.MatchString(id) {
|
||||
return errInvalidFileID
|
||||
}
|
||||
log.Tag(tagFileCache).Field("message_id", id).Debug("Deleting attachment")
|
||||
file := filepath.Join(c.dir, id)
|
||||
if err := os.Remove(file); err != nil {
|
||||
log.Tag(tagFileCache).Field("message_id", id).Err(err).Debug("Error deleting attachment")
|
||||
}
|
||||
}
|
||||
size, err := dirSize(c.dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.totalSizeCurrent = size
|
||||
c.mu.Unlock()
|
||||
mset(metricAttachmentsTotalSize, size)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fileCache) Size() int64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.totalSizeCurrent
|
||||
}
|
||||
|
||||
func (c *fileCache) Remaining() int64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
remaining := c.totalSizeLimit - c.totalSizeCurrent
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
|
||||
func dirSize(dir string) (int64, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var size int64
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
size += info.Size()
|
||||
}
|
||||
return size, nil
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var (
|
||||
oneKilobyteArray = make([]byte, 1024)
|
||||
)
|
||||
|
||||
func TestFileCache_Write_Success(t *testing.T) {
|
||||
dir, c := newTestFileCache(t)
|
||||
size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(11), size)
|
||||
require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl"))
|
||||
require.Equal(t, int64(11), c.Size())
|
||||
require.Equal(t, int64(10229), c.Remaining())
|
||||
}
|
||||
|
||||
func TestFileCache_Write_Remove_Success(t *testing.T) {
|
||||
dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024)
|
||||
for i := 0; i < 10; i++ { // 10x999 = 9990
|
||||
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999)))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(999), size)
|
||||
}
|
||||
require.Equal(t, int64(9990), c.Size())
|
||||
require.Equal(t, int64(250), c.Remaining())
|
||||
require.FileExists(t, dir+"/abcdefghijk1")
|
||||
require.FileExists(t, dir+"/abcdefghijk5")
|
||||
|
||||
require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5"))
|
||||
require.NoFileExists(t, dir+"/abcdefghijk1")
|
||||
require.NoFileExists(t, dir+"/abcdefghijk5")
|
||||
require.Equal(t, int64(7992), c.Size())
|
||||
require.Equal(t, int64(2248), c.Remaining())
|
||||
}
|
||||
|
||||
func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) {
|
||||
dir, c := newTestFileCache(t)
|
||||
for i := 0; i < 10; i++ {
|
||||
size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(1024), size)
|
||||
}
|
||||
_, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray))
|
||||
require.Equal(t, util.ErrLimitReached, err)
|
||||
require.NoFileExists(t, dir+"/abcdefghijkX")
|
||||
}
|
||||
|
||||
func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) {
|
||||
dir, c := newTestFileCache(t)
|
||||
_, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000))
|
||||
require.Equal(t, util.ErrLimitReached, err)
|
||||
require.NoFileExists(t, dir+"/abcdefghijkl")
|
||||
}
|
||||
|
||||
func newTestFileCache(t *testing.T) (dir string, cache *fileCache) {
|
||||
dir = t.TempDir()
|
||||
cache, err := newFileCache(dir, 10*1024)
|
||||
require.Nil(t, err)
|
||||
return dir, cache
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, f string) string {
|
||||
b, err := os.ReadFile(f)
|
||||
require.Nil(t, err)
|
||||
return string(b)
|
||||
}
|
||||
+12
-9
@@ -1,14 +1,17 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// Log tags
|
||||
@@ -21,7 +24,6 @@ const (
|
||||
tagSMTP = "smtp" // Receive email
|
||||
tagEmail = "email" // Send email
|
||||
tagTwilio = "twilio"
|
||||
tagFileCache = "file_cache"
|
||||
tagMessageCache = "message_cache"
|
||||
tagStripe = "stripe"
|
||||
tagAccount = "account"
|
||||
@@ -33,7 +35,7 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage}
|
||||
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge}
|
||||
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
|
||||
)
|
||||
|
||||
@@ -53,12 +55,12 @@ func logvr(v *visitor, r *http.Request) *log.Event {
|
||||
}
|
||||
|
||||
// logvrm creates a new log event with HTTP request, visitor fields and message fields
|
||||
func logvrm(v *visitor, r *http.Request, m *message) *log.Event {
|
||||
func logvrm(v *visitor, r *http.Request, m *model.Message) *log.Event {
|
||||
return logvr(v, r).With(m)
|
||||
}
|
||||
|
||||
// logvrm creates a new log event with visitor fields and message fields
|
||||
func logvm(v *visitor, m *message) *log.Event {
|
||||
func logvm(v *visitor, m *model.Message) *log.Event {
|
||||
return logv(v).With(m)
|
||||
}
|
||||
|
||||
@@ -83,7 +85,8 @@ func httpContext(r *http.Request) log.Context {
|
||||
}
|
||||
|
||||
func websocketErrorContext(err error) log.Context {
|
||||
if c, ok := err.(*websocket.CloseError); ok {
|
||||
var c *websocket.CloseError
|
||||
if errors.As(err, &c) {
|
||||
return log.Context{
|
||||
"error": c.Error(),
|
||||
"error_code": c.Code,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,745 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSqliteCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessages(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "my message")
|
||||
m1.Time = 1
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = 2
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
|
||||
// Adding invalid
|
||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added!
|
||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
||||
|
||||
// mytopic: count
|
||||
counts, err := c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, counts["mytopic"])
|
||||
|
||||
// mytopic: since all
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "my message", messages[0].Message)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
require.Equal(t, messageEvent, messages[0].Event)
|
||||
require.Equal(t, "", messages[0].Title)
|
||||
require.Equal(t, 0, messages[0].Priority)
|
||||
require.Nil(t, messages[0].Tags)
|
||||
require.Equal(t, "my other message", messages[1].Message)
|
||||
|
||||
// mytopic: since none
|
||||
messages, _ = c.Messages("mytopic", sinceNoMessages, false)
|
||||
require.Empty(t, messages)
|
||||
|
||||
// mytopic: since m1 (by ID)
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m1.ID), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, m2.ID, messages[0].ID)
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
|
||||
// mytopic: since 2
|
||||
messages, _ = c.Messages("mytopic", newSinceTime(2), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// mytopic: latest
|
||||
messages, _ = c.Messages("mytopic", sinceLatestMessage, false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// example: count
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, counts["example"])
|
||||
|
||||
// example: since all
|
||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||
require.Equal(t, "my example message", messages[0].Message)
|
||||
|
||||
// non-existing: count
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, counts["doesnotexist"])
|
||||
|
||||
// non-existing: since all
|
||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesLock(t *testing.T) {
|
||||
testCacheMessagesLock(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesLock(t *testing.T) {
|
||||
testCacheMessagesLock(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesLock(t *testing.T, c *messageCache) {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5000; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "test message")))
|
||||
wg.Done()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesScheduled(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "message 1")
|
||||
m2 := newDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = time.Now().Add(time.Hour).Unix()
|
||||
m3 := newDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
||||
m4 := newDefaultMessage("mytopic2", "message 4")
|
||||
m4.Time = time.Now().Add(time.Minute).Unix()
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
|
||||
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
require.Equal(t, "message 3", messages[1].Message) // Order!
|
||||
require.Equal(t, "message 2", messages[2].Message)
|
||||
|
||||
messages, _ = c.MessagesDue()
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheTopics(t *testing.T, c *messageCache) {
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2")))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3")))
|
||||
|
||||
topics, err := c.Topics()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
require.Equal(t, 2, len(topics))
|
||||
require.Equal(t, "topic1", topics["topic1"].ID)
|
||||
require.Equal(t, "topic2", topics["topic2"].ID)
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c *messageCache) {
|
||||
m := newDefaultMessage("mytopic", "some message")
|
||||
m.Tags = []string{"tag1", "tag2"}
|
||||
m.Priority = 5
|
||||
m.Title = "some title"
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags)
|
||||
require.Equal(t, 5, messages[0].Priority)
|
||||
require.Equal(t, "some title", messages[0].Title)
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesSinceID(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "message 1")
|
||||
m1.Time = 100
|
||||
m2 := newDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = 200
|
||||
m3 := newDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5
|
||||
m4 := newDefaultMessage("mytopic", "message 4")
|
||||
m4.Time = 400
|
||||
m5 := newDefaultMessage("mytopic", "message 5")
|
||||
m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7
|
||||
m6 := newDefaultMessage("mytopic", "message 6")
|
||||
m6.Time = 600
|
||||
m7 := newDefaultMessage("mytopic", "message 7")
|
||||
m7.Time = 700
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
require.Nil(t, c.AddMessage(m4))
|
||||
require.Nil(t, c.AddMessage(m5))
|
||||
require.Nil(t, c.AddMessage(m6))
|
||||
require.Nil(t, c.AddMessage(m7))
|
||||
|
||||
// Case 1: Since ID exists, exclude scheduled
|
||||
messages, _ := c.Messages("mytopic", newSinceID(m2.ID), false)
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "message 4", messages[0].Message)
|
||||
require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5!
|
||||
require.Equal(t, "message 7", messages[2].Message)
|
||||
|
||||
// Case 2: Since ID exists, include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m2.ID), true)
|
||||
require.Equal(t, 5, len(messages))
|
||||
require.Equal(t, "message 4", messages[0].Message)
|
||||
require.Equal(t, "message 6", messages[1].Message)
|
||||
require.Equal(t, "message 7", messages[2].Message)
|
||||
require.Equal(t, "message 5", messages[3].Message) // Order!
|
||||
require.Equal(t, "message 3", messages[4].Message) // Order!
|
||||
|
||||
// Case 3: Since ID does not exist (-> Return all messages), include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID("doesntexist"), true)
|
||||
require.Equal(t, 7, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
require.Equal(t, "message 2", messages[1].Message)
|
||||
require.Equal(t, "message 4", messages[2].Message)
|
||||
require.Equal(t, "message 6", messages[3].Message)
|
||||
require.Equal(t, "message 7", messages[4].Message)
|
||||
require.Equal(t, "message 5", messages[5].Message) // Order!
|
||||
require.Equal(t, "message 3", messages[6].Message) // Order!
|
||||
|
||||
// Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), false)
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
// Case 5: Since ID exists and is last message (-> Return no messages), include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), true)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "message 5", messages[0].Message)
|
||||
require.Equal(t, "message 3", messages[1].Message)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Prune(t *testing.T) {
|
||||
testCachePrune(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Prune(t *testing.T) {
|
||||
testCachePrune(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCachePrune(t *testing.T, c *messageCache) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
m1 := newDefaultMessage("mytopic", "my message")
|
||||
m1.Time = now - 10
|
||||
m1.Expires = now - 5
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = now - 5
|
||||
m2.Expires = now + 5 // In the future
|
||||
|
||||
m3 := newDefaultMessage("another_topic", "and another one")
|
||||
m3.Time = now - 12
|
||||
m3.Expires = now - 2
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
|
||||
counts, err := c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, counts["mytopic"])
|
||||
require.Equal(t, 1, counts["another_topic"])
|
||||
|
||||
expiredMessageIDs, err := c.MessagesExpired()
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, c.DeleteMessages(expiredMessageIDs...))
|
||||
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, counts["mytopic"])
|
||||
require.Equal(t, 0, counts["another_topic"])
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
||||
m := newDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &attachment{
|
||||
Name: "flower.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 5000,
|
||||
Expires: expires1,
|
||||
URL: "https://ntfy.sh/file/AbDeFgJhal.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("mytopic", "sending you a car")
|
||||
m.ID = "m2"
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 10000,
|
||||
Expires: expires2,
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("another-topic", "sending you another car")
|
||||
m.ID = "m3"
|
||||
m.User = "u_BAsbaAa"
|
||||
m.Sender = netip.MustParseAddr("5.6.7.8")
|
||||
m.Attachment = &attachment{
|
||||
Name: "another-car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 20000,
|
||||
Expires: expires3,
|
||||
URL: "https://ntfy.sh/file/zakaDHFW.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
|
||||
require.Equal(t, "flower for you", messages[0].Message)
|
||||
require.Equal(t, "flower.jpg", messages[0].Attachment.Name)
|
||||
require.Equal(t, "image/jpeg", messages[0].Attachment.Type)
|
||||
require.Equal(t, int64(5000), messages[0].Attachment.Size)
|
||||
require.Equal(t, expires1, messages[0].Attachment.Expires)
|
||||
require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[0].Sender.String())
|
||||
|
||||
require.Equal(t, "sending you a car", messages[1].Message)
|
||||
require.Equal(t, "car.jpg", messages[1].Attachment.Name)
|
||||
require.Equal(t, "image/jpeg", messages[1].Attachment.Type)
|
||||
require.Equal(t, int64(10000), messages[1].Attachment.Size)
|
||||
require.Equal(t, expires2, messages[1].Attachment.Expires)
|
||||
require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL)
|
||||
require.Equal(t, "1.2.3.4", messages[1].Sender.String())
|
||||
|
||||
size, err := c.AttachmentBytesUsedBySender("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(10000), size)
|
||||
|
||||
size, err = c.AttachmentBytesUsedBySender("5.6.7.8")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size) // Accounted to the user, not the IP!
|
||||
|
||||
size, err = c.AttachmentBytesUsedByUser("u_BAsbaAa")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(20000), size)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Attachments_Expired(t *testing.T) {
|
||||
testCacheAttachmentsExpired(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Attachments_Expired(t *testing.T) {
|
||||
testCacheAttachmentsExpired(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
||||
m := newDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.Expires = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
m = newDefaultMessage("mytopic", "message with attachment")
|
||||
m.ID = "m2"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 10000,
|
||||
Expires: time.Now().Add(2 * time.Hour).Unix(),
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
m = newDefaultMessage("mytopic", "message with external attachment")
|
||||
m.ID = "m3"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Expires: 0, // Unknown!
|
||||
URL: "https://somedomain.com/car.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
m = newDefaultMessage("mytopic2", "message with expired attachment")
|
||||
m.ID = "m4"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "expired-car.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 20000,
|
||||
Expires: time.Now().Add(-1 * time.Hour).Unix(),
|
||||
URL: "https://ntfy.sh/file/aCaRURL.jpg",
|
||||
}
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
ids, err := c.AttachmentsExpired()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(ids))
|
||||
require.Equal(t, "m4", ids[0])
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 0" schema
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(1024) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
require.Equal(t, "some message 5", messages[5].Message)
|
||||
require.Equal(t, "", messages[5].Title)
|
||||
require.Nil(t, messages[5].Tags)
|
||||
require.Equal(t, 0, messages[5].Priority)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 1" schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(512) NOT NULL,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags VARCHAR(256) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
// Add delayed message
|
||||
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
|
||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||
require.Nil(t, c.AddMessage(delayedMessage))
|
||||
|
||||
// 10, not 11!
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
|
||||
// 11!
|
||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 11, len(messages))
|
||||
|
||||
// Check that index "idx_topic" exists
|
||||
rows, err := c.db.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name='idx_topic'`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
var indexName string
|
||||
require.Nil(t, rows.Scan(&indexName))
|
||||
require.Equal(t, "idx_topic", indexName)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From9(t *testing.T) {
|
||||
// This primarily tests the awkward migration that introduces the "expires" column.
|
||||
// The migration logic has to update the column, using the existing "cache-duration" value.
|
||||
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 8" schema
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
time INT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
click TEXT NOT NULL,
|
||||
icon TEXT NOT NULL,
|
||||
actions TEXT NOT NULL,
|
||||
attachment_name TEXT NOT NULL,
|
||||
attachment_type TEXT NOT NULL,
|
||||
attachment_size INT NOT NULL,
|
||||
attachment_expires INT NOT NULL,
|
||||
attachment_url TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO schemaVersion (id, version) VALUES (1, 9);
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
insertQuery := `
|
||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(
|
||||
insertQuery,
|
||||
fmt.Sprintf("abcd%d", i),
|
||||
time.Now().Unix(),
|
||||
"mytopic",
|
||||
fmt.Sprintf("some message %d", i),
|
||||
"", // title
|
||||
0, // priority
|
||||
"", // tags
|
||||
"", // click
|
||||
"", // icon
|
||||
"", // actions
|
||||
"", // attachment_name
|
||||
"", // attachment_type
|
||||
0, // attachment_size
|
||||
0, // attachment_type
|
||||
"", // attachment_url
|
||||
"9.9.9.9", // sender
|
||||
"", // encoding
|
||||
1, // published
|
||||
)
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
// Create cache to trigger migration
|
||||
cacheDuration := 17 * time.Hour
|
||||
c, err := newSqliteCache(filename, "", cacheDuration, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
// Check version
|
||||
rows, err := db.Query(`SELECT version FROM main.schemaVersion WHERE id = 1`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
var version int
|
||||
require.Nil(t, rows.Scan(&version))
|
||||
require.Equal(t, currentSchemaVersion, version)
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
for _, m := range messages {
|
||||
require.True(t, m.Expires > time.Now().Add(cacheDuration-5*time.Second).Unix())
|
||||
require.True(t, m.Expires < time.Now().Add(cacheDuration+5*time.Second).Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := `pragma journal_mode = WAL;
|
||||
pragma synchronous = normal;
|
||||
pragma temp_store = memory;`
|
||||
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||
require.FileExists(t, filename)
|
||||
require.FileExists(t, filename+"-wal")
|
||||
require.FileExists(t, filename+"-shm")
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := ""
|
||||
db, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||
require.FileExists(t, filename)
|
||||
require.NoFileExists(t, filename+"-wal")
|
||||
require.NoFileExists(t, filename+"-shm")
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := `xx error`
|
||||
_, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Sender(t *testing.T) {
|
||||
testSender(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Sender(t *testing.T) {
|
||||
testSender(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testSender(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "mymessage")
|
||||
m1.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "mymessage without sender")
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, messages[0].Sender, netip.MustParseAddr("1.2.3.4"))
|
||||
require.Equal(t, messages[1].Sender, netip.Addr{})
|
||||
}
|
||||
|
||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
|
||||
var schemaVersion int
|
||||
require.Nil(t, rows.Scan(&schemaVersion))
|
||||
require.Equal(t, currentSchemaVersion, schemaVersion)
|
||||
require.Nil(t, rows.Close())
|
||||
}
|
||||
|
||||
func TestMemCache_NopCache(t *testing.T) {
|
||||
c, _ := newNopCache()
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, messages)
|
||||
|
||||
topics, err := c.Topics()
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, topics)
|
||||
}
|
||||
|
||||
func newSqliteTestCache(t *testing.T) *messageCache {
|
||||
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", time.Hour, 0, 0, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFile(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "cache.db")
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
||||
c, err := newSqliteCache(filename, startupQueries, time.Hour, 0, 0, false)
|
||||
require.Nil(t, err)
|
||||
return c
|
||||
}
|
||||
|
||||
func newMemTestCache(t *testing.T) *messageCache {
|
||||
c, err := newMemCache()
|
||||
require.Nil(t, err)
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/sbin/openrc-run
|
||||
|
||||
# OpenRC service configuration for ntfy Server.
|
||||
# Should be placed in /etc/init.d/ as "ntfy" or "ntfy-server" (no extension), owned by root:root and with permissions 755.
|
||||
# Assumes an ntfy system user and group have been created, for example using this command:
|
||||
# useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy
|
||||
|
||||
name=$RC_SVCNAME
|
||||
description="ntfy server"
|
||||
|
||||
command="/usr/local/bin/ntfy"
|
||||
command_background=true
|
||||
command_args="serve"
|
||||
command_user="ntfy:ntfy"
|
||||
extra_started_commands="reload"
|
||||
|
||||
pidfile="/run/${RC_SVCNAME}/${RC_SVCNAME}.pid"
|
||||
|
||||
# Changes the hard number of open files (nofile) limit to 2048 for the service.
|
||||
rc_ulimit="-n 2048"
|
||||
|
||||
# Allows the service to bind to privileged ports (<1024).
|
||||
capabilities="^cap_net_bind_service"
|
||||
|
||||
error_log="/var/log/ntfy.log"
|
||||
|
||||
# Service dependencies
|
||||
depend() {
|
||||
use net
|
||||
after firewall
|
||||
}
|
||||
|
||||
# Check for - and if necessary - create required files and folders. Might require some adjustment dependings on the content of the server.yml file.
|
||||
start_pre() {
|
||||
checkpath -f --owner "$command_user" --mode 0644 \
|
||||
/var/log/ntfy.log
|
||||
checkpath -d --owner "$command_user" --mode 0750 \
|
||||
/run/ntfy/
|
||||
checkpath -d --owner "$command_user" --mode 0755 \
|
||||
/var/lib/ntfy/
|
||||
checkpath -d --owner "$command_user" --mode 0750 \
|
||||
/var/cache/ntfy/
|
||||
}
|
||||
|
||||
reload() {
|
||||
ebegin "Reloading $RC_SVCNAME's configuration"
|
||||
start-stop-daemon --signal SIGHUP --pidfile "${pidfile}"
|
||||
eend $? "Failed to reload $RC_SVCNAME's configuration"
|
||||
}
|
||||
@@ -10,6 +10,18 @@ ExecReload=/bin/kill --signal HUP $MAINPID
|
||||
Restart=on-failure
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
LimitNOFILE=10000
|
||||
PrivateDevices=true
|
||||
ProtectClock=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
RestrictRealtime=true
|
||||
ProtectHostname=true
|
||||
|
||||
# These will be added in a future update.
|
||||
# ProtectSystem=full
|
||||
# PrivateTmp=true
|
||||
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
+490
-212
File diff suppressed because it is too large
Load Diff
+50
-5
@@ -38,8 +38,32 @@
|
||||
#
|
||||
# firebase-key-file: <filename>
|
||||
|
||||
# If "database-url" is set, ntfy will use PostgreSQL for all database-backed stores (message cache,
|
||||
# user manager, and web push subscriptions) instead of SQLite. When set, the "cache-file",
|
||||
# "auth-file", and "web-push-file" options must not be set.
|
||||
#
|
||||
# Note: Setting "database-url" implicitly enables authentication and access control.
|
||||
# The default access is "read-write" (see "auth-default-access").
|
||||
#
|
||||
# The URL supports standard PostgreSQL parameters (sslmode, connect_timeout, sslcert, etc.),
|
||||
# as well as ntfy-specific connection pool parameters:
|
||||
# pool_max_conns=10 - Maximum number of open connections (default: 10)
|
||||
# pool_max_idle_conns=N - Maximum number of idle connections
|
||||
# pool_conn_max_lifetime=5m - Maximum lifetime of a connection (Go duration)
|
||||
# pool_conn_max_idle_time=1m - Maximum idle time of a connection (Go duration)
|
||||
#
|
||||
# See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-PARAMKEYWORDS
|
||||
# for the full list of supported PostgreSQL connection parameters.
|
||||
#
|
||||
# Examples:
|
||||
# database-url: "postgres://user:pass@host:5432/ntfy"
|
||||
# database-url: "postgres://user:pass@host:5432/ntfy?sslmode=require&pool_max_conns=50"
|
||||
#
|
||||
# database-url: <connection-string>
|
||||
|
||||
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
||||
# This allows for service restarts without losing messages in support of the since= parameter.
|
||||
# Not required if "database-url" is set (messages are stored in PostgreSQL instead).
|
||||
#
|
||||
# The "cache-duration" parameter defines the duration for which messages will be buffered
|
||||
# before they are deleted. This is required to support the "since=..." and "poll=1" parameter.
|
||||
@@ -77,6 +101,8 @@
|
||||
# If set, access to the ntfy server and API can be controlled on a granular level using
|
||||
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
|
||||
#
|
||||
# Note: If "database-url" is set, auth is implicitly enabled and "auth-file" must not be set.
|
||||
#
|
||||
# - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist
|
||||
# - auth-default-access defines the default/fallback access if no access control entry is found; it can be
|
||||
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
|
||||
@@ -90,6 +116,8 @@
|
||||
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
|
||||
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
|
||||
# Use 'ntfy token generate' to generate a new access token.
|
||||
# - auth-access-cache enables an in-memory snapshot of the access control table that authorizes every
|
||||
# request without a database round-trip.
|
||||
#
|
||||
# Debian/RPM package users:
|
||||
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
|
||||
@@ -105,6 +133,7 @@
|
||||
# auth-users:
|
||||
# auth-access:
|
||||
# auth-tokens:
|
||||
# auth-access-cache: false
|
||||
|
||||
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
|
||||
# the visitor IP address instead of the remote address of the connection.
|
||||
@@ -127,7 +156,8 @@
|
||||
# If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments
|
||||
# are "attachment-cache-dir" and "base-url".
|
||||
#
|
||||
# - attachment-cache-dir is the cache directory for attached files
|
||||
# - attachment-cache-dir is the cache directory for attached files, or an S3 URL for object storage
|
||||
# e.g. /var/cache/ntfy/attachments, or s3://ACCESS_KEY:SECRET_KEY@bucket/prefix?region=us-east-1&endpoint=https://...
|
||||
# - attachment-total-size-limit is the limit of the on-disk attachment cache directory (total size)
|
||||
# - attachment-file-size-limit is the per-file attachment size limit (e.g. 300k, 2M, 100M)
|
||||
# - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h)
|
||||
@@ -160,17 +190,20 @@
|
||||
# If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set,
|
||||
# messages will additionally be sent out as e-mail using an external SMTP server.
|
||||
#
|
||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTLS are supported.
|
||||
# As of today, only SMTP servers with plain text auth (or no auth at all), and STARTTLS are supported.
|
||||
# Please also refer to the rate limiting settings below (visitor-email-limit-burst & visitor-email-limit-burst).
|
||||
#
|
||||
# - smtp-sender-addr is the hostname:port of the SMTP server
|
||||
# - smtp-sender-from is the e-mail address of the sender
|
||||
# - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth)
|
||||
# - smtp-sender-verify is a flag that forces email recipient verification when enabled. If set to true,
|
||||
# only verified email recipients can be used in the X-Email header.
|
||||
#
|
||||
# smtp-sender-addr:
|
||||
# smtp-sender-from:
|
||||
# smtp-sender-user:
|
||||
# smtp-sender-pass:
|
||||
# smtp-sender-verify: false
|
||||
|
||||
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send
|
||||
# emails to a topic e-mail address to publish messages to a topic.
|
||||
@@ -197,9 +230,10 @@
|
||||
# - web-push-public-key is the generated VAPID public key, e.g. AA1234BBCCddvveekaabcdfqwertyuiopasdfghjklzxcvbnm1234567890
|
||||
# - web-push-private-key is the generated VAPID private key, e.g. AA2BB1234567890abcdefzxcvbnm1234567890
|
||||
# - web-push-file is a database file to keep track of browser subscription endpoints, e.g. /var/cache/ntfy/webpush.db
|
||||
# Not required if "database-url" is set (subscriptions are stored in PostgreSQL instead).
|
||||
# - web-push-email-address is the admin email address send to the push provider, e.g. sysadmin@example.com
|
||||
# - web-push-startup-queries is an optional list of queries to run on startup`
|
||||
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d`)
|
||||
# - web-push-startup-queries is an optional list of queries to run on startup
|
||||
# - web-push-expiry-warning-duration defines the duration after which unused subscriptions are sent a warning (default is 55d)
|
||||
# - web-push-expiry-duration defines the duration after which unused subscriptions will expire (default is 60d)
|
||||
#
|
||||
# web-push-public-key:
|
||||
@@ -216,11 +250,13 @@
|
||||
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||
# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586
|
||||
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||
# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml)
|
||||
#
|
||||
# twilio-account:
|
||||
# twilio-auth-token:
|
||||
# twilio-phone-number:
|
||||
# twilio-verify-service:
|
||||
# twilio-call-format:
|
||||
|
||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||
# intermediaries closing the connection for inactivity.
|
||||
@@ -278,7 +314,7 @@
|
||||
#
|
||||
# - upstream-base-url is the base URL of the upstream server. Should be "https://ntfy.sh".
|
||||
# - upstream-access-token is the token used to authenticate with the upstream server. This is only required
|
||||
# if you exceed the upstream rate limits, or the uptream server requires authentication.
|
||||
# if you exceed the upstream rate limits, or the upstream server requires authentication.
|
||||
#
|
||||
# upstream-base-url:
|
||||
# upstream-access-token:
|
||||
@@ -325,6 +361,15 @@
|
||||
# visitor-email-limit-burst: 16
|
||||
# visitor-email-limit-replenish: "1h"
|
||||
|
||||
# Rate limiting: Allowed new topic creations per visitor. A "creation" is when a request
|
||||
# causes a previously-unknown topic ID to be added to the in-memory topic map. Touches of
|
||||
# existing topics do not consume tokens. Mitigates topic-enumeration / squatting attacks.
|
||||
# - visitor-topic-creation-limit-burst is the initial bucket of new-topic tokens (0 = disabled)
|
||||
# - visitor-topic-creation-limit-replenish is the rate at which the bucket is refilled
|
||||
#
|
||||
# visitor-topic-creation-limit-burst: 100
|
||||
# visitor-topic-creation-limit-replenish: "1m"
|
||||
|
||||
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
|
||||
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
|
||||
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
|
||||
|
||||
+352
-39
@@ -3,18 +3,22 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
syncTopicAccountSyncEvent = "sync"
|
||||
tokenExpiryDuration = 72 * time.Hour // Extend tokens by this much
|
||||
emailVerificationTokenExpiry = 24 * time.Hour // Magic-link lifetime for email verification
|
||||
passwordResetTokenExpiry = time.Hour // Magic-link lifetime for password reset (higher-privilege -> shorter)
|
||||
)
|
||||
|
||||
func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -25,14 +29,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
} else if u != nil {
|
||||
return errHTTPUnauthorized // Cannot create account from user context
|
||||
}
|
||||
if !v.AccountCreationAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountCreation
|
||||
if !v.AccountActionAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountActions
|
||||
}
|
||||
}
|
||||
newAccount, err := readJSONWithLimit[apiAccountCreateRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if newAccount.Email != "" && !emailAddressRegex.MatchString(newAccount.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if existingUser, _ := s.userManager.User(newAccount.Username); existingUser != nil {
|
||||
return errHTTPConflictUserExists
|
||||
}
|
||||
@@ -43,7 +50,17 @@ func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *
|
||||
}
|
||||
return err
|
||||
}
|
||||
v.AccountCreated()
|
||||
v.AccountActionPerformed()
|
||||
// If an email was provided and email sending is configured, start verification (best-effort).
|
||||
// The address becomes the primary email on verify (the new account has no primary yet); a
|
||||
// failure to send must not fail signup, so we only log it.
|
||||
if newAccount.Email != "" && s.mailer != nil {
|
||||
if u, err := s.userManager.User(newAccount.Username); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to load new user for email verification")
|
||||
} else if err := s.enqueueEmailVerification(u.ID, newAccount.Email); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send signup email verification")
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
@@ -158,6 +175,31 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
|
||||
response.PhoneNumbers = phoneNumbers
|
||||
}
|
||||
}
|
||||
if s.mailer != nil {
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
primaryEmail, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pendingEmails, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Combine verified (with primary flag) and pending (unverified) into one list
|
||||
emailInfos := make([]*apiAccountEmailInfo, 0, len(emails)+len(pendingEmails))
|
||||
for _, email := range emails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Primary: email == primaryEmail})
|
||||
}
|
||||
for _, email := range pendingEmails {
|
||||
emailInfos = append(emailInfos, &apiAccountEmailInfo{Address: email, Pending: true})
|
||||
}
|
||||
if len(emailInfos) > 0 {
|
||||
response.Emails = emailInfos
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.Username = user.Everyone
|
||||
response.Role = string(user.RoleAnonymous)
|
||||
@@ -454,21 +496,8 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
||||
return errHTTPUnauthorized
|
||||
} else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {
|
||||
return errHTTPConflictTopicReserved
|
||||
} else if u.IsUser() {
|
||||
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasReservation {
|
||||
reservations, err := s.userManager.ReservationsCount(u.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if reservations >= u.Tier.ReservationLimit {
|
||||
return errHTTPTooManyRequestsLimitReservations
|
||||
}
|
||||
}
|
||||
}
|
||||
// Actually add the reservation
|
||||
// Actually add the reservation (with limit check inside the transaction to avoid races)
|
||||
logvr(v, r).
|
||||
Tag(tagAccount).
|
||||
Fields(log.Context{
|
||||
@@ -476,11 +505,18 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
|
||||
"everyone": everyone.String(),
|
||||
}).
|
||||
Debug("Adding topic reservation")
|
||||
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil {
|
||||
var limit int64
|
||||
if u.IsUser() && u.Tier != nil {
|
||||
limit = u.Tier.ReservationLimit
|
||||
}
|
||||
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone, limit); err != nil {
|
||||
if errors.Is(err, user.ErrTooManyReservations) {
|
||||
return errHTTPTooManyRequestsLimitReservations
|
||||
}
|
||||
return err
|
||||
}
|
||||
// Kill existing subscribers
|
||||
t, err := s.topicFromID(req.Topic)
|
||||
t, err := s.topicFromID(v, req.Topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -529,22 +565,15 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
|
||||
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
|
||||
// The process relies on the manager to perform the actual deletions (see runManager).
|
||||
func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error {
|
||||
reservations, err := s.userManager.Reservations(u.Name)
|
||||
removedTopics, err := s.userManager.RemoveExcessReservations(u.Name, reservationsLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if int64(len(reservations)) <= reservationsLimit {
|
||||
} else if len(removedTopics) == 0 {
|
||||
logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove")
|
||||
return nil
|
||||
}
|
||||
topics := make([]string, 0)
|
||||
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
|
||||
topics = append(topics, reservations[i].Topic)
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Info("Removing excess reservations for topics %s", strings.Join(topics, ", "))
|
||||
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.messageCache.ExpireMessages(topics...); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Info("Removed excess topic reservations, now removing messages for topics %s", strings.Join(removedTopics, ", "))
|
||||
if err := s.messageCache.ExpireMessages(removedTopics...); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.pruneMessages()
|
||||
@@ -617,6 +646,269 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailAdd starts email verification (PUT /v1/account/email): it generates a
|
||||
// magic-link token, stores a pending verification, and emails the link. The address is NOT
|
||||
// added to the verified list until the user clicks the link (handleAccountEmailVerify).
|
||||
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
// Check user is allowed to add emails (the tier email limit gates the feature)
|
||||
if u.IsUser() && u.Tier != nil && u.Tier.EmailLimit == 0 {
|
||||
return errHTTPUnauthorized
|
||||
} else if u.IsUser() && u.Tier == nil && s.config.VisitorEmailLimitBurst == 0 {
|
||||
return errHTTPUnauthorized
|
||||
}
|
||||
// Reject if already verified on this account (pending re-requests are fine -- they replace)
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if util.Contains(emails, req.Email) {
|
||||
return errHTTPConflictEmailExists
|
||||
}
|
||||
// Rate limit (counts against the user's email quota)
|
||||
if !v.EmailAllowed() {
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Starting email verification")
|
||||
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailVerify performs verification from the (unauthenticated) landing page
|
||||
// (POST /v1/account/email/verify): it validates the raw token, adds the address to the user's
|
||||
// verified emails, and -- if the user has no primary yet -- promotes it. No auth is required;
|
||||
// the token binds the action to a user, so the click works from a logged-out mail client.
|
||||
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if req.Token == "" {
|
||||
return errHTTPBadRequestEmailVerificationLinkInvalid
|
||||
}
|
||||
m, err := s.userManager.VerifyEmail(req.Token)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) {
|
||||
return errHTTPBadRequestEmailVerificationLinkInvalid
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified")
|
||||
// Refresh the verified user's other sessions. The request is unauthenticated (v.User() is
|
||||
// usually nil), so resolve the user from the token row and publish to their sync topic.
|
||||
s.publishSyncEventForUserIDAsync(v, m.UserID)
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailDelete removes an email address, whether verified or still pending
|
||||
// (DELETE /v1/account/email). Removing the primary leaves the account with no primary.
|
||||
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting email (verified or pending)")
|
||||
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
// Also drop any pending verification for the address (no-op if there is none)
|
||||
if err := s.userManager.DeleteEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery)
|
||||
// email (POST /v1/account/email/primary).
|
||||
func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Setting primary email")
|
||||
err = s.userManager.SetPrimaryEmail(u.ID, req.Email)
|
||||
if errors.Is(err, user.ErrEmailPrimaryElsewhere) {
|
||||
return errHTTPConflictEmailPrimaryElsewhere
|
||||
} else if errors.Is(err, user.ErrEmailNotFound) {
|
||||
return errHTTPBadRequestEmailAddressNotVerified
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend).
|
||||
func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
u := v.User()
|
||||
req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !emailAddressRegex.MatchString(req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
// Only resend for an address that is actually pending on this account
|
||||
pending, err := s.userManager.PendingEmails(u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !util.Contains(pending, req.Email) {
|
||||
return errHTTPBadRequestEmailAddressInvalid
|
||||
}
|
||||
if !v.EmailAllowed() {
|
||||
return errHTTPTooManyRequestsLimitEmails
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Resending email verification")
|
||||
if err := s.enqueueEmailVerification(u.ID, req.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// enqueueEmailVerification generates a magic-link token for the given address, stores the
|
||||
// pending verification (replacing any existing one), and emails the link. Shared by the add,
|
||||
// resend, signup, and Stripe paths. Requires base-url to build an absolute link.
|
||||
func (s *Server) enqueueEmailVerification(userID, email string) error {
|
||||
if s.config.BaseURL == "" {
|
||||
return errHTTPInternalErrorMissingBaseURL
|
||||
}
|
||||
token, err := s.userManager.AddMagicLink(user.MagicLinkKindEmailVerify, userID, email, emailVerificationTokenExpiry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := s.config.BaseURL + webAppEmailVerifyPathPrefix + token
|
||||
return s.mailer.SendEmailVerification(email, link)
|
||||
}
|
||||
|
||||
// handleAccountPasswordResetRequest starts a password reset (POST /v1/account/password/reset/request,
|
||||
// unauthenticated). It resolves the identifier (username or primary email) to at most one account
|
||||
// and emails a reset link to that account's primary email. The response is always a uniform 200,
|
||||
// regardless of whether anything matched, so it cannot be used to probe for accounts.
|
||||
func (s *Server) handleAccountPasswordResetRequest(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountPasswordResetRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Rate limit via the shared per-visitor account-creation bucket (no new limiter/config)
|
||||
if !v.AccountActionAllowed() {
|
||||
return errHTTPTooManyRequestsLimitAccountActions
|
||||
}
|
||||
v.AccountActionPerformed() // Consume a token on every request (including no-match), to throttle probing
|
||||
identifier := strings.TrimSpace(req.Identifier)
|
||||
if identifier != "" && s.config.BaseURL != "" {
|
||||
if userID, email, ok := s.resolveResetPasswordTarget(identifier); ok {
|
||||
token, err := s.userManager.AddMagicLink(user.MagicLinkKindPasswordReset, userID, "", passwordResetTokenExpiry)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to create password reset token")
|
||||
} else {
|
||||
link := s.config.BaseURL + webAppPasswordResetPathPrefix + token
|
||||
logvr(v, r).Tag(tagAccount).Field("user_id", userID).Info("Sending password reset link")
|
||||
if err := s.mailer.SendPasswordReset(email, link); err != nil {
|
||||
logvr(v, r).Tag(tagAccount).Err(err).Warn("Failed to send password reset email")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logvr(v, r).Tag(tagAccount).Debug("Password reset requested for unknown identifier (uniform response)")
|
||||
}
|
||||
}
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// resolveResetPasswordTarget resolves a reset identifier (username or primary email) to a single account
|
||||
// and its primary email. It applies the reset policy on top of the lookup: provisioned users are
|
||||
// excluded, and ok=false is returned unless the account has a verified primary email (reset
|
||||
// requires one, and that is where the link is sent).
|
||||
func (s *Server) resolveResetPasswordTarget(identifier string) (userID string, email string, ok bool) {
|
||||
u, err := s.userManager.UserByEmailOrUsername(identifier)
|
||||
if err != nil || u == nil || u.Provisioned {
|
||||
return "", "", false
|
||||
}
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil || primary == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return u.ID, primary, true
|
||||
}
|
||||
|
||||
// handleAccountPasswordReset performs the reset (POST /v1/account/password/reset, unauthenticated):
|
||||
// it validates the token and sets the new password. Existing access tokens stay valid.
|
||||
func (s *Server) handleAccountPasswordReset(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiAccountPasswordResetConfirmRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Token == "" {
|
||||
return errHTTPBadRequestResetLinkInvalid
|
||||
} else if req.Password == "" {
|
||||
return errHTTPBadRequest
|
||||
}
|
||||
err = s.userManager.ResetPassword(req.Token, req.Password)
|
||||
if errors.Is(err, user.ErrMagicLinkNotFound) || errors.Is(err, user.ErrProvisionedUserChange) {
|
||||
return errHTTPBadRequestResetLinkInvalid // Generic 400 (provisioned users can't be reset; don't leak that)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
logvr(v, r).Tag(tagAccount).Info("Password reset performed")
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
// convertEmailAddress resolves the X-Email value to the address ntfy should send to.
|
||||
//
|
||||
// "yes"/"true"/"1" resolves to the user's primary verified address -- or, if no primary is
|
||||
// designated (e.g. a provisioned user), the first verified address (alphabetically). This is
|
||||
// independent of smtp-sender-verify: it only requires an authenticated user with a verified
|
||||
// address, since it means "send to my own email".
|
||||
//
|
||||
// A literal address is sent as-is when smtp-sender-verify is false (the default, backwards
|
||||
// compatible); when true, the address must be one the user has verified.
|
||||
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
|
||||
if toBool(email) {
|
||||
if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||
} else if s.userManager == nil {
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if primary != "" {
|
||||
return primary, nil
|
||||
}
|
||||
// No primary designated -> fall back to the first verified address, if any
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if len(emails) > 0 {
|
||||
return emails[0], nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
// A literal address
|
||||
if !s.config.SMTPSenderVerify {
|
||||
return email, nil
|
||||
} else if u == nil {
|
||||
return "", errHTTPBadRequestAnonymousEmailNotAllowed
|
||||
} else if s.userManager == nil {
|
||||
return email, nil
|
||||
}
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
if err != nil {
|
||||
return "", errHTTPInternalError
|
||||
} else if util.Contains(emails, email) {
|
||||
return email, nil
|
||||
}
|
||||
return "", errHTTPBadRequestEmailAddressNotVerified
|
||||
}
|
||||
|
||||
// publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
|
||||
func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||
go func() {
|
||||
@@ -626,14 +918,35 @@ func (s *Server) publishSyncEventAsync(v *visitor) {
|
||||
}()
|
||||
}
|
||||
|
||||
// publishSyncEvent publishes a sync message to the user's sync topic
|
||||
// publishSyncEvent publishes a sync message to the authenticated user's sync topic
|
||||
func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
u := v.User()
|
||||
return s.publishSyncEventForUser(v, v.User())
|
||||
}
|
||||
|
||||
// publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the
|
||||
// given ID, resolving the user first. Used by the unauthenticated email-verify handler, where
|
||||
// the request visitor has no associated user but the token identifies the account to refresh.
|
||||
func (s *Server) publishSyncEventForUserIDAsync(v *visitor, userID string) {
|
||||
go func() {
|
||||
u, err := s.userManager.UserByID(userID)
|
||||
if err != nil {
|
||||
logv(v).Err(err).Trace("Error loading user for sync event")
|
||||
return
|
||||
}
|
||||
if err := s.publishSyncEventForUser(v, u); err != nil {
|
||||
logv(v).Err(err).Trace("Error publishing to user's sync topic")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// publishSyncEventForUser publishes a sync message to the given user's sync topic, using v as
|
||||
// the publishing visitor (for rate-limit accounting). No-op if the user has no sync topic.
|
||||
func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
|
||||
if u == nil || u.SyncTopic == "" {
|
||||
return nil
|
||||
}
|
||||
logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic")
|
||||
syncTopic, err := s.topicFromID(u.SyncTopic)
|
||||
syncTopic, err := s.topicFromID(nil, u.SyncTopic) // internal: no rate limit
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -641,7 +954,7 @@ func (s *Server) publishSyncEvent(v *visitor) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m := newDefaultMessage(syncTopic.ID, string(messageBytes))
|
||||
m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))
|
||||
if err := syncTopic.Publish(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// captureMailer is a fake mailer that records the magic links it is asked to send, so tests can
|
||||
// "click" them without a real SMTP server. The notification side is a no-op.
|
||||
type captureMailer struct {
|
||||
verifyLinks map[string]string // email -> verification link
|
||||
resetLinks map[string]string // email -> reset link
|
||||
}
|
||||
|
||||
func newCaptureMailer() *captureMailer {
|
||||
return &captureMailer{verifyLinks: map[string]string{}, resetLinks: map[string]string{}}
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendEmailVerification(to, link string) error {
|
||||
c.verifyLinks[to] = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendPasswordReset(to, link string) error {
|
||||
c.resetLinks[to] = link
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) SendNotification(to string, m *model.Message, senderIP string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *captureMailer) NotificationCounts() (total int64, success int64, failure int64) {
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
// newEmailTestServer creates a server with email sending "enabled" (SMTP + base-url configured)
|
||||
// and a capturing mailer injected, plus a tier-less user "ben" logged in via basic auth.
|
||||
func newEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, map[string]string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
auth := map[string]string{"Authorization": util.BasicAuth("ben", "ben")}
|
||||
return s, mailer, auth
|
||||
}
|
||||
|
||||
func getAccount(t *testing.T, s *Server, auth map[string]string) *apiAccountResponse {
|
||||
rr := request(t, s, "GET", "/v1/account", "", auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account, err := util.UnmarshalJSON[apiAccountResponse](io.NopCloser(rr.Body))
|
||||
require.Nil(t, err)
|
||||
return account
|
||||
}
|
||||
|
||||
// verifiedAddrs / pendingAddrs / primaryAddr extract the addresses from the structured email
|
||||
// list returned by GET /v1/account, so assertions stay readable.
|
||||
func verifiedAddrs(account *apiAccountResponse) []string {
|
||||
addrs := make([]string, 0)
|
||||
for _, e := range account.Emails {
|
||||
if !e.Pending {
|
||||
addrs = append(addrs, e.Address)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func pendingAddrs(account *apiAccountResponse) []string {
|
||||
addrs := make([]string, 0)
|
||||
for _, e := range account.Emails {
|
||||
if e.Pending {
|
||||
addrs = append(addrs, e.Address)
|
||||
}
|
||||
}
|
||||
return addrs
|
||||
}
|
||||
|
||||
func primaryAddr(account *apiAccountResponse) string {
|
||||
for _, e := range account.Emails {
|
||||
if e.Primary {
|
||||
return e.Address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenFromLink(t *testing.T, link, prefix string) string {
|
||||
require.True(t, strings.HasPrefix(link, prefix), "link %q missing prefix %q", link, prefix)
|
||||
return strings.TrimPrefix(link, prefix)
|
||||
}
|
||||
|
||||
func TestAccount_Email_AddVerifySetsPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Start verification
|
||||
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Pending, not yet verified, no primary
|
||||
account := getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(account))
|
||||
require.Empty(t, verifiedAddrs(account))
|
||||
require.Equal(t, "", primaryAddr(account))
|
||||
|
||||
// "Click" the captured link (unauthenticated POST)
|
||||
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
rr = request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Now verified + primary, no longer pending
|
||||
account = getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"ben@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "ben@example.com", primaryAddr(account))
|
||||
require.Empty(t, pendingAddrs(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_VerifyInvalidToken(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/email/verify", `{"token":"doesnotexist"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40051, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Empty token also rejected
|
||||
rr = request(t, s, "POST", "/v1/account/email/verify", `{"token":""}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_DeletePending(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
require.Equal(t, []string{"ben@example.com"}, pendingAddrs(getAccount(t, s, auth)))
|
||||
|
||||
// Deleting the pending address clears it (no verification ever happened)
|
||||
require.Equal(t, 200, request(t, s, "DELETE", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
account := getAccount(t, s, auth)
|
||||
require.Empty(t, pendingAddrs(account))
|
||||
require.Empty(t, verifiedAddrs(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_Resend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
firstLink := mailer.verifyLinks["ben@example.com"]
|
||||
require.NotEmpty(t, firstLink)
|
||||
|
||||
// Resend issues a fresh link (the old one is replaced)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/resend", `{"email":"ben@example.com"}`, auth).Code)
|
||||
require.NotEqual(t, firstLink, mailer.verifyLinks["ben@example.com"])
|
||||
|
||||
// The old token no longer verifies; the new one does
|
||||
oldToken := tokenFromLink(t, firstLink, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, oldToken), nil).Code)
|
||||
newToken := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, newToken), nil).Code)
|
||||
|
||||
// Resending for a non-pending address is rejected
|
||||
require.Equal(t, 400, request(t, s, "POST", "/v1/account/email/resend", `{"email":"never@example.com"}`, auth).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_SetPrimaryCollision(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// ben verifies shared@ -> becomes his primary
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, auth).Code)
|
||||
benToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, benToken), nil).Code)
|
||||
require.Equal(t, "shared@example.com", primaryAddr(getAccount(t, s, auth)))
|
||||
|
||||
// alice verifies the same address -> allowed as secondary, but it is not her primary
|
||||
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||
aliceAuth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")}
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"shared@example.com"}`, aliceAuth).Code)
|
||||
aliceToken := tokenFromLink(t, mailer.verifyLinks["shared@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, aliceToken), nil).Code)
|
||||
aliceAccount := getAccount(t, s, aliceAuth)
|
||||
require.Equal(t, []string{"shared@example.com"}, verifiedAddrs(aliceAccount))
|
||||
require.Equal(t, "", primaryAddr(aliceAccount))
|
||||
|
||||
// alice trying to promote it to primary collides with ben's
|
||||
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"shared@example.com"}`, aliceAuth)
|
||||
require.Equal(t, 409, rr.Code)
|
||||
require.Equal(t, 40908, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
// verifyEmailFor runs the full add->click flow so the user ends up with a verified primary email.
|
||||
func verifyEmailFor(t *testing.T, s *Server, mailer *captureMailer, auth map[string]string, email string) {
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", fmt.Sprintf(`{"email":"%s"}`, email), auth).Code)
|
||||
token := tokenFromLink(t, mailer.verifyLinks[email], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
}
|
||||
|
||||
// canLogin returns true if username/password authenticates (via the token-create endpoint).
|
||||
func canLogin(t *testing.T, s *Server, username, password string) bool {
|
||||
rr := request(t, s, "POST", "/v1/account/token", "", map[string]string{"Authorization": util.BasicAuth(username, password)})
|
||||
return rr.Code == 200
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByUsername(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
// Request reset by username
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
|
||||
// Confirm with a new password
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||
require.False(t, canLogin(t, s, "ben", "ben"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ByEmail(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
token := tokenFromLink(t, mailer.resetLinks["ben@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_EmailLookalikeUsernameDoesNotShadow(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Account A (the email owner): user "ben" with verified primary email "phil@example.com"
|
||||
verifyEmailFor(t, s, mailer, auth, "phil@example.com")
|
||||
|
||||
// Account B (the squatter): a different account whose USERNAME looks like A's email, with
|
||||
// its own, different verified primary email
|
||||
require.Nil(t, s.userManager.AddUser("phil@example.com", "squatterpass", user.RoleUser, false))
|
||||
squatter, err := s.userManager.User("phil@example.com")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(squatter.ID, "squatter@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(squatter.ID, "squatter@example.com"))
|
||||
|
||||
// Reset by the ambiguous identifier: the verified email must win over the look-alike username
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"phil@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.NotEmpty(t, mailer.resetLinks["phil@example.com"]) // sent to the email owner (account A)
|
||||
require.Empty(t, mailer.resetLinks["squatter@example.com"]) // NOT the username squatter (account B)
|
||||
|
||||
// The token resets account A (ben); the squatter's password is untouched
|
||||
token := tokenFromLink(t, mailer.resetLinks["phil@example.com"], "https://ntfy.example.com/account/password/reset/")
|
||||
rr = request(t, s, "POST", "/v1/account/password/reset", fmt.Sprintf(`{"token":"%s","password":"brandnew"}`, token), nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.True(t, canLogin(t, s, "ben", "brandnew")) // account A was reset
|
||||
require.True(t, canLogin(t, s, "phil@example.com", "squatterpass")) // account B unaffected
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_UnknownIdentifierUniform(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Unknown identifier still returns a uniform 200, and no email is sent
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ghost"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_NoPrimaryEmailNoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// ben exists but has no verified primary email -> uniform 200, nothing sent
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"ben"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_WithEmail_SendsVerification(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.EnableSignup = true
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Sign up with an optional email -> account created and a verification link sent
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass","email":"emma@example.com"}`, nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
link := mailer.verifyLinks["emma@example.com"]
|
||||
require.NotEmpty(t, link)
|
||||
|
||||
// Verifying the link makes it the (first) primary email
|
||||
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
account := getAccount(t, s, map[string]string{"Authorization": util.BasicAuth("emma", "emmapass")})
|
||||
require.Equal(t, []string{"emma@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "emma@example.com", primaryAddr(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Signup_WithoutEmail_NoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.EnableSignup = true
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// No email -> account created, nothing sent
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account", `{"username":"emma","password":"emmapass"}`, nil).Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
|
||||
// Invalid email -> rejected
|
||||
rr := request(t, s, "POST", "/v1/account", `{"username":"otto","password":"ottopass","email":"not-an-email"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40050, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_ProvisionedPrimary(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
hash, err := user.HashPassword("provpass", user.DefaultUserPasswordBcryptCost)
|
||||
require.Nil(t, err)
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
conf.AuthUsers = []*user.User{{Name: "prov", Hash: hash, Role: user.RoleUser}}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
auth := map[string]string{"Authorization": util.BasicAuth("prov", "provpass")}
|
||||
|
||||
// A provisioned user's first verified email becomes their primary (used by X-Email: yes;
|
||||
// password reset stays blocked separately for provisioned users)
|
||||
verifyEmailFor(t, s, mailer, auth, "prov@example.com")
|
||||
account := getAccount(t, s, auth)
|
||||
require.Equal(t, []string{"prov@example.com"}, verifiedAddrs(account))
|
||||
require.Equal(t, "prov@example.com", primaryAddr(account))
|
||||
|
||||
// Verify a second address and explicitly set it primary -> allowed, star moves
|
||||
verifyEmailFor(t, s, mailer, auth, "prov2@example.com")
|
||||
rr := request(t, s, "POST", "/v1/account/email/primary", `{"email":"prov2@example.com"}`, auth)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
account = getAccount(t, s, auth)
|
||||
require.Equal(t, "prov2@example.com", primaryAddr(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_ProvisionedUserNoSend(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Provision a user via config (AuthUsers), with email sending enabled
|
||||
conf := newTestConfigWithAuthFile(t, databaseURL)
|
||||
conf.SMTPSenderAddr = "localhost:25"
|
||||
conf.SMTPSenderFrom = "noreply@example.com"
|
||||
conf.BaseURL = "https://ntfy.example.com"
|
||||
conf.AuthUsers = []*user.User{
|
||||
{Name: "prov", Hash: "$2a$10$YLiO8U21sX1uhZamTLJXHuxgVC0Z/GKISibrKCLohPgtG7yIxSk4C", Role: user.RoleUser},
|
||||
}
|
||||
s := newTestServer(t, conf)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Give the provisioned user a verified primary email anyway
|
||||
prov, err := s.userManager.User("prov")
|
||||
require.Nil(t, err)
|
||||
require.True(t, prov.Provisioned)
|
||||
require.Nil(t, s.userManager.AddEmail(prov.ID, "prov@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(prov.ID, "prov@example.com"))
|
||||
|
||||
// Reset request by username and by email -> uniform 200, but no email sent (can't reset)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov"}`, nil).Code)
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/password/reset/request", `{"identifier":"prov@example.com"}`, nil).Code)
|
||||
require.Empty(t, mailer.resetLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_PasswordReset_InvalidToken(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, _, _ := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
rr := request(t, s, "POST", "/v1/account/password/reset", `{"token":"nope","password":"brandnew"}`, nil)
|
||||
require.Equal(t, 400, rr.Code)
|
||||
require.Equal(t, 40054, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccount_Email_AddDuplicateVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, auth := newEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
|
||||
require.Equal(t, 200, request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth).Code)
|
||||
token := tokenFromLink(t, mailer.verifyLinks["ben@example.com"], "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
|
||||
// Adding the same already-verified address is a conflict
|
||||
rr := request(t, s, "PUT", "/v1/account/email", `{"email":"ben@example.com"}`, auth)
|
||||
require.Equal(t, 409, rr.Code)
|
||||
require.Equal(t, 40907, toHTTPError(t, rr.Body.String()).Code)
|
||||
})
|
||||
}
|
||||
+637
-577
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,14 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.writeJSON(w, &apiVersionResponse{
|
||||
Version: s.config.BuildVersion,
|
||||
Commit: s.config.BuildCommit,
|
||||
Date: s.config.BuildDate,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUsersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
users, err := s.userManager.Users()
|
||||
if err != nil {
|
||||
|
||||
+432
-372
@@ -1,6 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
@@ -9,393 +10,452 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVersion_Admin(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.BuildVersion = "1.2.3"
|
||||
c.BuildCommit = "abcdef0"
|
||||
c.BuildDate = "2026-02-08T00:00:00Z"
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin and regular user
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Admin can access /v1/version
|
||||
rr := request(t, s, "GET", "/v1/version", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
var versionResponse apiVersionResponse
|
||||
require.Nil(t, json.NewDecoder(rr.Body).Decode(&versionResponse))
|
||||
require.Equal(t, "1.2.3", versionResponse.Version)
|
||||
require.Equal(t, "abcdef0", versionResponse.Commit)
|
||||
require.Equal(t, "2026-02-08T00:00:00Z", versionResponse.Date)
|
||||
|
||||
// Non-admin user cannot access /v1/version
|
||||
rr = request(t, s, "GET", "/v1/version", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Unauthenticated user cannot access /v1/version
|
||||
rr = request(t, s, "GET", "/v1/version", "", nil)
|
||||
require.Equal(t, 401, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_AddRemove(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Create user with tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 4, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Nil(t, users[1].Tier)
|
||||
require.Equal(t, "emma", users[2].Name)
|
||||
require.Equal(t, user.RoleUser, users[2].Role)
|
||||
require.Equal(t, "tier1", users[2].Tier.Code)
|
||||
require.Equal(t, user.Everyone, users[3].Name)
|
||||
|
||||
// Delete user via API
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check user was deleted
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "emma", users[1].Name)
|
||||
require.Equal(t, user.Everyone, users[2].Name)
|
||||
|
||||
// Reject invalid user change
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Create user with tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "emma", "password":"emma", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 4, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Nil(t, users[1].Tier)
|
||||
require.Equal(t, "emma", users[2].Name)
|
||||
require.Equal(t, user.RoleUser, users[2].Role)
|
||||
require.Equal(t, "tier1", users[2].Tier.Code)
|
||||
require.Equal(t, user.Everyone, users[3].Name)
|
||||
|
||||
// Delete user via API
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check user was deleted
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "emma", users[1].Name)
|
||||
require.Equal(t, user.Everyone, users[2].Name)
|
||||
|
||||
// Reject invalid user change
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_AddWithPasswordHash(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check that user can login with password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, user.RoleAdmin, users[0].Role)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPassword(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with first password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Change password via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Make sure first password fails
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserTier(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier2",
|
||||
}))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||
|
||||
// Change user tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users again
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier2",
|
||||
}))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||
|
||||
// Change user password and tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Make sure first password fails
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check new tier
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with first password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "not-ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Change user password and tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_DontChangeAdminPassword(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
|
||||
|
||||
// Try to change password via API
|
||||
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
}
|
||||
|
||||
func TestUser_AddRemove_Failures(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Cannot create user with invalid username
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
|
||||
// Cannot create user if user already exists
|
||||
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Cannot create user with invalid tier
|
||||
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Cannot delete user as non-admin
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Delete user via API
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User and admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Subscribing not allowed
|
||||
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
|
||||
// Grant access
|
||||
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Now subscribing is allowed
|
||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Reset access
|
||||
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Subscribing not allowed (again)
|
||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Grant access fails, because non-admin
|
||||
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset_KillConnection(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User and admin, grant access to "gol*" topics
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
|
||||
|
||||
start, timeTaken := time.Now(), atomic.Int64{}
|
||||
go func() {
|
||||
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
|
||||
// Check that user can login with password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
timeTaken.Store(time.Since(start).Milliseconds())
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Reset access
|
||||
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, user.RoleAdmin, users[0].Role)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
}
|
||||
|
||||
// Wait for connection to be killed; this will fail if the connection is never killed
|
||||
waitFor(t, func() bool {
|
||||
return timeTaken.Load() >= 500
|
||||
func TestUser_ChangeUserPassword(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with first password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Change password via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password": "ben-two"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Make sure first password fails
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserTier(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier2",
|
||||
}))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||
|
||||
// Change user tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "tier": "tier2"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users again
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPasswordAndTier(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin, tier
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier1",
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "tier2",
|
||||
}))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"ben", "tier": "tier1"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check users
|
||||
users, err := s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 3, len(users))
|
||||
require.Equal(t, "phil", users[0].Name)
|
||||
require.Equal(t, "ben", users[1].Name)
|
||||
require.Equal(t, user.RoleUser, users[1].Role)
|
||||
require.Equal(t, "tier1", users[1].Tier.Code)
|
||||
|
||||
// Change user password and tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "password":"ben-two", "tier": "tier2"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Make sure first password fails
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben-two"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Check new tier
|
||||
users, err = s.userManager.Users()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "tier2", users[1].Tier.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_ChangeUserPasswordWithHash(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
|
||||
// Create user with tier via API
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "ben", "password":"not-ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with first password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "not-ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Change user password and tier via API
|
||||
rr = request(t, s, "PUT", "/v1/users", `{"username": "ben", "hash":"$2a$04$2aPIIqPXQU16OfkSUZH1XOzpu1gsPRKkrfVdFLgWQ.tqb.vtTCuVe"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Try to login with second password
|
||||
rr = request(t, s, "POST", "/v1/account/token", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_DontChangeAdminPassword(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("admin", "admin", user.RoleAdmin, false))
|
||||
|
||||
// Try to change password via API
|
||||
rr := request(t, s, "PUT", "/v1/users", `{"username": "admin", "password": "admin-new"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUser_AddRemove_Failures(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithAuthFile(t, databaseURL))
|
||||
defer s.closeDatabases()
|
||||
|
||||
// Create admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Cannot create user with invalid username
|
||||
rr := request(t, s, "POST", "/v1/users", `{"username": "not valid", "password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 400, rr.Code)
|
||||
|
||||
// Cannot create user if user already exists
|
||||
rr = request(t, s, "POST", "/v1/users", `{"username": "phil", "password":"phil"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 40901, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Cannot create user with invalid tier
|
||||
rr = request(t, s, "POST", "/v1/users", `{"username": "emma", "password":"emma", "tier": "invalid"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 40030, toHTTPError(t, rr.Body.String()).Code)
|
||||
|
||||
// Cannot delete user as non-admin
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
|
||||
// Delete user via API
|
||||
rr = request(t, s, "DELETE", "/v1/users", `{"username": "ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User and admin
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Subscribing not allowed
|
||||
rr := request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
|
||||
// Grant access
|
||||
rr = request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Now subscribing is allowed
|
||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Reset access
|
||||
rr = request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gold"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Subscribing not allowed (again)
|
||||
rr = request(t, s, "GET", "/gold/json?poll=1", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 403, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset_NonAdminAttempt(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
|
||||
// Grant access fails, because non-admin
|
||||
rr := request(t, s, "POST", "/v1/users/access", `{"username": "ben", "topic":"gold", "permission":"ro"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 401, rr.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccess_AllowReset_KillConnection(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, c)
|
||||
defer s.closeDatabases()
|
||||
|
||||
// User and admin, grant access to "gol*" topics
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "gol*", user.PermissionRead)) // Wildcard!
|
||||
|
||||
start, timeTaken := time.Now(), atomic.Int64{}
|
||||
go func() {
|
||||
rr := request(t, s, "GET", "/gold/json", "", map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
timeTaken.Store(time.Since(start).Milliseconds())
|
||||
}()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Reset access
|
||||
rr := request(t, s, "DELETE", "/v1/users/access", `{"username": "ben", "topic":"gol*"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
// Wait for connection to be killed; this will fail if the connection is never killed
|
||||
waitFor(t, func() bool {
|
||||
return timeTaken.Load() >= 500
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"firebase.google.com/go/v4/messaging"
|
||||
"fmt"
|
||||
"google.golang.org/api/option"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"strings"
|
||||
@@ -43,7 +44,7 @@ func newFirebaseClient(sender firebaseSender, auther user.Auther) *firebaseClien
|
||||
}
|
||||
}
|
||||
|
||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||
func (c *firebaseClient) Send(v *visitor, m *model.Message) error {
|
||||
if !v.FirebaseAllowed() {
|
||||
return errFirebaseTemporarilyBanned
|
||||
}
|
||||
@@ -80,7 +81,7 @@ type firebaseSenderImpl struct {
|
||||
}
|
||||
|
||||
func newFirebaseSender(credentialsFile string) (firebaseSender, error) {
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
|
||||
fb, err := firebase.NewApp(context.Background(), nil, option.WithAuthCredentialsFile(option.ServiceAccount, credentialsFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -121,11 +122,11 @@ func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
|
||||
// On Android, this will trigger the app to poll the topic and thereby displaying new messages.
|
||||
// - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
|
||||
// to Firebase here. This is mainly for iOS to support self-hosted servers.
|
||||
func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, error) {
|
||||
func toFirebaseMessage(m *model.Message, auther user.Auther) (*messaging.Message, error) {
|
||||
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
|
||||
var apnsConfig *messaging.APNSConfig
|
||||
switch m.Event {
|
||||
case keepaliveEvent, openEvent:
|
||||
case model.KeepaliveEvent, model.OpenEvent:
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
@@ -133,7 +134,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
||||
"topic": m.Topic,
|
||||
}
|
||||
apnsConfig = createAPNSBackgroundConfig(data)
|
||||
case pollRequestEvent:
|
||||
case model.PollRequestEvent:
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
@@ -143,7 +144,16 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
||||
"poll_id": m.PollID,
|
||||
}
|
||||
apnsConfig = createAPNSAlertConfig(m, data)
|
||||
case messageEvent:
|
||||
case model.MessageDeleteEvent, model.MessageClearEvent:
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
"sequence_id": m.SequenceID,
|
||||
}
|
||||
apnsConfig = createAPNSBackgroundConfig(data)
|
||||
case model.MessageEvent:
|
||||
if auther != nil {
|
||||
// If "anonymous read" for a topic is not allowed, we cannot send the message along
|
||||
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
|
||||
@@ -161,6 +171,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
"sequence_id": m.SequenceID,
|
||||
"priority": fmt.Sprintf("%d", m.Priority),
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": m.Click,
|
||||
@@ -225,7 +236,7 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
|
||||
// createAPNSAlertConfig creates an APNS config for iOS notifications that show up as an alert (only relevant for iOS).
|
||||
// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
|
||||
// Extension in iOS can modify the message.
|
||||
func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
|
||||
func createAPNSAlertConfig(m *model.Message, data map[string]string) *messaging.APNSConfig {
|
||||
apnsData := make(map[string]any)
|
||||
for k, v := range data {
|
||||
apnsData[k] = v
|
||||
@@ -286,8 +297,8 @@ func maybeTruncateAPNSBodyMessage(s string) string {
|
||||
//
|
||||
// This empties all the fields that are not needed for a poll request and just sets the required fields,
|
||||
// most importantly, the PollID.
|
||||
func toPollRequest(m *message) *message {
|
||||
pr := newPollRequestMessage(m.Topic, m.ID)
|
||||
func toPollRequest(m *model.Message) *model.Message {
|
||||
pr := model.NewPollRequestMessage(m.Topic, m.ID)
|
||||
pr.ID = m.ID
|
||||
pr.Time = m.Time
|
||||
pr.Priority = m.Priority // Keep priority
|
||||
|
||||
@@ -4,6 +4,7 @@ package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
@@ -21,7 +22,7 @@ var (
|
||||
type firebaseClient struct {
|
||||
}
|
||||
|
||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||
func (c *firebaseClient) Send(v *visitor, m *model.Message) error {
|
||||
return errFirebaseNotAvailable
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"net/netip"
|
||||
"strings"
|
||||
@@ -63,7 +64,7 @@ func (s *testFirebaseSender) Messages() []*messaging.Message {
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_Keepalive(t *testing.T) {
|
||||
m := newKeepaliveMessage("mytopic")
|
||||
m := model.NewKeepaliveMessage("mytopic")
|
||||
fbm, err := toFirebaseMessage(m, nil)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "mytopic", fbm.Topic)
|
||||
@@ -94,7 +95,7 @@ func TestToFirebaseMessage_Keepalive(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_Open(t *testing.T) {
|
||||
m := newOpenMessage("mytopic")
|
||||
m := model.NewOpenMessage("mytopic")
|
||||
fbm, err := toFirebaseMessage(m, nil)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "mytopic", fbm.Topic)
|
||||
@@ -125,13 +126,13 @@ func TestToFirebaseMessage_Open(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
m := newDefaultMessage("mytopic", "this is a message")
|
||||
m := model.NewDefaultMessage("mytopic", "this is a message")
|
||||
m.Priority = 4
|
||||
m.Tags = []string{"tag 1", "tag2"}
|
||||
m.Click = "https://google.com"
|
||||
m.Icon = "https://ntfy.sh/static/img/ntfy.png"
|
||||
m.Title = "some title"
|
||||
m.Actions = []*action{
|
||||
m.Actions = []*model.Action{
|
||||
{
|
||||
ID: "123",
|
||||
Action: "view",
|
||||
@@ -150,7 +151,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
m.Attachment = &attachment{
|
||||
m.Attachment = &model.Attachment{
|
||||
Name: "some file.jpg",
|
||||
Type: "image/jpeg",
|
||||
Size: 12345,
|
||||
@@ -177,6 +178,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"priority": "4",
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": "https://google.com",
|
||||
@@ -199,6 +201,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"priority": "4",
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": "https://google.com",
|
||||
@@ -217,7 +220,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
||||
m := newDefaultMessage("mytopic", "this is a message")
|
||||
m := model.NewDefaultMessage("mytopic", "this is a message")
|
||||
m.Priority = 5
|
||||
fbm, err := toFirebaseMessage(m, &testAuther{Allow: false}) // Not allowed!
|
||||
require.Nil(t, err)
|
||||
@@ -232,6 +235,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "poll_request",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"message": "New message",
|
||||
"title": "",
|
||||
"tags": "",
|
||||
@@ -247,7 +251,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToFirebaseMessage_PollRequest(t *testing.T) {
|
||||
m := newPollRequestMessage("mytopic", "fOv6k1QbCzo6")
|
||||
m := model.NewPollRequestMessage("mytopic", "fOv6k1QbCzo6")
|
||||
fbm, err := toFirebaseMessage(m, nil)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "mytopic", fbm.Topic)
|
||||
@@ -341,18 +345,18 @@ func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
|
||||
func TestToFirebaseSender_Abuse(t *testing.T) {
|
||||
sender := &testFirebaseSender{allowed: 2}
|
||||
client := newFirebaseClient(sender, &testAuther{})
|
||||
visitor := newVisitor(newTestConfig(t), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
visitor := newVisitor(newTestConfig(t, ""), newMemTestCache(t), nil, netip.MustParseAddr("1.2.3.4"), nil)
|
||||
|
||||
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"}))
|
||||
require.Equal(t, 1, len(sender.Messages()))
|
||||
|
||||
require.Nil(t, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Nil(t, client.Send(visitor, &model.Message{Topic: "mytopic"}))
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
|
||||
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &model.Message{Topic: "mytopic"}))
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
|
||||
sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working
|
||||
require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &model.Message{Topic: "mytopic"}))
|
||||
require.Equal(t, 0, len(sender.Messages()))
|
||||
}
|
||||
|
||||
+27
-39
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Server) execManager() {
|
||||
@@ -17,15 +16,10 @@ func (s *Server) execManager() {
|
||||
s.pruneMessages()
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
|
||||
// Message count per topic
|
||||
var messagesCached int
|
||||
messageCounts, err := s.messageCache.MessageCounts()
|
||||
// Message count
|
||||
messagesCached, err := s.messageCache.MessagesCount()
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Cannot get message counts")
|
||||
messageCounts = make(map[string]int) // Empty, so we can continue
|
||||
}
|
||||
for _, count := range messageCounts {
|
||||
messagesCached += count
|
||||
log.Tag(tagManager).Err(err).Warn("Cannot get messages count")
|
||||
}
|
||||
|
||||
// Remove subscriptions without subscribers
|
||||
@@ -60,8 +54,8 @@ func (s *Server) execManager() {
|
||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure = s.smtpServerBackend.Counts()
|
||||
}
|
||||
var sentMailTotal, sentMailSuccess, sentMailFailure int64
|
||||
if s.smtpSender != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.smtpSender.Counts()
|
||||
if s.mailer != nil {
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure = s.mailer.NotificationCounts()
|
||||
}
|
||||
|
||||
// Users
|
||||
@@ -104,6 +98,9 @@ func (s *Server) execManager() {
|
||||
mset(metricUsers, usersCount)
|
||||
mset(metricSubscribers, subscribers)
|
||||
mset(metricTopics, topicsCount)
|
||||
if s.attachment != nil {
|
||||
mset(metricAttachmentsTotalSize, s.attachment.Size())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pruneVisitors() {
|
||||
@@ -122,7 +119,7 @@ func (s *Server) pruneVisitors() {
|
||||
}
|
||||
}).
|
||||
Field("stale_visitors", staleVisitors).
|
||||
Debug("Deleted %d stale visitor(s)", staleVisitors)
|
||||
Debug("Finished deleting stale visitors")
|
||||
}
|
||||
|
||||
func (s *Server) pruneTokens() {
|
||||
@@ -137,56 +134,47 @@ func (s *Server) pruneTokens() {
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users")
|
||||
}
|
||||
}).
|
||||
Debug("Removed expired tokens and users")
|
||||
Debug("Finished deleting expired tokens and users")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) pruneAttachments() {
|
||||
if s.fileCache == nil {
|
||||
if s.attachment == nil {
|
||||
return
|
||||
}
|
||||
// Only mark as deleted in DB. The actual storage files are cleaned up
|
||||
// by the attachment store's sync() loop, which periodically reconciles
|
||||
// storage with the database and removes orphaned files.
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Timing(func() {
|
||||
ids, err := s.messageCache.AttachmentsExpired()
|
||||
count, err := s.messageCache.MarkExpiredAttachmentsDeleted(s.config.ManagerBatchSize)
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments")
|
||||
} else if len(ids) > 0 {
|
||||
if log.Tag(tagManager).IsDebug() {
|
||||
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
|
||||
}
|
||||
if err := s.fileCache.Remove(ids...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting attachments")
|
||||
}
|
||||
if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
|
||||
}
|
||||
log.Tag(tagManager).Err(err).Warn("Error marking expired attachments as deleted")
|
||||
} else if count > 0 {
|
||||
log.Tag(tagManager).Debug("Marked %d expired attachment(s) as deleted", count)
|
||||
} else {
|
||||
log.Tag(tagManager).Debug("No expired attachments to delete")
|
||||
}
|
||||
}).
|
||||
Debug("Deleted expired attachments")
|
||||
Debug("Finished marking expired attachments as deleted")
|
||||
}
|
||||
|
||||
func (s *Server) pruneMessages() {
|
||||
// Only delete DB rows. Attachment storage files are cleaned up by the
|
||||
// attachment store's sync() loop, which periodically reconciles storage
|
||||
// with the database and removes orphaned files.
|
||||
log.
|
||||
Tag(tagManager).
|
||||
Timing(func() {
|
||||
expiredMessageIDs, err := s.messageCache.MessagesExpired()
|
||||
count, err := s.messageCache.DeleteExpiredMessages(s.config.ManagerBatchSize)
|
||||
if err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages")
|
||||
} else if len(expiredMessageIDs) > 0 {
|
||||
if s.fileCache != nil {
|
||||
if err := s.fileCache.Remove(expiredMessageIDs...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages")
|
||||
}
|
||||
}
|
||||
if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil {
|
||||
log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted")
|
||||
}
|
||||
log.Tag(tagManager).Err(err).Warn("Error deleting expired messages")
|
||||
} else if count > 0 {
|
||||
log.Tag(tagManager).Debug("Deleted %d expired message(s)", count)
|
||||
} else {
|
||||
log.Tag(tagManager).Debug("No expired messages to delete")
|
||||
}
|
||||
}).
|
||||
Debug("Pruned messages")
|
||||
Debug("Finished deleting expired messages")
|
||||
}
|
||||
|
||||
@@ -2,27 +2,30 @@ package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServer_Manager_Prune_Messages_Without_Attachments_DoesNotPanic(t *testing.T) {
|
||||
// Tests that the manager runs without attachment-cache-dir set, see #617
|
||||
c := newTestConfig(t)
|
||||
c.AttachmentCacheDir = ""
|
||||
s := newTestServer(t, c)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
// Tests that the manager runs without attachment-cache-dir set, see #617
|
||||
c := newTestConfig(t, databaseURL)
|
||||
c.AttachmentCacheDir = ""
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Publish a message
|
||||
rr := request(t, s, "POST", "/mytopic", "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
m := toMessage(t, rr.Body.String())
|
||||
// Publish a message
|
||||
rr := request(t, s, "POST", "/mytopic", "hi", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
m := toMessage(t, rr.Body.String())
|
||||
|
||||
// Expire message
|
||||
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
|
||||
// Expire message
|
||||
require.Nil(t, s.messageCache.ExpireMessages("mytopic"))
|
||||
|
||||
// Does not panic
|
||||
s.pruneMessages()
|
||||
// Does not panic
|
||||
s.pruneMessages()
|
||||
|
||||
// Actually deleted
|
||||
_, err := s.messageCache.Message(m.ID)
|
||||
require.Equal(t, errMessageNotFound, err)
|
||||
// Actually deleted
|
||||
_, err := s.messageCache.Message(m.ID)
|
||||
require.Equal(t, model.ErrMessageNotFound, err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,10 +165,5 @@ func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
|
||||
Rejected: rejected,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(response); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return util.EncodeJSON(w, response)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *Server) limitRequests(next handleFunc) handleFunc {
|
||||
// limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context
|
||||
func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
t, err := s.topicFromPath(r.URL.Path)
|
||||
t, err := s.topicFromPath(v, r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,6 +103,15 @@ func (s *Server) ensureCallsEnabled(next handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ensureEmailsEnabled(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.mailer == nil || s.userManager == nil {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if s.config.StripeSecretKey == "" || s.stripe == nil {
|
||||
|
||||
@@ -237,10 +237,41 @@ func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWr
|
||||
if err := s.updateSubscriptionAndTier(r, v, u, tier, sess.Customer.ID, sub.ID, string(sub.Status), string(interval), sub.CurrentPeriodEnd, sub.CancelAt); err != nil {
|
||||
return err
|
||||
}
|
||||
// Offer email recovery: auto-send a verification link to the billing email (best-effort).
|
||||
// Provisioned users can't reset their password, so recovery setup doesn't apply to them.
|
||||
if sess.CustomerDetails != nil && !u.Provisioned {
|
||||
s.maybeEnqueueBillingEmailVerification(r, v, u.ID, sess.CustomerDetails.Email)
|
||||
}
|
||||
http.Redirect(w, r, s.config.BaseURL+accountPath, http.StatusSeeOther)
|
||||
return nil
|
||||
}
|
||||
|
||||
// maybeEnqueueBillingEmailVerification sends an email-verification link to a paying user's
|
||||
// billing email, so they can use it for password recovery -- but only if they have no verified
|
||||
// email yet and the billing email is not already the recovery email on another account. On a
|
||||
// collision (or any other skip), the generic "no recovery email set" warning on the account page
|
||||
// nudges the user to add one. This is best-effort: failures are logged, never surfaced.
|
||||
func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visitor, userID, billingEmail string) {
|
||||
if s.mailer == nil || s.config.BaseURL == "" || billingEmail == "" || !emailAddressRegex.MatchString(billingEmail) {
|
||||
return
|
||||
}
|
||||
emails, err := s.userManager.Emails(userID)
|
||||
if err != nil {
|
||||
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to load emails for billing verification")
|
||||
return
|
||||
} else if len(emails) > 0 {
|
||||
return // User already has a verified email -- don't nag
|
||||
}
|
||||
if _, err := s.userManager.UserIDByPrimaryEmail(billingEmail); err == nil {
|
||||
logvr(v, r).Tag(tagStripe).Debug("Billing email is primary on another account, skipping auto-verification")
|
||||
return // Collision: skip + let the generic no-recovery-email warning nudge instead
|
||||
}
|
||||
logvr(v, r).Tag(tagStripe).Field("email", billingEmail).Info("Sending verification link to billing email")
|
||||
if err := s.enqueueEmailVerification(userID, billingEmail); err != nil {
|
||||
logvr(v, r).Tag(tagStripe).Err(err).Warn("Failed to enqueue billing email verification")
|
||||
}
|
||||
}
|
||||
|
||||
// handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates
|
||||
// a user's tier accordingly. This endpoint only works if there is an existing subscription.
|
||||
func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//go:build !nopayments
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stripe/stripe-go/v74"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
)
|
||||
|
||||
// stripeCheckoutMock wires up a testStripeAPI for a successful checkout of user u, with the given
|
||||
// billing email on the session's CustomerDetails.
|
||||
func stripeCheckoutMock(u *user.User, billingEmail string) *testStripeAPI {
|
||||
m := &testStripeAPI{}
|
||||
m.On("GetSession", "SOMETOKEN").Return(&stripe.CheckoutSession{
|
||||
ClientReferenceID: u.ID,
|
||||
Customer: &stripe.Customer{ID: "acct_5555"},
|
||||
Subscription: &stripe.Subscription{ID: "sub_1234"},
|
||||
CustomerDetails: &stripe.CheckoutSessionCustomerDetails{Email: billingEmail},
|
||||
}, nil)
|
||||
m.On("GetSubscription", "sub_1234").Return(&stripe.Subscription{
|
||||
ID: "sub_1234",
|
||||
Status: stripe.SubscriptionStatusActive,
|
||||
CurrentPeriodEnd: 123456789,
|
||||
Items: &stripe.SubscriptionItemList{
|
||||
Data: []*stripe.SubscriptionItem{
|
||||
{Price: &stripe.Price{ID: "price_1234", Recurring: &stripe.PriceRecurring{Interval: stripe.PriceRecurringIntervalMonth}}},
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
m.On("UpdateCustomer", "acct_5555", mock.Anything).Return(&stripe.Customer{}, nil)
|
||||
return m
|
||||
}
|
||||
|
||||
func newCheckoutEmailTestServer(t *testing.T, databaseURL string) (*Server, *captureMailer, *user.User) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.StripeSecretKey = "secret key"
|
||||
c.BaseURL = "https://ntfy.example.com"
|
||||
c.SMTPSenderAddr = "localhost:25"
|
||||
c.SMTPSenderFrom = "noreply@example.com"
|
||||
s := newTestServer(t, c)
|
||||
mailer := newCaptureMailer()
|
||||
s.mailer = mailer
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
ID: "ti_123", Code: "starter", StripeMonthlyPriceID: "price_1234", MessageLimit: 100, MessageExpiryDuration: time.Hour,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
return s, mailer, u
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SendsBillingEmailVerification(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
|
||||
// A verification link was auto-sent to the billing email; clicking it verifies + sets primary
|
||||
link := mailer.verifyLinks["billing@example.com"]
|
||||
require.NotEmpty(t, link)
|
||||
token := tokenFromLink(t, link, "https://ntfy.example.com/account/email/verify/")
|
||||
require.Equal(t, 200, request(t, s, "POST", "/v1/account/email/verify", fmt.Sprintf(`{"token":"%s"}`, token), nil).Code)
|
||||
|
||||
emails, err := s.userManager.Emails(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, []string{"billing@example.com"}, emails)
|
||||
primary, err := s.userManager.PrimaryEmail(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "billing@example.com", primary)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SkipsBillingEmailWhenAlreadyVerified(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
// User already has a verified email -> no auto-send on checkout
|
||||
require.Nil(t, s.userManager.AddEmail(u.ID, "existing@example.com"))
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPayments_Checkout_SkipsBillingEmailWhenPrimaryElsewhere(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s, mailer, u := newCheckoutEmailTestServer(t, databaseURL)
|
||||
defer s.closeDatabases()
|
||||
s.stripe = stripeCheckoutMock(u, "billing@example.com")
|
||||
|
||||
// The billing email is already the recovery email on another account -> skip
|
||||
require.Nil(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false))
|
||||
alice, err := s.userManager.User("alice")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddEmail(alice.ID, "billing@example.com"))
|
||||
require.Nil(t, s.userManager.SetPrimaryEmail(alice.ID, "billing@example.com"))
|
||||
|
||||
rr := request(t, s, "GET", "/v1/account/billing/subscription/success/SOMETOKEN", "", nil)
|
||||
require.Equal(t, 303, rr.Code)
|
||||
require.Empty(t, mailer.verifyLinks)
|
||||
})
|
||||
}
|
||||
+644
-617
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
//go:build !race
|
||||
|
||||
package server
|
||||
|
||||
const raceEnabled = false
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build race
|
||||
|
||||
package server
|
||||
|
||||
const raceEnabled = true
|
||||
+4020
-2343
File diff suppressed because one or more lines are too long
+54
-15
@@ -4,33 +4,50 @@ import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
twilioCallFormat = `
|
||||
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
|
||||
// It can be overridden in the server configuration's twilio-call-format field.
|
||||
//
|
||||
// The format uses Go template syntax with the following fields:
|
||||
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
|
||||
// String fields are automatically XML-escaped.
|
||||
var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
You have a message from notify on topic %s. Message:
|
||||
You have a message from notify on topic {{.Topic}}. Message:
|
||||
<break time="1s"/>
|
||||
%s
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user %s. It will be repeated three times.
|
||||
This message was sent by user {{.Sender}}. It will be repeated three times.
|
||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>Goodbye.</Say>
|
||||
</Response>`
|
||||
)
|
||||
</Response>`))
|
||||
|
||||
// twilioCallData holds the data passed to the Twilio call format template
|
||||
type twilioCallData struct {
|
||||
Topic string
|
||||
Title string
|
||||
Message string
|
||||
Priority int
|
||||
Tags []string
|
||||
Sender string
|
||||
}
|
||||
|
||||
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
|
||||
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
|
||||
@@ -60,12 +77,34 @@ func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *
|
||||
|
||||
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
|
||||
// Failures will be logged, but not returned to the caller.
|
||||
func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
|
||||
func (s *Server) callPhone(v *visitor, r *http.Request, m *model.Message, to string) {
|
||||
u, sender := v.User(), m.Sender.String()
|
||||
if u != nil {
|
||||
sender = u.Name
|
||||
}
|
||||
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
|
||||
tmpl := defaultTwilioCallFormatTemplate
|
||||
if s.config.TwilioCallFormat != nil {
|
||||
tmpl = s.config.TwilioCallFormat
|
||||
}
|
||||
tags := make([]string, len(m.Tags))
|
||||
for i, tag := range m.Tags {
|
||||
tags[i] = xmlEscapeText(tag)
|
||||
}
|
||||
templateData := &twilioCallData{
|
||||
Topic: xmlEscapeText(m.Topic),
|
||||
Title: xmlEscapeText(m.Title),
|
||||
Message: xmlEscapeText(m.Message),
|
||||
Priority: m.Priority,
|
||||
Tags: tags,
|
||||
Sender: xmlEscapeText(sender),
|
||||
}
|
||||
var bodyBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&bodyBuf, templateData); err != nil {
|
||||
logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
|
||||
minc(metricCallsMadeFailure)
|
||||
return
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioPhoneNumber)
|
||||
data.Set("To", to)
|
||||
@@ -87,7 +126,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
@@ -111,7 +150,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
@@ -137,7 +176,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
|
||||
+289
-210
@@ -1,264 +1,343 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
|
||||
var called, verified atomic.Bool
|
||||
var code atomic.Pointer[string]
|
||||
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
|
||||
if code.Load() != nil {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
var called, verified atomic.Bool
|
||||
var code atomic.Pointer[string]
|
||||
twilioVerifyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
if r.URL.Path == "/v2/Services/VA1234567890/Verifications" {
|
||||
if code.Load() != nil {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
|
||||
code.Store(util.String("123456"))
|
||||
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
|
||||
if verified.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
|
||||
verified.Store(true)
|
||||
} else {
|
||||
t.Fatal("Unexpected path:", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer twilioVerifyServer.Close()
|
||||
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Channel=sms&To=%2B12223334444", string(body))
|
||||
code.Store(util.String("123456"))
|
||||
} else if r.URL.Path == "/v2/Services/VA1234567890/VerificationCheck" {
|
||||
if verified.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
require.Equal(t, "Code=123456&To=%2B12223334444", string(body))
|
||||
verified.Store(true)
|
||||
} else {
|
||||
t.Fatal("Unexpected path:", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer twilioVerifyServer.Close()
|
||||
twilioCallsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioCallsServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
|
||||
c.TwilioCallsBaseURL = twilioCallsServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
c.TwilioVerifyService = "VA1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B12223334444&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioCallsServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioVerifyBaseURL = twilioVerifyServer.URL
|
||||
c.TwilioCallsBaseURL = twilioCallsServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
c.TwilioVerifyService = "VA1234567890"
|
||||
s := newTestServer(t, c)
|
||||
// Send verification code for phone number
|
||||
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return *code.Load() == "123456"
|
||||
})
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
// Add phone number with code
|
||||
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return verified.Load()
|
||||
})
|
||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(phoneNumbers))
|
||||
require.Equal(t, "+12223334444", phoneNumbers[0])
|
||||
|
||||
// Send verification code for phone number
|
||||
response := request(t, s, "PUT", "/v1/account/phone/verify", `{"number":"+12223334444","channel":"sms"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return *code.Load() == "123456"
|
||||
})
|
||||
// Do the thing
|
||||
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
|
||||
// Add phone number with code
|
||||
response = request(t, s, "PUT", "/v1/account/phone", `{"number":"+12223334444","code":"123456"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
waitFor(t, func() bool {
|
||||
return verified.Load()
|
||||
})
|
||||
phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(phoneNumbers))
|
||||
require.Equal(t, "+12223334444", phoneNumbers[0])
|
||||
// Remove the phone number
|
||||
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Do the thing
|
||||
response = request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes",
|
||||
// Verify the phone number is gone from the DB
|
||||
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(phoneNumbers))
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
|
||||
// Remove the phone number
|
||||
response = request(t, s, "DELETE", "/v1/account/phone", `{"number":"+12223334444"}`, map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Verify the phone number is gone from the DB
|
||||
phoneNumbers, err = s.userManager.PhoneNumbers(u.ID)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, len(phoneNumbers))
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+loop%3D%223%22%3E%0A%09%09You+have+a+message+from+notify+on+topic+mytopic.+Message%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09End+of+message.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09This+message+was+sent+by+user+phil.+It+will+be+repeated+three+times.%0A%09%09To+unsubscribe+from+calls+like+this%2C+remove+your+phone+number+in+the+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay%3EGoodbye.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes", // <<<------
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "yes", // <<<------
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) {
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
c.TwilioCallFormat = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say language="de-DE" loop="3">
|
||||
Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
Ende der Nachricht.
|
||||
<break time="1s"/>
|
||||
Diese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
|
||||
Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say language="de-DE">Auf Wiederhören.</Say>
|
||||
</Response>`))
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "http://dummy.invalid"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.TwilioCallsBaseURL = "http://dummy.invalid"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
require.Equal(t, 40034, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_InvalidNumber(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+invalid",
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+invalid",
|
||||
})
|
||||
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
require.Equal(t, 40033, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Anonymous(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
c := newTestConfigWithAuthFile(t, databaseURL)
|
||||
c.TwilioCallsBaseURL = "https://127.0.0.1"
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+123123",
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+123123",
|
||||
})
|
||||
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
require.Equal(t, 40035, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Unconfigured(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+1234",
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
response := request(t, s, "POST", "/mytopic", "test", map[string]string{
|
||||
"x-call": "+1234",
|
||||
})
|
||||
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
|
||||
})
|
||||
require.Equal(t, 40032, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
+28
-24
@@ -7,11 +7,12 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
wpush "heckel.io/ntfy/v2/webpush"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,37 +23,40 @@ const (
|
||||
webPushTopicSubscribeLimit = 50
|
||||
)
|
||||
|
||||
var (
|
||||
webPushAllowedEndpointsPatterns = []string{
|
||||
"https://*.google.com/",
|
||||
"https://*.googleapis.com/",
|
||||
"https://*.mozilla.com/",
|
||||
"https://*.mozaws.net/",
|
||||
"https://*.windows.com/",
|
||||
"https://*.microsoft.com/",
|
||||
"https://*.apple.com/",
|
||||
}
|
||||
webPushAllowedEndpointsRegex *regexp.Regexp
|
||||
)
|
||||
// webPushAllowedEndpointsRegexes is the host-level allow-list of web push services ntfy
|
||||
// will deliver to. Each regex anchors the scheme and matches the stable service host,
|
||||
// followed by the authority/path boundary "/". Instance-specific labels (e.g. the
|
||||
// "wns2-<region>" prefix on Windows Notification Service hosts) are wildcarded with
|
||||
// a single-label pattern ([^/]+) that cannot span into the path.
|
||||
// See GHSA-w9hq-5jg7-q4j7 for why wildcarding the entire host is insufficient.
|
||||
var webPushAllowedEndpointsRegexes = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^https://fcm\.googleapis\.com/`),
|
||||
regexp.MustCompile(`^https://jmt17\.google\.com/`),
|
||||
regexp.MustCompile(`^https://updates\.push\.services\.mozilla\.com/`),
|
||||
regexp.MustCompile(`^https://[^/]+\.mozaws\.net/`),
|
||||
regexp.MustCompile(`^https://web\.push\.apple\.com/`),
|
||||
regexp.MustCompile(`^https://[^/]+\.notify\.windows\.com/`),
|
||||
}
|
||||
|
||||
func init() {
|
||||
for i, pattern := range webPushAllowedEndpointsPatterns {
|
||||
webPushAllowedEndpointsPatterns[i] = strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".+")
|
||||
func webPushEndpointAllowed(endpoint string) bool {
|
||||
for _, re := range webPushAllowedEndpointsRegexes {
|
||||
if re.MatchString(endpoint) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
allPatterns := fmt.Sprintf("^(%s)", strings.Join(webPushAllowedEndpointsPatterns, "|"))
|
||||
webPushAllowedEndpointsRegex = regexp.MustCompile(allPatterns)
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false)
|
||||
if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" {
|
||||
return errHTTPBadRequestWebPushSubscriptionInvalid
|
||||
} else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) {
|
||||
} else if !webPushEndpointAllowed(req.Endpoint) {
|
||||
return errHTTPBadRequestWebPushEndpointUnknown
|
||||
} else if len(req.Topics) > webPushTopicSubscribeLimit {
|
||||
return errHTTPBadRequestWebPushTopicCountTooHigh
|
||||
}
|
||||
topics, err := s.topicsFromIDs(req.Topics...)
|
||||
topics, err := s.topicsFromIDs(v, req.Topics...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -82,14 +86,14 @@ func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *
|
||||
return s.writeJSON(w, newSuccessResponse())
|
||||
}
|
||||
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) {
|
||||
subscriptions, err := s.webPush.SubscriptionsForTopic(m.Topic)
|
||||
if err != nil {
|
||||
logvm(v, m).Err(err).With(v, m).Warn("Unable to publish web push messages")
|
||||
return
|
||||
}
|
||||
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
|
||||
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
|
||||
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.ForJSON()))
|
||||
if err != nil {
|
||||
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
|
||||
return
|
||||
@@ -128,7 +132,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
warningSent := make([]*webPushSubscription, 0)
|
||||
warningSent := make([]*wpush.Subscription, 0)
|
||||
for _, subscription := range subscriptions {
|
||||
if err := s.sendWebPushNotification(subscription, payload); err != nil {
|
||||
log.Tag(tagWebPush).Err(err).With(subscription).Warn("Unable to publish expiry imminent warning")
|
||||
@@ -143,7 +147,7 @@ func (s *Server) pruneAndNotifyWebPushSubscriptionsInternal() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) sendWebPushNotification(sub *webPushSubscription, message []byte, contexters ...log.Contexter) error {
|
||||
func (s *Server) sendWebPushNotification(sub *wpush.Subscription, message []byte, contexters ...log.Contexter) error {
|
||||
log.Tag(tagWebPush).With(sub).With(contexters...).Debug("Sending web push message")
|
||||
payload := &webpush.Subscription{
|
||||
Endpoint: sub.Endpoint,
|
||||
|
||||
@@ -4,6 +4,8 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -20,7 +22,7 @@ func (s *Server) handleWebPushDelete(w http.ResponseWriter, r *http.Request, _ *
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||
func (s *Server) publishToWebPushEndpoints(v *visitor, m *model.Message) {
|
||||
// Nothing to see here
|
||||
}
|
||||
|
||||
|
||||
+258
-159
@@ -5,10 +5,6 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,6 +14,11 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SherClockHolmes/webpush-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,237 +26,333 @@ const (
|
||||
)
|
||||
|
||||
func TestServer_WebPush_Enabled(t *testing.T) {
|
||||
conf := newTestConfig(t)
|
||||
conf.WebRoot = "" // Disable web app
|
||||
s := newTestServer(t, conf)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
conf := newTestConfig(t, databaseURL)
|
||||
conf.WebRoot = "" // Disable web app
|
||||
s := newTestServer(t, conf)
|
||||
|
||||
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
rr := request(t, s, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf2 := newTestConfig(t)
|
||||
s2 := newTestServer(t, conf2)
|
||||
conf2 := newTestConfig(t, databaseURL)
|
||||
s2 := newTestServer(t, conf2)
|
||||
|
||||
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
rr = request(t, s2, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
conf3 := newTestConfigWithWebPush(t)
|
||||
s3 := newTestServer(t, conf3)
|
||||
conf3 := newTestConfigWithWebPush(t, databaseURL)
|
||||
s3 := newTestServer(t, conf3)
|
||||
|
||||
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||
rr = request(t, s3, "GET", "/manifest.webmanifest", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "application/manifest+json", rr.Header().Get("Content-Type"))
|
||||
|
||||
})
|
||||
}
|
||||
func TestServer_WebPush_Disabled(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfig(t, databaseURL))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
|
||||
require.Len(t, subs, 1)
|
||||
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
|
||||
require.Equal(t, subs[0].P256dh, "p256dh-key")
|
||||
require.Equal(t, subs[0].Auth, "auth-key")
|
||||
require.Equal(t, subs[0].UserID, "")
|
||||
require.Len(t, subs, 1)
|
||||
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
|
||||
require.Equal(t, subs[0].P256dh, "p256dh-key")
|
||||
require.Equal(t, subs[0].Auth, "auth-key")
|
||||
require.Equal(t, subs[0].UserID, "")
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://ddos-target.example.com/webpush"), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_EndpointRegex(t *testing.T) {
|
||||
// Synthetic endpoint samples representing each supported push service host shape.
|
||||
allowed := []string{
|
||||
// Google FCM (legacy send, webpush, preprod webpush)
|
||||
"https://fcm.googleapis.com/fcm/send/FAKETOKEN:APA91b-placeholder-not-a-real-token",
|
||||
"https://fcm.googleapis.com/wp/FAKETOKEN:APA91b-placeholder-not-a-real-token",
|
||||
"https://fcm.googleapis.com/preprod/wp/FAKETOKEN:APA91b-placeholder-not-a-real-token",
|
||||
"https://jmt17.google.com/fcm/send/FAKETOKEN:APA91b-placeholder-not-a-real-token",
|
||||
// Mozilla autopush (v1 legacy, v2 current, plus AWS-hosted infra)
|
||||
"https://updates.push.services.mozilla.com/wpush/v1/placeholder-not-a-real-token",
|
||||
"https://updates.push.services.mozilla.com/wpush/v2/placeholder-not-a-real-token",
|
||||
"https://autopush.mozaws.net/wpush/v1/placeholder-not-a-real-token",
|
||||
// Apple Web Push
|
||||
"https://web.push.apple.com/placeholder-not-a-real-token",
|
||||
// Microsoft WNS: instance-specific "wns2-<region>" prefix is wildcarded
|
||||
"https://wns2-bn3p.notify.windows.com/w/?token=placeholder",
|
||||
"https://wns2-ch1p.notify.windows.com/w/?token=placeholder",
|
||||
"https://wns2-par02p.notify.windows.com/w/?token=placeholder",
|
||||
"https://wns2-pn1p.notify.windows.com/w/?token=placeholder",
|
||||
"https://wns2-am3p.notify.windows.com/w/?token=placeholder",
|
||||
}
|
||||
denied := []string{
|
||||
// HTTP (not HTTPS)
|
||||
"http://fcm.googleapis.com/fcm/send/abc",
|
||||
// Unrelated host
|
||||
"https://attacker.example.com/webpush",
|
||||
// GHSA-w9hq-5jg7-q4j7 bypass: allowed host embedded in path
|
||||
"https://attacker.com/x.google.com/push",
|
||||
"https://attacker.example.com/fcm.googleapis.com/fcm/send/abc",
|
||||
"https://evil.test/web.push.apple.com/3/device/abc",
|
||||
"https://ntfytest.requestcatcher.com/path.google.com/push",
|
||||
"https://ntfytest.requestcatcher.com/a.google.com/toto",
|
||||
"https://ntfytest.requestcatcher.com/bypass.google.com/test",
|
||||
"https://webhook.site/86e94e2e-2af4-4a31-a80b-e2f335cc6495/path.google.com/push",
|
||||
"https://webhook.site/86e94e2e-2af4-4a31-a80b-e2f335cc6495/bypass.google.com/",
|
||||
// Allowed host as a prefix of a different host (no separating slash)
|
||||
"https://fcm.googleapis.com.attacker.com/fcm/send/abc",
|
||||
"https://web.push.apple.com.evil.test/tok",
|
||||
// Allowed host as a suffix of a different host (no separating dot)
|
||||
"https://evilgoogle.com/",
|
||||
"https://notapple.com/",
|
||||
// Credentials/userinfo in the URL pointing at a different host
|
||||
"https://fcm.googleapis.com@attacker.com/fcm/send/abc",
|
||||
// Previously allowed by the wildcard allowlist but not actually used by Web Push
|
||||
"https://api.push.apple.com/3/device/abc",
|
||||
"https://android.googleapis.com/send/xyz",
|
||||
"https://login.microsoft.com/anything",
|
||||
// Bare notify.windows.com with no subdomain label
|
||||
"https://notify.windows.com/w/?token=abc",
|
||||
}
|
||||
for _, endpoint := range allowed {
|
||||
require.Truef(t, webPushEndpointAllowed(endpoint), "expected endpoint to be allowed: %s", endpoint)
|
||||
}
|
||||
for _, endpoint := range denied {
|
||||
require.Falsef(t, webPushEndpointAllowed(endpoint), "expected endpoint to be denied: %s", endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd_BypassAttempt(t *testing.T) {
|
||||
// Regression test for GHSA-w9hq-5jg7-q4j7: the allow-list regex previously had no
|
||||
// end anchor, so a URL like https://attacker.example.com/x.google.com/... passed
|
||||
// validation and caused the server to deliver push payloads to attacker-controlled
|
||||
// endpoints (SSRF + message exfiltration via attacker-supplied p256dh key).
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://attacker.example.com/x.google.com/push"), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
topicList := make([]string, 51)
|
||||
for i := range topicList {
|
||||
topicList[i] = util.RandomString(5)
|
||||
}
|
||||
topicList := make([]string, 51)
|
||||
for i := range topicList {
|
||||
topicList[i] = util.RandomString(5)
|
||||
}
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, topicList, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, `{"code":40040,"http":400,"error":"invalid request: too many web push topic subscriptions"}`+"\n", response.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicUnsubscribe(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
addSubscription(t, s, testWebPushEndpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
addSubscription(t, s, testWebPushEndpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_Delete(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
addSubscription(t, s, testWebPushEndpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
addSubscription(t, s, testWebPushEndpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
response := request(t, s, "DELETE", "/v1/webpush", fmt.Sprintf(`{"endpoint":"%s"}`, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicSubscribeProtected_Allowed(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
config.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, config)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
config.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, config)
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
|
||||
})
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
require.True(t, strings.HasPrefix(subs[0].UserID, "u_"))
|
||||
}
|
||||
|
||||
func TestServer_WebPush_TopicSubscribeProtected_Denied(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
config.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, config)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
config.AuthDefault = user.PermissionDenyAll
|
||||
s := newTestServer(t, config)
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 403, response.Code)
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), nil)
|
||||
require.Equal(t, 403, response.Code)
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_DeleteAccountUnsubscribe(t *testing.T) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t))
|
||||
s := newTestServer(t, config)
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
config := configureAuth(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
s := newTestServer(t, config)
|
||||
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.AllowAccess("ben", "test-topic", user.PermissionReadWrite))
|
||||
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, testWebPushEndpoint), map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
// should've been deleted with the account
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
})
|
||||
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
request(t, s, "DELETE", "/v1/account", `{"password":"ben"}`, map[string]string{
|
||||
"Authorization": util.BasicAuth("ben", "ben"),
|
||||
})
|
||||
// should've been deleted with the account
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
}
|
||||
|
||||
func TestServer_WebPush_Publish(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
var received atomic.Bool
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/push-receive", r.URL.Path)
|
||||
require.Equal(t, "high", r.Header.Get("Urgency"))
|
||||
require.Equal(t, "", r.Header.Get("Topic"))
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
var received atomic.Bool
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/push-receive", r.URL.Path)
|
||||
require.Equal(t, "high", r.Header.Get("Urgency"))
|
||||
require.Equal(t, "", r.Header.Get("Topic"))
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
|
||||
request(t, s, "POST", "/test-topic", "web push test", nil)
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
|
||||
request(t, s, "POST", "/test-topic", "web push test", nil)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
var received atomic.Bool
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
var received atomic.Bool
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
requireSubscriptionCount(t, s, "test-topic-abc", 1)
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic", "test-topic-abc")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
requireSubscriptionCount(t, s, "test-topic-abc", 1)
|
||||
|
||||
request(t, s, "POST", "/test-topic", "web push test", nil)
|
||||
request(t, s, "POST", "/test-topic", "web push test", nil)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
// Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
|
||||
waitFor(t, func() bool {
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
return len(subs) == 0
|
||||
})
|
||||
requireSubscriptionCount(t, s, "test-topic-abc", 0)
|
||||
})
|
||||
|
||||
// Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint
|
||||
|
||||
requireSubscriptionCount(t, s, "test-topic", 0)
|
||||
requireSubscriptionCount(t, s, "test-topic-abc", 0)
|
||||
}
|
||||
|
||||
func TestServer_WebPush_Expiry(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t))
|
||||
forEachBackend(t, func(t *testing.T, databaseURL string) {
|
||||
s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL))
|
||||
|
||||
var received atomic.Bool
|
||||
var received atomic.Bool
|
||||
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(``))
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
pushService := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
w.WriteHeader(200)
|
||||
w.Write([]byte(``))
|
||||
received.Store(true)
|
||||
}))
|
||||
defer pushService.Close()
|
||||
|
||||
addSubscription(t, s, pushService.URL+"/push-receive", "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
endpoint := pushService.URL + "/push-receive"
|
||||
addSubscription(t, s, endpoint, "test-topic")
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
_, err := s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-55*24*time.Hour).Unix())
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-55*24*time.Hour).Unix()))
|
||||
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
requireSubscriptionCount(t, s, "test-topic", 1)
|
||||
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
})
|
||||
waitFor(t, func() bool {
|
||||
return received.Load()
|
||||
})
|
||||
|
||||
_, err = s.webPush.db.Exec("UPDATE subscription SET updated_at = ?", time.Now().Add(-60*24*time.Hour).Unix())
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.webPush.SetSubscriptionUpdatedAt(endpoint, time.Now().Add(-60*24*time.Hour).Unix()))
|
||||
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
waitFor(t, func() bool {
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
return len(subs) == 0
|
||||
s.pruneAndNotifyWebPushSubscriptions()
|
||||
waitFor(t, func() bool {
|
||||
subs, err := s.webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
return len(subs) == 0
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -281,11 +378,13 @@ func requireSubscriptionCount(t *testing.T, s *Server, topic string, expectedLen
|
||||
require.Len(t, subs, expectedLength)
|
||||
}
|
||||
|
||||
func newTestConfigWithWebPush(t *testing.T) *Config {
|
||||
conf := newTestConfig(t)
|
||||
func newTestConfigWithWebPush(t *testing.T, databaseURL string) *Config {
|
||||
conf := newTestConfig(t, databaseURL)
|
||||
privateKey, publicKey, err := webpush.GenerateVAPIDKeys()
|
||||
require.Nil(t, err)
|
||||
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
||||
if conf.DatabaseURL == "" {
|
||||
conf.WebPushFile = filepath.Join(t.TempDir(), "webpush.db")
|
||||
}
|
||||
conf.WebPushEmailAddress = "testing@example.com"
|
||||
conf.WebPushPrivateKey = privateKey
|
||||
conf.WebPushPublicKey = publicKey
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
_ "embed" // required by go:embed
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
type mailer interface {
|
||||
Send(v *visitor, m *message, to string) error
|
||||
Counts() (total int64, success int64, failure int64)
|
||||
}
|
||||
|
||||
type smtpSender struct {
|
||||
config *Config
|
||||
success int64
|
||||
failure int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *smtpSender) Send(v *visitor, m *message, to string) error {
|
||||
return s.withCount(v, m, func() error {
|
||||
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message, err := formatMail(s.config.BaseURL, v.ip.String(), s.config.SMTPSenderFrom, to, m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var auth smtp.Auth
|
||||
if s.config.SMTPSenderUser != "" {
|
||||
auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host)
|
||||
}
|
||||
ev := logvm(v, m).
|
||||
Tag(tagEmail).
|
||||
Fields(log.Context{
|
||||
"email_via": s.config.SMTPSenderAddr,
|
||||
"email_user": s.config.SMTPSenderUser,
|
||||
"email_to": to,
|
||||
})
|
||||
if ev.IsTrace() {
|
||||
ev.Field("email_body", message).Trace("Sending email")
|
||||
} else if ev.IsDebug() {
|
||||
ev.Debug("Sending email")
|
||||
}
|
||||
return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *smtpSender) Counts() (total int64, success int64, failure int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.success + s.failure, s.success, s.failure
|
||||
}
|
||||
|
||||
func (s *smtpSender) withCount(v *visitor, m *message, fn func() error) error {
|
||||
err := fn()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err != nil {
|
||||
logvm(v, m).Err(err).Debug("Sending mail failed")
|
||||
s.failure++
|
||||
} else {
|
||||
s.success++
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func formatMail(baseURL, senderIP, from, to string, m *message) (string, error) {
|
||||
topicURL := baseURL + "/" + m.Topic
|
||||
subject := m.Title
|
||||
if subject == "" {
|
||||
subject = m.Message
|
||||
}
|
||||
subject = strings.ReplaceAll(strings.ReplaceAll(subject, "\r", ""), "\n", " ")
|
||||
message := m.Message
|
||||
trailer := ""
|
||||
if len(m.Tags) > 0 {
|
||||
emojis, tags, err := toEmojis(m.Tags)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(emojis) > 0 {
|
||||
subject = strings.Join(emojis, " ") + " " + subject
|
||||
}
|
||||
if len(tags) > 0 {
|
||||
trailer = "Tags: " + strings.Join(tags, ", ")
|
||||
}
|
||||
}
|
||||
if m.Priority != 0 && m.Priority != 3 {
|
||||
priority, err := util.PriorityString(m.Priority)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if trailer != "" {
|
||||
trailer += "\n"
|
||||
}
|
||||
trailer += fmt.Sprintf("Priority: %s", priority)
|
||||
}
|
||||
if trailer != "" {
|
||||
message += "\n\n" + trailer
|
||||
}
|
||||
date := time.Unix(m.Time, 0).UTC().Format(time.RFC1123Z)
|
||||
subject = mime.BEncoding.Encode("utf-8", subject)
|
||||
body := `From: "{shortTopicURL}" <{from}>
|
||||
To: {to}
|
||||
Date: {date}
|
||||
Subject: {subject}
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
{message}
|
||||
|
||||
--
|
||||
This message was sent by {ip} at {time} via {topicURL}`
|
||||
body = strings.ReplaceAll(body, "{from}", from)
|
||||
body = strings.ReplaceAll(body, "{to}", to)
|
||||
body = strings.ReplaceAll(body, "{date}", date)
|
||||
body = strings.ReplaceAll(body, "{subject}", subject)
|
||||
body = strings.ReplaceAll(body, "{message}", message)
|
||||
body = strings.ReplaceAll(body, "{topicURL}", topicURL)
|
||||
body = strings.ReplaceAll(body, "{shortTopicURL}", util.ShortTopicURL(topicURL))
|
||||
body = strings.ReplaceAll(body, "{time}", time.Unix(m.Time, 0).UTC().Format(time.RFC1123))
|
||||
body = strings.ReplaceAll(body, "{ip}", senderIP)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
var (
|
||||
//go:embed "mailer_emoji_map.json"
|
||||
emojisJSON string
|
||||
)
|
||||
|
||||
func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
|
||||
var emojiMap map[string]string
|
||||
if err = json.Unmarshal([]byte(emojisJSON), &emojiMap); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tagsOut = make([]string, 0)
|
||||
emojisOut = make([]string, 0)
|
||||
for _, t := range tags {
|
||||
if emoji, ok := emojiMap[t]; ok {
|
||||
emojisOut = append(emojisOut, emoji)
|
||||
} else {
|
||||
tagsOut = append(tagsOut, t)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatMail_Basic(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
Topic: "alerts",
|
||||
Message: "A simple message",
|
||||
})
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
To: phil@example.com
|
||||
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||
Subject: A simple message
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
A simple message
|
||||
|
||||
--
|
||||
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestFormatMail_JustEmojis(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
Topic: "alerts",
|
||||
Message: "A simple message",
|
||||
Tags: []string{"grinning"},
|
||||
})
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
To: phil@example.com
|
||||
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||
Subject: =?utf-8?b?8J+YgCBBIHNpbXBsZSBtZXNzYWdl?=
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
A simple message
|
||||
|
||||
--
|
||||
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestFormatMail_JustOtherTags(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
Topic: "alerts",
|
||||
Message: "A simple message",
|
||||
Tags: []string{"not-an-emoji"},
|
||||
})
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
To: phil@example.com
|
||||
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||
Subject: A simple message
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
A simple message
|
||||
|
||||
Tags: not-an-emoji
|
||||
|
||||
--
|
||||
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestFormatMail_JustPriority(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
Topic: "alerts",
|
||||
Message: "A simple message",
|
||||
Priority: 2,
|
||||
})
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
To: phil@example.com
|
||||
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||
Subject: A simple message
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
A simple message
|
||||
|
||||
Priority: low
|
||||
|
||||
--
|
||||
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestFormatMail_UTF8Subject(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
Topic: "alerts",
|
||||
Message: "A simple message",
|
||||
Title: " :: A not so simple title öäüß ¡Hola, señor!",
|
||||
})
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
To: phil@example.com
|
||||
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||
Subject: =?utf-8?b?IDo6IEEgbm90IHNvIHNpbXBsZSB0aXRsZSDDtsOkw7zDnyDCoUhvbGEsIHNl?= =?utf-8?b?w7FvciE=?=
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
A simple message
|
||||
|
||||
--
|
||||
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
|
||||
func TestFormatMail_WithAllTheThings(t *testing.T) {
|
||||
actual, _ := formatMail("https://ntfy.sh", "1.2.3.4", "ntfy@ntfy.sh", "phil@example.com", &message{
|
||||
ID: "abc",
|
||||
Time: 1640382204,
|
||||
Event: "message",
|
||||
Topic: "alerts",
|
||||
Priority: 5,
|
||||
Tags: []string{"warning", "skull", "tag123", "other"},
|
||||
Title: "Oh no 🙈\nThis is a message across\nmultiple lines",
|
||||
Message: "A message that contains monkeys 🙉\nNo really, though. Monkeys!",
|
||||
})
|
||||
expected := `From: "ntfy.sh/alerts" <ntfy@ntfy.sh>
|
||||
To: phil@example.com
|
||||
Date: Fri, 24 Dec 2021 21:43:24 +0000
|
||||
Subject: =?utf-8?b?4pqg77iPIPCfkoAgT2ggbm8g8J+ZiCBUaGlzIGlzIGEgbWVzc2FnZSBhY3Jv?= =?utf-8?b?c3MgbXVsdGlwbGUgbGluZXM=?=
|
||||
Content-Type: text/plain; charset="utf-8"
|
||||
|
||||
A message that contains monkeys 🙉
|
||||
No really, though. Monkeys!
|
||||
|
||||
Tags: tag123, other
|
||||
Priority: max
|
||||
|
||||
--
|
||||
This message was sent by 1.2.3.4 at Fri, 24 Dec 2021 21:43:24 UTC via https://ntfy.sh/alerts`
|
||||
require.Equal(t, expected, actual)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -33,6 +34,7 @@ var (
|
||||
var (
|
||||
onlySpacesRegex = regexp.MustCompile(`(?m)^\s+$`)
|
||||
consecutiveNewLinesRegex = regexp.MustCompile(`\n{3,}`)
|
||||
htmlLineBreakRegex = regexp.MustCompile(`(?i)<br\s*/?>`)
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -158,7 +160,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
if len(body) > conf.MessageSizeLimit {
|
||||
body = body[:conf.MessageSizeLimit]
|
||||
}
|
||||
m := newDefaultMessage(s.topic, body)
|
||||
m := model.NewDefaultMessage(s.topic, body)
|
||||
subject := strings.TrimSpace(msg.Header.Get("Subject"))
|
||||
if subject != "" {
|
||||
dec := mime.WordDecoder{}
|
||||
@@ -183,7 +185,7 @@ func (s *smtpSession) Data(r io.Reader) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *smtpSession) publishMessage(m *message) error {
|
||||
func (s *smtpSession) publishMessage(m *model.Message) error {
|
||||
// Extract remote address (for rate limiting)
|
||||
remoteAddr, _, err := net.SplitHostPort(s.conn.Conn().RemoteAddr().String())
|
||||
if err != nil {
|
||||
@@ -327,6 +329,9 @@ func readHTMLMailBody(reader io.Reader, transferEncoding string) (string, error)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Convert <br> tags to newlines before stripping HTML, so that line breaks
|
||||
// in HTML emails (e.g. from Synology DSM, and other appliances) are preserved.
|
||||
body = htmlLineBreakRegex.ReplaceAllString(body, "\n")
|
||||
stripped := bluemonday.
|
||||
StrictPolicy().
|
||||
AddSpaceWhenStrippingTag(true).
|
||||
|
||||
@@ -694,7 +694,8 @@ home automation setup
|
||||
Now the light is on
|
||||
|
||||
If you don't want to receive this message anymore, stop the push
|
||||
services in your FRITZ!Box .
|
||||
services in your FRITZ!Box .
|
||||
|
||||
Here you can see the active push services: "System > Push Service".
|
||||
|
||||
This mail has ben sent by your FRITZ!Box automatically.`
|
||||
@@ -1354,9 +1355,11 @@ Congratulations! You have successfully set up the email notification on Synology
|
||||
s, c, conf, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/synology", r.URL.Path)
|
||||
require.Equal(t, "[Synology NAS] Test Message from Litts_NAS", r.Header.Get("Title"))
|
||||
actual := readAll(t, r.Body)
|
||||
expected := `Congratulations! You have successfully set up the email notification on Synology_NAS. For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/. (If you cannot connect to the server, please contact the administrator.) From Synology_NAS`
|
||||
require.Equal(t, expected, actual)
|
||||
expected := "Congratulations! You have successfully set up the email notification on Synology_NAS.\n" +
|
||||
"For further system configurations, please visit http://192.168.1.28:5000/, http://172.16.60.5:5000/.\n" +
|
||||
"(If you cannot connect to the server, please contact the administrator.)\n\n" +
|
||||
"From Synology_NAS"
|
||||
require.Equal(t, expected, readAll(t, r.Body))
|
||||
})
|
||||
conf.SMTPServerDomain = "mydomain.me"
|
||||
conf.SMTPServerAddrPrefix = ""
|
||||
@@ -1365,6 +1368,36 @@ Congratulations! You have successfully set up the email notification on Synology
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_HTMLEmail_BrTagsPreserved(t *testing.T) {
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: nas@example.com
|
||||
RCPT TO: ntfy-alerts@ntfy.sh
|
||||
DATA
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 8bit
|
||||
Subject: Task Scheduler: daily-backup
|
||||
|
||||
Task Scheduler has completed a scheduled task.<BR><BR>Task: daily-backup<BR>Start time: Mon, 01 Jan 2026 02:00:00 +0000<BR>Stop time: Mon, 01 Jan 2024 02:03:00 +0000<BR>Current status: 0 (Normal)<BR>Standard output/error:<BR>OK<BR><BR>From MyNAS
|
||||
.
|
||||
`
|
||||
s, c, _, scanner := newTestSMTPServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/alerts", r.URL.Path)
|
||||
require.Equal(t, "Task Scheduler: daily-backup", r.Header.Get("Title"))
|
||||
expected := "Task Scheduler has completed a scheduled task.\n\n" +
|
||||
"Task: daily-backup\n" +
|
||||
"Start time: Mon, 01 Jan 2026 02:00:00 +0000\n" +
|
||||
"Stop time: Mon, 01 Jan 2024 02:03:00 +0000\n" +
|
||||
"Current status: 0 (Normal)\n" +
|
||||
"Standard output/error:\n" +
|
||||
"OK\n\n" +
|
||||
"From MyNAS"
|
||||
require.Equal(t, expected, readAll(t, r.Body))
|
||||
})
|
||||
defer s.Close()
|
||||
defer c.Close()
|
||||
writeAndReadUntilLine(t, email, c, scanner, "250 2.0.0 OK: queued")
|
||||
}
|
||||
|
||||
func TestSmtpBackend_PlaintextWithToken(t *testing.T) {
|
||||
email := `EHLO example.com
|
||||
MAIL FROM: phil@example.com
|
||||
@@ -1411,7 +1444,7 @@ what's up
|
||||
type smtpHandlerFunc func(http.ResponseWriter, *http.Request)
|
||||
|
||||
func newTestSMTPServer(t *testing.T, handler smtpHandlerFunc) (s *smtp.Server, c net.Conn, conf *Config, scanner *bufio.Scanner) {
|
||||
conf = newTestConfig(t)
|
||||
conf = newTestConfig(t, "")
|
||||
conf.SMTPServerListen = ":25"
|
||||
conf.SMTPServerDomain = "ntfy.sh"
|
||||
conf.SMTPServerAddrPrefix = "ntfy-"
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -33,7 +34,7 @@ type topicSubscriber struct {
|
||||
}
|
||||
|
||||
// subscriber is a function that is called for every new message on a topic
|
||||
type subscriber func(v *visitor, msg *message) error
|
||||
type subscriber func(v *visitor, msg *model.Message) error
|
||||
|
||||
// newTopic creates a new topic
|
||||
func newTopic(id string) *topic {
|
||||
@@ -103,7 +104,7 @@ func (t *topic) Unsubscribe(id int) {
|
||||
}
|
||||
|
||||
// Publish asynchronously publishes to all subscribers
|
||||
func (t *topic) Publish(v *visitor, m *message) error {
|
||||
func (t *topic) Publish(v *visitor, m *model.Message) error {
|
||||
go func() {
|
||||
// We want to lock the topic as short as possible, so we make a shallow copy of the
|
||||
// subscribers map here. Actually sending out the messages then doesn't have to lock.
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
)
|
||||
|
||||
func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
subFn := func(v *visitor, msg *model.Message) error {
|
||||
return nil
|
||||
}
|
||||
canceled1 := atomic.Bool{}
|
||||
@@ -33,7 +34,7 @@ func TestTopic_CancelSubscribersExceptUser(t *testing.T) {
|
||||
func TestTopic_CancelSubscribersUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
subFn := func(v *visitor, msg *model.Message) error {
|
||||
return nil
|
||||
}
|
||||
canceled1 := atomic.Bool{}
|
||||
@@ -76,7 +77,7 @@ func TestTopic_Subscribe_DuplicateID(t *testing.T) {
|
||||
cancel: func() {},
|
||||
}
|
||||
|
||||
subFn := func(v *visitor, msg *message) error {
|
||||
subFn := func(v *visitor, msg *model.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+87
-217
@@ -2,195 +2,35 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/model"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
// List of possible events
|
||||
const (
|
||||
openEvent = "open"
|
||||
keepaliveEvent = "keepalive"
|
||||
messageEvent = "message"
|
||||
pollRequestEvent = "poll_request"
|
||||
)
|
||||
|
||||
const (
|
||||
messageIDLength = 12
|
||||
)
|
||||
|
||||
// message represents a message published to a topic
|
||||
type message struct {
|
||||
ID string `json:"id"` // Random message ID
|
||||
Time int64 `json:"time"` // Unix time in seconds
|
||||
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
||||
Event string `json:"event"` // One of the above
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Click string `json:"click,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Actions []*action `json:"actions,omitempty"`
|
||||
Attachment *attachment `json:"attachment,omitempty"`
|
||||
PollID string `json:"poll_id,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
|
||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
||||
User string `json:"-"` // UserID of the uploader, used to associated attachments
|
||||
}
|
||||
|
||||
func (m *message) Context() log.Context {
|
||||
fields := map[string]any{
|
||||
"topic": m.Topic,
|
||||
"message_id": m.ID,
|
||||
"message_time": m.Time,
|
||||
"message_event": m.Event,
|
||||
"message_body_size": len(m.Message),
|
||||
}
|
||||
if m.Sender.IsValid() {
|
||||
fields["message_sender"] = m.Sender.String()
|
||||
}
|
||||
if m.User != "" {
|
||||
fields["message_user"] = m.User
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
type attachment struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Expires int64 `json:"expires,omitempty"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type action struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"` // "view", "broadcast", or "http"
|
||||
Label string `json:"label"` // action button label
|
||||
Clear bool `json:"clear"` // clear notification after successful execution
|
||||
URL string `json:"url,omitempty"` // used in "view" and "http" actions
|
||||
Method string `json:"method,omitempty"` // used in "http" action, default is POST (!)
|
||||
Headers map[string]string `json:"headers,omitempty"` // used in "http" action
|
||||
Body string `json:"body,omitempty"` // used in "http" action
|
||||
Intent string `json:"intent,omitempty"` // used in "broadcast" action
|
||||
Extras map[string]string `json:"extras,omitempty"` // used in "broadcast" action
|
||||
}
|
||||
|
||||
func newAction() *action {
|
||||
return &action{
|
||||
Headers: make(map[string]string),
|
||||
Extras: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// publishMessage is used as input when publishing as JSON
|
||||
type publishMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Click string `json:"click"`
|
||||
Icon string `json:"icon"`
|
||||
Actions []action `json:"actions"`
|
||||
Attach string `json:"attach"`
|
||||
Markdown bool `json:"markdown"`
|
||||
Filename string `json:"filename"`
|
||||
Email string `json:"email"`
|
||||
Call string `json:"call"`
|
||||
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
|
||||
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
|
||||
Delay string `json:"delay"`
|
||||
Topic string `json:"topic"`
|
||||
SequenceID string `json:"sequence_id"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Click string `json:"click"`
|
||||
Icon string `json:"icon"`
|
||||
Actions []model.Action `json:"actions"`
|
||||
Attach string `json:"attach"`
|
||||
Markdown bool `json:"markdown"`
|
||||
Filename string `json:"filename"`
|
||||
Email string `json:"email"`
|
||||
Call string `json:"call"`
|
||||
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
|
||||
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
|
||||
Delay string `json:"delay"`
|
||||
}
|
||||
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
type messageEncoder func(msg *message) (string, error)
|
||||
|
||||
// newMessage creates a new message with the current timestamp
|
||||
func newMessage(event, topic, msg string) *message {
|
||||
return &message{
|
||||
ID: util.RandomString(messageIDLength),
|
||||
Time: time.Now().Unix(),
|
||||
Event: event,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// newOpenMessage is a convenience method to create an open message
|
||||
func newOpenMessage(topic string) *message {
|
||||
return newMessage(openEvent, topic, "")
|
||||
}
|
||||
|
||||
// newKeepaliveMessage is a convenience method to create a keepalive message
|
||||
func newKeepaliveMessage(topic string) *message {
|
||||
return newMessage(keepaliveEvent, topic, "")
|
||||
}
|
||||
|
||||
// newDefaultMessage is a convenience method to create a notification message
|
||||
func newDefaultMessage(topic, msg string) *message {
|
||||
return newMessage(messageEvent, topic, msg)
|
||||
}
|
||||
|
||||
// newPollRequestMessage is a convenience method to create a poll request message
|
||||
func newPollRequestMessage(topic, pollID string) *message {
|
||||
m := newMessage(pollRequestEvent, topic, newMessageBody)
|
||||
m.PollID = pollID
|
||||
return m
|
||||
}
|
||||
|
||||
func validMessageID(s string) bool {
|
||||
return util.ValidRandomString(s, messageIDLength)
|
||||
}
|
||||
|
||||
type sinceMarker struct {
|
||||
time time.Time
|
||||
id string
|
||||
}
|
||||
|
||||
func newSinceTime(timestamp int64) sinceMarker {
|
||||
return sinceMarker{time.Unix(timestamp, 0), ""}
|
||||
}
|
||||
|
||||
func newSinceID(id string) sinceMarker {
|
||||
return sinceMarker{time.Unix(0, 0), id}
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsAll() bool {
|
||||
return t == sinceAllMessages
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsNone() bool {
|
||||
return t == sinceNoMessages
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsLatest() bool {
|
||||
return t == sinceLatestMessage
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsID() bool {
|
||||
return t.id != "" && t.id != "latest"
|
||||
}
|
||||
|
||||
func (t sinceMarker) Time() time.Time {
|
||||
return t.time
|
||||
}
|
||||
|
||||
func (t sinceMarker) ID() string {
|
||||
return t.id
|
||||
}
|
||||
|
||||
var (
|
||||
sinceAllMessages = sinceMarker{time.Unix(0, 0), ""}
|
||||
sinceNoMessages = sinceMarker{time.Unix(1, 0), ""}
|
||||
sinceLatestMessage = sinceMarker{time.Unix(0, 0), "latest"}
|
||||
)
|
||||
type messageEncoder func(msg *model.Message) (string, error)
|
||||
|
||||
type queryFilter struct {
|
||||
ID string
|
||||
@@ -222,8 +62,8 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *queryFilter) Pass(msg *message) bool {
|
||||
if msg.Event != messageEvent {
|
||||
func (q *queryFilter) Pass(msg *model.Message) bool {
|
||||
if msg.Event != model.MessageEvent && msg.Event != model.MessageDeleteEvent && msg.Event != model.MessageClearEvent {
|
||||
return true // filters only apply to messages
|
||||
} else if q.ID != "" && msg.ID != q.ID {
|
||||
return false
|
||||
@@ -276,7 +116,7 @@ func (t templateMode) FileName() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// templateFile represents a template file with title and message
|
||||
// templateFile represents a template file with title, message, and priority
|
||||
// It is used for file-based templates, e.g. grafana, influxdb, etc.
|
||||
//
|
||||
// Example YAML:
|
||||
@@ -285,15 +125,23 @@ func (t templateMode) FileName() string {
|
||||
// message: |
|
||||
// This is a {{ .Type }} alert.
|
||||
// It can be multiline.
|
||||
// priority: '{{ if eq .status "Error" }}5{{ else }}3{{ end }}'
|
||||
type templateFile struct {
|
||||
Title *string `yaml:"title"`
|
||||
Message *string `yaml:"message"`
|
||||
Title *string `yaml:"title"`
|
||||
Message *string `yaml:"message"`
|
||||
Priority *string `yaml:"priority"`
|
||||
}
|
||||
|
||||
type apiHealthResponse struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
}
|
||||
|
||||
type apiVersionResponse struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
|
||||
type apiStatsResponse struct {
|
||||
Messages int64 `json:"messages"`
|
||||
MessagesRate float64 `json:"messages_rate"` // Average number of messages per second
|
||||
@@ -337,6 +185,7 @@ type apiAccessResetRequest struct {
|
||||
type apiAccountCreateRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"` // Optional; if set (and SMTP configured), a verification link is sent
|
||||
}
|
||||
|
||||
type apiAccountPasswordChangeRequest struct {
|
||||
@@ -378,6 +227,31 @@ type apiAccountPhoneNumberAddRequest struct {
|
||||
Code string `json:"code"` // Only set when adding a phone number
|
||||
}
|
||||
|
||||
// apiAccountEmailRequest carries an email address for the add/delete/set-primary/resend
|
||||
// endpoints (all of which identify an email by address in the JSON body).
|
||||
type apiAccountEmailRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// apiAccountEmailVerifyRequest carries the raw magic-link token submitted (unauthenticated)
|
||||
// from the verification landing page.
|
||||
type apiAccountEmailVerifyRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// apiAccountPasswordResetRequest is the body of the (unauthenticated) reset-request endpoint.
|
||||
// The identifier is a username or a primary email address.
|
||||
type apiAccountPasswordResetRequest struct {
|
||||
Identifier string `json:"identifier"`
|
||||
}
|
||||
|
||||
// apiAccountPasswordResetConfirmRequest is the body of the (unauthenticated) reset-confirm
|
||||
// endpoint, submitted from the set-new-password landing page.
|
||||
type apiAccountPasswordResetConfirmRequest struct {
|
||||
Token string `json:"token"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type apiAccountTier struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
@@ -414,6 +288,15 @@ type apiAccountReservation struct {
|
||||
Everyone string `json:"everyone"`
|
||||
}
|
||||
|
||||
// apiAccountEmailInfo describes one email address on the account, as returned by GET /v1/account.
|
||||
// Verified addresses have pending=false; exactly one verified address may be primary (the
|
||||
// recovery email). Pending addresses are awaiting a magic-link click and are never primary.
|
||||
type apiAccountEmailInfo struct {
|
||||
Address string `json:"address"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
}
|
||||
|
||||
type apiAccountBilling struct {
|
||||
Customer bool `json:"customer"`
|
||||
Subscription bool `json:"subscription"`
|
||||
@@ -434,6 +317,7 @@ type apiAccountResponse struct {
|
||||
Reservations []*apiAccountReservation `json:"reservations,omitempty"`
|
||||
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
|
||||
PhoneNumbers []string `json:"phone_numbers,omitempty"`
|
||||
Emails []*apiAccountEmailInfo `json:"emails,omitempty"`
|
||||
Tier *apiAccountTier `json:"tier,omitempty"`
|
||||
Limits *apiAccountLimits `json:"limits,omitempty"`
|
||||
Stats *apiAccountStats `json:"stats,omitempty"`
|
||||
@@ -446,19 +330,21 @@ type apiAccountReservationRequest struct {
|
||||
}
|
||||
|
||||
type apiConfigResponse struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
EnableEmails bool `json:"enable_emails"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
EnableWebPush bool `json:"enable_web_push"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
BaseURL string `json:"base_url"`
|
||||
AppRoot string `json:"app_root"`
|
||||
EnableLogin bool `json:"enable_login"`
|
||||
RequireLogin bool `json:"require_login"`
|
||||
EnableSignup bool `json:"enable_signup"`
|
||||
EnablePayments bool `json:"enable_payments"`
|
||||
EnableCalls bool `json:"enable_calls"`
|
||||
EnableEmails bool `json:"enable_emails"`
|
||||
EnableResetPassword bool `json:"enable_reset_password"`
|
||||
EnableReservations bool `json:"enable_reservations"`
|
||||
EnableWebPush bool `json:"enable_web_push"`
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPrices struct {
|
||||
@@ -537,12 +423,12 @@ const (
|
||||
)
|
||||
|
||||
type webPushPayload struct {
|
||||
Event string `json:"event"`
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
Message *message `json:"message"`
|
||||
Event string `json:"event"`
|
||||
SubscriptionID string `json:"subscription_id"`
|
||||
Message *model.Message `json:"message"`
|
||||
}
|
||||
|
||||
func newWebPushPayload(subscriptionID string, message *message) *webPushPayload {
|
||||
func newWebPushPayload(subscriptionID string, message *model.Message) *webPushPayload {
|
||||
return &webPushPayload{
|
||||
Event: webPushMessageEvent,
|
||||
SubscriptionID: subscriptionID,
|
||||
@@ -560,22 +446,6 @@ func newWebPushSubscriptionExpiringPayload() *webPushControlMessagePayload {
|
||||
}
|
||||
}
|
||||
|
||||
type webPushSubscription struct {
|
||||
ID string
|
||||
Endpoint string
|
||||
Auth string
|
||||
P256dh string
|
||||
UserID string
|
||||
}
|
||||
|
||||
func (w *webPushSubscription) Context() log.Context {
|
||||
return map[string]any{
|
||||
"web_push_subscription_id": w.ID,
|
||||
"web_push_subscription_user_id": w.UserID,
|
||||
"web_push_subscription_endpoint": w.Endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/Manifest
|
||||
type webManifestResponse struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
+74
-47
@@ -2,12 +2,14 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/message"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
@@ -52,22 +54,23 @@ const (
|
||||
|
||||
// visitor represents an API user, and its associated rate.Limiter used for rate limiting
|
||||
type visitor struct {
|
||||
config *Config
|
||||
messageCache *messageCache
|
||||
userManager *user.Manager // May be nil
|
||||
ip netip.Addr // Visitor IP address
|
||||
user *user.User // Only set if authenticated user, otherwise nil
|
||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
||||
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
||||
callsLimiter *util.FixedLimiter // Rate limiter for calls
|
||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||
accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil
|
||||
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||
mu sync.RWMutex
|
||||
config *Config
|
||||
messageCache *message.Cache
|
||||
userManager *user.Manager // May be nil
|
||||
ip netip.Addr // Visitor IP address
|
||||
user *user.User // Only set if authenticated user, otherwise nil
|
||||
requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages)
|
||||
messagesLimiter *util.FixedLimiter // Rate limiter for messages
|
||||
emailsLimiter *util.RateLimiter // Rate limiter for emails
|
||||
callsLimiter *util.FixedLimiter // Rate limiter for calls
|
||||
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
|
||||
topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map
|
||||
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads
|
||||
accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil
|
||||
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
|
||||
firebase time.Time // Next allowed Firebase message
|
||||
seen time.Time // Last seen time of this visitor (needed for removal of stale visitors)
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type visitorInfo struct {
|
||||
@@ -114,7 +117,7 @@ const (
|
||||
visitorLimitBasisTier = visitorLimitBasis("tier")
|
||||
)
|
||||
|
||||
func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
||||
func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Manager, ip netip.Addr, user *user.User) *visitor {
|
||||
var messages, emails, calls int64
|
||||
if user != nil {
|
||||
messages = user.Stats.Messages
|
||||
@@ -122,21 +125,22 @@ func newVisitor(conf *Config, messageCache *messageCache, userManager *user.Mana
|
||||
calls = user.Stats.Calls
|
||||
}
|
||||
v := &visitor{
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
userManager: userManager, // May be nil
|
||||
ip: ip,
|
||||
user: user,
|
||||
firebase: time.Unix(0, 0),
|
||||
seen: time.Now(),
|
||||
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||
requestLimiter: nil, // Set in resetLimiters
|
||||
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
||||
emailsLimiter: nil, // Set in resetLimiters
|
||||
callsLimiter: nil, // Set in resetLimiters, may be nil
|
||||
bandwidthLimiter: nil, // Set in resetLimiters
|
||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
userManager: userManager, // May be nil
|
||||
ip: ip,
|
||||
user: user,
|
||||
firebase: time.Unix(0, 0),
|
||||
seen: time.Now(),
|
||||
subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)),
|
||||
requestLimiter: nil, // Set in resetLimiters
|
||||
messagesLimiter: nil, // Set in resetLimiters, may be nil
|
||||
emailsLimiter: nil, // Set in resetLimiters
|
||||
callsLimiter: nil, // Set in resetLimiters, may be nil
|
||||
topicCreationLimiter: nil, // Set in resetLimiters
|
||||
bandwidthLimiter: nil, // Set in resetLimiters
|
||||
accountLimiter: nil, // Set in resetLimiters, may be nil
|
||||
authLimiter: nil, // Set in resetLimiters, may be nil
|
||||
}
|
||||
v.resetLimitersNoLock(messages, emails, calls, false)
|
||||
return v
|
||||
@@ -151,14 +155,13 @@ func (v *visitor) Context() log.Context {
|
||||
func (v *visitor) contextNoLock() log.Context {
|
||||
info := v.infoLightNoLock()
|
||||
fields := log.Context{
|
||||
"visitor_id": visitorID(v.ip, v.user, v.config),
|
||||
"visitor_ip": v.ip.String(),
|
||||
"visitor_seen": util.FormatTime(v.seen),
|
||||
"visitor_messages": info.Stats.Messages,
|
||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||
"visitor_request_limiter_limit": v.requestLimiter.Limit(),
|
||||
"visitor_request_limiter_tokens": v.requestLimiter.Tokens(),
|
||||
"visitor_id": visitorID(v.ip, v.user, v.config),
|
||||
"visitor_ip": v.ip.String(),
|
||||
"visitor_seen": util.FormatTime(v.seen),
|
||||
"visitor_messages": info.Stats.Messages,
|
||||
"visitor_messages_limit": info.Limits.MessageLimit,
|
||||
"visitor_messages_remaining": info.Stats.MessagesRemaining,
|
||||
"visitor_requests_remaining": int64(math.Floor(v.requestLimiter.Tokens())),
|
||||
}
|
||||
if v.config.SMTPSenderFrom != "" {
|
||||
fields["visitor_emails"] = info.Stats.Emails
|
||||
@@ -171,8 +174,10 @@ func (v *visitor) contextNoLock() log.Context {
|
||||
fields["visitor_calls_remaining"] = info.Stats.CallsRemaining
|
||||
}
|
||||
if v.authLimiter != nil {
|
||||
fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit()
|
||||
fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens()
|
||||
fields["visitor_auth_attempts_remaining"] = int64(math.Floor(v.authLimiter.Tokens()))
|
||||
}
|
||||
if v.topicCreationLimiter != nil {
|
||||
fields["visitor_topic_creations_remaining"] = int64(math.Floor(v.topicCreationLimiter.Tokens()))
|
||||
}
|
||||
if v.user != nil {
|
||||
fields["user_id"] = v.user.ID
|
||||
@@ -245,6 +250,17 @@ func (v *visitor) SubscriptionAllowed() bool {
|
||||
return v.subscriptionLimiter.Allow()
|
||||
}
|
||||
|
||||
// TopicCreationAllowed returns true if the visitor is allowed to cause a new topic to be
|
||||
// inserted into the server's in-memory topic map. Returns true if no limiter is configured.
|
||||
func (v *visitor) TopicCreationAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.topicCreationLimiter == nil {
|
||||
return true
|
||||
}
|
||||
return v.topicCreationLimiter.Allow()
|
||||
}
|
||||
|
||||
// AuthAllowed returns true if an auth request can be attempted (> 1 token available)
|
||||
func (v *visitor) AuthAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
@@ -264,8 +280,9 @@ func (v *visitor) AuthFailed() {
|
||||
}
|
||||
}
|
||||
|
||||
// AccountCreationAllowed returns true if a new account can be created
|
||||
func (v *visitor) AccountCreationAllowed() bool {
|
||||
// AccountActionAllowed returns true if a rate-limited account action (signup or password-reset
|
||||
// request) is currently allowed for this visitor
|
||||
func (v *visitor) AccountActionAllowed() bool {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter == nil || (v.accountLimiter != nil && v.accountLimiter.Tokens() < 1) {
|
||||
@@ -274,8 +291,9 @@ func (v *visitor) AccountCreationAllowed() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// AccountCreated decreases the account limiter. This is to be called after an account was created.
|
||||
func (v *visitor) AccountCreated() {
|
||||
// AccountActionPerformed decreases the account limiter. This is to be called after a rate-limited
|
||||
// account action (signup or password-reset request).
|
||||
func (v *visitor) AccountActionPerformed() {
|
||||
v.mu.RLock() // limiters could be replaced!
|
||||
defer v.mu.RUnlock()
|
||||
if v.accountLimiter != nil {
|
||||
@@ -384,6 +402,11 @@ func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpda
|
||||
v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages)
|
||||
v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails)
|
||||
v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls)
|
||||
if v.config.VisitorTopicCreationLimitBurst > 0 && v.config.VisitorTopicCreationLimitReplenish > 0 {
|
||||
v.topicCreationLimiter = rate.NewLimiter(rate.Every(v.config.VisitorTopicCreationLimitReplenish), v.config.VisitorTopicCreationLimitBurst)
|
||||
} else {
|
||||
v.topicCreationLimiter = nil // Disabled
|
||||
}
|
||||
v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay)
|
||||
if v.user == nil {
|
||||
v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst)
|
||||
@@ -439,13 +462,17 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
|
||||
if conf.VisitorMessageDailyLimit > 0 {
|
||||
messagesLimit = int64(conf.VisitorMessageDailyLimit)
|
||||
}
|
||||
var emailLimit int64
|
||||
if conf.VisitorEmailLimitBurst > 0 {
|
||||
emailLimit = replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish) // Approximation!
|
||||
}
|
||||
return &visitorLimits{
|
||||
Basis: visitorLimitBasisIP,
|
||||
RequestLimitBurst: conf.VisitorRequestLimitBurst,
|
||||
RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish),
|
||||
MessageLimit: messagesLimit,
|
||||
MessageExpiryDuration: conf.CacheDuration,
|
||||
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation!
|
||||
EmailLimit: emailLimit,
|
||||
EmailLimitBurst: conf.VisitorEmailLimitBurst,
|
||||
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
|
||||
CallLimit: visitorDefaultCallsLimit,
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
)
|
||||
|
||||
const (
|
||||
subscriptionIDPrefix = "wps_"
|
||||
subscriptionIDLength = 10
|
||||
subscriptionEndpointLimitPerSubscriberIP = 10
|
||||
)
|
||||
|
||||
var (
|
||||
errWebPushNoRows = errors.New("no rows found")
|
||||
errWebPushTooManySubscriptions = errors.New("too many subscriptions")
|
||||
errWebPushUserIDCannotBeEmpty = errors.New("user ID cannot be empty")
|
||||
)
|
||||
|
||||
const (
|
||||
createWebPushSubscriptionsTableQuery = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS subscription (
|
||||
id TEXT PRIMARY KEY,
|
||||
endpoint TEXT NOT NULL,
|
||||
key_auth TEXT NOT NULL,
|
||||
key_p256dh TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
subscriber_ip TEXT NOT NULL,
|
||||
updated_at INT NOT NULL,
|
||||
warned_at INT NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
|
||||
CREATE TABLE IF NOT EXISTS subscription_topic (
|
||||
subscription_id TEXT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
PRIMARY KEY (subscription_id, topic),
|
||||
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
COMMIT;
|
||||
`
|
||||
builtinStartupQueries = `
|
||||
PRAGMA foreign_keys = ON;
|
||||
`
|
||||
|
||||
selectWebPushSubscriptionIDByEndpoint = `SELECT id FROM subscription WHERE endpoint = ?`
|
||||
selectWebPushSubscriptionCountBySubscriberIP = `SELECT COUNT(*) FROM subscription WHERE subscriber_ip = ?`
|
||||
selectWebPushSubscriptionsForTopicQuery = `
|
||||
SELECT id, endpoint, key_auth, key_p256dh, user_id
|
||||
FROM subscription_topic st
|
||||
JOIN subscription s ON s.id = st.subscription_id
|
||||
WHERE st.topic = ?
|
||||
ORDER BY endpoint
|
||||
`
|
||||
selectWebPushSubscriptionsExpiringSoonQuery = `
|
||||
SELECT id, endpoint, key_auth, key_p256dh, user_id
|
||||
FROM subscription
|
||||
WHERE warned_at = 0 AND updated_at <= ?
|
||||
`
|
||||
insertWebPushSubscriptionQuery = `
|
||||
INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (endpoint)
|
||||
DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
|
||||
`
|
||||
updateWebPushSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
|
||||
deleteWebPushSubscriptionByEndpointQuery = `DELETE FROM subscription WHERE endpoint = ?`
|
||||
deleteWebPushSubscriptionByUserIDQuery = `DELETE FROM subscription WHERE user_id = ?`
|
||||
deleteWebPushSubscriptionByAgeQuery = `DELETE FROM subscription WHERE updated_at <= ?` // Full table scan!
|
||||
|
||||
insertWebPushSubscriptionTopicQuery = `INSERT INTO subscription_topic (subscription_id, topic) VALUES (?, ?)`
|
||||
deleteWebPushSubscriptionTopicAllQuery = `DELETE FROM subscription_topic WHERE subscription_id = ?`
|
||||
deleteWebPushSubscriptionTopicWithoutSubscription = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentWebPushSchemaVersion = 1
|
||||
insertWebPushSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
|
||||
selectWebPushSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
|
||||
)
|
||||
|
||||
type webPushStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func newWebPushStore(filename, startupQueries string) (*webPushStore, error) {
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupWebPushDB(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := runWebPushStartupQueries(db, startupQueries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &webPushStore{
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setupWebPushDB(db *sql.DB) error {
|
||||
// If 'schemaVersion' table does not exist, this must be a new database
|
||||
rows, err := db.Query(selectWebPushSchemaVersionQuery)
|
||||
if err != nil {
|
||||
return setupNewWebPushDB(db)
|
||||
}
|
||||
return rows.Close()
|
||||
}
|
||||
|
||||
func setupNewWebPushDB(db *sql.DB) error {
|
||||
if _, err := db.Exec(createWebPushSubscriptionsTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(insertWebPushSchemaVersion, currentWebPushSchemaVersion); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runWebPushStartupQueries(db *sql.DB, startupQueries string) error {
|
||||
if _, err := db.Exec(startupQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(builtinStartupQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpsertSubscription adds or updates Web Push subscriptions for the given topics and user ID. It always first deletes all
|
||||
// existing entries for a given endpoint.
|
||||
func (c *webPushStore) UpsertSubscription(endpoint string, auth, p256dh, userID string, subscriberIP netip.Addr, topics []string) error {
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// Read number of subscriptions for subscriber IP address
|
||||
rowsCount, err := tx.Query(selectWebPushSubscriptionCountBySubscriberIP, subscriberIP.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rowsCount.Close()
|
||||
var subscriptionCount int
|
||||
if !rowsCount.Next() {
|
||||
return errWebPushNoRows
|
||||
}
|
||||
if err := rowsCount.Scan(&subscriptionCount); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rowsCount.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Read existing subscription ID for endpoint (or create new ID)
|
||||
rows, err := tx.Query(selectWebPushSubscriptionIDByEndpoint, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var subscriptionID string
|
||||
if rows.Next() {
|
||||
if err := rows.Scan(&subscriptionID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if subscriptionCount >= subscriptionEndpointLimitPerSubscriberIP {
|
||||
return errWebPushTooManySubscriptions
|
||||
}
|
||||
subscriptionID = util.RandomStringPrefix(subscriptionIDPrefix, subscriptionIDLength)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Insert or update subscription
|
||||
updatedAt, warnedAt := time.Now().Unix(), 0
|
||||
if _, err = tx.Exec(insertWebPushSubscriptionQuery, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
// Replace all subscription topics
|
||||
if _, err := tx.Exec(deleteWebPushSubscriptionTopicAllQuery, subscriptionID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, topic := range topics {
|
||||
if _, err = tx.Exec(insertWebPushSubscriptionTopicQuery, subscriptionID, topic); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SubscriptionsForTopic returns all subscriptions for the given topic
|
||||
func (c *webPushStore) SubscriptionsForTopic(topic string) ([]*webPushSubscription, error) {
|
||||
rows, err := c.db.Query(selectWebPushSubscriptionsForTopicQuery, topic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return c.subscriptionsFromRows(rows)
|
||||
}
|
||||
|
||||
// SubscriptionsExpiring returns all subscriptions that have not been updated for a given time period
|
||||
func (c *webPushStore) SubscriptionsExpiring(warnAfter time.Duration) ([]*webPushSubscription, error) {
|
||||
rows, err := c.db.Query(selectWebPushSubscriptionsExpiringSoonQuery, time.Now().Add(-warnAfter).Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return c.subscriptionsFromRows(rows)
|
||||
}
|
||||
|
||||
// MarkExpiryWarningSent marks the given subscriptions as having received a warning about expiring soon
|
||||
func (c *webPushStore) MarkExpiryWarningSent(subscriptions []*webPushSubscription) error {
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, subscription := range subscriptions {
|
||||
if _, err := tx.Exec(updateWebPushSubscriptionWarningSentQuery, time.Now().Unix(), subscription.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *webPushStore) subscriptionsFromRows(rows *sql.Rows) ([]*webPushSubscription, error) {
|
||||
subscriptions := make([]*webPushSubscription, 0)
|
||||
for rows.Next() {
|
||||
var id, endpoint, auth, p256dh, userID string
|
||||
if err := rows.Scan(&id, &endpoint, &auth, &p256dh, &userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subscriptions = append(subscriptions, &webPushSubscription{
|
||||
ID: id,
|
||||
Endpoint: endpoint,
|
||||
Auth: auth,
|
||||
P256dh: p256dh,
|
||||
UserID: userID,
|
||||
})
|
||||
}
|
||||
return subscriptions, nil
|
||||
}
|
||||
|
||||
// RemoveSubscriptionsByEndpoint removes the subscription for the given endpoint
|
||||
func (c *webPushStore) RemoveSubscriptionsByEndpoint(endpoint string) error {
|
||||
_, err := c.db.Exec(deleteWebPushSubscriptionByEndpointQuery, endpoint)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveSubscriptionsByUserID removes all subscriptions for the given user ID
|
||||
func (c *webPushStore) RemoveSubscriptionsByUserID(userID string) error {
|
||||
if userID == "" {
|
||||
return errWebPushUserIDCannotBeEmpty
|
||||
}
|
||||
_, err := c.db.Exec(deleteWebPushSubscriptionByUserIDQuery, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveExpiredSubscriptions removes all subscriptions that have not been updated for a given time period
|
||||
func (c *webPushStore) RemoveExpiredSubscriptions(expireAfter time.Duration) error {
|
||||
_, err := c.db.Exec(deleteWebPushSubscriptionByAgeQuery, time.Now().Add(-expireAfter).Unix())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.db.Exec(deleteWebPushSubscriptionTopicWithoutSubscription)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close closes the underlying database connection
|
||||
func (c *webPushStore) Close() error {
|
||||
return c.db.Close()
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWebPushStore_UpsertSubscription_SubscriptionsForTopic(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
|
||||
|
||||
subs, err := webPush.SubscriptionsForTopic("test-topic")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
require.Equal(t, subs[0].Endpoint, testWebPushEndpoint)
|
||||
require.Equal(t, subs[0].P256dh, "p256dh-key")
|
||||
require.Equal(t, subs[0].Auth, "auth-key")
|
||||
require.Equal(t, subs[0].UserID, "u_1234")
|
||||
|
||||
subs2, err := webPush.SubscriptionsForTopic("mytopic")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs2, 1)
|
||||
require.Equal(t, subs[0].Endpoint, subs2[0].Endpoint)
|
||||
}
|
||||
|
||||
func TestWebPushStore_UpsertSubscription_SubscriberIPLimitReached(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
|
||||
// Insert 10 subscriptions with the same IP address
|
||||
for i := 0; i < 10; i++ {
|
||||
endpoint := fmt.Sprintf(testWebPushEndpoint+"%d", i)
|
||||
require.Nil(t, webPush.UpsertSubscription(endpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
|
||||
}
|
||||
|
||||
// Another one for the same endpoint should be fine
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
|
||||
|
||||
// But with a different endpoint it should fail
|
||||
require.Equal(t, errWebPushTooManySubscriptions, webPush.UpsertSubscription(testWebPushEndpoint+"11", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"test-topic", "mytopic"}))
|
||||
|
||||
// But with a different IP address it should be fine again
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"99", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("9.9.9.9"), []string{"test-topic", "mytopic"}))
|
||||
}
|
||||
|
||||
func TestWebPushStore_UpsertSubscription_UpdateTopics(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
|
||||
// Insert subscription with two topics, and another with one topic
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"1", "auth-key", "p256dh-key", "", netip.MustParseAddr("9.9.9.9"), []string{"topic1"}))
|
||||
|
||||
subs, err := webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 2)
|
||||
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
|
||||
require.Equal(t, testWebPushEndpoint+"1", subs[1].Endpoint)
|
||||
|
||||
subs, err = webPush.SubscriptionsForTopic("topic2")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
|
||||
|
||||
// Update the first subscription to have only one topic
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint+"0", "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1"}))
|
||||
|
||||
subs, err = webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 2)
|
||||
require.Equal(t, testWebPushEndpoint+"0", subs[0].Endpoint)
|
||||
|
||||
subs, err = webPush.SubscriptionsForTopic("topic2")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 0)
|
||||
}
|
||||
|
||||
func TestWebPushStore_RemoveSubscriptionsByEndpoint(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
|
||||
// Insert subscription with two topics
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
|
||||
subs, err := webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
|
||||
// And remove it again
|
||||
require.Nil(t, webPush.RemoveSubscriptionsByEndpoint(testWebPushEndpoint))
|
||||
subs, err = webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 0)
|
||||
}
|
||||
|
||||
func TestWebPushStore_RemoveSubscriptionsByUserID(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
|
||||
// Insert subscription with two topics
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
|
||||
subs, err := webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
|
||||
// And remove it again
|
||||
require.Nil(t, webPush.RemoveSubscriptionsByUserID("u_1234"))
|
||||
subs, err = webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 0)
|
||||
}
|
||||
|
||||
func TestWebPushStore_RemoveSubscriptionsByUserID_Empty(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
require.Equal(t, errWebPushUserIDCannotBeEmpty, webPush.RemoveSubscriptionsByUserID(""))
|
||||
}
|
||||
|
||||
func TestWebPushStore_MarkExpiryWarningSent(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
|
||||
// Insert subscription with two topics
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
|
||||
subs, err := webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
|
||||
// Mark them as warning sent
|
||||
require.Nil(t, webPush.MarkExpiryWarningSent(subs))
|
||||
|
||||
rows, err := webPush.db.Query("SELECT endpoint FROM subscription WHERE warned_at > 0")
|
||||
require.Nil(t, err)
|
||||
defer rows.Close()
|
||||
var endpoint string
|
||||
require.True(t, rows.Next())
|
||||
require.Nil(t, rows.Scan(&endpoint))
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, testWebPushEndpoint, endpoint)
|
||||
require.False(t, rows.Next())
|
||||
}
|
||||
|
||||
func TestWebPushStore_SubscriptionsExpiring(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
|
||||
// Insert subscription with two topics
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
|
||||
subs, err := webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
|
||||
// Fake-mark them as soon-to-expire
|
||||
_, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-8*24*time.Hour).Unix(), testWebPushEndpoint)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Should not be cleaned up yet
|
||||
require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
|
||||
|
||||
// Run expiration
|
||||
subs, err = webPush.SubscriptionsExpiring(7 * 24 * time.Hour)
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
|
||||
}
|
||||
|
||||
func TestWebPushStore_RemoveExpiredSubscriptions(t *testing.T) {
|
||||
webPush := newTestWebPushStore(t)
|
||||
defer webPush.Close()
|
||||
|
||||
// Insert subscription with two topics
|
||||
require.Nil(t, webPush.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"topic1", "topic2"}))
|
||||
subs, err := webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 1)
|
||||
|
||||
// Fake-mark them as expired
|
||||
_, err = webPush.db.Exec("UPDATE subscription SET updated_at = ? WHERE endpoint = ?", time.Now().Add(-10*24*time.Hour).Unix(), testWebPushEndpoint)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Run expiration
|
||||
require.Nil(t, webPush.RemoveExpiredSubscriptions(9*24*time.Hour))
|
||||
|
||||
// List again, should be 0
|
||||
subs, err = webPush.SubscriptionsForTopic("topic1")
|
||||
require.Nil(t, err)
|
||||
require.Len(t, subs, 0)
|
||||
}
|
||||
|
||||
func newTestWebPushStore(t *testing.T) *webPushStore {
|
||||
webPush, err := newWebPushStore(filepath.Join(t.TempDir(), "webpush.db"), "")
|
||||
require.Nil(t, err)
|
||||
return webPush
|
||||
}
|
||||
Reference in New Issue
Block a user