Compare commits

..
Author SHA1 Message Date
binwiederhier 6219784aae Bump 2026-03-30 16:38:01 -04:00
binwiederhier 51da5e0f77 Review 2026-03-30 16:33:19 -04:00
binwiederhier e57ef84f13 Fix limits for anon users 2026-03-30 16:06:53 -04:00
binwiederhier 4c6225e311 Docs 2026-03-30 12:38:47 -04:00
binwiederhier 07b3812549 Docs, fix generator 2026-03-30 12:37:07 -04:00
binwiederhier 63ec73a319 Tests 2026-03-30 12:21:19 -04:00
binwiederhier bdea8c314f Refine docs and bump deps 2026-03-30 09:00:47 -04:00
binwiederhier 3e634e0a5a Refine 2026-03-30 08:51:18 -04:00
binwiederhier 6aebc5c677 Refine 2026-03-30 08:26:56 -04:00
binwiederhier 61dd788dac WIP: Email verification 2026-03-29 22:47:38 -04:00
Philipp C. Heckel 266d0b9d37 Merge pull request #1679 from binwiederhier/attachment-no-http2
Attachment: Disable HTTP/2
2026-03-27 14:21:06 -04:00
binwiederhier 835d1faac4 Bump 2026-03-27 14:14:31 -04:00
binwiederhier 67fc7fe96a Disable HTTP2 for S3 backend with ?disable_http2=true option 2026-03-27 13:59:07 -04:00
binwiederhier 92fa88cf12 Merge branch 'main' into attachment-no-http2 2026-03-27 10:04:42 -04:00
binwiederhier b5fee121d7 Empty commit 2026-03-26 22:58:24 -04:00
binwiederhier e1a344339f Dummy 2026-03-26 22:20:07 -04:00
binwiederhier ae1ecfa1e9 BUmp 2026-03-26 22:17:54 -04:00
binwiederhier 874bdcf9f1 No HTTP/2 2026-03-26 20:47:06 -04:00
binwiederhier 2235d44726 Update changelog 2026-03-26 13:39:04 -04:00
binwiederhier 27bbb10a31 Bump 2026-03-26 11:13:43 -04:00
binwiederhier be4134fc3b Try to fix flaky again 2026-03-26 10:49:39 -04:00
binwiederhier e19ba059b5 Make attachment.Close() synchronous 2026-03-26 09:57:06 -04:00
binwiederhier 11a14d8fe7 Fix CI 2026-03-26 09:36:34 -04:00
binwiederhier 3759ff26b4 "npm ci" 2026-03-26 09:28:05 -04:00
binwiederhier 136b50f926 I'm tired and GitHub Actions hates me. I'll release this tomorrow ... 2026-03-25 21:35:51 -04:00
binwiederhier 2770f65027 Arrrg 2026-03-25 21:12:27 -04:00
binwiederhier db6f813386 Release notes derp 2026-03-25 21:03:49 -04:00
binwiederhier 15d963cb53 Fix flaky test due to new attachment expiry cap logic 2026-03-25 21:00:58 -04:00
binwiederhier a2206dba9f Fix races in tests 2026-03-25 20:47:20 -04:00
binwiederhier d159580ecf Bump 2026-03-25 20:34:48 -04:00
binwiederhier 0de9dc11ad Bump 2026-03-25 20:29:12 -04:00
binwiederhier f790143b0b Refined wording, review 2026-03-25 17:35:24 -04:00
binwiederhier 42b0254c9b Merge branch 'main' of github.com:binwiederhier/ntfy into ageru/main 2026-03-25 17:26:49 -04:00
Philipp C. Heckel d183af61fa Merge pull request #1672 from binwiederhier/attachment-fixes
Attachment fixes to address inconsistencies between DB and backend store
2026-03-25 17:26:18 -04:00
binwiederhier f256a4101b Fix test 2026-03-25 17:20:02 -04:00
binwiederhier 3ff8bacc45 Update changelog 2026-03-25 17:09:44 -04:00
binwiederhier ca59cfc1e1 Words 2026-03-25 17:01:29 -04:00
binwiederhier 071543efda Fixes 2026-03-25 15:28:23 -04:00
ageru e22a77d4bb Rename ntfy-server.openrc to ntfy.openrc 2026-03-24 23:38:16 +01:00
ageru e0362dce36 install.md - Install ntfy server service manually
Rework of steps to install ntfy server service on systemd and OpenRC
2026-03-24 23:36:19 +01:00
binwiederhier e55d1cee6b Attachment fixes to address inconsistencies between DB and backend store 2026-03-24 17:37:50 -04:00
ageru 676f1ff1cb Merge branch 'binwiederhier:main' into main 2026-03-24 22:37:38 +01:00
binwiederhier dd760f16f7 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-03-23 13:05:09 -04:00
binwiederhier d00277107a Release notes 2026-03-23 13:04:47 -04:00
binwiederhier b95efe8dd3 Tighten message IDs in attachments 2026-03-23 12:54:13 -04:00
binwiederhier 075f2ffa15 Refine sync() to properly update sizes 2026-03-23 12:44:40 -04:00
Philipp C. Heckel 2f6a044c34 Merge pull request #1656 from binwiederhier/attachment-s3
S3 attachment storage
2026-03-22 21:33:07 -04:00
ageru e8199fa6b5 install.md - Install ntfy server service manually
First draft - Add manual steps to install ntfy server service on systemd and OpenRC
2026-03-16 23:30:21 +01:00
ageru c29a7bc8cc Merge branch 'binwiederhier:main' into main 2026-03-16 22:35:14 +01:00
ageru 78f0593abe Merge pull request #1 from ageru/add-openrc-init-file-for-ntfy-server
Create Init service file for OpenRC
2026-03-11 22:00:03 +01:00
ageru aca58f040f Create Init service file for OpenRC
This in an init file for OpenRC systems.

It should be equivalent in features to the current systemd file, with 2 deliberate changes:
- removed "no-log-dates", as the logs are fine as-is
- Lower nofile limit, as it seems largely sufficient for a self-hosted instance. Feel free to increase to 8192 or 10240 if necessary.

