mirror of
https://github.com/multipleof4/ntfy.git
synced 2026-10-11 22:35:22 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6219784aae | ||
|
|
51da5e0f77 | ||
|
|
e57ef84f13 | ||
|
|
4c6225e311 | ||
|
|
07b3812549 | ||
|
|
63ec73a319 | ||
|
|
bdea8c314f | ||
|
|
3e634e0a5a | ||
|
|
6aebc5c677 | ||
|
|
61dd788dac | ||
|
|
266d0b9d37 | ||
|
|
835d1faac4 | ||
|
|
67fc7fe96a | ||
|
|
92fa88cf12 | ||
|
|
b5fee121d7 | ||
|
|
e1a344339f | ||
|
|
ae1ecfa1e9 | ||
|
|
874bdcf9f1 | ||
|
|
2235d44726 | ||
|
|
27bbb10a31 | ||
|
|
be4134fc3b | ||
|
|
e19ba059b5 | ||
|
|
11a14d8fe7 | ||
|
|
3759ff26b4 | ||
|
|
136b50f926 | ||
|
|
2770f65027 | ||
|
|
db6f813386 | ||
|
|
15d963cb53 | ||
|
|
a2206dba9f | ||
|
|
d159580ecf | ||
|
|
0de9dc11ad | ||
|
|
f790143b0b | ||
|
|
42b0254c9b | ||
|
|
d183af61fa | ||
|
|
f256a4101b | ||
|
|
3ff8bacc45 | ||
|
|
ca59cfc1e1 | ||
|
|
071543efda | ||
|
|
e22a77d4bb | ||
|
|
e0362dce36 | ||
|
|
e55d1cee6b | ||
|
|
676f1ff1cb | ||
|
|
dd760f16f7 | ||
|
|
d00277107a | ||
|
|
b95efe8dd3 | ||
|
|
075f2ffa15 | ||
|
|
2f6a044c34 | ||
|
|
e8199fa6b5 | ||
|
|
c29a7bc8cc | ||
|
|
78f0593abe | ||
|
|
aca58f040f |
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
|
||||||
|
|||||||
Vendored
+6
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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: "",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+170
-157
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?:\/\/.+/);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user