Confirmed functional with Gentoo amd64 and ntfy 2.17.0.
2026-03-11 21:56:14 +01:00
54 changed files with 1786 additions and 497 deletions
+3 -1
View File
@@ -146,11 +146,13 @@ web-build:
../server/site/config.js ../server/site/config.js
web-deps: web-deps:
cd web && $(NPM) install cd web && $(NPM) ci
# Use "npm ci" so that we don't change the package lock file
# If this fails for .svg files, optimize them with svgo # If this fails for .svg files, optimize them with svgo
web-deps-update: web-deps-update:
cd web && $(NPM) update cd web && $(NPM) update
cd web && $(NPM) install
web-fmt: web-fmt:
cd web && $(NPM) run format cd web && $(NPM) run format
+62 -48
View File
@@ -4,7 +4,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"regexp"
"sync" "sync"
"time" "time"
@@ -15,58 +14,70 @@ import (
) )
const ( const (
tagStore = "attachment_store" tagStore = "attachment_store"
syncInterval = 15 * time.Minute // How often to run the background sync loop syncInterval = 15 * time.Minute // How often to run the background sync loop
orphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads
) )
var ( var errInvalidFileID = errors.New("invalid file ID")
fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength))
errInvalidFileID = errors.New("invalid file ID")
)
// Store manages attachment storage with shared logic for size tracking, limiting, // Store manages attachment storage with shared logic for size tracking, limiting,
// ID validation, and background sync to reconcile storage with the database. // ID validation, and background sync to reconcile storage with the database.
type Store struct { type Store struct {
backend backend backend backend
limit int64 // Defined limit of the store in bytes limit int64 // Defined limit of the store in bytes
size int64 // Current size of the store in bytes size int64 // Current size of the store in bytes
sizes map[string]int64 // File ID -> size, for subtracting on Remove sizes map[string]int64 // File ID -> size, for subtracting on Remove
localIDs func() ([]string, error) // Returns file IDs that should exist locally, used for sync() attachmentsWithSizes func() (map[string]int64, error) // Returns file ID -> size for active attachments
closeChan chan struct{} orphanGracePeriod time.Duration // Don't delete orphaned objects younger than this
mu sync.RWMutex // Protects size and sizes closeChan chan struct{}
doneChan chan struct{}
mu sync.RWMutex // Protects size and sizes
} }
// NewFileStore creates a new file-system backed attachment cache // NewFileStore creates a new file-system backed attachment cache
func NewFileStore(dir string, totalSizeLimit int64, localIDsFn func() ([]string, error)) (*Store, error) { func NewFileStore(dir string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
b, err := newFileBackend(dir) b, err := newFileBackend(dir)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newStore(b, totalSizeLimit, localIDsFn) return newStore(b, totalSizeLimit, orphanGracePeriod, attachmentsWithSizes)
} }
// NewS3Store creates a new S3-backed attachment cache. The s3URL must be in the format: // NewS3Store creates a new S3-backed attachment cache. The s3URL must be in the format:
// //
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] // s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
func NewS3Store(s3URL string, totalSizeLimit int64, localIDs func() ([]string, error)) (*Store, error) { func NewS3Store(s3URL string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
config, err := s3.ParseURL(s3URL) config, err := s3.ParseURL(s3URL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return newStore(newS3Backend(s3.New(config)), totalSizeLimit, localIDs) return newStore(newS3Backend(s3.New(config)), totalSizeLimit, orphanGracePeriod, attachmentsWithSizes)
} }
func newStore(backend backend, totalSizeLimit int64, localIDs func() ([]string, error)) (*Store, error) { func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) {
c := &Store{ c := &Store{
backend: backend, backend: backend,
limit: totalSizeLimit, limit: totalSizeLimit,
sizes: make(map[string]int64), sizes: make(map[string]int64),
localIDs: localIDs, attachmentsWithSizes: attachmentsWithSizes,
closeChan: make(chan struct{}), orphanGracePeriod: orphanGracePeriod,
closeChan: make(chan struct{}),
doneChan: make(chan struct{}),
} }
if localIDs != nil { // Hydrate sizes from the database immediately so that Size()/Remaining()/Remove()
// are accurate from the start, without waiting for the first sync() call.
if attachmentsWithSizes != nil {
attachments, err := attachmentsWithSizes()
if err != nil {
return nil, fmt.Errorf("attachment store: failed to load existing attachments: %w", err)
}
for id, size := range attachments {
c.sizes[id] = size
c.size += size
}
go c.syncLoop() go c.syncLoop()
} else {
close(c.doneChan)
} }
return c, nil return c, nil
} }
@@ -76,7 +87,7 @@ func newStore(backend backend, totalSizeLimit int64, localIDs func() ([]string,
// from the client's Content-Length header; backends may use it to optimize uploads (e.g. // from the client's Content-Length header; backends may use it to optimize uploads (e.g.
// streaming directly to S3 without buffering). // streaming directly to S3 without buffering).
func (c *Store) Write(id string, reader io.Reader, untrustedLength int64, limiters ...util.Limiter) (int64, error) { func (c *Store) Write(id string, reader io.Reader, untrustedLength int64, limiters ...util.Limiter) (int64, error) {
if !fileIDRegex.MatchString(id) { if !model.ValidMessageID(id) {
return 0, errInvalidFileID return 0, errInvalidFileID
} }
log.Tag(tagStore).Field("message_id", id).Debug("Writing attachment") log.Tag(tagStore).Field("message_id", id).Debug("Writing attachment")
@@ -97,7 +108,7 @@ func (c *Store) Write(id string, reader io.Reader, untrustedLength int64, limite
// Read retrieves an attachment file by ID // Read retrieves an attachment file by ID
func (c *Store) Read(id string) (io.ReadCloser, int64, error) { func (c *Store) Read(id string) (io.ReadCloser, int64, error) {
if !fileIDRegex.MatchString(id) { if !model.ValidMessageID(id) {
return nil, 0, errInvalidFileID return nil, 0, errInvalidFileID
} }
return c.backend.Get(id) return c.backend.Get(id)
@@ -108,7 +119,7 @@ func (c *Store) Read(id string) (io.ReadCloser, int64, error) {
// started and before the first sync) are corrected by the next sync() call. // started and before the first sync) are corrected by the next sync() call.
func (c *Store) Remove(ids ...string) error { func (c *Store) Remove(ids ...string) error {
for _, id := range ids { for _, id := range ids {
if !fileIDRegex.MatchString(id) { if !model.ValidMessageID(id) {
return errInvalidFileID return errInvalidFileID
} }
} }
@@ -134,20 +145,21 @@ func (c *Store) Remove(ids ...string) error {
return nil return nil
} }
// Sync triggers an immediate reconciliation of storage with the database.
func (c *Store) Sync() error {
return c.sync()
}
// sync reconciles the backend storage with the database. It lists all objects, // sync reconciles the backend storage with the database. It lists all objects,
// deletes orphans (not in the valid ID set and older than 1 hour), and recomputes // deletes orphans (not in the valid ID set and older than the grace period), and
// the total size from the remaining objects. // recomputes the total size from the existing attachments in the database.
func (c *Store) sync() error { func (c *Store) sync() error {
if c.localIDs == nil { if c.attachmentsWithSizes == nil {
return nil return nil
} }
localIDs, err := c.localIDs() attachmentsWithSizes, err := c.attachmentsWithSizes()
if err != nil { if err != nil {
return fmt.Errorf("attachment sync: failed to get valid IDs: %w", err) return fmt.Errorf("attachment sync: failed to get existing attachments: %w", err)
}
localIDMap := make(map[string]struct{}, len(localIDs))
for _, id := range localIDs {
localIDMap[id] = struct{}{}
} }
remoteObjects, err := c.backend.List() remoteObjects, err := c.backend.List()
if err != nil { if err != nil {
@@ -155,25 +167,25 @@ func (c *Store) sync() error {
} }
// Calculate total cache size and collect orphaned attachments, excluding objects younger // Calculate total cache size and collect orphaned attachments, excluding objects younger
// than the grace period to account for races, and skipping objects with invalid IDs. // than the grace period to account for races, and skipping objects with invalid IDs.
cutoff := time.Now().Add(-orphanGracePeriod) cutoff := time.Now().Add(-c.orphanGracePeriod)
var orphanIDs []string var orphanIDs []string
var count, size int64 var count, totalSize int64
sizes := make(map[string]int64, len(remoteObjects)) sizes := make(map[string]int64, len(remoteObjects))
for _, obj := range remoteObjects { for _, obj := range remoteObjects {
if !fileIDRegex.MatchString(obj.ID) { if !model.ValidMessageID(obj.ID) {
continue continue
} }
if _, ok := localIDMap[obj.ID]; !ok && obj.LastModified.Before(cutoff) { if _, ok := attachmentsWithSizes[obj.ID]; !ok && obj.LastModified.Before(cutoff) {
orphanIDs = append(orphanIDs, obj.ID) orphanIDs = append(orphanIDs, obj.ID)
} else { } else {
count++ count++
size += obj.Size totalSize += attachmentsWithSizes[obj.ID]
sizes[obj.ID] = obj.Size sizes[obj.ID] = attachmentsWithSizes[obj.ID]
} }
} }
log.Tag(tagStore).Debug("Attachment store updated: %d attachment(s), %s", count, util.FormatSizeHuman(size)) log.Tag(tagStore).Debug("Attachment store updated: %d attachment(s), %s", count, util.FormatSizeHuman(totalSize))
c.mu.Lock() c.mu.Lock()
c.size = size c.size = totalSize
c.sizes = sizes c.sizes = sizes
c.mu.Unlock() c.mu.Unlock()
// Delete orphaned attachments // Delete orphaned attachments
@@ -208,12 +220,14 @@ func (c *Store) Remaining() int64 {
return remaining return remaining
} }
// Close stops the background sync goroutine // Close stops the background sync goroutine and waits for it to finish
func (c *Store) Close() { func (c *Store) Close() {
close(c.closeChan) close(c.closeChan)
<-c.doneChan
} }
func (c *Store) syncLoop() { func (c *Store) syncLoop() {
defer close(c.doneChan)
if err := c.sync(); err != nil { if err := c.sync(); err != nil {
log.Tag(tagStore).Err(err).Warn("Attachment sync failed") log.Tag(tagStore).Err(err).Warn("Attachment sync failed")
} }
+2 -1
View File
@@ -2,6 +2,7 @@ package attachment
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -9,7 +10,7 @@ import (
func newTestFileStore(t *testing.T, totalSizeLimit int64) (dir string, cache *Store) { func newTestFileStore(t *testing.T, totalSizeLimit int64) (dir string, cache *Store) {
t.Helper() t.Helper()
dir = t.TempDir() dir = t.TempDir()
cache, err := NewFileStore(dir, totalSizeLimit, nil) cache, err := NewFileStore(dir, totalSizeLimit, time.Hour, nil)
require.Nil(t, err) require.Nil(t, err)
t.Cleanup(func() { cache.Close() }) t.Cleanup(func() { cache.Close() })
return dir, cache return dir, cache
+2 -2
View File
@@ -24,7 +24,7 @@ func TestS3Store_WriteWithPrefix(t *testing.T) {
client := s3.New(cfg) client := s3.New(cfg)
deleteAllObjects(t, client) deleteAllObjects(t, client)
backend := newS3Backend(client) backend := newS3Backend(client)
cache, err := newStore(backend, 10*1024, nil) cache, err := newStore(backend, 10*1024, time.Hour, nil)
require.Nil(t, err) require.Nil(t, err)
t.Cleanup(func() { t.Cleanup(func() {
deleteAllObjects(t, client) deleteAllObjects(t, client)
@@ -62,7 +62,7 @@ func newTestRealS3Store(t *testing.T, totalSizeLimit int64) (*Store, *modTimeOve
inner := newS3Backend(client) inner := newS3Backend(client)
wrapper := &modTimeOverrideBackend{backend: inner, modTimes: make(map[string]time.Time)} wrapper := &modTimeOverrideBackend{backend: inner, modTimes: make(map[string]time.Time)}
deleteAllObjects(t, client) deleteAllObjects(t, client)
store, err := newStore(wrapper, totalSizeLimit, nil) store, err := newStore(wrapper, totalSizeLimit, time.Hour, nil)
require.Nil(t, err) require.Nil(t, err)
t.Cleanup(func() { t.Cleanup(func() {
deleteAllObjects(t, client) deleteAllObjects(t, client)
+7 -7
View File
@@ -162,9 +162,9 @@ func TestStore_SyncRecomputesSize(t *testing.T) {
s.mu.Unlock() s.mu.Unlock()
require.Equal(t, int64(999), s.Size()) require.Equal(t, int64(999), s.Size())
// Set localIDs to include both files so nothing gets deleted // Set attachmentsWithSizes to include both files so nothing gets deleted
s.localIDs = func() ([]string, error) { s.attachmentsWithSizes = func() (map[string]int64, error) {
return []string{"abcdefghijk0", "abcdefghijk1"}, nil return map[string]int64{"abcdefghijk0": 100, "abcdefghijk1": 200}, nil
} }
// Sync should recompute size from the backend // Sync should recompute size from the backend
@@ -280,8 +280,8 @@ func TestStore_Sync(t *testing.T) {
require.Equal(t, int64(15), s.Size()) require.Equal(t, int64(15), s.Size())
// Set the ID provider to only know about file 0 and 2 // Set the ID provider to only know about file 0 and 2
s.localIDs = func() ([]string, error) { s.attachmentsWithSizes = func() (map[string]int64, error) {
return []string{"abcdefghijk0", "abcdefghijk2"}, nil return map[string]int64{"abcdefghijk0": 5, "abcdefghijk2": 5}, nil
} }
// Make file 1 old enough to be cleaned up // Make file 1 old enough to be cleaned up
@@ -314,8 +314,8 @@ func TestStore_Sync_SkipsRecentFiles(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
// Set the ID provider to return empty (no valid IDs) // Set the ID provider to return empty (no valid IDs)
s.localIDs = func() ([]string, error) { s.attachmentsWithSizes = func() (map[string]int64, error) {
return []string{}, nil return map[string]int64{}, nil
} }
// File was just created, so it should NOT be deleted (< 1 hour old) // File was just created, so it should NOT be deleted (< 1 hour old)
+5
View File
@@ -71,6 +71,7 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", Aliases: []string{"smtp_sender_user"}, EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", Aliases: []string{"smtp_sender_pass"}, EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-from", Aliases: []string{"smtp_sender_from"}, EnvVars: []string{"NTFY_SMTP_SENDER_FROM"}, Usage: "SMTP sender address (if e-mail sending is enabled)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-verify", Aliases: []string{"smtp_sender_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-listen", Aliases: []string{"smtp_server_listen"}, EnvVars: []string{"NTFY_SMTP_SERVER_LISTEN"}, Usage: "SMTP server address (ip:port) for incoming emails, e.g. :25"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-domain", Aliases: []string{"smtp_server_domain"}, EnvVars: []string{"NTFY_SMTP_SERVER_DOMAIN"}, Usage: "SMTP domain for incoming e-mail, e.g. ntfy.sh"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", Aliases: []string{"smtp_server_addr_prefix"}, EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}),
@@ -184,6 +185,7 @@ func execServe(c *cli.Context) error {
smtpSenderUser := c.String("smtp-sender-user") smtpSenderUser := c.String("smtp-sender-user")
smtpSenderPass := c.String("smtp-sender-pass") smtpSenderPass := c.String("smtp-sender-pass")
smtpSenderFrom := c.String("smtp-sender-from") smtpSenderFrom := c.String("smtp-sender-from")
smtpSenderVerify := c.Bool("smtp-sender-verify")
smtpServerListen := c.String("smtp-server-listen") smtpServerListen := c.String("smtp-server-listen")
smtpServerDomain := c.String("smtp-server-domain") smtpServerDomain := c.String("smtp-server-domain")
smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix")
@@ -310,6 +312,8 @@ func execServe(c *cli.Context) error {
return errors.New("if listen-https is set, both key-file and cert-file must be set") return errors.New("if listen-https is set, both key-file and cert-file must be set")
} else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") {
return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set")
} else if smtpSenderVerify && smtpSenderAddr == "" {
return errors.New("if smtp-sender-verify is set, smtp-sender-addr must also be set")
} else if smtpServerListen != "" && smtpServerDomain == "" { } else if smtpServerListen != "" && smtpServerDomain == "" {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" { } else if attachmentCacheDir != "" && baseURL == "" {
@@ -471,6 +475,7 @@ func execServe(c *cli.Context) error {
conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderUser = smtpSenderUser
conf.SMTPSenderPass = smtpSenderPass conf.SMTPSenderPass = smtpSenderPass
conf.SMTPSenderFrom = smtpSenderFrom conf.SMTPSenderFrom = smtpSenderFrom
conf.SMTPSenderVerify = smtpSenderVerify
conf.SMTPServerListen = smtpServerListen conf.SMTPServerListen = smtpServerListen
conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerDomain = smtpServerDomain
conf.SMTPServerAddrPrefix = smtpServerAddrPrefix conf.SMTPServerAddrPrefix = smtpServerAddrPrefix
+29 -5
View File
@@ -353,6 +353,14 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
<label>SMTP password</label> <label>SMTP password</label>
<input type="password" data-key="smtp-sender-pass" placeholder="Password"> <input type="password" data-key="smtp-sender-pass" placeholder="Password">
</div> </div>
<div class="cg-field cg-inline-field">
<label>Require email verification</label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-smtp-sender-verify" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-smtp-sender-verify" value="yes"><span>Yes</span></label>
</div>
</div>
<input type="checkbox" data-key="smtp-sender-verify" id="cg-smtp-sender-verify-hidden" style="display:none">
</div> </div>
<div id="cg-email-in-section" class="cg-hidden"> <div id="cg-email-in-section" class="cg-hidden">
<div class="cg-field"><label><strong>Incoming (publishing)</strong></label></div> <div class="cg-field"><label><strong>Incoming (publishing)</strong></label></div>
@@ -538,7 +546,7 @@ As an alternative to the local filesystem, you can store attachments in an S3-co
To use an S3-compatible storage for attachments, set `attachment-cache-dir` to an S3 URL with the following format: To use an S3-compatible storage for attachments, set `attachment-cache-dir` to an S3 URL with the following format:
``` ```
s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
``` ```
Here are a few examples: Here are a few examples:
@@ -546,7 +554,7 @@ Here are a few examples:
=== "/etc/ntfy/server.yml (DigitalOcean Spaces)" === "/etc/ntfy/server.yml (DigitalOcean Spaces)"
``` yaml ``` yaml
base-url: "https://ntfy.example.com" base-url: "https://ntfy.example.com"
attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=nyc3&endpoint=https://nyc3.digitaloceanspaces.com" attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=nyc3&endpoint=https://nyc3.digitaloceanspaces.com&disable_http2=true"
``` ```
=== "/etc/ntfy/server.yml (AWS S3)" === "/etc/ntfy/server.yml (AWS S3)"
@@ -564,6 +572,9 @@ Here are a few examples:
Note that the access key and secret key may have to be URL encoded. For instance, a secret key `YmxhY+mxhYmxhC` (note the `+`) should Note that the access key and secret key may have to be URL encoded. For instance, a secret key `YmxhY+mxhYmxhC` (note the `+`) should
be encoded as `YmxhY%2BmxhYmxhC` (note the `%2B`), so the URL would be `s3://ACCESS_KEY:YmxhY%2BmxhYmxhC@my-bucket/attachments...`. be encoded as `YmxhY%2BmxhYmxhC` (note the `%2B`), so the URL would be `s3://ACCESS_KEY:YmxhY%2BmxhYmxhC@my-bucket/attachments...`.
If you experience upload failures with HTTP/2 stream errors (common with DigitalOcean Spaces and some other S3-compatible providers),
add `&disable_http2=true` to force HTTP/1.1 connections.
!!! info !!! info
ntfy.sh is hosted and sponsored by DigitalOcean. I can highly recommend their public cloud offering. It's been rock solid ntfy.sh is hosted and sponsored by DigitalOcean. I can highly recommend their public cloud offering. It's been rock solid
for 4 years. They offer an S3-compatible storage for $5/month and 250 GB of storage, with 1 TiB of bandwidth. for 4 years. They offer an S3-compatible storage for $5/month and 250 GB of storage, with 1 TiB of bandwidth.
@@ -1008,6 +1019,10 @@ To allow forwarding messages via e-mail, you can configure an **SMTP server for
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g. you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
`curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`). `curl -d "hi there" -H "X-Email: phil@example.com" ntfy.sh/mytopic`).
!!! info
On ntfy.sh, anonymous email sending was disabled due to abuse. To use the email notification feature,
you must verify your email in the web app's [Account section](https://ntfy.sh/account).
As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the As of today, only SMTP servers with PLAIN auth and STARTLS are supported. To enable e-mail sending, you must set the
following settings: following settings:
@@ -1015,6 +1030,8 @@ following settings:
* `smtp-sender-addr` is the hostname:port of the SMTP server * `smtp-sender-addr` is the hostname:port of the SMTP server
* `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user * `smtp-sender-user` and `smtp-sender-pass` are the username and password of the SMTP user
* `smtp-sender-from` is the e-mail address of the sender * `smtp-sender-from` is the e-mail address of the sender
* `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.
Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is Here's an example config using [Amazon SES](https://aws.amazon.com/ses/) for outgoing mail (this is how it is
configured for `ntfy.sh`): configured for `ntfy.sh`):
@@ -1026,9 +1043,15 @@ configured for `ntfy.sh`):
smtp-sender-user: "AKIDEADBEEFAFFE12345" smtp-sender-user: "AKIDEADBEEFAFFE12345"
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
smtp-sender-from: "ntfy@ntfy.sh" smtp-sender-from: "ntfy@ntfy.sh"
smtp-sender-verify: true
``` ```
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` By default, any user (including anonymous users) can send email notifications to any address. To require email
address verification, set `smtp-sender-verify` to `true`. When enabled, anonymous users cannot send emails,
and authenticated users can only send to email addresses they have verified in their account settings. Users can
also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified address.
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst`
and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse.
## E-mail publishing ## E-mail publishing
@@ -2189,7 +2212,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) | | `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) | | `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header | | `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory or S3 URL* | - | Cache directory for attached files, or S3 URL for object storage (format: `s3://KEY:SECRET@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT]`). | | `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory or S3 URL* | - | Cache directory for attached files, or S3 URL for object storage (format: `s3://KEY:SECRET@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]`). |
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. | | `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. | | `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. | | `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
@@ -2197,6 +2220,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled | | `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled | | `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | | `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` | | `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` | | `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
@@ -2291,7 +2315,7 @@ OPTIONS:
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE] --auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES] --auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS] --auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT]) [$NTFY_ATTACHMENT_CACHE_DIR] --attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]) [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT] --attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] --attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: "3h") [$NTFY_ATTACHMENT_EXPIRY_DURATION] --attachment-expiry-duration value, --attachment_expiry_duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: "3h") [$NTFY_ATTACHMENT_EXPIRY_DURATION]
+118 -30
View File
@@ -28,42 +28,130 @@ resources to get started. _I am not affiliated with Kris or Alex, I just liked t
Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and Please check out the [releases page](https://github.com/binwiederhier/ntfy/releases) for binaries and
deb/rpm packages. deb/rpm packages.
### Download and run
The steps below allow you to download ntfy server and run it in a pinch. But it won't be enough to install it permanently
as a service starting at boot time.
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.tar.gz
tar zxvf ntfy_2.19.2_linux_amd64.tar.gz tar zxvf ntfy_2.21.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.19.2_linux_amd64/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.21.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_amd64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.tar.gz
tar zxvf ntfy_2.19.2_linux_armv6.tar.gz tar zxvf ntfy_2.21.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.19.2_linux_armv6/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.21.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv6/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.tar.gz
tar zxvf ntfy_2.19.2_linux_armv7.tar.gz tar zxvf ntfy_2.21.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.19.2_linux_armv7/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.21.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv7/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.tar.gz
tar zxvf ntfy_2.19.2_linux_arm64.tar.gz tar zxvf ntfy_2.21.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.19.2_linux_arm64/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.21.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_arm64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
### Install as a service
If you want to install ntfy server permanently as a service, and your OS/distribution of choice doesn't offer a package,
there are a few more steps to follow.
Create the ntfy user and group:
```bash
useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy
```
Depending on your init system, the following steps will diverge.
#### On systemd systems
Install the ntfy server unit file (which contains parameters to start the service at boot time):
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.21.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv6"
```bash
sudo mv ntfy_2.21.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.21.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
=== "arm64"
```bash
sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service
```
Then notify systemd we have added a new service and start the service:
```bash
sudo systemctl daemon-reload
sudo systemctl start ntfy
```
#### On OpenRC systems
Install the ntfy server service script:
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.21.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv6"
```bash
sudo mv ntfy_2.21.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.21.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
=== "arm64"
```bash
sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy
```
Start the ntfy server service:
```bash
sudo rc-service ntfy start
```
Add the ntfy server service to the default runlevel (so that it starts at boot time):
```bash
sudo rc-update add ntfy default
```
## Debian/Ubuntu repository ## Debian/Ubuntu repository
!!! info !!! info
@@ -116,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -124,7 +212,7 @@ Manually installing the .deb file:
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -132,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -140,7 +228,7 @@ Manually installing the .deb file:
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -150,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv6" === "armv6"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "arm64" === "arm64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
@@ -213,18 +301,18 @@ pkg install go-ntfy
## macOS ## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz), To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash ```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz > ntfy_2.19.2_darwin_all.tar.gz curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_darwin_all.tar.gz > ntfy_2.21.0_darwin_all.tar.gz
tar zxvf ntfy_2.19.2_darwin_all.tar.gz tar zxvf ntfy_2.21.0_darwin_all.tar.gz
sudo cp -a ntfy_2.19.2_darwin_all/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.21.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.19.2_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml cp ntfy_2.21.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help ntfy --help
``` ```
@@ -245,7 +333,7 @@ brew install ntfy
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service. The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
To install, you can either To install, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_windows_amd64.zip), * [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy` * Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
+11 -5
View File
@@ -3213,12 +3213,18 @@ You can forward messages to e-mail by specifying an address in the header. This
you'd like to persist longer, or to blast-notify yourself on all possible channels. you'd like to persist longer, or to blast-notify yourself on all possible channels.
Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`). Usage is easy: Simply pass the `X-Email` header (or any of its aliases: `X-E-mail`, `Email`, `E-mail`, `Mail`, or `e`).
Only one e-mail address is supported. Only one e-mail address is supported. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled (ntfy.sh has this enabled),
you can also pass `yes`, `true`, or `1` to send to your first verified email address.
Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see [limitations](#limitations)). In the ntfy allows anonymous email sending (if enabled), so the rate limiting is pretty strict (see [limitations](#limitations)). In the
default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of
that, your IP address appears in the e-mail body. This is to prevent abuse. that, your IP address appears in the e-mail body. This is to prevent abuse.
!!! info
On ntfy.sh, anonymous email sending was disabled due to abuse. To use the email notification feature,
you must verify your email in the web app's [Account section](https://ntfy.sh/account). The daily limit for
free users is **5 emails per visitor per day**.
=== "Command line (curl)" === "Command line (curl)"
``` ```
curl \ curl \
@@ -3658,7 +3664,7 @@ all the supported fields:
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | | `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment | | `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery | | `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications | | `email` | - | *e-mail address or 'yes'* | `phil@example.com` or `yes` | E-mail address for e-mail notifications, or `yes` to use first verified address |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) | | `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | | `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
@@ -4871,7 +4877,7 @@ table in their canonical form.
| `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body | | `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body |
| `X-Icon` | `Icon` | URL to use as notification [icon](#icons) | | `X-Icon` | `Icon` | URL to use as notification [icon](#icons) |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client | | `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address (or `yes`) for [e-mail notifications](#e-mail-notifications) |
| `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) | | `X-Call` | `Call` | Phone number for [phone calls](#phone-calls) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
+51 -8
View File
@@ -6,13 +6,59 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date | | Component | Version | Release date |
|------------------|---------|--------------| |------------------|---------|--------------|
| ntfy server | v2.19.2 | Mar 16, 2026 | | ntfy server | v2.21.0 | Mar 30, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.3 | Nov 26, 2023 | | ntfy iOS app | v1.3 | Nov 26, 2023 |
Please check out the release notes for [upcoming releases](#not-released-yet) below. Please check out the release notes for [upcoming releases](#not-released-yet) below.
### ntfy server v2.19.2 ### ntfy server v2.21.0
Released March 30, 2026
This release adds the ability to verify email addresses using the `smtp-sender-verify` flag. This is a change that is
required because ntfy.sh was used to send unsolicited emails and the AWS SES account was suspended. Going forward,
ntfy.sh won't be able to send emails unless the email address was verified ahead of time.
**Features:**
* Add verified email recipients feature with `smtp-sender-verify` config flag, allowing server admins to require email
address verification before sending email notifications ([#1681](https://github.com/binwiederhier/ntfy/pull/1681))
### ntfy server v2.20.1
Released March 27, 2026
This is a small bugfix release that only affects high volume S3 backends that struggle with HTTP/2.
**Bug fixes + maintenance:**
* [Attachments](config.md#attachments): Add `disable_http2=true` S3 URL option to work around HTTP/2 stream errors with DigitalOcean Spaces and other S3-compatible providers ([#1678](https://github.com/binwiederhier/ntfy/issues/1678)/[#1679](https://github.com/binwiederhier/ntfy/pull/1679))
### ntfy server v2.20.0
Released March 26, 2026
This release is another step towards making it possible to help scale ntfy up and out 🔥! With this release, you can store
attachments in an S3-compatible object store as an alterative to the directory. See [attachment store](config.md#attachments)
for details.
!!! warning
With this release, ntfy will take full control over the attachment directory or S3 bucket. Files/objects in the configured `attachment-cache-dir`
that match the message ID format (12 chars, matching `^[A-Za-z0-9]{12}$`), and have no entries in the message database will be deleted.
**Do not use a directory or S3 bucket as `attachment-cache-dir` that is also used for something else.**
This is a small behavioral change that was necessary because the old logic often left attachments behind and would not clean them
up. Unless you have re-used the attachment directory for anything else (which is hopefully never done), this should not affect
you at all.
**Features:**
* Add S3-compatible object storage as an alternative [attachment store](config.md#attachments) via `attachment-cache-dir` config option ([#1656](https://github.com/binwiederhier/ntfy/pull/1656)/[#1672](https://github.com/binwiederhier/ntfy/pull/1672))
**Bug fixes + maintenance:**
* Reject invalid e-mail addresses (e.g. multiple comma-separated recipients) with HTTP 400
* Add OpenRC init service file ([#1650](https://github.com/binwiederhier/ntfy/pull/1650), thanks to [@ageru](https://github.com/ageru) for the contribution)
## ntfy server v2.19.2
Released March 16, 2026 Released March 16, 2026
This is another small bugfix release for PostgreSQL, avoiding races between primary and read replica, as well as to This is another small bugfix release for PostgreSQL, avoiding races between primary and read replica, as well as to
@@ -1798,12 +1844,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy server v2.20.x (UNRELEASED) ## ntfy Android v1.25.x (UNRELEASED)
**Features:** **Features:**
* Add S3-compatible object storage as an alternative [attachment store](config.md#attachments) via `attachment-cache-dir` config option * Add configurable "Alert when connection is lost" setting ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Suppress connection alerts and stop foreground service when there is no network ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution)
**Bug fixes + maintenance:**
* Reject invalid e-mail addresses (e.g. multiple comma-separated recipients) with HTTP 400
+6
View File
@@ -125,6 +125,7 @@
{ key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" }, { key: "smtp-sender-from", env: "NTFY_SMTP_SENDER_FROM", section: "smtp-out" },
{ key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" }, { key: "smtp-sender-user", env: "NTFY_SMTP_SENDER_USER", section: "smtp-out" },
{ key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, { key: "smtp-sender-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" },
{ key: "smtp-sender-verify", env: "NTFY_SMTP_SENDER_VERIFY", section: "smtp-out", type: "bool" },
{ key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" }, { key: "smtp-server-listen", env: "NTFY_SMTP_SERVER_LISTEN", section: "smtp-in" },
{ key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" }, { key: "smtp-server-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" },
{ key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, { key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" },
@@ -171,6 +172,7 @@
requireLoginHidden: modal.querySelector("#cg-require-login-hidden"), requireLoginHidden: modal.querySelector("#cg-require-login-hidden"),
signupHidden: modal.querySelector("#cg-enable-signup-hidden"), signupHidden: modal.querySelector("#cg-enable-signup-hidden"),
proxyCheckbox: modal.querySelector("#cg-behind-proxy"), proxyCheckbox: modal.querySelector("#cg-behind-proxy"),
smtpSenderVerifyHidden: modal.querySelector("#cg-smtp-sender-verify-hidden"),
dbStep: modal.querySelector("#cg-wizard-db"), dbStep: modal.querySelector("#cg-wizard-db"),
navDb: modal.querySelector("#cg-nav-database"), navDb: modal.querySelector("#cg-nav-database"),
navEmail: modal.querySelector("#cg-nav-email"), navEmail: modal.querySelector("#cg-nav-email"),
@@ -743,6 +745,10 @@
const signupYes = modal.querySelector("input[name=\"cg-enable-signup\"][value=\"yes\"]"); const signupYes = modal.querySelector("input[name=\"cg-enable-signup\"][value=\"yes\"]");
if (signupYes && signupHidden) signupHidden.checked = signupYes.checked; if (signupYes && signupHidden) signupHidden.checked = signupYes.checked;
// SMTP sender verify radio → hidden checkbox
const smtpVerifyYes = modal.querySelector("input[name=\"cg-smtp-sender-verify\"][value=\"yes\"]");
if (smtpVerifyYes && els.smtpSenderVerifyHidden) els.smtpSenderVerifyHidden.checked = smtpVerifyYes.checked;
return loginModeVal; return loginModeVal;
} }
+6 -6
View File
@@ -10,7 +10,7 @@ require (
github.com/emersion/go-smtp v0.18.0 github.com/emersion/go-smtp v0.18.0
github.com/gabriel-vasile/mimetype v1.4.13 github.com/gabriel-vasile/mimetype v1.4.13
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.37 github.com/mattn/go-sqlite3 v1.14.38
github.com/olebedev/when v1.1.0 github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7 github.com/urfave/cli/v2 v2.27.7
@@ -19,7 +19,7 @@ require (
golang.org/x/sync v0.20.0 golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0 golang.org/x/term v0.41.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
google.golang.org/api v0.272.0 google.golang.org/api v0.273.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
@@ -30,7 +30,7 @@ require github.com/pkg/errors v0.9.1 // indirect
require ( require (
firebase.google.com/go/v4 v4.19.0 firebase.google.com/go/v4 v4.19.0
github.com/SherClockHolmes/webpush-go v1.4.0 github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.9.0 github.com/jackc/pgx/v5 v5.9.1
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/stripe/stripe-go/v74 v74.30.0 github.com/stripe/stripe-go/v74 v74.30.0
@@ -41,10 +41,10 @@ require (
require ( require (
cel.dev/expr v0.25.1 // indirect cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 // indirect cloud.google.com/go/auth v0.19.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect cloud.google.com/go/iam v1.6.0 // indirect
cloud.google.com/go/longrunning v0.8.0 // indirect cloud.google.com/go/longrunning v0.8.0 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect cloud.google.com/go/monitoring v1.24.3 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect
@@ -70,7 +70,7 @@ require (
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.19.0 // indirect github.com/googleapis/gax-go/v2 v2.20.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+12 -12
View File
@@ -2,16 +2,16 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 h1:PJt3KrySfZkKdcEV2wlyNkfAPbMZGjtnv5oLrT4tWPg= cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ=
cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568/go.mod h1:/Tt0rLCp4FHXEBtdyYqvIZPcJzbpJ/fmqtgIaXseDK4= cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM= cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= cloud.google.com/go/iam v1.6.0 h1:JiSIcEi38dWBKhB3BtfKCW+dMvCZJEhBA2BsaGJgoxs=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= cloud.google.com/go/iam v1.6.0/go.mod h1:ZS6zEy7QHmcNO18mjO2viYv/n+wOUkhJqGNkPPGueGU=
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA= cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
@@ -98,8 +98,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= github.com/googleapis/gax-go/v2 v2.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs=
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -108,8 +108,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE= github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
@@ -120,8 +120,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -272,8 +272,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ=
google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
+154
View File
@@ -0,0 +1,154 @@
package mail
import (
"fmt"
"mime"
"net"
"net/smtp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
const (
verifyCodeExpiry = 10 * time.Minute
verifyCodeLength = 6
verifyCodeSubject = "ntfy email verification"
)
// Config holds the SMTP configuration for the mail sender
type Config struct {
SMTPAddr string // SMTP server address (host:port)
SMTPUser string // SMTP auth username
SMTPPass string // SMTP auth password
From string // Sender email address
}
// Sender sends emails and manages email verification codes
type Sender struct {
config *Config
codes map[string]verifyCode // Verification codes, keyed by email
mu sync.Mutex
closeChan chan struct{}
}
type verifyCode struct {
code string
expires time.Time
}
// NewSender creates a new mail Sender with the given SMTP config
func NewSender(config *Config) *Sender {
s := &Sender{
config: config,
codes: make(map[string]verifyCode),
closeChan: make(chan struct{}),
}
go s.expireLoop()
return s
}
// Close stops the background expiry loop
func (s *Sender) Close() {
close(s.closeChan)
}
// Addr returns the SMTP server address
func (s *Sender) Addr() string {
return s.config.SMTPAddr
}
// User returns the SMTP username
func (s *Sender) User() string {
return s.config.SMTPUser
}
// From returns the sender email address
func (s *Sender) From() string {
return s.config.From
}
// SendRaw sends a raw email message via SMTP
func (s *Sender) SendRaw(to string, message []byte) error {
host, _, err := net.SplitHostPort(s.config.SMTPAddr)
if err != nil {
return err
}
var auth smtp.Auth
if s.config.SMTPUser != "" {
auth = smtp.PlainAuth("", s.config.SMTPUser, s.config.SMTPPass, host)
}
return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, message)
}
// Send sends a plain text email via SMTP
func (s *Sender) Send(to, subject, body string) error {
date := time.Now().UTC().Format(time.RFC1123Z)
encodedSubject := mime.BEncoding.Encode("utf-8", subject)
message := `From: ntfy <{from}>
To: {to}
Date: {date}
Subject: {subject}
Content-Type: text/plain; charset="utf-8"
{body}`
message = strings.ReplaceAll(message, "{from}", s.config.From)
message = strings.ReplaceAll(message, "{to}", to)
message = strings.ReplaceAll(message, "{date}", date)
message = strings.ReplaceAll(message, "{subject}", encodedSubject)
message = strings.ReplaceAll(message, "{body}", body)
log.Tag("mail").Field("email_to", to).Debug("Sending email")
return s.SendRaw(to, []byte(message))
}
// SendVerification generates a random code, stores it in-memory, and sends a verification email
func (s *Sender) SendVerification(to string) error {
code := util.RandomString(verifyCodeLength)
s.mu.Lock()
s.codes[to] = verifyCode{
code: code,
expires: time.Now().Add(verifyCodeExpiry),
}
s.mu.Unlock()
body := fmt.Sprintf("Your ntfy email verification code is: %s\n\nThis code expires in 10 minutes.", code)
return s.Send(to, verifyCodeSubject, body)
}
// CheckVerification checks if the code matches and hasn't expired. Removes the entry on success.
func (s *Sender) CheckVerification(email, code string) bool {
s.mu.Lock()
defer s.mu.Unlock()
vc, ok := s.codes[email]
if !ok || time.Now().After(vc.expires) || vc.code != code {
return false
}
delete(s.codes, email)
return true
}
func (s *Sender) expireLoop() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.expireVerificationCodes()
case <-s.closeChan:
return
}
}
}
func (s *Sender) expireVerificationCodes() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for email, vc := range s.codes {
if now.After(vc.expires) {
delete(s.codes, email)
}
}
}
+44 -56
View File
@@ -24,7 +24,6 @@ var errNoRows = errors.New("no rows found")
// queries holds the database-specific SQL queries // queries holds the database-specific SQL queries
type queries struct { type queries struct {
insertMessage string insertMessage string
deleteMessage string
selectScheduledMessageIDsBySeqID string selectScheduledMessageIDsBySeqID string
deleteScheduledBySequenceID string deleteScheduledBySequenceID string
updateMessagesForTopicExpiry string updateMessagesForTopicExpiry string
@@ -35,18 +34,17 @@ type queries struct {
selectMessagesSinceIDScheduled string selectMessagesSinceIDScheduled string
selectMessagesLatest string selectMessagesLatest string
selectMessagesDue string selectMessagesDue string
selectMessagesExpired string deleteExpiredMessages string
updateMessagePublished string updateMessagePublished string
selectMessagesCount string selectMessagesCount string
selectTopics string selectTopics string
updateAttachmentDeleted string markExpiredAttachmentsDeleted string
selectAttachmentsExpired string
selectAttachmentsSizeBySender string selectAttachmentsSizeBySender string
selectAttachmentsSizeByUserID string selectAttachmentsSizeByUserID string
selectAttachmentsWithSizes string
selectStats string selectStats string
updateStats string updateStats string
updateMessageTime string updateMessageTime string
selectAttachmentIDs string
} }
// Cache stores published messages // Cache stores published messages
@@ -246,14 +244,16 @@ func (c *Cache) MessagesDue() ([]*model.Message, error) {
return readMessages(rows) return readMessages(rows)
} }
// MessagesExpired returns a list of IDs for messages that have expired (should be deleted) // DeleteExpiredMessages deletes up to `limit` expired messages in a single query
func (c *Cache) MessagesExpired() ([]string, error) { // and returns the number of deleted rows.
rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix()) func (c *Cache) DeleteExpiredMessages(limit int) (int64, error) {
c.maybeLock()
defer c.maybeUnlock()
result, err := c.db.Exec(c.queries.deleteExpiredMessages, time.Now().Unix(), limit)
if err != nil { if err != nil {
return nil, err return 0, err
} }
defer rows.Close() return result.RowsAffected()
return readStrings(rows)
} }
// Message returns the message with the given ID, or ErrMessageNotFound if not found // Message returns the message with the given ID, or ErrMessageNotFound if not found
@@ -262,10 +262,10 @@ func (c *Cache) Message(id string) (*model.Message, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer rows.Close()
if !rows.Next() { if !rows.Next() {
return nil, model.ErrMessageNotFound return nil, model.ErrMessageNotFound
} }
defer rows.Close()
return readMessage(rows) return readMessage(rows)
} }
@@ -312,20 +312,6 @@ func (c *Cache) Topics() ([]string, error) {
return readStrings(rows) return readStrings(rows)
} }
// DeleteMessages deletes the messages with the given IDs
func (c *Cache) DeleteMessages(ids ...string) error {
c.maybeLock()
defer c.maybeUnlock()
return db.ExecTx(c.db, func(tx *sql.Tx) error {
for _, id := range ids {
if _, err := tx.Exec(c.queries.deleteMessage, id); err != nil {
return err
}
}
return nil
})
}
// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID. // DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.
// It returns the message IDs of the deleted messages, which can be used to clean up attachment files. // It returns the message IDs of the deleted messages, which can be used to clean up attachment files.
func (c *Cache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) { func (c *Cache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {
@@ -363,38 +349,16 @@ func (c *Cache) ExpireMessages(topics ...string) error {
}) })
} }
// AttachmentIDs returns message IDs with active (non-expired, non-deleted) attachments // MarkExpiredAttachmentsDeleted marks up to `limit` expired attachments as deleted in a single
func (c *Cache) AttachmentIDs() ([]string, error) { // query and returns the number of updated rows.
rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentIDs, time.Now().Unix()) func (c *Cache) MarkExpiredAttachmentsDeleted(limit int) (int64, error) {
if err != nil {
return nil, err
}
defer rows.Close()
return readStrings(rows)
}
// AttachmentsExpired returns message IDs with expired attachments that have not been deleted
func (c *Cache) AttachmentsExpired() ([]string, error) {
rows, err := c.db.Query(c.queries.selectAttachmentsExpired, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
return readStrings(rows)
}
// MarkAttachmentsDeleted marks the attachments for the given message IDs as deleted
func (c *Cache) MarkAttachmentsDeleted(ids ...string) error {
c.maybeLock() c.maybeLock()
defer c.maybeUnlock() defer c.maybeUnlock()
return db.ExecTx(c.db, func(tx *sql.Tx) error { result, err := c.db.Exec(c.queries.markExpiredAttachmentsDeleted, time.Now().Unix(), limit)
for _, id := range ids { if err != nil {
if _, err := tx.Exec(c.queries.updateAttachmentDeleted, id); err != nil { return 0, err
return err }
} return result.RowsAffected()
}
return nil
})
} }
// AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender // AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender
@@ -415,6 +379,30 @@ func (c *Cache) AttachmentBytesUsedByUser(userID string) (int64, error) {
return c.readAttachmentBytesUsed(rows) return c.readAttachmentBytesUsed(rows)
} }
// AttachmentsWithSizes returns a map of message ID to attachment size for all active
// (non-expired, non-deleted) attachments. This is used to hydrate the attachment store's
// size tracking on startup and during periodic sync.
func (c *Cache) AttachmentsWithSizes() (map[string]int64, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentsWithSizes, time.Now().Unix())
if err != nil {
return nil, err
}
defer rows.Close()
attachments := make(map[string]int64)
for rows.Next() {
var id string
var size int64
if err := rows.Scan(&id, &size); err != nil {
return nil, err
}
attachments[id] = size
}
if err := rows.Err(); err != nil {
return nil, err
}
return attachments, nil
}
func (c *Cache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) { func (c *Cache) readAttachmentBytesUsed(rows *sql.Rows) (int64, error) {
defer rows.Close() defer rows.Close()
var size int64 var size int64
+6 -11
View File
@@ -12,7 +12,6 @@ const (
INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published) INSERT INTO message (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user_id, content_type, encoding, published)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
` `
postgresDeleteMessageQuery = `DELETE FROM message WHERE mid = $1`
postgresSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` postgresSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE`
postgresDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` postgresDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE`
postgresUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2` postgresUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2`
@@ -61,26 +60,23 @@ const (
WHERE time <= $1 AND published = FALSE WHERE time <= $1 AND published = FALSE
ORDER BY time, id ORDER BY time, id
` `
postgresSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE`
postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1` postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`
postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message` postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message`
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic` postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
postgresUpdateAttachmentDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1` postgresDeleteExpiredMessagesQuery = `DELETE FROM message WHERE mid IN (SELECT mid FROM message WHERE expires <= $1 AND published = TRUE LIMIT $2)`
postgresSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE` postgresMarkExpiredAttachmentsDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid IN (SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE LIMIT $2)`
postgresSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2` postgresSelectAttachmentsSizeBySenderQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = '' AND sender = $1 AND attachment_expires >= $2`
postgresSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2` postgresSelectAttachmentsSizeByUserIDQuery = `SELECT COALESCE(SUM(attachment_size), 0) FROM message WHERE user_id = $1 AND attachment_expires >= $2`
postgresSelectAttachmentsWithSizesQuery = `SELECT mid, attachment_size FROM message WHERE attachment_expires > $1 AND attachment_deleted = FALSE`
postgresSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` postgresSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'`
postgresUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` postgresUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'`
postgresUpdateMessageTimeQuery = `UPDATE message SET time = $1 WHERE mid = $2` postgresUpdateMessageTimeQuery = `UPDATE message SET time = $1 WHERE mid = $2`
postgresSelectAttachmentIDsQuery = `SELECT mid FROM message WHERE attachment_expires > $1 AND attachment_deleted = FALSE`
) )
var postgresQueries = queries{ var postgresQueries = queries{
insertMessage: postgresInsertMessageQuery, insertMessage: postgresInsertMessageQuery,
deleteMessage: postgresDeleteMessageQuery,
selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery, selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery,
deleteScheduledBySequenceID: postgresDeleteScheduledBySequenceIDQuery, deleteScheduledBySequenceID: postgresDeleteScheduledBySequenceIDQuery,
updateMessagesForTopicExpiry: postgresUpdateMessagesForTopicExpiryQuery, updateMessagesForTopicExpiry: postgresUpdateMessagesForTopicExpiryQuery,
@@ -91,18 +87,17 @@ var postgresQueries = queries{
selectMessagesSinceIDScheduled: postgresSelectMessagesSinceIDIncludeScheduledQuery, selectMessagesSinceIDScheduled: postgresSelectMessagesSinceIDIncludeScheduledQuery,
selectMessagesLatest: postgresSelectMessagesLatestQuery, selectMessagesLatest: postgresSelectMessagesLatestQuery,
selectMessagesDue: postgresSelectMessagesDueQuery, selectMessagesDue: postgresSelectMessagesDueQuery,
selectMessagesExpired: postgresSelectMessagesExpiredQuery, deleteExpiredMessages: postgresDeleteExpiredMessagesQuery,
updateMessagePublished: postgresUpdateMessagePublishedQuery, updateMessagePublished: postgresUpdateMessagePublishedQuery,
selectMessagesCount: postgresSelectMessagesCountQuery, selectMessagesCount: postgresSelectMessagesCountQuery,
selectTopics: postgresSelectTopicsQuery, selectTopics: postgresSelectTopicsQuery,
updateAttachmentDeleted: postgresUpdateAttachmentDeletedQuery, markExpiredAttachmentsDeleted: postgresMarkExpiredAttachmentsDeletedQuery,
selectAttachmentsExpired: postgresSelectAttachmentsExpiredQuery,
selectAttachmentsSizeBySender: postgresSelectAttachmentsSizeBySenderQuery, selectAttachmentsSizeBySender: postgresSelectAttachmentsSizeBySenderQuery,
selectAttachmentsSizeByUserID: postgresSelectAttachmentsSizeByUserIDQuery, selectAttachmentsSizeByUserID: postgresSelectAttachmentsSizeByUserIDQuery,
selectAttachmentsWithSizes: postgresSelectAttachmentsWithSizesQuery,
selectStats: postgresSelectStatsQuery, selectStats: postgresSelectStatsQuery,
updateStats: postgresUpdateStatsQuery, updateStats: postgresUpdateStatsQuery,
updateMessageTime: postgresUpdateMessageTimeQuery, updateMessageTime: postgresUpdateMessageTimeQuery,
selectAttachmentIDs: postgresSelectAttachmentIDsQuery,
} }
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. // NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.
+42 -4
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
) )
// Initial PostgreSQL schema // Initial PostgreSQL schema
@@ -41,6 +42,7 @@ const (
CREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id); CREATE INDEX IF NOT EXISTS idx_message_sequence_id ON message (sequence_id);
CREATE INDEX IF NOT EXISTS idx_message_topic_published_time ON message (topic, published, time, id); CREATE INDEX IF NOT EXISTS idx_message_topic_published_time ON message (topic, published, time, id);
CREATE INDEX IF NOT EXISTS idx_message_published_expires ON message (published, expires); CREATE INDEX IF NOT EXISTS idx_message_published_expires ON message (published, expires);
CREATE INDEX IF NOT EXISTS idx_message_attachment_expires ON message (attachment_expires) WHERE attachment_deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = ''; CREATE INDEX IF NOT EXISTS idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';
CREATE INDEX IF NOT EXISTS idx_message_user_id_attachment_expires ON message (user_id, attachment_expires); CREATE INDEX IF NOT EXISTS idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);
CREATE TABLE IF NOT EXISTS message_stats ( CREATE TABLE IF NOT EXISTS message_stats (
@@ -57,21 +59,57 @@ const (
// PostgreSQL schema management queries // PostgreSQL schema management queries
const ( const (
postgresCurrentSchemaVersion = 14 postgresCurrentSchemaVersion = 15
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)` postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)`
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'message'`
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'` postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'`
) )
func setupPostgres(db *sql.DB) error { // PostgreSQL schema migrations
const (
// 14 -> 15
postgresMigrate14To15CreateIndexQuery = `
CREATE INDEX IF NOT EXISTS idx_message_attachment_expires ON message (attachment_expires) WHERE attachment_deleted = FALSE;
`
)
var postgresMigrations = map[int]func(d *sql.DB) error{
14: postgresMigrateFrom14,
}
func setupPostgres(d *sql.DB) error {
var schemaVersion int var schemaVersion int
if err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { if err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
return setupNewPostgresDB(db) return setupNewPostgresDB(d)
} else if schemaVersion == postgresCurrentSchemaVersion {
return nil
} else if schemaVersion > postgresCurrentSchemaVersion { } else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion) return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
} }
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
fn, ok := postgresMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(d); err != nil {
return err
}
}
return nil return nil
} }
func postgresMigrateFrom14(d *sql.DB) error {
log.Tag(tagMessageCache).Info("Migrating message cache database schema: from 14 to 15")
return db.ExecTx(d, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresMigrate14To15CreateIndexQuery); err != nil {
return err
}
if _, err := tx.Exec(postgresUpdateSchemaVersionQuery, 15); err != nil {
return err
}
return nil
})
}
func setupNewPostgresDB(sqlDB *sql.DB) error { func setupNewPostgresDB(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error { return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
+6 -11
View File
@@ -18,7 +18,6 @@ const (
INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published) INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
sqliteDeleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
sqliteSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` sqliteSelectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
sqliteDeleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0` sqliteDeleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
sqliteUpdateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` sqliteUpdateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
@@ -64,26 +63,23 @@ const (
WHERE time <= ? AND published = 0 WHERE time <= ? AND published = 0
ORDER BY time, id ORDER BY time, id
` `
sqliteSelectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages` sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
sqliteUpdateAttachmentDeletedQuery = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` sqliteDeleteExpiredMessagesQuery = `DELETE FROM messages WHERE mid IN (SELECT mid FROM messages WHERE expires <= ? AND published = 1 LIMIT ?)`
sqliteSelectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` sqliteMarkExpiredAttachmentsDeletedQuery = `UPDATE messages SET attachment_deleted = 1 WHERE mid IN (SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0 LIMIT ?)`
sqliteSelectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?` sqliteSelectAttachmentsSizeBySenderQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = '' AND sender = ? AND attachment_expires >= ?`
sqliteSelectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?` sqliteSelectAttachmentsSizeByUserIDQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE user = ? AND attachment_expires >= ?`
sqliteSelectAttachmentsWithSizesQuery = `SELECT mid, attachment_size FROM messages WHERE attachment_expires > ? AND attachment_deleted = 0`
sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'`
sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'`
sqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?` sqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?`
sqliteSelectAttachmentIDsQuery = `SELECT mid FROM messages WHERE attachment_expires > ? AND attachment_deleted = 0`
) )
var sqliteQueries = queries{ var sqliteQueries = queries{
insertMessage: sqliteInsertMessageQuery, insertMessage: sqliteInsertMessageQuery,
deleteMessage: sqliteDeleteMessageQuery,
selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery, selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery,
deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery, deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery,
updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery, updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery,
@@ -94,18 +90,17 @@ var sqliteQueries = queries{
selectMessagesSinceIDScheduled: sqliteSelectMessagesSinceIDIncludeScheduledQuery, selectMessagesSinceIDScheduled: sqliteSelectMessagesSinceIDIncludeScheduledQuery,
selectMessagesLatest: sqliteSelectMessagesLatestQuery, selectMessagesLatest: sqliteSelectMessagesLatestQuery,
selectMessagesDue: sqliteSelectMessagesDueQuery, selectMessagesDue: sqliteSelectMessagesDueQuery,
selectMessagesExpired: sqliteSelectMessagesExpiredQuery, deleteExpiredMessages: sqliteDeleteExpiredMessagesQuery,
updateMessagePublished: sqliteUpdateMessagePublishedQuery, updateMessagePublished: sqliteUpdateMessagePublishedQuery,
selectMessagesCount: sqliteSelectMessagesCountQuery, selectMessagesCount: sqliteSelectMessagesCountQuery,
selectTopics: sqliteSelectTopicsQuery, selectTopics: sqliteSelectTopicsQuery,
updateAttachmentDeleted: sqliteUpdateAttachmentDeletedQuery, markExpiredAttachmentsDeleted: sqliteMarkExpiredAttachmentsDeletedQuery,
selectAttachmentsExpired: sqliteSelectAttachmentsExpiredQuery,
selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery, selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery,
selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery, selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery,
selectAttachmentsWithSizes: sqliteSelectAttachmentsWithSizesQuery,
selectStats: sqliteSelectStatsQuery, selectStats: sqliteSelectStatsQuery,
updateStats: sqliteUpdateStatsQuery, updateStats: sqliteUpdateStatsQuery,
updateMessageTime: sqliteUpdateMessageTimeQuery, updateMessageTime: sqliteUpdateMessageTimeQuery,
selectAttachmentIDs: sqliteSelectAttachmentIDsQuery,
} }
// NewSQLiteStore creates a SQLite file-backed cache // NewSQLiteStore creates a SQLite file-backed cache
+14 -1
View File
@@ -57,7 +57,7 @@ const (
// Schema version management for SQLite // Schema version management for SQLite
const ( const (
sqliteCurrentSchemaVersion = 14 sqliteCurrentSchemaVersion = 15
sqliteCreateSchemaVersionTableQuery = ` sqliteCreateSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion ( CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY, id INT PRIMARY KEY,
@@ -208,6 +208,7 @@ var (
11: sqliteMigrateFrom11, 11: sqliteMigrateFrom11,
12: sqliteMigrateFrom12, 12: sqliteMigrateFrom12,
13: sqliteMigrateFrom13, 13: sqliteMigrateFrom13,
14: sqliteMigrateFrom14,
} }
) )
@@ -451,3 +452,15 @@ func sqliteMigrateFrom13(sqlDB *sql.DB, _ time.Duration) error {
return nil return nil
}) })
} }
// sqliteMigrateFrom14 is a no-op; the corresponding Postgres migration adds
// idx_message_attachment_expires, which SQLite already has from the initial schema.
func sqliteMigrateFrom14(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 14 to 15")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 15); err != nil {
return err
}
return nil
})
}
+2 -2
View File
@@ -209,7 +209,7 @@ func TestSqliteStore_Migration_From9(t *testing.T) {
require.True(t, rows.Next()) require.True(t, rows.Next())
var version int var version int
require.Nil(t, rows.Scan(&version)) require.Nil(t, rows.Scan(&version))
require.Equal(t, 14, version) require.Equal(t, 15, version)
require.Nil(t, rows.Close()) require.Nil(t, rows.Close())
messages, err := s.Messages("mytopic", model.SinceAllMessages, false) messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
@@ -287,6 +287,6 @@ func checkSqliteSchemaVersion(t *testing.T, filename string) {
require.True(t, rows.Next()) require.True(t, rows.Next())
var schemaVersion int var schemaVersion int
require.Nil(t, rows.Scan(&schemaVersion)) require.Nil(t, rows.Scan(&schemaVersion))
require.Equal(t, 14, schemaVersion) require.Equal(t, 15, schemaVersion)
require.Nil(t, rows.Close()) require.Nil(t, rows.Close())
} }
+11 -29
View File
@@ -3,7 +3,6 @@ package message_test
import ( import (
"net/netip" "net/netip"
"path/filepath" "path/filepath"
"sort"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -274,9 +273,9 @@ func TestStore_Prune(t *testing.T) {
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 3, count) require.Equal(t, 3, count)
expiredMessageIDs, err := s.MessagesExpired() deleted, err := s.DeleteExpiredMessages(10)
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, s.DeleteMessages(expiredMessageIDs...)) require.Equal(t, int64(2), deleted)
count, err = s.MessagesCount() count, err = s.MessagesCount()
require.Nil(t, err) require.Nil(t, err)
@@ -414,10 +413,9 @@ func TestStore_AttachmentsExpired(t *testing.T) {
} }
require.Nil(t, s.AddMessage(m)) require.Nil(t, s.AddMessage(m))
ids, err := s.AttachmentsExpired() count, err := s.MarkExpiredAttachmentsDeleted(10)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 1, len(ids)) require.Equal(t, int64(1), count)
require.Equal(t, "m4", ids[0])
}) })
} }
@@ -583,13 +581,9 @@ func TestStore_ExpireMessages(t *testing.T) {
require.Nil(t, s.ExpireMessages("topic1")) require.Nil(t, s.ExpireMessages("topic1"))
// topic1 messages should now be expired (expires set to past) // topic1 messages should now be expired (expires set to past)
expiredIDs, err := s.MessagesExpired() deleted, err := s.DeleteExpiredMessages(100)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 2, len(expiredIDs)) require.Equal(t, int64(2), deleted)
sort.Strings(expiredIDs)
expectedIDs := []string{m1.ID, m2.ID}
sort.Strings(expectedIDs)
require.Equal(t, expectedIDs, expiredIDs)
// topic2 should be unaffected // topic2 should be unaffected
messages, err = s.Messages("topic2", model.SinceAllMessages, false) messages, err = s.Messages("topic2", model.SinceAllMessages, false)
@@ -629,27 +623,15 @@ func TestStore_MarkAttachmentsDeleted(t *testing.T) {
} }
require.Nil(t, s.AddMessage(m2)) require.Nil(t, s.AddMessage(m2))
// Both should show as expired attachments needing cleanup // Both should be marked as deleted in one batch
ids, err := s.AttachmentsExpired() count, err := s.MarkExpiredAttachmentsDeleted(10)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 2, len(ids)) require.Equal(t, int64(2), count)
// Mark msg1's attachment as deleted (file cleaned up)
require.Nil(t, s.MarkAttachmentsDeleted("msg1"))
// Now only msg2 should show as needing cleanup
ids, err = s.AttachmentsExpired()
require.Nil(t, err)
require.Equal(t, 1, len(ids))
require.Equal(t, "msg2", ids[0])
// Mark msg2 too
require.Nil(t, s.MarkAttachmentsDeleted("msg2"))
// No more expired attachments to clean up // No more expired attachments to clean up
ids, err = s.AttachmentsExpired() count, err = s.MarkExpiredAttachmentsDeleted(10)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, 0, len(ids)) require.Equal(t, int64(0), count)
// Messages themselves still exist // Messages themselves still exist
messages, err := s.Messages("mytopic", model.SinceAllMessages, false) messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
+13 -8
View File
@@ -19,8 +19,8 @@ const (
PollRequestEvent = "poll_request" PollRequestEvent = "poll_request"
) )
// MessageIDLength is the length of a randomly generated message ID // messageIDLength is the length of a randomly generated message ID
const MessageIDLength = 12 const messageIDLength = 12
// Errors for message operations // Errors for message operations
var ( var (
@@ -133,10 +133,20 @@ func NewAction() *Action {
} }
} }
// GenerateMessageID creates a new random message ID
func GenerateMessageID() string {
return util.RandomString(messageIDLength)
}
// ValidMessageID returns true if the given string is a valid message ID
func ValidMessageID(s string) bool {
return util.ValidRandomString(s, messageIDLength)
}
// NewMessage creates a new message with the current timestamp // NewMessage creates a new message with the current timestamp
func NewMessage(event, topic, msg string) *Message { func NewMessage(event, topic, msg string) *Message {
return &Message{ return &Message{
ID: util.RandomString(MessageIDLength), ID: GenerateMessageID(),
Time: time.Now().Unix(), Time: time.Now().Unix(),
Event: event, Event: event,
Topic: topic, Topic: topic,
@@ -173,11 +183,6 @@ func NewPollRequestMessage(topic, pollID string) *Message {
return m return m
} }
// ValidMessageID returns true if the given string is a valid message ID
func ValidMessageID(s string) bool {
return util.ValidRandomString(s, MessageIDLength)
}
// SinceMarker represents a point in time or message ID from which to retrieve messages // SinceMarker represents a point in time or message ID from which to retrieve messages
type SinceMarker struct { type SinceMarker struct {
time time.Time time time.Time
+23 -1
View File
@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"crypto/md5" //nolint:gosec // MD5 is required by the S3 protocol for Content-MD5 headers "crypto/md5" //nolint:gosec // MD5 is required by the S3 protocol for Content-MD5 headers
"crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/xml" "encoding/xml"
"errors" "errors"
@@ -61,7 +62,11 @@ type Client struct {
func New(config *Config) *Client { func New(config *Config) *Client {
httpClient := config.HTTPClient httpClient := config.HTTPClient
if httpClient == nil { if httpClient == nil {
httpClient = http.DefaultClient if config.DisableHTTP2 {
httpClient = newHTTP1Client()
} else {
httpClient = http.DefaultClient
}
} }
return &Client{ return &Client{
config: config, config: config,
@@ -300,3 +305,20 @@ func (c *Client) do(ctx context.Context, op, method, reqURL string, body []byte,
} }
return respBody, nil return respBody, nil
} }
// newHTTP1Client creates an HTTP client that forces HTTP/1.1 by disabling HTTP/2
// ALPN negotiation. This works around HTTP/2 stream errors with some S3-compatible
// providers (e.g. DigitalOcean Spaces) that can cause non-retryable failures on
// streaming uploads when the server resets the stream mid-transfer.
// See https://github.com/rclone/rclone/issues/4673, https://github.com/golang/go/issues/42777
func newHTTP1Client() *http.Client {
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
ForceAttemptHTTP2: false,
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
},
}
}
+12
View File
@@ -92,6 +92,18 @@ func TestParseURL_EmptyBucket(t *testing.T) {
require.Contains(t, err.Error(), "bucket") require.Contains(t, err.Error(), "bucket")
} }
func TestParseURL_DisableHTTP2(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&disable_http2=true")
require.Nil(t, err)
require.True(t, cfg.DisableHTTP2)
}
func TestParseURL_DisableHTTP2_NotSet(t *testing.T) {
cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1")
require.Nil(t, err)
require.False(t, cfg.DisableHTTP2)
}
// --- Unit tests: URL construction --- // --- Unit tests: URL construction ---
func TestConfig_BucketURL_PathStyle(t *testing.T) { func TestConfig_BucketURL_PathStyle(t *testing.T) {
+9 -8
View File
@@ -11,14 +11,15 @@ import (
// Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string. // Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string.
type Config struct { type Config struct {
Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com" Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com"
PathStyle bool PathStyle bool
Bucket string Bucket string
Prefix string Prefix string
Region string Region string
AccessKey string AccessKey string
SecretKey string SecretKey string
HTTPClient *http.Client // if nil, http.DefaultClient is used DisableHTTP2 bool // Force HTTP/1.1 to work around HTTP/2 issues with some S3-compatible providers
HTTPClient *http.Client // if nil, a default client is created (respecting DisableHTTP2)
} }
// BucketURL returns the base URL for bucket-level operations. // BucketURL returns the base URL for bucket-level operations.
+13 -8
View File
@@ -10,6 +10,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"sort" "sort"
"strconv"
"strings" "strings"
) )
@@ -41,9 +42,11 @@ const (
// ParseURL parses an S3 URL of the form: // ParseURL parses an S3 URL of the form:
// //
// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] // s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]
// //
// When endpoint is specified, path-style addressing is enabled automatically. // When endpoint is specified, path-style addressing is enabled automatically.
// When disable_http2=true is set, the client forces HTTP/1.1 to work around
// HTTP/2 stream errors with some S3-compatible providers (e.g. DigitalOcean Spaces).
func ParseURL(s3URL string) (*Config, error) { func ParseURL(s3URL string) (*Config, error) {
u, err := url.Parse(s3URL) u, err := url.Parse(s3URL)
if err != nil { if err != nil {
@@ -80,14 +83,16 @@ func ParseURL(s3URL string) (*Config, error) {
endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region) endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region)
pathStyle = false pathStyle = false
} }
disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2"))
return &Config{ return &Config{
Endpoint: endpoint, Endpoint: endpoint,
PathStyle: pathStyle, PathStyle: pathStyle,
Bucket: bucket, Bucket: bucket,
Prefix: prefix, Prefix: prefix,
Region: region, Region: region,
AccessKey: accessKey, AccessKey: accessKey,
SecretKey: secretKey, SecretKey: secretKey,
DisableHTTP2: disableHTTP2,
}, nil }, nil
} }
+14 -5
View File
@@ -20,6 +20,7 @@ const (
DefaultCacheBatchTimeout = time.Duration(0) DefaultCacheBatchTimeout = time.Duration(0)
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
DefaultManagerInterval = time.Minute DefaultManagerInterval = time.Minute
DefaultManagerBatchSize = 30000
DefaultDelayedSenderInterval = 10 * time.Second DefaultDelayedSenderInterval = 10 * time.Second
DefaultMessageDelayMin = 10 * time.Second DefaultMessageDelayMin = 10 * time.Second
DefaultMessageDelayMax = 3 * 24 * time.Hour DefaultMessageDelayMax = 3 * 24 * time.Hour
@@ -46,11 +47,13 @@ const (
// - total topic limit: max number of topics overall // - total topic limit: max number of topics overall
// - various attachment limits // - various attachment limits
const ( const (
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
DefaultTotalTopicLimit = 15000 DefaultTotalTopicLimit = 15000
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
DefaultAttachmentExpiryDuration = 3 * time.Hour 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 // Defines all per-visitor limits
@@ -115,9 +118,11 @@ type Config struct {
AttachmentTotalSizeLimit int64 AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64 AttachmentFileSizeLimit int64
AttachmentExpiryDuration time.Duration AttachmentExpiryDuration time.Duration
AttachmentOrphanGracePeriod time.Duration
TemplateDir string // Directory to load named templates from TemplateDir string // Directory to load named templates from
KeepaliveInterval time.Duration KeepaliveInterval time.Duration
ManagerInterval time.Duration ManagerInterval time.Duration
ManagerBatchSize int
DisallowedTopics []string DisallowedTopics []string
WebRoot string // empty to disable WebRoot string // empty to disable
DelayedSenderInterval time.Duration DelayedSenderInterval time.Duration
@@ -130,6 +135,7 @@ type Config struct {
SMTPSenderUser string SMTPSenderUser string
SMTPSenderPass string SMTPSenderPass string
SMTPSenderFrom string SMTPSenderFrom string
SMTPSenderVerify bool
SMTPServerListen string SMTPServerListen string
SMTPServerDomain string SMTPServerDomain string
SMTPServerAddrPrefix string SMTPServerAddrPrefix string
@@ -217,9 +223,11 @@ func NewConfig() *Config {
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, AttachmentExpiryDuration: DefaultAttachmentExpiryDuration,
AttachmentOrphanGracePeriod: DefaultAttachmentOrphanGracePeriod,
TemplateDir: DefaultTemplateDir, TemplateDir: DefaultTemplateDir,
KeepaliveInterval: DefaultKeepaliveInterval, KeepaliveInterval: DefaultKeepaliveInterval,
ManagerInterval: DefaultManagerInterval, ManagerInterval: DefaultManagerInterval,
ManagerBatchSize: DefaultManagerBatchSize,
DisallowedTopics: DefaultDisallowedTopics, DisallowedTopics: DefaultDisallowedTopics,
WebRoot: "/", WebRoot: "/",
DelayedSenderInterval: DefaultDelayedSenderInterval, DelayedSenderInterval: DefaultDelayedSenderInterval,
@@ -232,6 +240,7 @@ func NewConfig() *Config {
SMTPSenderUser: "", SMTPSenderUser: "",
SMTPSenderPass: "", SMTPSenderPass: "",
SMTPSenderFrom: "", SMTPSenderFrom: "",
SMTPSenderVerify: false,
SMTPServerListen: "", SMTPServerListen: "",
SMTPServerDomain: "", SMTPServerDomain: "",
SMTPServerAddrPrefix: "", SMTPServerAddrPrefix: "",
+4
View File
@@ -143,6 +143,9 @@ var (
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "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} 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} errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestEmailVerificationCodeInvalid = &errHTTP{40051, http.StatusBadRequest, "invalid request: email verification code 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}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", 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} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
@@ -152,6 +155,7 @@ var (
errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil} errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil}
errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", 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} errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil}
errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil}
errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", 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} 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} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil}
+49
View File
@@ -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"
}
+47 -13
View File
@@ -36,6 +36,7 @@ import (
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/payments"
@@ -57,6 +58,7 @@ type Server struct {
smtpServer *smtp.Server smtpServer *smtp.Server
smtpServerBackend *smtpBackend smtpServerBackend *smtpBackend
smtpSender mailer smtpSender mailer
mailSender *mail.Sender
topics map[string]*topic topics map[string]*topic
visitors map[string]*visitor // ip:<ip> or user:<user> visitors map[string]*visitor // ip:<ip> or user:<user>
firebaseClient *firebaseClient firebaseClient *firebaseClient
@@ -112,6 +114,8 @@ var (
apiAccountReservationPath = "/v1/account/reservation" apiAccountReservationPath = "/v1/account/reservation"
apiAccountPhonePath = "/v1/account/phone" apiAccountPhonePath = "/v1/account/phone"
apiAccountPhoneVerifyPath = "/v1/account/phone/verify" apiAccountPhoneVerifyPath = "/v1/account/phone/verify"
apiAccountEmailPath = "/v1/account/email"
apiAccountEmailVerifyPath = "/v1/account/email/verify"
apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingPortalPath = "/v1/account/billing/portal"
apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingWebhookPath = "/v1/account/billing/webhook"
apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription"
@@ -173,8 +177,15 @@ const (
// subscriber (if configured). // subscriber (if configured).
func New(conf *Config) (*Server, error) { func New(conf *Config) (*Server, error) {
var mailer mailer var mailer mailer
var mailSender *mail.Sender
if conf.SMTPSenderAddr != "" { if conf.SMTPSenderAddr != "" {
mailer = &smtpSender{config: conf} mailSender = mail.NewSender(&mail.Config{
SMTPAddr: conf.SMTPSenderAddr,
SMTPUser: conf.SMTPSenderUser,
SMTPPass: conf.SMTPSenderPass,
From: conf.SMTPSenderFrom,
})
mailer = &smtpSender{config: conf, sender: mailSender}
} }
var stripe stripeAPI var stripe stripeAPI
if payments.Available && conf.StripeSecretKey != "" { if payments.Available && conf.StripeSecretKey != "" {
@@ -278,6 +289,7 @@ func New(conf *Config) (*Server, error) {
attachment: attachmentStore, attachment: attachmentStore,
firebaseClient: firebaseClient, firebaseClient: firebaseClient,
smtpSender: mailer, smtpSender: mailer,
mailSender: mailSender,
topics: topics, topics: topics,
userManager: userManager, userManager: userManager,
messages: messages, messages: messages,
@@ -301,13 +313,10 @@ func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) {
} }
func createAttachmentStore(conf *Config, messageCache *message.Cache) (*attachment.Store, error) { func createAttachmentStore(conf *Config, messageCache *message.Cache) (*attachment.Store, error) {
attachmentIDs := func() ([]string, error) {
return messageCache.AttachmentIDs()
}
if strings.HasPrefix(conf.AttachmentCacheDir, "s3://") { if strings.HasPrefix(conf.AttachmentCacheDir, "s3://") {
return attachment.NewS3Store(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, attachmentIDs) return attachment.NewS3Store(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, conf.AttachmentOrphanGracePeriod, messageCache.AttachmentsWithSizes)
} else if conf.AttachmentCacheDir != "" { } else if conf.AttachmentCacheDir != "" {
return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, attachmentIDs) return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, conf.AttachmentOrphanGracePeriod, messageCache.AttachmentsWithSizes)
} }
return nil, nil return nil, nil
} }
@@ -432,18 +441,25 @@ func (s *Server) Stop() {
if s.smtpServer != nil { if s.smtpServer != nil {
s.smtpServer.Close() s.smtpServer.Close()
} }
if s.mailSender != nil {
s.mailSender.Close()
}
if s.attachment != nil { if s.attachment != nil {
s.attachment.Close() s.attachment.Close()
} }
s.closeDatabases() s.closeDatabases()
close(s.closeChan) if s.closeChan != nil {
close(s.closeChan)
}
} }
func (s *Server) closeDatabases() { func (s *Server) closeDatabases() {
if s.userManager != nil { if s.userManager != nil {
s.userManager.Close() s.userManager.Close()
} }
s.messageCache.Close() if s.messageCache != nil {
s.messageCache.Close()
}
if s.webPush != nil { if s.webPush != nil {
s.webPush.Close() s.webPush.Close()
} }
@@ -593,6 +609,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v) return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath {
return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v) return s.ensureUser(s.ensureCallsEnabled(s.withAccountSync(s.handleAccountPhoneNumberDelete)))(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailVerifyPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailVerify)))(w, r, v)
} else if r.Method == http.MethodPut && r.URL.Path == apiAccountEmailPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailAdd)))(w, r, v)
} else if r.Method == http.MethodDelete && r.URL.Path == apiAccountEmailPath {
return s.ensureUser(s.ensureEmailsEnabled(s.withAccountSync(s.handleAccountEmailDelete)))(w, r, v)
} else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path { } else if r.Method == http.MethodPost && apiWebPushPath == r.URL.Path {
return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v) return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v)
} else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path { } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path {
@@ -699,6 +721,7 @@ func (s *Server) configResponse() *apiConfigResponse {
EnablePayments: s.config.StripeSecretKey != "", EnablePayments: s.config.StripeSecretKey != "",
EnableCalls: s.config.TwilioAccount != "", EnableCalls: s.config.TwilioAccount != "",
EnableEmails: s.config.SMTPSenderFrom != "", EnableEmails: s.config.SMTPSenderFrom != "",
EnableEmailVerify: s.config.SMTPSenderVerify,
EnableReservations: s.config.EnableReservations, EnableReservations: s.config.EnableReservations,
EnableWebPush: s.config.WebPushPublicKey != "", EnableWebPush: s.config.WebPushPublicKey != "",
BillingContact: s.config.BillingContact, BillingContact: s.config.BillingContact,
@@ -864,9 +887,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
return nil, errHTTPInsufficientStorageUnifiedPush.With(t) return nil, errHTTPInsufficientStorageUnifiedPush.With(t)
} else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { } else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return nil, errHTTPTooManyRequestsLimitMessages.With(t) return nil, errHTTPTooManyRequestsLimitMessages.With(t)
} else if email != "" && !vrate.EmailAllowed() { }
return nil, errHTTPTooManyRequestsLimitEmails.With(t) if email != "" {
} else if call != "" { var httpErr *errHTTP
email, httpErr = s.convertEmailAddress(v.User(), email)
if httpErr != nil {
return nil, httpErr.With(t)
} else if !vrate.EmailAllowed() {
return nil, errHTTPTooManyRequestsLimitEmails.With(t)
}
}
if call != "" {
var httpErr *errHTTP var httpErr *errHTTP
call, httpErr = s.convertPhoneNumber(v.User(), call) call, httpErr = s.convertPhoneNumber(v.User(), call)
if httpErr != nil { if httpErr != nil {
@@ -1074,7 +1105,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
} }
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) { func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
logvm(v, m).Tag(tagEmail).Field("email", email).Debug("Sending email to %s", email) logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
if err := s.smtpSender.Send(v, m, email); err != nil { if err := s.smtpSender.Send(v, m, email); err != nil {
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
minc(metricEmailsPublishedFailure) minc(metricEmailsPublishedFailure)
@@ -1172,7 +1203,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
m.Icon = icon m.Icon = icon
} }
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if email != "" && !emailAddressRegex.MatchString(email) { if email != "" && !emailAddressRegex.MatchString(email) && !toBool(email) {
return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid return false, false, "", "", "", false, "", errHTTPBadRequestEmailAddressInvalid
} }
if s.smtpSender == nil && email != "" { if s.smtpSender == nil && email != "" {
@@ -1429,6 +1460,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me
return err return err
} }
attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix() attachmentExpiry := time.Now().Add(vinfo.Limits.AttachmentExpiryDuration).Unix()
if m.Expires > 0 && attachmentExpiry > m.Expires {
attachmentExpiry = m.Expires // Attachment must never outlive the message
}
if m.Time > attachmentExpiry { if m.Time > attachmentExpiry {
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m) return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m)
} }
+3
View File
@@ -193,11 +193,14 @@
# - smtp-sender-addr is the hostname:port of the SMTP server # - smtp-sender-addr is the hostname:port of the SMTP server
# - smtp-sender-from is the e-mail address of the sender # - 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-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-addr:
# smtp-sender-from: # smtp-sender-from:
# smtp-sender-user: # smtp-sender-user:
# smtp-sender-pass: # smtp-sender-pass:
# smtp-sender-verify: false
# If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send # 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. # emails to a topic e-mail address to publish messages to a topic.
+106
View File
@@ -160,6 +160,15 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis
response.PhoneNumbers = phoneNumbers response.PhoneNumbers = phoneNumbers
} }
} }
if s.mailSender != nil {
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return err
}
if len(emails) > 0 {
response.Emails = emails
}
}
} else { } else {
response.Username = user.Everyone response.Username = user.Everyone
response.Role = string(user.RoleAnonymous) response.Role = string(user.RoleAnonymous)
@@ -606,6 +615,103 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
// Check user is allowed to add emails
if u == nil {
return errHTTPUnauthorized
} else 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
}
// Check if email already exists
emails, err := s.userManager.Emails(u.ID)
if err != nil {
return err
} else if util.Contains(emails, req.Email) {
return errHTTPConflictEmailExists
}
// Check email rate limit (counts against the user's email quota)
if !v.EmailAllowed() {
return errHTTPTooManyRequestsLimitEmails
}
// Send verification email
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification")
if err := s.mailSender.SendVerification(req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
} else if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
} else if !s.mailSender.CheckVerification(req.Email, req.Code) {
return errHTTPBadRequestEmailVerificationCodeInvalid
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Adding email as verified")
if err := s.userManager.AddEmail(u.ID, req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
req, err := readJSONWithLimit[apiAccountEmailVerifyRequest](r.Body, jsonBodyBytesLimit, false)
if err != nil {
return err
}
if !emailAddressRegex.MatchString(req.Email) {
return errHTTPBadRequestEmailAddressInvalid
}
logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Deleting verified email")
if err := s.userManager.RemoveEmail(u.ID, req.Email); err != nil {
return err
}
return s.writeJSON(w, newSuccessResponse())
}
// convertEmailAddress checks the email address against the user's verified email list.
// If smtp-sender-verify is false (default), the email is passed through as-is for
// backwards compatibility. If true, the user must be authenticated and the email must be
// in their verified list. "yes"/"true"/"1" resolves to the first verified email.
func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHTTP) {
if !s.config.SMTPSenderVerify {
if toBool(email) {
return "", errHTTPBadRequestEmailAddressInvalid
}
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 len(emails) == 0 {
return "", errHTTPBadRequestEmailAddressNotVerified
}
if toBool(email) {
return emails[0], nil
} 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 // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic
func (s *Server) publishSyncEventAsync(v *visitor) { func (s *Server) publishSyncEventAsync(v *visitor) {
go func() { go func() {
+7 -1
View File
@@ -9,6 +9,7 @@ import (
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/netip" "net/netip"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -740,8 +741,13 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
// Verify that messages and attachments were deleted // Verify that messages and attachments were deleted
// This does not explicitly call the manager! // This does not explicitly call the manager! We backdate the files so sync's
// grace period doesn't protect them.
past := time.Now().Add(-2 * time.Hour)
os.Chtimes(filepath.Join(s.config.AttachmentCacheDir, m1.ID), past, past)
os.Chtimes(filepath.Join(s.config.AttachmentCacheDir, m2.ID), past, past)
waitFor(t, func() bool { waitFor(t, func() bool {
s.attachment.Sync() // File cleanup is done by sync, not by the manager
ms, err := s.messageCache.Messages("mytopic1", model.SinceAllMessages, false) ms, err := s.messageCache.Messages("mytopic1", model.SinceAllMessages, false)
require.Nil(t, err) require.Nil(t, err)
return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID)) return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID))
+18 -28
View File
@@ -3,7 +3,6 @@ package server
import ( import (
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"strings"
) )
func (s *Server) execManager() { func (s *Server) execManager() {
@@ -120,7 +119,7 @@ func (s *Server) pruneVisitors() {
} }
}). }).
Field("stale_visitors", staleVisitors). Field("stale_visitors", staleVisitors).
Debug("Deleted %d stale visitor(s)", staleVisitors) Debug("Finished deleting stale visitors")
} }
func (s *Server) pruneTokens() { func (s *Server) pruneTokens() {
@@ -135,7 +134,7 @@ func (s *Server) pruneTokens() {
log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users") log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users")
} }
}). }).
Debug("Removed expired tokens and users") Debug("Finished deleting expired tokens and users")
} }
} }
@@ -143,48 +142,39 @@ func (s *Server) pruneAttachments() {
if s.attachment == nil { if s.attachment == nil {
return 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. log.
Tag(tagManager). Tag(tagManager).
Timing(func() { Timing(func() {
ids, err := s.messageCache.AttachmentsExpired() count, err := s.messageCache.MarkExpiredAttachmentsDeleted(s.config.ManagerBatchSize)
if err != nil { if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments") log.Tag(tagManager).Err(err).Warn("Error marking expired attachments as deleted")
} else if len(ids) > 0 { } else if count > 0 {
if log.Tag(tagManager).IsDebug() { log.Tag(tagManager).Debug("Marked %d expired attachment(s) as deleted", count)
log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", "))
}
if err := s.attachment.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")
}
} else { } else {
log.Tag(tagManager).Debug("No expired attachments to delete") log.Tag(tagManager).Debug("No expired attachments to delete")
} }
}). }).
Debug("Deleted expired attachments") Debug("Finished marking expired attachments as deleted")
} }
func (s *Server) pruneMessages() { 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. log.
Tag(tagManager). Tag(tagManager).
Timing(func() { Timing(func() {
expiredMessageIDs, err := s.messageCache.MessagesExpired() count, err := s.messageCache.DeleteExpiredMessages(s.config.ManagerBatchSize)
if err != nil { if err != nil {
log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages") log.Tag(tagManager).Err(err).Warn("Error deleting expired messages")
} else if len(expiredMessageIDs) > 0 { } else if count > 0 {
if s.attachment != nil { log.Tag(tagManager).Debug("Deleted %d expired message(s)", count)
if err := s.attachment.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")
}
} else { } else {
log.Tag(tagManager).Debug("No expired messages to delete") log.Tag(tagManager).Debug("No expired messages to delete")
} }
}). }).
Debug("Pruned messages") Debug("Finished deleting expired messages")
} }
+9
View File
@@ -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.mailSender == nil || s.userManager == nil {
return errHTTPNotFound
}
return next(w, r, v)
}
}
func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error { return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.config.StripeSecretKey == "" || s.stripe == nil { if s.config.StripeSecretKey == "" || s.stripe == nil {
+7 -1
View File
@@ -14,6 +14,7 @@ import (
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"io" "io"
"net/netip" "net/netip"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -543,9 +544,14 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
require.Equal(t, 1, len(r)) // "ztopic" reservation was deleted require.Equal(t, 1, len(r)) // "ztopic" reservation was deleted
require.Equal(t, "atopic", r[0].Topic) require.Equal(t, "atopic", r[0].Topic)
// Verify that messages and attachments were deleted // Verify that messages and attachments were deleted. We backdate the
// attachment files so sync's grace period doesn't protect them.
past := time.Now().Add(-2 * time.Hour)
os.Chtimes(filepath.Join(s.config.AttachmentCacheDir, a2.ID), past, past)
os.Chtimes(filepath.Join(s.config.AttachmentCacheDir, z2.ID), past, past)
time.Sleep(time.Second) time.Sleep(time.Second)
s.execManager() s.execManager()
s.attachment.Sync() // File cleanup is done by sync, not by the manager
ms, err := s.messageCache.Messages("atopic", model.SinceAllMessages, false) ms, err := s.messageCache.Messages("atopic", model.SinceAllMessages, false)
require.Nil(t, err) require.Nil(t, err)
+179 -4
View File
@@ -1567,6 +1567,177 @@ func TestServer_PublishEmailAddressInvalid(t *testing.T) {
}) })
} }
func TestServer_PublishEmailVerify_VerifiedAddress(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
// Verified address should succeed
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "phil@example.com",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
// Unverified address should fail
response = request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "other@example.com",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40052, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_BoolValue(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
// "yes" should resolve to first verified email
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code)
// "true" and "1" should also work
for _, val := range []string{"true", "1"} {
response = request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": val,
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 200, response.Code, "expected 200 for email: %s", val)
}
})
}
func TestServer_PublishEmailVerify_BoolValue_NoVerify(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
// "yes" without smtp-sender-verify should fail with invalid address
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "yes",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40050, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_Anonymous(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
defer s.closeDatabases()
// Anonymous user should be rejected
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "test@example.com",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40053, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_NoVerifiedEmails(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
s := newTestServer(t, conf)
s.smtpSender = &testMailer{}
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
// Authenticated user with no verified emails should fail
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "phil@example.com",
"Authorization": util.BasicAuth("phil", "phil"),
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40052, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.smtpSender = &testMailer{}
// Without smtp-sender-verify, any email address should work (backwards compatible)
response := request(t, s, "PUT", "/mytopic", "hi", map[string]string{
"Email": "anyone@example.com",
})
require.Equal(t, 200, response.Code)
})
}
func TestServer_AccountEmailVerify_UserWithoutTier(t *testing.T) {
// This test verifies that an authenticated user WITHOUT a tier can verify emails
// when the default visitor email limit allows it.
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
conf.SMTPSenderAddr = "localhost:25" // Dummy SMTP server (will fail to send, but that's ok)
conf.SMTPSenderFrom = "noreply@example.com"
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create a user without a tier
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Verify email request should NOT return 401
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
// The request will fail (SMTP not available), but it must NOT be a 401
require.NotEqual(t, 401, response.Code)
})
}
func TestServer_AccountEmailVerify_UserWithoutTier_EmailLimitZero(t *testing.T) {
// This test verifies that a tier-less user is rejected when the server's
// visitor email limit is zero (email sending disabled).
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
conf.SMTPSenderVerify = true
conf.SMTPSenderAddr = "localhost:25"
conf.SMTPSenderFrom = "noreply@example.com"
conf.VisitorEmailLimitBurst = 0
s := newTestServer(t, conf)
defer s.closeDatabases()
// Create a user without a tier
require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false))
// Should be rejected with 401 since email sending is disabled
response := request(t, s, "PUT", "/v1/account/email/verify", `{"email":"ben@example.com"}`, map[string]string{
"Authorization": util.BasicAuth("ben", "ben"),
})
require.Equal(t, 401, response.Code)
})
}
func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) { func TestServer_PublishAndExpungeTopicAfter16Hours(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel() t.Parallel()
@@ -2299,9 +2470,12 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) {
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
require.Equal(t, content, response.Body.String()) require.Equal(t, content, response.Body.String())
// Prune and makes sure it's gone // Prune and makes sure it's gone. We backdate the file so sync's grace
// period doesn't protect it, then run the manager + sync explicitly.
require.Nil(t, os.Chtimes(file, time.Now().Add(-2*time.Hour), time.Now().Add(-2*time.Hour)))
waitFor(t, func() bool { waitFor(t, func() bool {
s.execManager() // May run many times s.execManager()
s.attachment.Sync() // File cleanup is done by sync, not by the manager
return !util.FileExists(file) return !util.FileExists(file)
}) })
response = request(t, s, "GET", path, "", nil) response = request(t, s, "GET", path, "", nil)
@@ -2411,6 +2585,7 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) {
require.Nil(t, s.userManager.AddTier(&user.Tier{ require.Nil(t, s.userManager.AddTier(&user.Tier{
Code: "test", Code: "test",
MessageLimit: 100, MessageLimit: 100,
MessageExpiryDuration: time.Hour,
AttachmentFileSizeLimit: 50_000, AttachmentFileSizeLimit: 50_000,
AttachmentTotalSizeLimit: 200_000, AttachmentTotalSizeLimit: 200_000,
AttachmentExpiryDuration: 30 * time.Second, AttachmentExpiryDuration: 30 * time.Second,
@@ -2698,7 +2873,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
response := request(t, s, "PUT", "/mytopic", "some body", nil) response := request(t, s, "PUT", "/mytopic", "some body", nil)
m := toMessage(t, response.Body.String()) m := toMessage(t, response.Body.String())
require.Equal(t, "some body", m.Message) require.Equal(t, "some body", m.Message)
require.True(t, time.Since(start) < 100*time.Millisecond) require.True(t, time.Since(start) < 2*time.Second)
log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
// Wait for all Goroutines // Wait for all Goroutines
@@ -4191,7 +4366,7 @@ func newTestConfigWithAuthFile(t *testing.T, databaseURL string) *Config {
func newTestServer(t *testing.T, config *Config) *Server { func newTestServer(t *testing.T, config *Config) *Server {
server, err := New(config) server, err := New(config)
require.Nil(t, err) require.Nil(t, err)
t.Cleanup(server.closeDatabases) t.Cleanup(server.Stop)
return server return server
} }
+7 -16
View File
@@ -5,13 +5,12 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"mime" "mime"
"net"
"net/smtp"
"strings" "strings"
"sync" "sync"
"time" "time"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -23,6 +22,7 @@ type mailer interface {
type smtpSender struct { type smtpSender struct {
config *Config config *Config
sender *mail.Sender
success int64 success int64
failure int64 failure int64
mu sync.Mutex mu sync.Mutex
@@ -30,31 +30,22 @@ type smtpSender struct {
func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error { func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error {
return s.withCount(v, m, func() error { return s.withCount(v, m, func() error {
host, _, err := net.SplitHostPort(s.config.SMTPSenderAddr) message, err := formatMail(s.config.BaseURL, v.ip.String(), s.sender.From(), to, m)
if err != nil { if err != nil {
return err 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). ev := logvm(v, m).
Tag(tagEmail). Tag(tagEmail).
Fields(log.Context{ Fields(log.Context{
"email_via": s.config.SMTPSenderAddr, "email_via": s.sender.Addr(),
"email_user": s.config.SMTPSenderUser, "email_user": s.sender.User(),
"email_to": to, "email_to": to,
}) })
if ev.IsTrace() { if ev.IsTrace() {
ev.Field("email_body", message).Trace("Sending email") 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)) ev.Info("Sending email")
return s.sender.SendRaw(to, []byte(message))
}) })
} }
+11
View File
@@ -226,6 +226,15 @@ type apiAccountPhoneNumberAddRequest struct {
Code string `json:"code"` // Only set when adding a phone number Code string `json:"code"` // Only set when adding a phone number
} }
type apiAccountEmailVerifyRequest struct {
Email string `json:"email"`
}
type apiAccountEmailAddRequest struct {
Email string `json:"email"`
Code string `json:"code"`
}
type apiAccountTier struct { type apiAccountTier struct {
Code string `json:"code"` Code string `json:"code"`
Name string `json:"name"` Name string `json:"name"`
@@ -282,6 +291,7 @@ type apiAccountResponse struct {
Reservations []*apiAccountReservation `json:"reservations,omitempty"` Reservations []*apiAccountReservation `json:"reservations,omitempty"`
Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"`
PhoneNumbers []string `json:"phone_numbers,omitempty"` PhoneNumbers []string `json:"phone_numbers,omitempty"`
Emails []string `json:"emails,omitempty"`
Tier *apiAccountTier `json:"tier,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"`
Limits *apiAccountLimits `json:"limits,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"`
Stats *apiAccountStats `json:"stats,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"`
@@ -302,6 +312,7 @@ type apiConfigResponse struct {
EnablePayments bool `json:"enable_payments"` EnablePayments bool `json:"enable_payments"`
EnableCalls bool `json:"enable_calls"` EnableCalls bool `json:"enable_calls"`
EnableEmails bool `json:"enable_emails"` EnableEmails bool `json:"enable_emails"`
EnableEmailVerify bool `json:"enable_email_verify"`
EnableReservations bool `json:"enable_reservations"` EnableReservations bool `json:"enable_reservations"`
EnableWebPush bool `json:"enable_web_push"` EnableWebPush bool `json:"enable_web_push"`
BillingContact string `json:"billing_contact"` BillingContact string `json:"billing_contact"`
+5 -1
View File
@@ -440,13 +440,17 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits {
if conf.VisitorMessageDailyLimit > 0 { if conf.VisitorMessageDailyLimit > 0 {
messagesLimit = int64(conf.VisitorMessageDailyLimit) messagesLimit = int64(conf.VisitorMessageDailyLimit)
} }
var emailLimit int64
if conf.VisitorEmailLimitBurst > 0 {
emailLimit = replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish) // Approximation!
}
return &visitorLimits{ return &visitorLimits{
Basis: visitorLimitBasisIP, Basis: visitorLimitBasisIP,
RequestLimitBurst: conf.VisitorRequestLimitBurst, RequestLimitBurst: conf.VisitorRequestLimitBurst,
RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish), RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish),
MessageLimit: messagesLimit, MessageLimit: messagesLimit,
MessageExpiryDuration: conf.CacheDuration, MessageExpiryDuration: conf.CacheDuration,
EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! EmailLimit: emailLimit,
EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitBurst: conf.VisitorEmailLimitBurst,
EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish),
CallLimit: visitorDefaultCallsLimit, CallLimit: visitorDefaultCallsLimit,
+50
View File
@@ -1294,6 +1294,56 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) {
return phoneNumber, nil return phoneNumber, nil
} }
// Emails returns all verified email addresses for the user with the given user ID
func (a *Manager) Emails(userID string) ([]string, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectEmails, userID)
if err != nil {
return nil, err
}
defer rows.Close()
emails := make([]string, 0)
for {
email, err := a.readEmail(rows)
if errors.Is(err, ErrEmailNotFound) {
break
} else if err != nil {
return nil, err
}
emails = append(emails, email)
}
return emails, nil
}
// AddEmail adds a verified email address to the user with the given user ID
func (a *Manager) AddEmail(userID, email string) error {
if _, err := a.db.Exec(a.queries.insertEmail, userID, email); err != nil {
if isUniqueConstraintError(err) {
return ErrEmailExists
}
return err
}
return nil
}
// RemoveEmail deletes a verified email address from the user with the given user ID
func (a *Manager) RemoveEmail(userID, email string) error {
_, err := a.db.Exec(a.queries.deleteEmail, userID, email)
return err
}
func (a *Manager) readEmail(rows *sql.Rows) (string, error) {
var email string
if !rows.Next() {
return "", ErrEmailNotFound
}
if err := rows.Scan(&email); err != nil {
return "", err
} else if err := rows.Err(); err != nil {
return "", err
}
return email, nil
}
// ChangeBilling updates a user's billing fields // ChangeBilling updates a user's billing fields
func (a *Manager) ChangeBilling(username string, billing *Billing) error { func (a *Manager) ChangeBilling(username string, billing *Billing) error {
if _, err := a.db.Exec(a.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil { if _, err := a.db.Exec(a.queries.updateBilling, nullString(billing.StripeCustomerID), nullString(billing.StripeSubscriptionID), nullString(string(billing.StripeSubscriptionStatus)), nullString(string(billing.StripeSubscriptionInterval)), nullInt64(billing.StripeSubscriptionPaidUntil.Unix()), nullInt64(billing.StripeSubscriptionCancelAt.Unix()), username); err != nil {
+8
View File
@@ -208,6 +208,11 @@ const (
postgresInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)` postgresInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES ($1, $2)`
postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2` postgresDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = $1 AND phone_number = $2`
// Email queries
postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1 ORDER BY email`
postgresInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES ($1, $2)`
postgresDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = $1 AND email = $2`
// Billing queries // Billing queries
postgresUpdateBillingQuery = ` postgresUpdateBillingQuery = `
UPDATE "user" UPDATE "user"
@@ -274,6 +279,9 @@ var postgresQueries = queries{
selectPhoneNumbers: postgresSelectPhoneNumbersQuery, selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
insertPhoneNumber: postgresInsertPhoneNumberQuery, insertPhoneNumber: postgresInsertPhoneNumberQuery,
deletePhoneNumber: postgresDeletePhoneNumberQuery, deletePhoneNumber: postgresDeletePhoneNumberQuery,
selectEmails: postgresSelectEmailsQuery,
insertEmail: postgresInsertEmailQuery,
deleteEmail: postgresDeleteEmailQuery,
updateBilling: postgresUpdateBillingQuery, updateBilling: postgresUpdateBillingQuery,
} }
+42 -3
View File
@@ -72,6 +72,11 @@ const (
phone_number TEXT NOT NULL, phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number) PRIMARY KEY (user_id, phone_number)
); );
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
email TEXT NOT NULL,
PRIMARY KEY (user_id, email)
);
CREATE TABLE IF NOT EXISTS schema_version ( CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY, store TEXT PRIMARY KEY,
version INT NOT NULL version INT NOT NULL
@@ -84,21 +89,55 @@ const (
// Schema table management queries for Postgres // Schema table management queries for Postgres
const ( const (
postgresCurrentSchemaVersion = 6 postgresCurrentSchemaVersion = 7
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'` postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)` postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
) )
const (
postgresMigrate6To7UpdateQueries = `
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
email TEXT NOT NULL,
PRIMARY KEY (user_id, email)
);
`
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
)
var postgresMigrations = map[int]func(db *sql.DB) error{
6: postgresMigrateFrom6,
}
func setupPostgres(db *sql.DB) error { func setupPostgres(db *sql.DB) error {
var schemaVersion int var schemaVersion int
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil { if err != nil {
return setupNewPostgres(db) return setupNewPostgres(db)
} }
if schemaVersion > postgresCurrentSchemaVersion { if schemaVersion == postgresCurrentSchemaVersion {
return nil
} else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion) return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
} }
// Note: PostgreSQL migrations will be added when needed for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
fn, ok := postgresMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func postgresMigrateFrom6(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil return nil
} }
+8
View File
@@ -207,6 +207,11 @@ const (
sqliteInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` sqliteInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)`
sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?`
// Email queries
sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ? ORDER BY email`
sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)`
sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?`
// Billing queries // Billing queries
sqliteUpdateBillingQuery = ` sqliteUpdateBillingQuery = `
UPDATE user UPDATE user
@@ -272,6 +277,9 @@ var sqliteQueries = queries{
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
insertPhoneNumber: sqliteInsertPhoneNumberQuery, insertPhoneNumber: sqliteInsertPhoneNumberQuery,
deletePhoneNumber: sqliteDeletePhoneNumberQuery, deletePhoneNumber: sqliteDeletePhoneNumberQuery,
selectEmails: sqliteSelectEmailsQuery,
insertEmail: sqliteInsertEmailQuery,
deleteEmail: sqliteDeleteEmailQuery,
updateBilling: sqliteUpdateBillingQuery, updateBilling: sqliteUpdateBillingQuery,
} }
+31 -1
View File
@@ -85,6 +85,12 @@ const (
PRIMARY KEY (user_id, phone_number), PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL,
email TEXT NOT NULL,
PRIMARY KEY (user_id, email),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS schemaVersion ( CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY, id INT PRIMARY KEY,
version INT NOT NULL version INT NOT NULL
@@ -101,7 +107,7 @@ const (
// Schema version table management for SQLite // Schema version table management for SQLite
const ( const (
sqliteCurrentSchemaVersion = 6 sqliteCurrentSchemaVersion = 7
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1` sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
@@ -220,6 +226,16 @@ const (
UPDATE user_access SET topic = REPLACE(topic, '_', '\_'); UPDATE user_access SET topic = REPLACE(topic, '_', '\_');
` `
// 6 -> 7
sqliteMigrate6To7UpdateQueries = `
CREATE TABLE IF NOT EXISTS user_email (
user_id TEXT NOT NULL,
email TEXT NOT NULL,
PRIMARY KEY (user_id, email),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
`
// 5 -> 6 // 5 -> 6
sqliteMigrate5To6UpdateQueries = ` sqliteMigrate5To6UpdateQueries = `
PRAGMA foreign_keys=off; PRAGMA foreign_keys=off;
@@ -322,6 +338,7 @@ var (
3: sqliteMigrateFrom3, 3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4, 4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5, 5: sqliteMigrateFrom5,
6: sqliteMigrateFrom6,
} }
) )
@@ -463,3 +480,16 @@ func sqliteMigrateFrom5(sqlDB *sql.DB) error {
return nil return nil
}) })
} }
func sqliteMigrateFrom6(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 6 to 7")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
})
}
+75
View File
@@ -1137,6 +1137,60 @@ func TestUser_PhoneNumberAdd_Multiple_Users_Same_Number(t *testing.T) {
}) })
} }
func TestUser_EmailAddListRemove(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(phil.ID, "phil@example.com"))
emails, err := a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, 1, len(emails))
require.Equal(t, "phil@example.com", emails[0])
require.Nil(t, a.RemoveEmail(phil.ID, "phil@example.com"))
emails, err = a.Emails(phil.ID)
require.Nil(t, err)
require.Equal(t, 0, len(emails))
// Paranoia check: We do NOT want to keep emails in there
rows, err := testDB(a).Query(`SELECT * FROM user_email`)
require.Nil(t, err)
require.False(t, rows.Next())
require.Nil(t, rows.Close())
})
}
func TestUser_EmailAdd_Multiple_Users_Same_Email(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
ben, err := a.User("ben")
require.Nil(t, err)
require.Nil(t, a.AddEmail(phil.ID, "shared@example.com"))
require.Nil(t, a.AddEmail(ben.ID, "shared@example.com"))
})
}
func TestUser_EmailAdd_Duplicate(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(phil.ID, "phil@example.com"))
require.ErrorIs(t, a.AddEmail(phil.ID, "phil@example.com"), ErrEmailExists)
})
}
func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) { func TestManager_Topic_Wildcard_With_Asterisk_Underscore(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll) a := newTestManager(t, newManager, PermissionDenyAll)
@@ -2328,6 +2382,27 @@ func TestStorePhoneNumbers(t *testing.T) {
}) })
} }
func TestStoreEmails(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
u, err := manager.User("phil")
require.Nil(t, err)
require.Nil(t, manager.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, manager.AddEmail(u.ID, "phil2@example.com"))
emails, err := manager.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 2)
require.Nil(t, manager.RemoveEmail(u.ID, "phil@example.com"))
emails, err = manager.Emails(u.ID)
require.Nil(t, err)
require.Len(t, emails, 1)
require.Equal(t, "phil2@example.com", emails[0])
})
}
func TestStoreChangeSettings(t *testing.T) { func TestStoreChangeSettings(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
+7
View File
@@ -271,6 +271,8 @@ var (
ErrPhoneNumberNotFound = errors.New("phone number not found") ErrPhoneNumberNotFound = errors.New("phone number not found")
ErrTooManyReservations = errors.New("new tier has lower reservation limit") ErrTooManyReservations = errors.New("new tier has lower reservation limit")
ErrPhoneNumberExists = errors.New("phone number already exists") ErrPhoneNumberExists = errors.New("phone number already exists")
ErrEmailNotFound = errors.New("email not found")
ErrEmailExists = errors.New("email already exists")
ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user") ErrProvisionedUserChange = errors.New("cannot change or delete provisioned user")
ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token")
) )
@@ -343,6 +345,11 @@ type queries struct {
insertPhoneNumber string insertPhoneNumber string
deletePhoneNumber string deletePhoneNumber string
// Email queries
selectEmails string
insertEmail string
deleteEmail string
// Billing queries // Billing queries
updateBilling string updateBilling string
} }
+170 -157
View File
@@ -2738,9 +2738,9 @@
} }
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2752,9 +2752,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2766,9 +2766,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2780,9 +2780,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2794,9 +2794,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2808,9 +2808,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2822,13 +2822,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2836,13 +2839,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2850,13 +2856,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2864,13 +2873,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2878,13 +2890,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2892,13 +2907,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2906,13 +2924,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2920,13 +2941,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2934,13 +2958,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2948,13 +2975,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2962,13 +2992,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2976,13 +3009,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"glibc"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -2990,13 +3026,16 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true, "dev": true,
"libc": [
"musl"
],
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@@ -3004,9 +3043,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3018,9 +3057,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3032,9 +3071,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -3046,9 +3085,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -3060,9 +3099,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3074,9 +3113,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -3642,9 +3681,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.10", "version": "2.10.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz",
"integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3655,9 +3694,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3766,9 +3805,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001780", "version": "1.0.30001782",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz",
"integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4203,9 +4242,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.321", "version": "1.5.328",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz",
"integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -5005,9 +5044,9 @@
} }
}, },
"node_modules/filelist/node_modules/brace-expansion": { "node_modules/filelist/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -6260,13 +6299,6 @@
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"dev": true,
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-schema-traverse": { "node_modules/json-schema-traverse": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -7068,9 +7100,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -7580,9 +7612,9 @@
} }
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.60.0", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7596,31 +7628,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@@ -9094,14 +9126,13 @@
} }
}, },
"node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
"version": "0.3.6", "version": "0.3.7",
"resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
"integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"json-schema": "^0.4.0", "jsonpointer": "^5.0.1",
"jsonpointer": "^5.0.0",
"leven": "^3.1.0" "leven": "^3.1.0"
}, },
"engines": { "engines": {
@@ -9202,9 +9233,9 @@
} }
}, },
"node_modules/workbox-build/node_modules/brace-expansion": { "node_modules/workbox-build/node_modules/brace-expansion": {
"version": "5.0.4", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9270,9 +9301,9 @@
} }
}, },
"node_modules/workbox-build/node_modules/picomatch": { "node_modules/workbox-build/node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -9514,24 +9545,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+14
View File
@@ -215,6 +215,19 @@
"account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code",
"account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_basics_phone_numbers_dialog_channel_sms": "SMS",
"account_basics_phone_numbers_dialog_channel_call": "Call", "account_basics_phone_numbers_dialog_channel_call": "Call",
"account_basics_emails_title": "Email addresses",
"account_basics_emails_description": "For email notifications",
"account_basics_emails_no_emails_yet": "No verified emails yet",
"account_basics_emails_copied_to_clipboard": "Email address copied to clipboard",
"account_basics_emails_dialog_title": "Add email address",
"account_basics_emails_dialog_description": "To receive email notifications, you need to add and verify at least one email address. A verification code will be sent to your email.",
"account_basics_emails_dialog_email_label": "Email address",
"account_basics_emails_dialog_email_placeholder": "e.g. user@example.com",
"account_basics_emails_dialog_verify_button": "Add email",
"account_basics_emails_dialog_code_label": "Verification code",
"account_basics_emails_dialog_code_placeholder": "e.g. 123456",
"account_basics_emails_dialog_code_invalid": "Verification code is invalid or expired",
"account_basics_emails_dialog_check_verification_button": "Confirm",
"account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted", "account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted",
"account_usage_title": "Usage", "account_usage_title": "Usage",
"account_usage_of_limit": "of {{limit}}", "account_usage_of_limit": "of {{limit}}",
@@ -238,6 +251,7 @@
"account_usage_messages_title": "Published messages", "account_usage_messages_title": "Published messages",
"account_usage_emails_title": "Emails sent", "account_usage_emails_title": "Emails sent",
"account_usage_calls_title": "Phone calls made", "account_usage_calls_title": "Phone calls made",
"account_usage_emails_none": "No email notifications can be sent with this account",
"account_usage_calls_none": "No phone calls can be made with this account", "account_usage_calls_none": "No phone calls can be made with this account",
"account_usage_reservations_title": "Reserved topics", "account_usage_reservations_title": "Reserved topics",
"account_usage_reservations_none": "No reserved topics for this account", "account_usage_reservations_none": "No reserved topics for this account",
+39
View File
@@ -2,6 +2,8 @@ import i18n from "i18next";
import { import {
accountBillingPortalUrl, accountBillingPortalUrl,
accountBillingSubscriptionUrl, accountBillingSubscriptionUrl,
accountEmailUrl,
accountEmailVerifyUrl,
accountPasswordUrl, accountPasswordUrl,
accountPhoneUrl, accountPhoneUrl,
accountPhoneVerifyUrl, accountPhoneVerifyUrl,
@@ -339,6 +341,43 @@ class AccountApi {
}); });
} }
async verifyEmail(email) {
const url = accountEmailVerifyUrl(config.base_url);
console.log(`[AccountApi] Sending email verification ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
}),
});
}
async addEmail(email, code) {
const url = accountEmailUrl(config.base_url);
console.log(`[AccountApi] Adding email with verification code ${url}`);
await fetchOrThrow(url, {
method: "PUT",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
code,
}),
});
}
async deleteEmail(email) {
const url = accountEmailUrl(config.base_url);
console.log(`[AccountApi] Deleting email ${url}`);
await fetchOrThrow(url, {
method: "DELETE",
headers: withBearerAuth({}, session.token()),
body: JSON.stringify({
email,
}),
});
}
async sync() { async sync() {
try { try {
if (!session.token()) { if (!session.token()) {
+10
View File
@@ -47,6 +47,14 @@ export class IncorrectPasswordError extends Error {
} }
} }
export class EmailVerificationCodeInvalidError extends Error {
static CODE = 40051; // errHTTPBadRequestEmailVerificationCodeInvalid
constructor() {
super("Email verification code invalid or expired");
}
}
export const throwAppError = async (response) => { export const throwAppError = async (response) => {
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
console.log(`[Error] HTTP ${response.status}`, response); console.log(`[Error] HTTP ${response.status}`, response);
@@ -63,6 +71,8 @@ export const throwAppError = async (response) => {
throw new AccountCreateLimitReachedError(); throw new AccountCreateLimitReachedError();
} else if (error.code === IncorrectPasswordError.CODE) { } else if (error.code === IncorrectPasswordError.CODE) {
throw new IncorrectPasswordError(); throw new IncorrectPasswordError();
} else if (error.code === EmailVerificationCodeInvalidError.CODE) {
throw new EmailVerificationCodeInvalidError();
} else if (error?.error) { } else if (error?.error) {
throw new Error(`Error ${error.code}: ${error.error}`); throw new Error(`Error ${error.code}: ${error.error}`);
} }
+2
View File
@@ -34,6 +34,8 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account
export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`;
export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`;
export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`; export const accountPhoneVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/phone/verify`;
export const accountEmailUrl = (baseUrl) => `${baseUrl}/v1/account/email`;
export const accountEmailVerifyUrl = (baseUrl) => `${baseUrl}/v1/account/email/verify`;
export const validUrl = (url) => url.match(/^https?:\/\/.+/); export const validUrl = (url) => url.match(/^https?:\/\/.+/);
+199 -2
View File
@@ -53,7 +53,7 @@ import UpgradeDialog from "./UpgradeDialog";
import { AccountContext } from "./App"; import { AccountContext } from "./App";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import { Paragraph } from "./styles"; import { Paragraph } from "./styles";
import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors";
import { ProChip } from "./SubscriptionPopup"; import { ProChip } from "./SubscriptionPopup";
import session from "../app/Session"; import session from "../app/Session";
@@ -84,6 +84,7 @@ const Basics = () => {
<PrefGroup> <PrefGroup>
<Username /> <Username />
<ChangePassword /> <ChangePassword />
<Emails />
<PhoneNumbers /> <PhoneNumbers />
<AccountType /> <AccountType />
</PrefGroup> </PrefGroup>
@@ -354,6 +355,200 @@ const AccountType = () => {
); );
}; };
const Emails = () => {
const { t } = useTranslation();
const { account } = useContext(AccountContext);
const [dialogKey, setDialogKey] = useState(0);
const [dialogOpen, setDialogOpen] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const labelId = "prefVerifiedEmails";
const handleDialogOpen = () => {
setDialogKey((prev) => prev + 1);
setDialogOpen(true);
};
const handleDialogClose = () => {
setDialogOpen(false);
};
const handleCopy = (email) => {
copyToClipboard(email);
setSnackOpen(true);
};
const handleDelete = async (email) => {
try {
await accountApi.deleteEmail(email);
} catch (e) {
console.log(`[Account] Error deleting email`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
}
}
};
if (!config.enable_email_verify) {
return null;
}
if (account?.limits.emails === 0) {
return (
<Pref
title={
<>
{t("account_basics_emails_title")}
{config.enable_payments && <ProChip />}
</>
}
description={t("account_basics_emails_description")}
>
<em>{t("account_usage_emails_none")}</em>
</Pref>
);
}
return (
<Pref labelId={labelId} title={t("account_basics_emails_title")} description={t("account_basics_emails_description")}>
<div aria-labelledby={labelId}>
{account?.emails?.map((email) => (
<Chip
key={email}
label={
<Tooltip title={t("common_copy_to_clipboard")}>
<span>{email}</span>
</Tooltip>
}
variant="outlined"
onClick={() => handleCopy(email)}
onDelete={() => handleDelete(email)}
/>
))}
{!account?.emails && <em>{t("account_basics_emails_no_emails_yet")}</em>}
<IconButton onClick={handleDialogOpen}>
<AddIcon />
</IconButton>
</div>
<AddEmailDialog key={`addEmailDialog${dialogKey}`} open={dialogOpen} onClose={handleDialogClose} />
<Portal>
<Snackbar
open={snackOpen}
autoHideDuration={3000}
onClose={() => setSnackOpen(false)}
message={t("account_basics_emails_copied_to_clipboard")}
/>
</Portal>
</Pref>
);
};
const AddEmailDialog = (props) => {
const theme = useTheme();
const { t } = useTranslation();
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [sending, setSending] = useState(false);
const [verificationCodeSent, setVerificationCodeSent] = useState(false);
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const verifyEmail = async () => {
try {
setSending(true);
await accountApi.verifyEmail(email);
setVerificationCodeSent(true);
} catch (e) {
console.log(`[Account] Error sending email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const checkVerifyEmail = async () => {
try {
setSending(true);
await accountApi.addEmail(email, code);
props.onClose();
} catch (e) {
console.log(`[Account] Error confirming email verification`, e);
if (e instanceof UnauthorizedError) {
await session.resetAndRedirect(routes.login);
} else if (e instanceof EmailVerificationCodeInvalidError) {
setError(t("account_basics_emails_dialog_code_invalid"));
} else {
setError(e.message);
}
} finally {
setSending(false);
}
};
const handleDialogSubmit = async () => {
if (!verificationCodeSent) {
await verifyEmail();
} else {
await checkVerifyEmail();
}
};
const handleCancel = () => {
if (verificationCodeSent) {
setVerificationCodeSent(false);
setCode("");
} else {
props.onClose();
}
};
return (
<Dialog open={props.open} onClose={props.onCancel} fullScreen={fullScreen}>
<DialogTitle>{t("account_basics_emails_dialog_title")}</DialogTitle>
<DialogContent>
<DialogContentText>{t("account_basics_emails_dialog_description")}</DialogContentText>
{!verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_email_label")}
aria-label={t("account_basics_emails_dialog_email_label")}
placeholder={t("account_basics_emails_dialog_email_placeholder")}
type="email"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
fullWidth
variant="standard"
/>
)}
{verificationCodeSent && (
<TextField
margin="dense"
label={t("account_basics_emails_dialog_code_label")}
aria-label={t("account_basics_emails_dialog_code_label")}
placeholder={t("account_basics_emails_dialog_code_placeholder")}
type="text"
value={code}
onChange={(ev) => setCode(ev.target.value)}
fullWidth
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
variant="standard"
/>
)}
</DialogContent>
<DialogFooter status={error}>
<Button onClick={handleCancel}>{verificationCodeSent ? t("common_back") : t("common_cancel")}</Button>
<Button onClick={handleDialogSubmit} disabled={sending || !/^[^\s,;]+@[^\s,;]+$/.test(email)}>
{!verificationCodeSent && t("account_basics_emails_dialog_verify_button")}
{verificationCodeSent && t("account_basics_emails_dialog_check_verification_button")}
</Button>
</DialogFooter>
</Dialog>
);
};
const PhoneNumbers = () => { const PhoneNumbers = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
@@ -750,7 +945,9 @@ const Stats = () => {
)} )}
</PrefGroup> </PrefGroup>
{account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( {account.role === Role.USER && account.limits.basis === LimitBasis.IP && (
<Typography variant="body1">{t("account_usage_basis_ip_description")}</Typography> <Typography variant="body1" sx={{ pt: 3 }}>
{t("account_usage_basis_ip_description")}
</Typography>
)} )}
</Card> </Card>
); );
+2 -1
View File
@@ -117,7 +117,8 @@ const NavList = (props) => {
const isAdmin = account?.role === Role.ADMIN; const isAdmin = account?.role === Role.ADMIN;
const isPaid = account?.billing?.subscription; const isPaid = account?.billing?.subscription;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid; const hasTier = !!account?.tier;
const showUpgradeBanner = config.enable_payments && !isAdmin && !isPaid && !hasTier;
const showSubscriptionsList = props.subscriptions?.length > 0; const showSubscriptionsList = props.subscriptions?.length > 0;
const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested()); const showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested());
const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied()); const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied());