From aca58f040f70e728392397c21ca276ce87f112f1 Mon Sep 17 00:00:00 2001 From: ageru Date: Wed, 11 Mar 2026 21:56:14 +0100 Subject: [PATCH 001/126] Create Init service file for OpenRC This in an init file for OpenRC systems. It should be equivalent in features to the current systemd file, with 2 deliberate changes: - removed "no-log-dates", as the logs are fine as-is - Lower nofile limit, as it seems largely sufficient for a self-hosted instance. Feel free to increase to 8192 or 10240 if necessary. Confirmed functional with Gentoo amd64 and ntfy 2.17.0. --- server/ntfy-server.openrc | 49 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 server/ntfy-server.openrc diff --git a/server/ntfy-server.openrc b/server/ntfy-server.openrc new file mode 100644 index 00000000..d928f28e --- /dev/null +++ b/server/ntfy-server.openrc @@ -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" +} From d517ce4a2aa21ef8379a8dc013fc6e58cdebbebe Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 14 Mar 2026 21:10:46 -0400 Subject: [PATCH 002/126] WIP: S3 --- attachment/store.go | 25 ++ .../file_cache.go => attachment/store_file.go | 57 ++-- attachment/store_file_test.go | 99 +++++++ attachment/store_s3.go | 255 ++++++++++++++++++ attachment/store_s3_test.go | 76 ++++++ cmd/serve.go | 7 + docs/config.md | 45 +++- docs/releases.md | 1 + go.mod | 12 + go.sum | 24 ++ server/config.go | 1 + server/file_cache_test.go | 76 ------ server/log.go | 1 - server/server.go | 42 +-- server/server.yml | 1 + server/server_manager.go | 3 + 16 files changed, 597 insertions(+), 128 deletions(-) create mode 100644 attachment/store.go rename server/file_cache.go => attachment/store_file.go (66%) create mode 100644 attachment/store_file_test.go create mode 100644 attachment/store_s3.go create mode 100644 attachment/store_s3_test.go delete mode 100644 server/file_cache_test.go diff --git a/attachment/store.go b/attachment/store.go new file mode 100644 index 00000000..c48a1e90 --- /dev/null +++ b/attachment/store.go @@ -0,0 +1,25 @@ +package attachment + +import ( + "errors" + "fmt" + "io" + "regexp" + + "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/util" +) + +// Store is an interface for storing and retrieving attachment files +type Store interface { + Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) + Read(id string) (io.ReadCloser, int64, error) + Remove(ids ...string) error + Size() int64 + Remaining() int64 +} + +var ( + fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength)) + errInvalidFileID = errors.New("invalid file ID") +) diff --git a/server/file_cache.go b/attachment/store_file.go similarity index 66% rename from server/file_cache.go rename to attachment/store_file.go index a1803724..b26e86f0 100644 --- a/server/file_cache.go +++ b/attachment/store_file.go @@ -1,32 +1,29 @@ -package server +package attachment import ( "errors" - "fmt" - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/model" - "heckel.io/ntfy/v2/util" "io" "os" "path/filepath" - "regexp" "sync" + + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/util" ) -var ( - fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength)) - errInvalidFileID = errors.New("invalid file ID") - errFileExists = errors.New("file exists") -) +const tagFileStore = "file_store" -type fileCache struct { +var errFileExists = errors.New("file exists") + +type fileStore struct { dir string totalSizeCurrent int64 totalSizeLimit int64 mu sync.Mutex } -func newFileCache(dir string, totalSizeLimit int64) (*fileCache, error) { +// NewFileStore creates a new file-system backed attachment store +func NewFileStore(dir string, totalSizeLimit int64) (Store, error) { if err := os.MkdirAll(dir, 0700); err != nil { return nil, err } @@ -34,18 +31,18 @@ func newFileCache(dir string, totalSizeLimit int64) (*fileCache, error) { if err != nil { return nil, err } - return &fileCache{ + return &fileStore{ dir: dir, totalSizeCurrent: size, totalSizeLimit: totalSizeLimit, }, nil } -func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { +func (c *fileStore) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { if !fileIDRegex.MatchString(id) { return 0, errInvalidFileID } - log.Tag(tagFileCache).Field("message_id", id).Debug("Writing attachment") + log.Tag(tagFileStore).Field("message_id", id).Debug("Writing attachment") file := filepath.Join(c.dir, id) if _, err := os.Stat(file); err == nil { return 0, errFileExists @@ -68,20 +65,35 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (in } c.mu.Lock() c.totalSizeCurrent += size - mset(metricAttachmentsTotalSize, c.totalSizeCurrent) c.mu.Unlock() return size, nil } -func (c *fileCache) Remove(ids ...string) error { +func (c *fileStore) Read(id string) (io.ReadCloser, int64, error) { + if !fileIDRegex.MatchString(id) { + return nil, 0, errInvalidFileID + } + file := filepath.Join(c.dir, id) + stat, err := os.Stat(file) + if err != nil { + return nil, 0, err + } + f, err := os.Open(file) + if err != nil { + return nil, 0, err + } + return f, stat.Size(), nil +} + +func (c *fileStore) Remove(ids ...string) error { for _, id := range ids { if !fileIDRegex.MatchString(id) { return errInvalidFileID } - log.Tag(tagFileCache).Field("message_id", id).Debug("Deleting attachment") + log.Tag(tagFileStore).Field("message_id", id).Debug("Deleting attachment") file := filepath.Join(c.dir, id) if err := os.Remove(file); err != nil { - log.Tag(tagFileCache).Field("message_id", id).Err(err).Debug("Error deleting attachment") + log.Tag(tagFileStore).Field("message_id", id).Err(err).Debug("Error deleting attachment") } } size, err := dirSize(c.dir) @@ -91,17 +103,16 @@ func (c *fileCache) Remove(ids ...string) error { c.mu.Lock() c.totalSizeCurrent = size c.mu.Unlock() - mset(metricAttachmentsTotalSize, size) return nil } -func (c *fileCache) Size() int64 { +func (c *fileStore) Size() int64 { c.mu.Lock() defer c.mu.Unlock() return c.totalSizeCurrent } -func (c *fileCache) Remaining() int64 { +func (c *fileStore) Remaining() int64 { c.mu.Lock() defer c.mu.Unlock() remaining := c.totalSizeLimit - c.totalSizeCurrent diff --git a/attachment/store_file_test.go b/attachment/store_file_test.go new file mode 100644 index 00000000..5cfe0db4 --- /dev/null +++ b/attachment/store_file_test.go @@ -0,0 +1,99 @@ +package attachment + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/util" +) + +var ( + oneKilobyteArray = make([]byte, 1024) +) + +func TestFileStore_Write_Success(t *testing.T) { + dir, s := newTestFileStore(t) + size, err := s.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999)) + require.Nil(t, err) + require.Equal(t, int64(11), size) + require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl")) + require.Equal(t, int64(11), s.Size()) + require.Equal(t, int64(10229), s.Remaining()) +} + +func TestFileStore_Write_Read_Success(t *testing.T) { + _, s := newTestFileStore(t) + size, err := s.Write("abcdefghijkl", strings.NewReader("hello world")) + require.Nil(t, err) + require.Equal(t, int64(11), size) + + reader, readSize, err := s.Read("abcdefghijkl") + require.Nil(t, err) + require.Equal(t, int64(11), readSize) + defer reader.Close() + data, err := io.ReadAll(reader) + require.Nil(t, err) + require.Equal(t, "hello world", string(data)) +} + +func TestFileStore_Write_Remove_Success(t *testing.T) { + dir, s := newTestFileStore(t) // max = 10k (10240), each = 1k (1024) + for i := 0; i < 10; i++ { // 10x999 = 9990 + size, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999))) + require.Nil(t, err) + require.Equal(t, int64(999), size) + } + require.Equal(t, int64(9990), s.Size()) + require.Equal(t, int64(250), s.Remaining()) + require.FileExists(t, dir+"/abcdefghijk1") + require.FileExists(t, dir+"/abcdefghijk5") + + require.Nil(t, s.Remove("abcdefghijk1", "abcdefghijk5")) + require.NoFileExists(t, dir+"/abcdefghijk1") + require.NoFileExists(t, dir+"/abcdefghijk5") + require.Equal(t, int64(7992), s.Size()) + require.Equal(t, int64(2248), s.Remaining()) +} + +func TestFileStore_Write_FailedTotalSizeLimit(t *testing.T) { + dir, s := newTestFileStore(t) + for i := 0; i < 10; i++ { + size, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray)) + require.Nil(t, err) + require.Equal(t, int64(1024), size) + } + _, err := s.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray)) + require.Equal(t, util.ErrLimitReached, err) + require.NoFileExists(t, dir+"/abcdefghijkX") +} + +func TestFileStore_Write_FailedAdditionalLimiter(t *testing.T) { + dir, s := newTestFileStore(t) + _, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) + require.Equal(t, util.ErrLimitReached, err) + require.NoFileExists(t, dir+"/abcdefghijkl") +} + +func TestFileStore_Read_NotFound(t *testing.T) { + _, s := newTestFileStore(t) + _, _, err := s.Read("abcdefghijkl") + require.Error(t, err) +} + +func newTestFileStore(t *testing.T) (dir string, store Store) { + dir = t.TempDir() + store, err := NewFileStore(dir, 10*1024) + require.Nil(t, err) + return dir, store +} + +func readFile(t *testing.T, f string) string { + b, err := os.ReadFile(f) + require.Nil(t, err) + return string(b) +} diff --git a/attachment/store_s3.go b/attachment/store_s3.go new file mode 100644 index 00000000..118da4ce --- /dev/null +++ b/attachment/store_s3.go @@ -0,0 +1,255 @@ +package attachment + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + "sync" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/util" +) + +const tagS3Store = "s3_store" + +type s3Store struct { + client *s3.Client + bucket string + prefix string + totalSizeCurrent int64 + totalSizeLimit int64 + mu sync.Mutex +} + +// NewS3Store creates a new S3-backed attachment store. The s3URL must be in the format: +// +// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] +func NewS3Store(s3URL string, totalSizeLimit int64) (Store, error) { + bucket, prefix, client, err := parseS3URL(s3URL) + if err != nil { + return nil, err + } + store := &s3Store{ + client: client, + bucket: bucket, + prefix: prefix, + totalSizeLimit: totalSizeLimit, + } + if totalSizeLimit > 0 { + size, err := store.computeSize() + if err != nil { + return nil, fmt.Errorf("s3 store: failed to compute initial size: %w", err) + } + store.totalSizeCurrent = size + } + return store, nil +} + +func parseS3URL(s3URL string) (bucket string, prefix string, client *s3.Client, err error) { + u, err := url.Parse(s3URL) + if err != nil { + return "", "", nil, fmt.Errorf("s3 store: invalid URL: %w", err) + } + if u.Scheme != "s3" { + return "", "", nil, fmt.Errorf("s3 store: URL scheme must be 's3', got '%s'", u.Scheme) + } + if u.Host == "" { + return "", "", nil, fmt.Errorf("s3 store: bucket name must be specified as host") + } + bucket = u.Host + prefix = strings.TrimPrefix(u.Path, "/") + + accessKey := u.User.Username() + secretKey, _ := u.User.Password() + if accessKey == "" || secretKey == "" { + return "", "", nil, fmt.Errorf("s3 store: access key and secret key must be specified in URL") + } + + region := u.Query().Get("region") + if region == "" { + return "", "", nil, fmt.Errorf("s3 store: region query parameter is required") + } + endpoint := u.Query().Get("endpoint") + + cfg := aws.Config{ + Region: region, + Credentials: credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), + } + var opts []func(*s3.Options) + if endpoint != "" { + opts = append(opts, func(o *s3.Options) { + o.BaseEndpoint = aws.String(endpoint) + o.UsePathStyle = true + }) + } + client = s3.NewFromConfig(cfg, opts...) + return bucket, prefix, client, nil +} + +func (c *s3Store) objectKey(id string) string { + if c.prefix != "" { + return c.prefix + "/" + id + } + return id +} + +func (c *s3Store) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { + if !fileIDRegex.MatchString(id) { + return 0, errInvalidFileID + } + log.Tag(tagS3Store).Field("message_id", id).Debug("Writing attachment to S3") + + // Use io.Pipe so we can apply limiters while streaming to S3 + pr, pw := io.Pipe() + var writeErr error + var size int64 + + limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) + go func() { + limitWriter := util.NewLimitWriter(pw, limiters...) + size, writeErr = io.Copy(limitWriter, in) + if writeErr != nil { + pw.CloseWithError(writeErr) + } else { + pw.Close() + } + }() + + key := c.objectKey(id) + _, err := c.client.PutObject(context.Background(), &s3.PutObjectInput{ + Bucket: aws.String(c.bucket), + Key: aws.String(key), + Body: pr, + }) + if err != nil { + // If the limiter caused the error, return the original write error + if writeErr != nil { + return 0, writeErr + } + return 0, fmt.Errorf("s3 store: PutObject failed: %w", err) + } + if writeErr != nil { + // The write goroutine failed but PutObject somehow succeeded; clean up + _, _ = c.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ + Bucket: aws.String(c.bucket), + Key: aws.String(key), + }) + return 0, writeErr + } + + c.mu.Lock() + c.totalSizeCurrent += size + c.mu.Unlock() + return size, nil +} + +func (c *s3Store) Read(id string) (io.ReadCloser, int64, error) { + if !fileIDRegex.MatchString(id) { + return nil, 0, errInvalidFileID + } + key := c.objectKey(id) + resp, err := c.client.GetObject(context.Background(), &s3.GetObjectInput{ + Bucket: aws.String(c.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, 0, fmt.Errorf("s3 store: GetObject failed: %w", err) + } + var size int64 + if resp.ContentLength != nil { + size = *resp.ContentLength + } + return resp.Body, size, nil +} + +func (c *s3Store) Remove(ids ...string) error { + for _, id := range ids { + if !fileIDRegex.MatchString(id) { + return errInvalidFileID + } + } + // S3 DeleteObjects supports up to 1000 keys per call + for i := 0; i < len(ids); i += 1000 { + end := i + 1000 + if end > len(ids) { + end = len(ids) + } + batch := ids[i:end] + objects := make([]s3types.ObjectIdentifier, len(batch)) + for j, id := range batch { + log.Tag(tagS3Store).Field("message_id", id).Debug("Deleting attachment from S3") + key := c.objectKey(id) + objects[j] = s3types.ObjectIdentifier{ + Key: aws.String(key), + } + } + _, err := c.client.DeleteObjects(context.Background(), &s3.DeleteObjectsInput{ + Bucket: aws.String(c.bucket), + Delete: &s3types.Delete{ + Objects: objects, + Quiet: aws.Bool(true), + }, + }) + if err != nil { + return fmt.Errorf("s3 store: DeleteObjects failed: %w", err) + } + } + // Recalculate totalSizeCurrent via ListObjectsV2 (matches fileStore's dirSize rescan pattern) + size, err := c.computeSize() + if err != nil { + return fmt.Errorf("s3 store: failed to compute size after remove: %w", err) + } + c.mu.Lock() + c.totalSizeCurrent = size + c.mu.Unlock() + return nil +} + +func (c *s3Store) Size() int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.totalSizeCurrent +} + +func (c *s3Store) Remaining() int64 { + c.mu.Lock() + defer c.mu.Unlock() + remaining := c.totalSizeLimit - c.totalSizeCurrent + if remaining < 0 { + return 0 + } + return remaining +} + +func (c *s3Store) computeSize() (int64, error) { + var size int64 + paginator := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{ + Bucket: aws.String(c.bucket), + Prefix: aws.String(c.prefixForList()), + }) + for paginator.HasMorePages() { + page, err := paginator.NextPage(context.Background()) + if err != nil { + return 0, err + } + for _, obj := range page.Contents { + if obj.Size != nil { + size += *obj.Size + } + } + } + return size, nil +} + +func (c *s3Store) prefixForList() string { + if c.prefix != "" { + return c.prefix + "/" + } + return "" +} diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go new file mode 100644 index 00000000..a1808d0c --- /dev/null +++ b/attachment/store_s3_test.go @@ -0,0 +1,76 @@ +package attachment + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseS3URL_Success(t *testing.T) { + bucket, prefix, client, err := parseS3URL("s3://AKID:SECRET@my-bucket/attachments?region=us-east-1") + require.Nil(t, err) + require.Equal(t, "my-bucket", bucket) + require.Equal(t, "attachments", prefix) + require.NotNil(t, client) +} + +func TestParseS3URL_NoPrefix(t *testing.T) { + bucket, prefix, client, err := parseS3URL("s3://AKID:SECRET@my-bucket?region=us-east-1") + require.Nil(t, err) + require.Equal(t, "my-bucket", bucket) + require.Equal(t, "", prefix) + require.NotNil(t, client) +} + +func TestParseS3URL_WithEndpoint(t *testing.T) { + bucket, prefix, client, err := parseS3URL("s3://AKID:SECRET@my-bucket/prefix?region=us-east-1&endpoint=https://s3.example.com") + require.Nil(t, err) + require.Equal(t, "my-bucket", bucket) + require.Equal(t, "prefix", prefix) + require.NotNil(t, client) +} + +func TestParseS3URL_NestedPrefix(t *testing.T) { + bucket, prefix, _, err := parseS3URL("s3://AKID:SECRET@my-bucket/a/b/c?region=us-east-1") + require.Nil(t, err) + require.Equal(t, "my-bucket", bucket) + require.Equal(t, "a/b/c", prefix) +} + +func TestParseS3URL_MissingRegion(t *testing.T) { + _, _, _, err := parseS3URL("s3://AKID:SECRET@my-bucket") + require.Error(t, err) + require.Contains(t, err.Error(), "region") +} + +func TestParseS3URL_MissingCredentials(t *testing.T) { + _, _, _, err := parseS3URL("s3://my-bucket?region=us-east-1") + require.Error(t, err) + require.Contains(t, err.Error(), "access key") +} + +func TestParseS3URL_MissingSecretKey(t *testing.T) { + _, _, _, err := parseS3URL("s3://AKID@my-bucket?region=us-east-1") + require.Error(t, err) + require.Contains(t, err.Error(), "secret key") +} + +func TestParseS3URL_WrongScheme(t *testing.T) { + _, _, _, err := parseS3URL("http://AKID:SECRET@my-bucket?region=us-east-1") + require.Error(t, err) + require.Contains(t, err.Error(), "scheme") +} + +func TestParseS3URL_EmptyBucket(t *testing.T) { + _, _, _, err := parseS3URL("s3://AKID:SECRET@?region=us-east-1") + require.Error(t, err) + require.Contains(t, err.Error(), "bucket") +} + +func TestS3Store_ObjectKey(t *testing.T) { + s := &s3Store{prefix: "attachments"} + require.Equal(t, "attachments/abcdefghijkl", s.objectKey("abcdefghijkl")) + + s2 := &s3Store{prefix: ""} + require.Equal(t, "abcdefghijkl", s2.objectKey("abcdefghijkl")) +} diff --git a/cmd/serve.go b/cmd/serve.go index 415868fc..c2ed210a 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -53,6 +53,7 @@ var flagsServe = append( altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-s3-url", Aliases: []string{"attachment_s3_url"}, EnvVars: []string{"NTFY_ATTACHMENT_S3_URL"}, Usage: "S3 URL for attachment storage (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), @@ -166,6 +167,7 @@ func execServe(c *cli.Context) error { authAccessRaw := c.StringSlice("auth-access") authTokensRaw := c.StringSlice("auth-tokens") attachmentCacheDir := c.String("attachment-cache-dir") + attachmentS3URL := c.String("attachment-s3-url") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") attachmentExpiryDurationStr := c.String("attachment-expiry-duration") @@ -314,6 +316,10 @@ func execServe(c *cli.Context) error { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { return errors.New("if attachment-cache-dir is set, base-url must also be set") + } else if attachmentS3URL != "" && baseURL == "" { + return errors.New("if attachment-s3-url is set, base-url must also be set") + } else if attachmentS3URL != "" && attachmentCacheDir != "" { + return errors.New("attachment-cache-dir and attachment-s3-url are mutually exclusive") } else if baseURL != "" { u, err := url.Parse(baseURL) if err != nil { @@ -457,6 +463,7 @@ func execServe(c *cli.Context) error { conf.AuthAccess = authAccess conf.AuthTokens = authTokens conf.AttachmentCacheDir = attachmentCacheDir + conf.AttachmentS3URL = attachmentS3URL conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentExpiryDuration = attachmentExpiryDuration diff --git a/docs/config.md b/docs/config.md index b9c8f07f..34484a51 100644 --- a/docs/config.md +++ b/docs/config.md @@ -489,20 +489,23 @@ Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscri ## Attachments If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable -this feature, you have to simply configure an attachment cache directory and a base URL (`attachment-cache-dir`, `base-url`). -Once these options are set and the directory is writable by the server user, you can upload attachments via PUT. +this feature, you have to configure an attachment storage backend and a base URL (`base-url`). Attachments can be stored +either on the local filesystem (`attachment-cache-dir`) or in an S3-compatible object store (`attachment-s3-url`). +Once configured, you can upload attachments via PUT. -By default, attachments are stored in the disk-cache **for only 3 hours**. The main reason for this is to avoid legal issues -and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download +By default, attachments are stored **for only 3 hours**. The main reason for this is to avoid legal issues +and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download feature) to download the file. The following config options are relevant to attachments: * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs -* `attachment-cache-dir` is the cache directory for attached files -* `attachment-total-size-limit` is the size limit of the on-disk attachment cache (default: 5G) +* `attachment-cache-dir` is the cache directory for attached files (mutually exclusive with `attachment-s3-url`) +* `attachment-s3-url` is the S3 URL for attachment storage (mutually exclusive with `attachment-cache-dir`) +* `attachment-total-size-limit` is the size limit of the attachment storage (default: 5G) * `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M) * `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h) -Here's an example config using mostly the defaults (except for the cache directory, which is empty by default): +### Filesystem storage +Here's an example config using the local filesystem for attachment storage: === "/etc/ntfy/server.yml (minimal)" ``` yaml @@ -521,6 +524,30 @@ Here's an example config using mostly the defaults (except for the cache directo visitor-attachment-daily-bandwidth-limit: "500M" ``` +### S3 storage +As an alternative to the local filesystem, you can store attachments in an S3-compatible object store (e.g. AWS S3, +MinIO, DigitalOcean Spaces). This is useful for HA/cloud deployments where you don't want to rely on local disk storage. + +The `attachment-s3-url` option uses the following format: + +``` +s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] +``` + +When `endpoint` is specified, path-style addressing is enabled automatically (useful for MinIO and other S3-compatible stores). + +=== "/etc/ntfy/server.yml (AWS S3)" + ``` yaml + base-url: "https://ntfy.sh" + attachment-s3-url: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1" + ``` + +=== "/etc/ntfy/server.yml (MinIO/custom endpoint)" + ``` yaml + base-url: "https://ntfy.sh" + attachment-s3-url: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" + ``` + Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. @@ -2116,7 +2143,8 @@ 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) | | `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 | -| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | +| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. Mutually exclusive with `attachment-s3-url`. | +| `attachment-s3-url` | `NTFY_ATTACHMENT_S3_URL` | *URL* | - | S3 URL for attachment storage (format: `s3://KEY:SECRET@BUCKET[/PREFIX]?region=REGION`). Mutually exclusive with `attachment-cache-dir`. | | `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-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`. | @@ -2219,6 +2247,7 @@ OPTIONS: --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] --attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] + --attachment-s3-url value, --attachment_s3_url value S3 URL for attachment storage (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION) [$NTFY_ATTACHMENT_S3_URL] --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-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] diff --git a/docs/releases.md b/docs/releases.md index 7a40e5c4..7f5d6b45 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1761,6 +1761,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Support PostgreSQL read replicas for offloading non-critical read queries via `database-replica-urls` config option * Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files +* Add S3-compatible object storage as an alternative attachment backend via `attachment-s3-url` config option **Bug fixes + maintenance:** diff --git a/go.mod b/go.mod index c073d6aa..ef8564c2 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,18 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 1c6eada9..3f373614 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,30 @@ github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/server/config.go b/server/config.go index 8ead312c..97f72a1c 100644 --- a/server/config.go +++ b/server/config.go @@ -112,6 +112,7 @@ type Config struct { AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string + AttachmentS3URL string AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 AttachmentExpiryDuration time.Duration diff --git a/server/file_cache_test.go b/server/file_cache_test.go deleted file mode 100644 index e7dee3b3..00000000 --- a/server/file_cache_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package server - -import ( - "bytes" - "fmt" - "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/util" - "os" - "strings" - "testing" -) - -var ( - oneKilobyteArray = make([]byte, 1024) -) - -func TestFileCache_Write_Success(t *testing.T) { - dir, c := newTestFileCache(t) - size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999)) - require.Nil(t, err) - require.Equal(t, int64(11), size) - require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl")) - require.Equal(t, int64(11), c.Size()) - require.Equal(t, int64(10229), c.Remaining()) -} - -func TestFileCache_Write_Remove_Success(t *testing.T) { - dir, c := newTestFileCache(t) // max = 10k (10240), each = 1k (1024) - for i := 0; i < 10; i++ { // 10x999 = 9990 - size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999))) - require.Nil(t, err) - require.Equal(t, int64(999), size) - } - require.Equal(t, int64(9990), c.Size()) - require.Equal(t, int64(250), c.Remaining()) - require.FileExists(t, dir+"/abcdefghijk1") - require.FileExists(t, dir+"/abcdefghijk5") - - require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5")) - require.NoFileExists(t, dir+"/abcdefghijk1") - require.NoFileExists(t, dir+"/abcdefghijk5") - require.Equal(t, int64(7992), c.Size()) - require.Equal(t, int64(2248), c.Remaining()) -} - -func TestFileCache_Write_FailedTotalSizeLimit(t *testing.T) { - dir, c := newTestFileCache(t) - for i := 0; i < 10; i++ { - size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray)) - require.Nil(t, err) - require.Equal(t, int64(1024), size) - } - _, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray)) - require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abcdefghijkX") -} - -func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) { - dir, c := newTestFileCache(t) - _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) - require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abcdefghijkl") -} - -func newTestFileCache(t *testing.T) (dir string, cache *fileCache) { - dir = t.TempDir() - cache, err := newFileCache(dir, 10*1024) - require.Nil(t, err) - return dir, cache -} - -func readFile(t *testing.T, f string) string { - b, err := os.ReadFile(f) - require.Nil(t, err) - return string(b) -} diff --git a/server/log.go b/server/log.go index 03600c0d..e4ddc178 100644 --- a/server/log.go +++ b/server/log.go @@ -24,7 +24,6 @@ const ( tagSMTP = "smtp" // Receive email tagEmail = "email" // Send email tagTwilio = "twilio" - tagFileCache = "file_cache" tagMessageCache = "message_cache" tagStripe = "stripe" tagAccount = "account" diff --git a/server/server.go b/server/server.go index 24c712bd..434f93af 100644 --- a/server/server.go +++ b/server/server.go @@ -32,6 +32,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" "golang.org/x/sync/errgroup" "gopkg.in/yaml.v2" + "heckel.io/ntfy/v2/attachment" "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/log" @@ -64,7 +65,7 @@ type Server struct { userManager *user.Manager // Might be nil! messageCache *message.Cache // Database that stores the messages webPush *webpush.Store // Database that stores web push subscriptions - fileCache *fileCache // File system based cache that stores attachments + fileCache attachment.Store // Attachment store (file system or S3) stripe stripeAPI // Stripe API, can be replaced with a mock priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set @@ -227,12 +228,9 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } - var fileCache *fileCache - if conf.AttachmentCacheDir != "" { - fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit) - if err != nil { - return nil, err - } + fileCache, err := createAttachmentStore(conf) + if err != nil { + return nil, err } var userManager *user.Manager if conf.AuthFile != "" || pool != nil { @@ -301,6 +299,15 @@ func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) { return message.NewMemStore() } +func createAttachmentStore(conf *Config) (attachment.Store, error) { + if conf.AttachmentS3URL != "" { + return attachment.NewS3Store(conf.AttachmentS3URL, conf.AttachmentTotalSizeLimit) + } else if conf.AttachmentCacheDir != "" { + return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit) + } + return nil, nil +} + // Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts // a manager go routine to print stats and prune messages. func (s *Server) Run() error { @@ -752,7 +759,7 @@ func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request, _ *visitor) // Before streaming the file to a client, it locates uploader (m.Sender or m.User) in the message cache, so it // can associate the download bandwidth with the uploader. func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.config.AttachmentCacheDir == "" { + if s.fileCache == nil { return errHTTPInternalError } matches := fileRegex.FindStringSubmatch(r.URL.Path) @@ -760,16 +767,16 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) return errHTTPInternalErrorInvalidPath } messageID := matches[1] - file := filepath.Join(s.config.AttachmentCacheDir, messageID) - stat, err := os.Stat(file) + reader, size, err := s.fileCache.Read(messageID) if err != nil { return errHTTPNotFound.Fields(log.Context{ "message_id": messageID, - "error_context": "filesystem", + "error_context": "attachment_store", }) } + defer reader.Close() w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests - w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) if r.Method == http.MethodHead { return nil } @@ -805,19 +812,14 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) } else if m.Sender.IsValid() { bandwidthVisitor = s.visitor(m.Sender, nil) } - if !bandwidthVisitor.BandwidthAllowed(stat.Size()) { + if !bandwidthVisitor.BandwidthAllowed(size) { return errHTTPTooManyRequestsLimitAttachmentBandwidth.With(m) } // Actually send file - f, err := os.Open(file) - if err != nil { - return err - } - defer f.Close() if m.Attachment.Name != "" { w.Header().Set("Content-Disposition", "attachment; filename="+strconv.Quote(m.Attachment.Name)) } - _, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f) + _, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), reader) return err } @@ -1408,7 +1410,7 @@ func (s *Server) renderTemplate(name, tpl, source string) (string, error) { } func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error { - if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { + if s.fileCache == nil || s.config.BaseURL == "" { return errHTTPBadRequestAttachmentsDisallowed.With(m) } vinfo, err := v.Info() diff --git a/server/server.yml b/server/server.yml index 43cb5fb4..e6f7afee 100644 --- a/server/server.yml +++ b/server/server.yml @@ -159,6 +159,7 @@ # - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h) # # attachment-cache-dir: +# attachment-s3-url: "s3://ACCESS_KEY:SECRET_KEY@bucket/prefix?region=us-east-1" # attachment-total-size-limit: "5G" # attachment-file-size-limit: "15M" # attachment-expiry-duration: "3h" diff --git a/server/server_manager.go b/server/server_manager.go index afed7b33..5bf42924 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -99,6 +99,9 @@ func (s *Server) execManager() { mset(metricUsers, usersCount) mset(metricSubscribers, subscribers) mset(metricTopics, topicsCount) + if s.fileCache != nil { + mset(metricAttachmentsTotalSize, s.fileCache.Size()) + } } func (s *Server) pruneVisitors() { From b4ec6fa8df41f9ad7e7079227a9a9c9e13b02534 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 15 Mar 2026 10:12:23 -0400 Subject: [PATCH 003/126] AWS deps.. --- go.mod | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ef8564c2..f3cd7791 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,9 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.19.0 github.com/SherClockHolmes/webpush-go v1.4.0 + github.com/aws/aws-sdk-go-v2 v1.41.4 + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 github.com/jackc/pgx/v5 v5.8.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 @@ -52,9 +55,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect @@ -62,7 +63,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 // indirect github.com/aws/smithy-go v1.24.2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect From 790ba243c766fed20f42df126dd0a69e9e662bb6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Mar 2026 09:48:26 -0400 Subject: [PATCH 004/126] S3 WIP --- attachment/store_s3.go | 186 +++------ attachment/store_s3_test.go | 298 ++++++++++++--- go.mod | 12 - go.sum | 24 -- s3/client.go | 325 ++++++++++++++++ s3/client_test.go | 727 ++++++++++++++++++++++++++++++++++++ s3/types.go | 65 ++++ s3/util.go | 161 ++++++++ s3/util_test.go | 181 +++++++++ tools/s3cli/main.go | 164 ++++++++ 10 files changed, 1917 insertions(+), 226 deletions(-) create mode 100644 s3/client.go create mode 100644 s3/client_test.go create mode 100644 s3/types.go create mode 100644 s3/util.go create mode 100644 s3/util_test.go create mode 100644 tools/s3cli/main.go diff --git a/attachment/store_s3.go b/attachment/store_s3.go index 118da4ce..5c47a81b 100644 --- a/attachment/store_s3.go +++ b/attachment/store_s3.go @@ -4,24 +4,20 @@ import ( "context" "fmt" "io" - "net/url" - "strings" + "os" "sync" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" - s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/s3" "heckel.io/ntfy/v2/util" ) -const tagS3Store = "s3_store" +const ( + tagS3Store = "s3_store" +) type s3Store struct { client *s3.Client - bucket string - prefix string totalSizeCurrent int64 totalSizeLimit int64 mu sync.Mutex @@ -31,14 +27,12 @@ type s3Store struct { // // s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] func NewS3Store(s3URL string, totalSizeLimit int64) (Store, error) { - bucket, prefix, client, err := parseS3URL(s3URL) + cfg, err := s3.ParseURL(s3URL) if err != nil { return nil, err } store := &s3Store{ - client: client, - bucket: bucket, - prefix: prefix, + client: s3.New(cfg), totalSizeLimit: totalSizeLimit, } if totalSizeLimit > 0 { @@ -51,98 +45,40 @@ func NewS3Store(s3URL string, totalSizeLimit int64) (Store, error) { return store, nil } -func parseS3URL(s3URL string) (bucket string, prefix string, client *s3.Client, err error) { - u, err := url.Parse(s3URL) - if err != nil { - return "", "", nil, fmt.Errorf("s3 store: invalid URL: %w", err) - } - if u.Scheme != "s3" { - return "", "", nil, fmt.Errorf("s3 store: URL scheme must be 's3', got '%s'", u.Scheme) - } - if u.Host == "" { - return "", "", nil, fmt.Errorf("s3 store: bucket name must be specified as host") - } - bucket = u.Host - prefix = strings.TrimPrefix(u.Path, "/") - - accessKey := u.User.Username() - secretKey, _ := u.User.Password() - if accessKey == "" || secretKey == "" { - return "", "", nil, fmt.Errorf("s3 store: access key and secret key must be specified in URL") - } - - region := u.Query().Get("region") - if region == "" { - return "", "", nil, fmt.Errorf("s3 store: region query parameter is required") - } - endpoint := u.Query().Get("endpoint") - - cfg := aws.Config{ - Region: region, - Credentials: credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""), - } - var opts []func(*s3.Options) - if endpoint != "" { - opts = append(opts, func(o *s3.Options) { - o.BaseEndpoint = aws.String(endpoint) - o.UsePathStyle = true - }) - } - client = s3.NewFromConfig(cfg, opts...) - return bucket, prefix, client, nil -} - -func (c *s3Store) objectKey(id string) string { - if c.prefix != "" { - return c.prefix + "/" + id - } - return id -} - func (c *s3Store) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { if !fileIDRegex.MatchString(id) { return 0, errInvalidFileID } log.Tag(tagS3Store).Field("message_id", id).Debug("Writing attachment to S3") - // Use io.Pipe so we can apply limiters while streaming to S3 - pr, pw := io.Pipe() - var writeErr error - var size int64 - + // Write through limiters into a temp file. This avoids buffering the full attachment in + // memory while still giving us the Content-Length that PutObject requires. limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) - go func() { - limitWriter := util.NewLimitWriter(pw, limiters...) - size, writeErr = io.Copy(limitWriter, in) - if writeErr != nil { - pw.CloseWithError(writeErr) - } else { - pw.Close() - } - }() - - key := c.objectKey(id) - _, err := c.client.PutObject(context.Background(), &s3.PutObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - Body: pr, - }) + tmpFile, err := os.CreateTemp("", "ntfy-s3-upload-*") if err != nil { - // If the limiter caused the error, return the original write error - if writeErr != nil { - return 0, writeErr - } - return 0, fmt.Errorf("s3 store: PutObject failed: %w", err) + return 0, fmt.Errorf("s3 store: failed to create temp file: %w", err) } - if writeErr != nil { - // The write goroutine failed but PutObject somehow succeeded; clean up - _, _ = c.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - }) - return 0, writeErr + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + limitWriter := util.NewLimitWriter(tmpFile, limiters...) + size, err := io.Copy(limitWriter, in) + if err != nil { + tmpFile.Close() + return 0, err + } + if err := tmpFile.Close(); err != nil { + return 0, err } + // Re-open the temp file for reading and stream it to S3 + f, err := os.Open(tmpPath) + if err != nil { + return 0, err + } + defer f.Close() + if err := c.client.PutObject(context.Background(), id, f, size); err != nil { + return 0, err + } c.mu.Lock() c.totalSizeCurrent += size c.mu.Unlock() @@ -153,19 +89,7 @@ func (c *s3Store) Read(id string) (io.ReadCloser, int64, error) { if !fileIDRegex.MatchString(id) { return nil, 0, errInvalidFileID } - key := c.objectKey(id) - resp, err := c.client.GetObject(context.Background(), &s3.GetObjectInput{ - Bucket: aws.String(c.bucket), - Key: aws.String(key), - }) - if err != nil { - return nil, 0, fmt.Errorf("s3 store: GetObject failed: %w", err) - } - var size int64 - if resp.ContentLength != nil { - size = *resp.ContentLength - } - return resp.Body, size, nil + return c.client.GetObject(context.Background(), id) } func (c *s3Store) Remove(ids ...string) error { @@ -181,23 +105,11 @@ func (c *s3Store) Remove(ids ...string) error { end = len(ids) } batch := ids[i:end] - objects := make([]s3types.ObjectIdentifier, len(batch)) - for j, id := range batch { + for _, id := range batch { log.Tag(tagS3Store).Field("message_id", id).Debug("Deleting attachment from S3") - key := c.objectKey(id) - objects[j] = s3types.ObjectIdentifier{ - Key: aws.String(key), - } } - _, err := c.client.DeleteObjects(context.Background(), &s3.DeleteObjectsInput{ - Bucket: aws.String(c.bucket), - Delete: &s3types.Delete{ - Objects: objects, - Quiet: aws.Bool(true), - }, - }) - if err != nil { - return fmt.Errorf("s3 store: DeleteObjects failed: %w", err) + if err := c.client.DeleteObjects(context.Background(), batch); err != nil { + return err } } // Recalculate totalSizeCurrent via ListObjectsV2 (matches fileStore's dirSize rescan pattern) @@ -227,29 +139,15 @@ func (c *s3Store) Remaining() int64 { return remaining } +// computeSize uses ListAllObjects to sum up the total size of all objects with our prefix. func (c *s3Store) computeSize() (int64, error) { - var size int64 - paginator := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{ - Bucket: aws.String(c.bucket), - Prefix: aws.String(c.prefixForList()), - }) - for paginator.HasMorePages() { - page, err := paginator.NextPage(context.Background()) - if err != nil { - return 0, err - } - for _, obj := range page.Contents { - if obj.Size != nil { - size += *obj.Size - } - } + objects, err := c.client.ListAllObjects(context.Background()) + if err != nil { + return 0, err } - return size, nil -} - -func (c *s3Store) prefixForList() string { - if c.prefix != "" { - return c.prefix + "/" + var totalSize int64 + for _, obj := range objects { + totalSize += obj.Size } - return "" + return totalSize, nil } diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index a1808d0c..c898244d 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -1,76 +1,282 @@ package attachment import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" "testing" "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/s3" + "heckel.io/ntfy/v2/util" ) -func TestParseS3URL_Success(t *testing.T) { - bucket, prefix, client, err := parseS3URL("s3://AKID:SECRET@my-bucket/attachments?region=us-east-1") +// --- Integration tests using a mock S3 server --- + +func TestS3Store_WriteReadRemove(t *testing.T) { + server := newMockS3Server() + defer server.Close() + + store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + + // Write + size, err := store.Write("abcdefghijkl", strings.NewReader("hello world")) require.Nil(t, err) - require.Equal(t, "my-bucket", bucket) - require.Equal(t, "attachments", prefix) - require.NotNil(t, client) -} + require.Equal(t, int64(11), size) + require.Equal(t, int64(11), store.Size()) -func TestParseS3URL_NoPrefix(t *testing.T) { - bucket, prefix, client, err := parseS3URL("s3://AKID:SECRET@my-bucket?region=us-east-1") + // Read back + reader, readSize, err := store.Read("abcdefghijkl") require.Nil(t, err) - require.Equal(t, "my-bucket", bucket) - require.Equal(t, "", prefix) - require.NotNil(t, client) -} - -func TestParseS3URL_WithEndpoint(t *testing.T) { - bucket, prefix, client, err := parseS3URL("s3://AKID:SECRET@my-bucket/prefix?region=us-east-1&endpoint=https://s3.example.com") + require.Equal(t, int64(11), readSize) + data, err := io.ReadAll(reader) + reader.Close() require.Nil(t, err) - require.Equal(t, "my-bucket", bucket) - require.Equal(t, "prefix", prefix) - require.NotNil(t, client) + require.Equal(t, "hello world", string(data)) + + // Remove + require.Nil(t, store.Remove("abcdefghijkl")) + require.Equal(t, int64(0), store.Size()) + + // Read after remove should fail + _, _, err = store.Read("abcdefghijkl") + require.Error(t, err) } -func TestParseS3URL_NestedPrefix(t *testing.T) { - bucket, prefix, _, err := parseS3URL("s3://AKID:SECRET@my-bucket/a/b/c?region=us-east-1") +func TestS3Store_WriteNoPrefix(t *testing.T) { + server := newMockS3Server() + defer server.Close() + + store := newTestS3Store(t, server, "my-bucket", "", 10*1024) + + size, err := store.Write("abcdefghijkl", strings.NewReader("test")) require.Nil(t, err) - require.Equal(t, "my-bucket", bucket) - require.Equal(t, "a/b/c", prefix) + require.Equal(t, int64(4), size) + + reader, _, err := store.Read("abcdefghijkl") + require.Nil(t, err) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "test", string(data)) } -func TestParseS3URL_MissingRegion(t *testing.T) { - _, _, _, err := parseS3URL("s3://AKID:SECRET@my-bucket") +func TestS3Store_WriteTotalSizeLimit(t *testing.T) { + server := newMockS3Server() + defer server.Close() + + store := newTestS3Store(t, server, "my-bucket", "pfx", 100) + + // First write fits + _, err := store.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80))) + require.Nil(t, err) + require.Equal(t, int64(80), store.Size()) + require.Equal(t, int64(20), store.Remaining()) + + // Second write exceeds total limit + _, err = store.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50))) + require.Equal(t, util.ErrLimitReached, err) +} + +func TestS3Store_WriteFileSizeLimit(t *testing.T) { + server := newMockS3Server() + defer server.Close() + + store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + + _, err := store.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), util.NewFixedLimiter(100)) + require.Equal(t, util.ErrLimitReached, err) +} + +func TestS3Store_WriteRemoveMultiple(t *testing.T) { + server := newMockS3Server() + defer server.Close() + + store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + + for i := 0; i < 5; i++ { + _, err := store.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100))) + require.Nil(t, err) + } + require.Equal(t, int64(500), store.Size()) + + require.Nil(t, store.Remove("abcdefghijk1", "abcdefghijk3")) + require.Equal(t, int64(300), store.Size()) +} + +func TestS3Store_ReadNotFound(t *testing.T) { + server := newMockS3Server() + defer server.Close() + + store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + + _, _, err := store.Read("abcdefghijkl") require.Error(t, err) - require.Contains(t, err.Error(), "region") } -func TestParseS3URL_MissingCredentials(t *testing.T) { - _, _, _, err := parseS3URL("s3://my-bucket?region=us-east-1") - require.Error(t, err) - require.Contains(t, err.Error(), "access key") +func TestS3Store_InvalidID(t *testing.T) { + server := newMockS3Server() + defer server.Close() + + store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + + _, err := store.Write("bad", strings.NewReader("x")) + require.Equal(t, errInvalidFileID, err) + + _, _, err = store.Read("bad") + require.Equal(t, errInvalidFileID, err) + + err = store.Remove("bad") + require.Equal(t, errInvalidFileID, err) } -func TestParseS3URL_MissingSecretKey(t *testing.T) { - _, _, _, err := parseS3URL("s3://AKID@my-bucket?region=us-east-1") - require.Error(t, err) - require.Contains(t, err.Error(), "secret key") +// --- Helpers --- + +func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string, totalSizeLimit int64) Store { + t.Helper() + // httptest.NewTLSServer URL is like "https://127.0.0.1:PORT" + host := strings.TrimPrefix(server.URL, "https://") + s := &s3Store{ + client: &s3.Client{ + AccessKey: "AKID", + SecretKey: "SECRET", + Region: "us-east-1", + Endpoint: host, + Bucket: bucket, + Prefix: prefix, + PathStyle: true, + HTTPClient: server.Client(), + }, + totalSizeLimit: totalSizeLimit, + } + // Compute initial size (should be 0 for fresh mock) + size, err := s.computeSize() + require.Nil(t, err) + s.totalSizeCurrent = size + return s } -func TestParseS3URL_WrongScheme(t *testing.T) { - _, _, _, err := parseS3URL("http://AKID:SECRET@my-bucket?region=us-east-1") - require.Error(t, err) - require.Contains(t, err.Error(), "scheme") +// --- Mock S3 server --- +// +// A minimal S3-compatible HTTP server that supports PutObject, GetObject, DeleteObjects, and +// ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory. + +type mockS3Server struct { + objects map[string][]byte // full key (bucket/key) -> body + mu sync.RWMutex } -func TestParseS3URL_EmptyBucket(t *testing.T) { - _, _, _, err := parseS3URL("s3://AKID:SECRET@?region=us-east-1") - require.Error(t, err) - require.Contains(t, err.Error(), "bucket") +func newMockS3Server() *httptest.Server { + m := &mockS3Server{objects: make(map[string][]byte)} + return httptest.NewTLSServer(m) } -func TestS3Store_ObjectKey(t *testing.T) { - s := &s3Store{prefix: "attachments"} - require.Equal(t, "attachments/abcdefghijkl", s.objectKey("abcdefghijkl")) +func (m *mockS3Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Path is /{bucket}[/{key...}] + path := strings.TrimPrefix(r.URL.Path, "/") - s2 := &s3Store{prefix: ""} - require.Equal(t, "abcdefghijkl", s2.objectKey("abcdefghijkl")) + switch { + case r.Method == http.MethodPut: + m.handlePut(w, r, path) + case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2": + m.handleList(w, r, path) + case r.Method == http.MethodGet: + m.handleGet(w, r, path) + case r.Method == http.MethodPost && r.URL.Query().Has("delete"): + m.handleDelete(w, r, path) + default: + http.Error(w, "not implemented", http.StatusNotImplemented) + } +} + +func (m *mockS3Server) handlePut(w http.ResponseWriter, r *http.Request, path string) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + m.mu.Lock() + m.objects[path] = body + m.mu.Unlock() + w.WriteHeader(http.StatusOK) +} + +func (m *mockS3Server) handleGet(w http.ResponseWriter, r *http.Request, path string) { + m.mu.RLock() + body, ok := m.objects[path] + m.mu.RUnlock() + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`NoSuchKeyThe specified key does not exist.`)) + return + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +func (m *mockS3Server) handleDelete(w http.ResponseWriter, r *http.Request, bucketPath string) { + // bucketPath is just the bucket name + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var req struct { + Objects []struct { + Key string `xml:"Key"` + } `xml:"Object"` + } + if err := xml.Unmarshal(body, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m.mu.Lock() + for _, obj := range req.Objects { + delete(m.objects, bucketPath+"/"+obj.Key) + } + m.mu.Unlock() + w.WriteHeader(http.StatusOK) + w.Write([]byte(``)) +} + +func (m *mockS3Server) handleList(w http.ResponseWriter, r *http.Request, bucketPath string) { + prefix := r.URL.Query().Get("prefix") + m.mu.RLock() + var contents []s3ListObject + for key, body := range m.objects { + // key is "bucket/objectkey", strip bucket prefix + objKey := strings.TrimPrefix(key, bucketPath+"/") + if objKey == key { + continue // different bucket + } + if prefix == "" || strings.HasPrefix(objKey, prefix) { + contents = append(contents, s3ListObject{Key: objKey, Size: int64(len(body))}) + } + } + m.mu.RUnlock() + + resp := s3ListResponse{ + Contents: contents, + IsTruncated: false, + } + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + xml.NewEncoder(w).Encode(resp) +} + +type s3ListResponse struct { + XMLName xml.Name `xml:"ListBucketResult"` + Contents []s3ListObject `xml:"Contents"` + IsTruncated bool `xml:"IsTruncated"` +} + +type s3ListObject struct { + Key string `xml:"Key"` + Size int64 `xml:"Size"` } diff --git a/go.mod b/go.mod index f3cd7791..c073d6aa 100644 --- a/go.mod +++ b/go.mod @@ -30,9 +30,6 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.19.0 github.com/SherClockHolmes/webpush-go v1.4.0 - github.com/aws/aws-sdk-go-v2 v1.41.4 - github.com/aws/aws-sdk-go-v2/credentials v1.19.12 - github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 github.com/jackc/pgx/v5 v5.8.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 @@ -55,15 +52,6 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect - github.com/aws/smithy-go v1.24.2 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 3f373614..1c6eada9 100644 --- a/go.sum +++ b/go.sum @@ -40,30 +40,6 @@ github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= -github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= -github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= -github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1 h1:csi9NLpFZXb9fxY7rS1xVzgPRGMt7MSNWeQ6eo247kE= -github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0= -github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= -github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/s3/client.go b/s3/client.go new file mode 100644 index 00000000..7fdd8093 --- /dev/null +++ b/s3/client.go @@ -0,0 +1,325 @@ +// Package s3 provides a minimal S3-compatible client that works with AWS S3, DigitalOcean Spaces, +// GCP Cloud Storage, MinIO, Backblaze B2, and other S3-compatible providers. It uses raw HTTP +// requests with AWS Signature V4 signing, no AWS SDK dependency required. +package s3 + +import ( + "bytes" + "context" + "crypto/md5" //nolint:gosec // MD5 is required by the S3 protocol for Content-MD5 headers + "encoding/base64" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" +) + +// Client is a minimal S3-compatible client. It supports PutObject, GetObject, DeleteObjects, +// and ListObjectsV2 operations using AWS Signature V4 signing. The bucket and optional key prefix +// are fixed at construction time. All operations target the same bucket and prefix. +// +// Fields must not be modified after the Client is passed to any method or goroutine. +type Client struct { + AccessKey string // AWS access key ID + SecretKey string // AWS secret access key + Region string // e.g. "us-east-1" + Endpoint string // host[:port] only, e.g. "s3.amazonaws.com" or "nyc3.digitaloceanspaces.com" + Bucket string // S3 bucket name + Prefix string // optional key prefix (e.g. "attachments"); prepended to all keys automatically + PathStyle bool // if true, use path-style addressing; otherwise virtual-hosted-style + HTTPClient *http.Client // if nil, http.DefaultClient is used +} + +// New creates a new S3 client from the given Config. +func New(config *Config) *Client { + return &Client{ + AccessKey: config.AccessKey, + SecretKey: config.SecretKey, + Region: config.Region, + Endpoint: config.Endpoint, + Bucket: config.Bucket, + Prefix: config.Prefix, + PathStyle: config.PathStyle, + } +} + +// PutObject uploads body to the given key. The key is automatically prefixed with the client's +// configured prefix. The body size must be known in advance. The payload is sent as +// UNSIGNED-PAYLOAD, which is supported by all major S3-compatible providers over HTTPS. +func (c *Client) PutObject(ctx context.Context, key string, body io.Reader, size int64) error { + fullKey := c.objectKey(key) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.objectURL(fullKey), body) + if err != nil { + return fmt.Errorf("s3: PutObject request: %w", err) + } + req.ContentLength = size + c.signV4(req, unsignedPayload) + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("s3: PutObject: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return parseError(resp) + } + return nil +} + +// GetObject downloads an object. The key is automatically prefixed with the client's configured +// prefix. The caller must close the returned ReadCloser. +func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int64, error) { + fullKey := c.objectKey(key) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.objectURL(fullKey), nil) + if err != nil { + return nil, 0, fmt.Errorf("s3: GetObject request: %w", err) + } + c.signV4(req, emptyPayloadHash) + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, 0, fmt.Errorf("s3: GetObject: %w", err) + } + if resp.StatusCode/100 != 2 { + err := parseError(resp) + resp.Body.Close() + return nil, 0, err + } + return resp.Body, resp.ContentLength, nil +} + +// DeleteObjects removes multiple objects in a single batch request. Keys are automatically +// prefixed with the client's configured prefix. S3 supports up to 1000 keys per call; the +// caller is responsible for batching if needed. +// +// Even when S3 returns HTTP 200, individual keys may fail. If any per-key errors are present +// in the response, they are returned as a combined error. +func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { + var body bytes.Buffer + body.WriteString("true") + for _, key := range keys { + body.WriteString("") + xml.EscapeText(&body, []byte(c.objectKey(key))) + body.WriteString("") + } + body.WriteString("") + bodyBytes := body.Bytes() + payloadHash := sha256Hex(bodyBytes) + + // Content-MD5 is required by the S3 protocol for DeleteObjects requests. + md5Sum := md5.Sum(bodyBytes) //nolint:gosec + contentMD5 := base64.StdEncoding.EncodeToString(md5Sum[:]) + + reqURL := c.bucketURL() + "?delete=" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("s3: DeleteObjects request: %w", err) + } + req.ContentLength = int64(len(bodyBytes)) + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Content-MD5", contentMD5) + c.signV4(req, payloadHash) + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("s3: DeleteObjects: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return parseError(resp) + } + + // S3 may return HTTP 200 with per-key errors in the response body + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("s3: DeleteObjects read response: %w", err) + } + var result deleteResult + if err := xml.Unmarshal(respBody, &result); err != nil { + return nil // If we can't parse, assume success (Quiet mode returns empty body on success) + } + if len(result.Errors) > 0 { + var msgs []string + for _, e := range result.Errors { + msgs = append(msgs, fmt.Sprintf("%s: %s", e.Key, e.Message)) + } + return fmt.Errorf("s3: DeleteObjects partial failure: %s", strings.Join(msgs, "; ")) + } + return nil +} + +// ListObjects performs a single ListObjectsV2 request using the client's configured prefix. +// Use continuationToken for pagination. Set maxKeys to 0 for the server default (typically 1000). +func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxKeys int) (*ListResult, error) { + query := url.Values{"list-type": {"2"}} + if prefix := c.prefixForList(); prefix != "" { + query.Set("prefix", prefix) + } + if continuationToken != "" { + query.Set("continuation-token", continuationToken) + } + if maxKeys > 0 { + query.Set("max-keys", strconv.Itoa(maxKeys)) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.bucketURL()+"?"+query.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("s3: ListObjects request: %w", err) + } + c.signV4(req, emptyPayloadHash) + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("s3: ListObjects: %w", err) + } + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("s3: ListObjects read: %w", err) + } + if resp.StatusCode/100 != 2 { + return nil, parseErrorFromBytes(resp.StatusCode, respBody) + } + var result listObjectsV2Response + if err := xml.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("s3: ListObjects XML: %w", err) + } + objects := make([]Object, len(result.Contents)) + for i, obj := range result.Contents { + objects[i] = Object(obj) + } + return &ListResult{ + Objects: objects, + IsTruncated: result.IsTruncated, + NextContinuationToken: result.NextContinuationToken, + }, nil +} + +// ListAllObjects returns all objects under the client's configured prefix by paginating through +// ListObjectsV2 results automatically. It stops after 10,000 pages as a safety valve. +func (c *Client) ListAllObjects(ctx context.Context) ([]Object, error) { + const maxPages = 10000 + var all []Object + var token string + for page := 0; page < maxPages; page++ { + result, err := c.ListObjects(ctx, token, 0) + if err != nil { + return nil, err + } + all = append(all, result.Objects...) + if !result.IsTruncated { + return all, nil + } + token = result.NextContinuationToken + } + return nil, fmt.Errorf("s3: ListAllObjects exceeded %d pages", maxPages) +} + +// signV4 signs req in place using AWS Signature V4. payloadHash is the hex-encoded SHA-256 +// of the request body, or the literal string "UNSIGNED-PAYLOAD" for streaming uploads. +func (c *Client) signV4(req *http.Request, payloadHash string) { + now := time.Now().UTC() + datestamp := now.Format("20060102") + amzDate := now.Format("20060102T150405Z") + + // Required headers + req.Header.Set("Host", c.hostHeader()) + req.Header.Set("X-Amz-Date", amzDate) + req.Header.Set("X-Amz-Content-Sha256", payloadHash) + + // Canonical headers (all headers we set, sorted by lowercase key) + signedKeys := make([]string, 0, len(req.Header)) + canonHeaders := make(map[string]string, len(req.Header)) + for k := range req.Header { + lk := strings.ToLower(k) + signedKeys = append(signedKeys, lk) + canonHeaders[lk] = strings.TrimSpace(req.Header.Get(k)) + } + sort.Strings(signedKeys) + signedHeadersStr := strings.Join(signedKeys, ";") + var chBuf strings.Builder + for _, k := range signedKeys { + chBuf.WriteString(k) + chBuf.WriteByte(':') + chBuf.WriteString(canonHeaders[k]) + chBuf.WriteByte('\n') + } + + // Canonical request + canonicalRequest := strings.Join([]string{ + req.Method, + canonicalURI(req.URL), + canonicalQueryString(req.URL.Query()), + chBuf.String(), + signedHeadersStr, + payloadHash, + }, "\n") + + // String to sign + credentialScope := datestamp + "/" + c.Region + "/s3/aws4_request" + stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex([]byte(canonicalRequest)) + + // Signing key + signingKey := hmacSHA256(hmacSHA256(hmacSHA256(hmacSHA256( + []byte("AWS4"+c.SecretKey), []byte(datestamp)), + []byte(c.Region)), + []byte("s3")), + []byte("aws4_request")) + + signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign))) + req.Header.Set("Authorization", fmt.Sprintf( + "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + c.AccessKey, credentialScope, signedHeadersStr, signature, + )) +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return http.DefaultClient +} + +// objectKey prepends the configured prefix to the given key. +func (c *Client) objectKey(key string) string { + if c.Prefix != "" { + return c.Prefix + "/" + key + } + return key +} + +// prefixForList returns the prefix to use in ListObjectsV2 requests, +// with a trailing slash so that only objects under the prefix directory are returned. +func (c *Client) prefixForList() string { + if c.Prefix != "" { + return c.Prefix + "/" + } + return "" +} + +// bucketURL returns the base URL for bucket-level operations. +func (c *Client) bucketURL() string { + if c.PathStyle { + return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket) + } + return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint) +} + +// objectURL returns the full URL for an object (key should already include the prefix). +// Each path segment is URI-encoded to handle special characters in keys. +func (c *Client) objectURL(key string) string { + segments := strings.Split(key, "/") + for i, seg := range segments { + segments[i] = uriEncode(seg) + } + return c.bucketURL() + "/" + strings.Join(segments, "/") +} + +// hostHeader returns the value for the Host header. +func (c *Client) hostHeader() string { + if c.PathStyle { + return c.Endpoint + } + return c.Bucket + "." + c.Endpoint +} diff --git a/s3/client_test.go b/s3/client_test.go new file mode 100644 index 00000000..f4a10213 --- /dev/null +++ b/s3/client_test.go @@ -0,0 +1,727 @@ +package s3 + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// --- Mock S3 server --- +// +// A minimal S3-compatible HTTP server that supports PutObject, GetObject, DeleteObjects, and +// ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory. + +type mockS3Server struct { + objects map[string][]byte // full key (bucket/key) -> body + mu sync.RWMutex +} + +func newMockS3Server() (*httptest.Server, *mockS3Server) { + m := &mockS3Server{objects: make(map[string][]byte)} + return httptest.NewTLSServer(m), m +} + +func (m *mockS3Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Path is /{bucket}[/{key...}] + path := strings.TrimPrefix(r.URL.Path, "/") + + switch { + case r.Method == http.MethodPut: + m.handlePut(w, r, path) + case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2": + m.handleList(w, r, path) + case r.Method == http.MethodGet: + m.handleGet(w, r, path) + case r.Method == http.MethodPost && r.URL.Query().Has("delete"): + m.handleDelete(w, r, path) + default: + http.Error(w, "not implemented", http.StatusNotImplemented) + } +} + +func (m *mockS3Server) handlePut(w http.ResponseWriter, r *http.Request, path string) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + m.mu.Lock() + m.objects[path] = body + m.mu.Unlock() + w.WriteHeader(http.StatusOK) +} + +func (m *mockS3Server) handleGet(w http.ResponseWriter, r *http.Request, path string) { + m.mu.RLock() + body, ok := m.objects[path] + m.mu.RUnlock() + if !ok { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`NoSuchKeyThe specified key does not exist.`)) + return + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) + w.WriteHeader(http.StatusOK) + w.Write(body) +} + +type listObjectsResponse struct { + XMLName xml.Name `xml:"ListBucketResult"` + Contents []listObject `xml:"Contents"` + // Pagination support + IsTruncated bool `xml:"IsTruncated"` + NextContinuationToken string `xml:"NextContinuationToken"` +} + +func (m *mockS3Server) handleDelete(w http.ResponseWriter, r *http.Request, bucketPath string) { + // bucketPath is just the bucket name + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var req struct { + Objects []struct { + Key string `xml:"Key"` + } `xml:"Object"` + } + if err := xml.Unmarshal(body, &req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m.mu.Lock() + for _, obj := range req.Objects { + delete(m.objects, bucketPath+"/"+obj.Key) + } + m.mu.Unlock() + w.WriteHeader(http.StatusOK) + w.Write([]byte(``)) +} + +func (m *mockS3Server) handleList(w http.ResponseWriter, r *http.Request, bucketPath string) { + prefix := r.URL.Query().Get("prefix") + contToken := r.URL.Query().Get("continuation-token") + + m.mu.RLock() + var allKeys []string + for key := range m.objects { + objKey := strings.TrimPrefix(key, bucketPath+"/") + if objKey == key { + continue // different bucket + } + if prefix == "" || strings.HasPrefix(objKey, prefix) { + allKeys = append(allKeys, objKey) + } + } + m.mu.RUnlock() + sort.Strings(allKeys) + + // Simple continuation token: it's the key to start after + startIdx := 0 + if contToken != "" { + for i, k := range allKeys { + if k == contToken { + startIdx = i + 1 + break + } + } + } + + maxKeys := 1000 + if mk := r.URL.Query().Get("max-keys"); mk != "" { + fmt.Sscanf(mk, "%d", &maxKeys) + } + + endIdx := startIdx + maxKeys + truncated := false + nextToken := "" + if endIdx < len(allKeys) { + truncated = true + nextToken = allKeys[endIdx-1] + allKeys = allKeys[startIdx:endIdx] + } else { + allKeys = allKeys[startIdx:] + } + + m.mu.RLock() + var contents []listObject + for _, objKey := range allKeys { + body := m.objects[bucketPath+"/"+objKey] + contents = append(contents, listObject{Key: objKey, Size: int64(len(body))}) + } + m.mu.RUnlock() + + resp := listObjectsResponse{ + Contents: contents, + IsTruncated: truncated, + NextContinuationToken: nextToken, + } + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + xml.NewEncoder(w).Encode(resp) +} + +func (m *mockS3Server) objectCount() int { + m.mu.RLock() + defer m.mu.RUnlock() + return len(m.objects) +} + +// --- Helper to create a test client pointing at mock server --- + +func newTestClient(server *httptest.Server, bucket, prefix string) *Client { + // httptest.NewTLSServer URL is like "https://127.0.0.1:PORT" + host := strings.TrimPrefix(server.URL, "https://") + return &Client{ + AccessKey: "AKID", + SecretKey: "SECRET", + Region: "us-east-1", + Endpoint: host, + Bucket: bucket, + Prefix: prefix, + PathStyle: true, + HTTPClient: server.Client(), + } +} + +// --- URL parsing tests --- + +func TestParseURL_Success(t *testing.T) { + cfg, err := ParseURL("s3://AKID:SECRET@my-bucket/attachments?region=us-east-1") + require.Nil(t, err) + require.Equal(t, "my-bucket", cfg.Bucket) + require.Equal(t, "attachments", cfg.Prefix) + require.Equal(t, "us-east-1", cfg.Region) + require.Equal(t, "AKID", cfg.AccessKey) + require.Equal(t, "SECRET", cfg.SecretKey) + require.Equal(t, "s3.us-east-1.amazonaws.com", cfg.Endpoint) + require.False(t, cfg.PathStyle) +} + +func TestParseURL_NoPrefix(t *testing.T) { + cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1") + require.Nil(t, err) + require.Equal(t, "my-bucket", cfg.Bucket) + require.Equal(t, "", cfg.Prefix) +} + +func TestParseURL_WithEndpoint(t *testing.T) { + cfg, err := ParseURL("s3://AKID:SECRET@my-bucket/prefix?region=us-east-1&endpoint=https://s3.example.com") + require.Nil(t, err) + require.Equal(t, "my-bucket", cfg.Bucket) + require.Equal(t, "prefix", cfg.Prefix) + require.Equal(t, "s3.example.com", cfg.Endpoint) + require.True(t, cfg.PathStyle) +} + +func TestParseURL_EndpointHTTP(t *testing.T) { + cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=http://localhost:9000") + require.Nil(t, err) + require.Equal(t, "localhost:9000", cfg.Endpoint) + require.True(t, cfg.PathStyle) +} + +func TestParseURL_EndpointTrailingSlash(t *testing.T) { + cfg, err := ParseURL("s3://AKID:SECRET@my-bucket?region=us-east-1&endpoint=https://s3.example.com/") + require.Nil(t, err) + require.Equal(t, "s3.example.com", cfg.Endpoint) +} + +func TestParseURL_NestedPrefix(t *testing.T) { + cfg, err := ParseURL("s3://AKID:SECRET@my-bucket/a/b/c?region=us-east-1") + require.Nil(t, err) + require.Equal(t, "my-bucket", cfg.Bucket) + require.Equal(t, "a/b/c", cfg.Prefix) +} + +func TestParseURL_MissingRegion(t *testing.T) { + _, err := ParseURL("s3://AKID:SECRET@my-bucket") + require.Error(t, err) + require.Contains(t, err.Error(), "region") +} + +func TestParseURL_MissingCredentials(t *testing.T) { + _, err := ParseURL("s3://my-bucket?region=us-east-1") + require.Error(t, err) + require.Contains(t, err.Error(), "access key") +} + +func TestParseURL_MissingSecretKey(t *testing.T) { + _, err := ParseURL("s3://AKID@my-bucket?region=us-east-1") + require.Error(t, err) + require.Contains(t, err.Error(), "secret key") +} + +func TestParseURL_WrongScheme(t *testing.T) { + _, err := ParseURL("http://AKID:SECRET@my-bucket?region=us-east-1") + require.Error(t, err) + require.Contains(t, err.Error(), "scheme") +} + +func TestParseURL_EmptyBucket(t *testing.T) { + _, err := ParseURL("s3://AKID:SECRET@?region=us-east-1") + require.Error(t, err) + require.Contains(t, err.Error(), "bucket") +} + +// --- Unit tests: URL construction --- + +func TestClient_BucketURL_PathStyle(t *testing.T) { + c := &Client{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true} + require.Equal(t, "https://s3.example.com/my-bucket", c.bucketURL()) +} + +func TestClient_BucketURL_VirtualHosted(t *testing.T) { + c := &Client{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} + require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.bucketURL()) +} + +func TestClient_ObjectURL_PathStyle(t *testing.T) { + c := &Client{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true} + require.Equal(t, "https://s3.example.com/my-bucket/prefix/obj", c.objectURL("prefix/obj")) +} + +func TestClient_ObjectURL_VirtualHosted(t *testing.T) { + c := &Client{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} + require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com/prefix/obj", c.objectURL("prefix/obj")) +} + +func TestClient_HostHeader_PathStyle(t *testing.T) { + c := &Client{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true} + require.Equal(t, "s3.example.com", c.hostHeader()) +} + +func TestClient_HostHeader_VirtualHosted(t *testing.T) { + c := &Client{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} + require.Equal(t, "my-bucket.s3.us-east-1.amazonaws.com", c.hostHeader()) +} + +func TestClient_ObjectKey(t *testing.T) { + c := &Client{Prefix: "attachments"} + require.Equal(t, "attachments/file123", c.objectKey("file123")) + + c2 := &Client{Prefix: ""} + require.Equal(t, "file123", c2.objectKey("file123")) +} + +func TestClient_PrefixForList(t *testing.T) { + c := &Client{Prefix: "attachments"} + require.Equal(t, "attachments/", c.prefixForList()) + + c2 := &Client{Prefix: ""} + require.Equal(t, "", c2.prefixForList()) +} + +// --- Integration tests using mock S3 server --- + +func TestClient_PutGetObject(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "") + + ctx := context.Background() + + // Put + err := client.PutObject(ctx, "test-key", strings.NewReader("hello world"), 11) + require.Nil(t, err) + + // Get + reader, size, err := client.GetObject(ctx, "test-key") + require.Nil(t, err) + require.Equal(t, int64(11), size) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello world", string(data)) +} + +func TestClient_PutGetObject_WithPrefix(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "pfx") + + ctx := context.Background() + + err := client.PutObject(ctx, "test-key", strings.NewReader("hello"), 5) + require.Nil(t, err) + + reader, _, err := client.GetObject(ctx, "test-key") + require.Nil(t, err) + data, _ := io.ReadAll(reader) + reader.Close() + require.Equal(t, "hello", string(data)) +} + +func TestClient_GetObject_NotFound(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "") + + _, _, err := client.GetObject(context.Background(), "nonexistent") + require.Error(t, err) + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + require.Equal(t, 404, errResp.StatusCode) + require.Equal(t, "NoSuchKey", errResp.Code) +} + +func TestClient_DeleteObjects(t *testing.T) { + server, mock := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "") + + ctx := context.Background() + + // Put several objects + for i := 0; i < 5; i++ { + err := client.PutObject(ctx, fmt.Sprintf("key-%d", i), bytes.NewReader([]byte("data")), 4) + require.Nil(t, err) + } + require.Equal(t, 5, mock.objectCount()) + + // Delete some + err := client.DeleteObjects(ctx, []string{"key-1", "key-3"}) + require.Nil(t, err) + require.Equal(t, 3, mock.objectCount()) + + // Verify deleted ones are gone + _, _, err = client.GetObject(ctx, "key-1") + require.Error(t, err) + _, _, err = client.GetObject(ctx, "key-3") + require.Error(t, err) + + // Verify remaining ones are still there + reader, _, err := client.GetObject(ctx, "key-0") + require.Nil(t, err) + reader.Close() +} + +func TestClient_ListObjects(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + + ctx := context.Background() + + // Client with prefix "pfx": list should only return objects under pfx/ + client := newTestClient(server, "my-bucket", "pfx") + for i := 0; i < 3; i++ { + err := client.PutObject(ctx, fmt.Sprintf("%d", i), bytes.NewReader([]byte("x")), 1) + require.Nil(t, err) + } + + // Also put an object outside the prefix using a no-prefix client + clientNoPrefix := newTestClient(server, "my-bucket", "") + err := clientNoPrefix.PutObject(ctx, "other", bytes.NewReader([]byte("y")), 1) + require.Nil(t, err) + + // List with prefix client: should only see 3 + result, err := client.ListObjects(ctx, "", 0) + require.Nil(t, err) + require.Len(t, result.Objects, 3) + require.False(t, result.IsTruncated) + + // List with no-prefix client: should see all 4 + result, err = clientNoPrefix.ListObjects(ctx, "", 0) + require.Nil(t, err) + require.Len(t, result.Objects, 4) +} + +func TestClient_ListObjects_Pagination(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "") + + ctx := context.Background() + + // Put 5 objects + for i := 0; i < 5; i++ { + err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x")), 1) + require.Nil(t, err) + } + + // List with max-keys=2 + result, err := client.ListObjects(ctx, "", 2) + require.Nil(t, err) + require.Len(t, result.Objects, 2) + require.True(t, result.IsTruncated) + require.NotEmpty(t, result.NextContinuationToken) + + // Get next page + result2, err := client.ListObjects(ctx, result.NextContinuationToken, 2) + require.Nil(t, err) + require.Len(t, result2.Objects, 2) + require.True(t, result2.IsTruncated) + + // Get last page + result3, err := client.ListObjects(ctx, result2.NextContinuationToken, 2) + require.Nil(t, err) + require.Len(t, result3.Objects, 1) + require.False(t, result3.IsTruncated) +} + +func TestClient_ListAllObjects(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "pfx") + + ctx := context.Background() + + for i := 0; i < 10; i++ { + err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x")), 1) + require.Nil(t, err) + } + + objects, err := client.ListAllObjects(ctx) + require.Nil(t, err) + require.Len(t, objects, 10) +} + +func TestClient_PutObject_LargeBody(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "") + + ctx := context.Background() + + // 1 MB object + data := make([]byte, 1024*1024) + for i := range data { + data[i] = byte(i % 256) + } + err := client.PutObject(ctx, "large", bytes.NewReader(data), int64(len(data))) + require.Nil(t, err) + + reader, size, err := client.GetObject(ctx, "large") + require.Nil(t, err) + require.Equal(t, int64(1024*1024), size) + got, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, data, got) +} + +func TestClient_PutObject_NestedKey(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "") + + ctx := context.Background() + + err := client.PutObject(ctx, "deep/nested/prefix/file.txt", strings.NewReader("nested"), 6) + require.Nil(t, err) + + reader, _, err := client.GetObject(ctx, "deep/nested/prefix/file.txt") + require.Nil(t, err) + data, _ := io.ReadAll(reader) + reader.Close() + require.Equal(t, "nested", string(data)) +} + +// --- Scale test: 20k objects (ntfy-adjacent) --- + +func TestClient_ListAllObjects_20k(t *testing.T) { + if testing.Short() { + t.Skip("skipping 20k object test in short mode") + } + + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "attachments") + + ctx := context.Background() + const numObjects = 20000 + const batchSize = 500 + + // Insert 20k objects in batches to keep it fast + for batch := 0; batch < numObjects/batchSize; batch++ { + for i := 0; i < batchSize; i++ { + idx := batch*batchSize + i + key := fmt.Sprintf("%08d", idx) + err := client.PutObject(ctx, key, bytes.NewReader([]byte("x")), 1) + require.Nil(t, err) + } + } + + // List all 20k objects with pagination + objects, err := client.ListAllObjects(ctx) + require.Nil(t, err) + require.Len(t, objects, numObjects) + + // Verify total size + var totalSize int64 + for _, obj := range objects { + totalSize += obj.Size + } + require.Equal(t, int64(numObjects), totalSize) + + // Delete 1000 objects (simulating attachment expiry cleanup) + keys := make([]string, 1000) + for i := range keys { + keys[i] = fmt.Sprintf("%08d", i) + } + err = client.DeleteObjects(ctx, keys) + require.Nil(t, err) + + // List again: should have 19000 + objects, err = client.ListAllObjects(ctx) + require.Nil(t, err) + require.Len(t, objects, numObjects-1000) +} + +// --- Real S3 integration test --- +// +// Set the following environment variables to run this test against a real S3 bucket: +// +// S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_BUCKET +// +// Optional: +// +// S3_ENDPOINT: host[:port] for S3-compatible providers (e.g. "nyc3.digitaloceanspaces.com") +// S3_PATH_STYLE: set to "true" for path-style addressing +// S3_PREFIX: key prefix to use (default: "ntfy-s3-test") +func TestClient_RealBucket(t *testing.T) { + accessKey := os.Getenv("S3_ACCESS_KEY") + secretKey := os.Getenv("S3_SECRET_KEY") + region := os.Getenv("S3_REGION") + bucket := os.Getenv("S3_BUCKET") + + if accessKey == "" || secretKey == "" || region == "" || bucket == "" { + t.Skip("skipping real S3 test: set S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_BUCKET") + } + + endpoint := os.Getenv("S3_ENDPOINT") + if endpoint == "" { + endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region) + } + pathStyle := os.Getenv("S3_PATH_STYLE") == "true" + prefix := os.Getenv("S3_PREFIX") + if prefix == "" { + prefix = "ntfy-s3-test" + } + + client := &Client{ + AccessKey: accessKey, + SecretKey: secretKey, + Region: region, + Endpoint: endpoint, + Bucket: bucket, + Prefix: prefix, + PathStyle: pathStyle, + } + + ctx := context.Background() + + // Clean up any leftover objects from previous runs + existing, err := client.ListAllObjects(ctx) + require.Nil(t, err) + if len(existing) > 0 { + keys := make([]string, len(existing)) + for i, obj := range existing { + // Strip the prefix since DeleteObjects will re-add it + keys[i] = strings.TrimPrefix(obj.Key, prefix+"/") + } + // Batch delete in groups of 1000 + for i := 0; i < len(keys); i += 1000 { + end := i + 1000 + if end > len(keys) { + end = len(keys) + } + err := client.DeleteObjects(ctx, keys[i:end]) + require.Nil(t, err) + } + } + + t.Run("PutGetDelete", func(t *testing.T) { + key := "test-object" + content := "hello from ntfy s3 test" + + // Put + err := client.PutObject(ctx, key, strings.NewReader(content), int64(len(content))) + require.Nil(t, err) + + // Get + reader, size, err := client.GetObject(ctx, key) + require.Nil(t, err) + require.Equal(t, int64(len(content)), size) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, content, string(data)) + + // Delete + err = client.DeleteObjects(ctx, []string{key}) + require.Nil(t, err) + + // Get after delete should fail + _, _, err = client.GetObject(ctx, key) + require.Error(t, err) + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + require.Equal(t, 404, errResp.StatusCode) + }) + + t.Run("ListObjects", func(t *testing.T) { + // Use a sub-prefix client for isolation + listClient := &Client{ + AccessKey: accessKey, + SecretKey: secretKey, + Region: region, + Endpoint: endpoint, + Bucket: bucket, + Prefix: prefix + "/list-test", + PathStyle: pathStyle, + } + + // Put 10 objects + for i := 0; i < 10; i++ { + err := listClient.PutObject(ctx, fmt.Sprintf("%d", i), strings.NewReader("x"), 1) + require.Nil(t, err) + } + + // List + objects, err := listClient.ListAllObjects(ctx) + require.Nil(t, err) + require.Len(t, objects, 10) + + // Clean up + keys := make([]string, 10) + for i := range keys { + keys[i] = fmt.Sprintf("%d", i) + } + err = listClient.DeleteObjects(ctx, keys) + require.Nil(t, err) + }) + + t.Run("LargeObject", func(t *testing.T) { + key := "large-object" + data := make([]byte, 5*1024*1024) // 5 MB + for i := range data { + data[i] = byte(i % 256) + } + + err := client.PutObject(ctx, key, bytes.NewReader(data), int64(len(data))) + require.Nil(t, err) + + reader, size, err := client.GetObject(ctx, key) + require.Nil(t, err) + require.Equal(t, int64(len(data)), size) + got, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, data, got) + + err = client.DeleteObjects(ctx, []string{key}) + require.Nil(t, err) + }) +} diff --git a/s3/types.go b/s3/types.go new file mode 100644 index 00000000..5929ec6c --- /dev/null +++ b/s3/types.go @@ -0,0 +1,65 @@ +package s3 + +import "fmt" + +// Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string. +type Config struct { + Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com" + PathStyle bool + Bucket string + Prefix string + Region string + AccessKey string + SecretKey string +} + +// Object represents an S3 object returned by list operations. +type Object struct { + Key string + Size int64 +} + +// ListResult holds the response from a ListObjectsV2 call. +type ListResult struct { + Objects []Object + IsTruncated bool + NextContinuationToken string +} + +// ErrorResponse is returned when S3 responds with a non-2xx status code. +type ErrorResponse struct { + StatusCode int + Code string `xml:"Code"` + Message string `xml:"Message"` + Body string `xml:"-"` // raw response body +} + +func (e *ErrorResponse) Error() string { + if e.Code != "" { + return fmt.Sprintf("s3: %s (HTTP %d): %s", e.Code, e.StatusCode, e.Message) + } + return fmt.Sprintf("s3: HTTP %d: %s", e.StatusCode, e.Body) +} + +// listObjectsV2Response is the XML response from S3 ListObjectsV2 +type listObjectsV2Response struct { + Contents []listObject `xml:"Contents"` + IsTruncated bool `xml:"IsTruncated"` + NextContinuationToken string `xml:"NextContinuationToken"` +} + +type listObject struct { + Key string `xml:"Key"` + Size int64 `xml:"Size"` +} + +// deleteResult is the XML response from S3 DeleteObjects +type deleteResult struct { + Errors []deleteError `xml:"Error"` +} + +type deleteError struct { + Key string `xml:"Key"` + Code string `xml:"Code"` + Message string `xml:"Message"` +} diff --git a/s3/util.go b/s3/util.go new file mode 100644 index 00000000..cf9d4ba8 --- /dev/null +++ b/s3/util.go @@ -0,0 +1,161 @@ +package s3 + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" +) + +const ( + // SHA-256 hash of the empty string, used as the payload hash for bodiless requests + emptyPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + // Sent as the payload hash for streaming uploads where the body is not buffered in memory + unsignedPayload = "UNSIGNED-PAYLOAD" + + // maxResponseBytes caps the size of S3 response bodies we read into memory (10 MB) + maxResponseBytes = 10 * 1024 * 1024 +) + +// ParseURL parses an S3 URL of the form: +// +// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] +// +// When endpoint is specified, path-style addressing is enabled automatically. +func ParseURL(s3URL string) (*Config, error) { + u, err := url.Parse(s3URL) + if err != nil { + return nil, fmt.Errorf("s3: invalid URL: %w", err) + } + if u.Scheme != "s3" { + return nil, fmt.Errorf("s3: URL scheme must be 's3', got '%s'", u.Scheme) + } + if u.Host == "" { + return nil, fmt.Errorf("s3: bucket name must be specified as host") + } + bucket := u.Host + prefix := strings.TrimPrefix(u.Path, "/") + accessKey := u.User.Username() + secretKey, _ := u.User.Password() + if accessKey == "" || secretKey == "" { + return nil, fmt.Errorf("s3: access key and secret key must be specified in URL") + } + region := u.Query().Get("region") + if region == "" { + return nil, fmt.Errorf("s3: region query parameter is required") + } + endpointParam := u.Query().Get("endpoint") + var endpoint string + var pathStyle bool + if endpointParam != "" { + // Custom endpoint: strip scheme prefix to extract host[:port] + ep := strings.TrimRight(endpointParam, "/") + ep = strings.TrimPrefix(ep, "https://") + ep = strings.TrimPrefix(ep, "http://") + endpoint = ep + pathStyle = true + } else { + endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region) + pathStyle = false + } + return &Config{ + Endpoint: endpoint, + PathStyle: pathStyle, + Bucket: bucket, + Prefix: prefix, + Region: region, + AccessKey: accessKey, + SecretKey: secretKey, + }, nil +} + +// parseError reads an S3 error response and returns an *ErrorResponse. +func parseError(resp *http.Response) error { + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("s3: reading error response: %w", err) + } + return parseErrorFromBytes(resp.StatusCode, body) +} + +func parseErrorFromBytes(statusCode int, body []byte) error { + errResp := &ErrorResponse{ + StatusCode: statusCode, + Body: string(body), + } + // Try to parse XML error; if it fails, we still have StatusCode and Body + _ = xml.Unmarshal(body, errResp) + return errResp +} + +// canonicalURI returns the URI-encoded path for the canonical request. Each path segment is +// percent-encoded per RFC 3986; forward slashes are preserved. +func canonicalURI(u *url.URL) string { + p := u.Path + if p == "" { + return "/" + } + segments := strings.Split(p, "/") + for i, seg := range segments { + segments[i] = uriEncode(seg) + } + return strings.Join(segments, "/") +} + +// canonicalQueryString builds the query string for the canonical request. Keys and values +// are URI-encoded per RFC 3986 (using %20, not +) and sorted lexically by key. +func canonicalQueryString(values url.Values) string { + if len(values) == 0 { + return "" + } + keys := make([]string, 0, len(values)) + for k := range values { + keys = append(keys, k) + } + sort.Strings(keys) + var pairs []string + for _, k := range keys { + ek := uriEncode(k) + vs := make([]string, len(values[k])) + copy(vs, values[k]) + sort.Strings(vs) + for _, v := range vs { + pairs = append(pairs, ek+"="+uriEncode(v)) + } + } + return strings.Join(pairs, "&") +} + +// uriEncode percent-encodes a string per RFC 3986, encoding everything except unreserved +// characters (A-Z a-z 0-9 - _ . ~). +func uriEncode(s string) string { + var buf strings.Builder + for i := 0; i < len(s); i++ { + b := s[i] + if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || + b == '-' || b == '_' || b == '.' || b == '~' { + buf.WriteByte(b) + } else { + fmt.Fprintf(&buf, "%%%02X", b) + } + } + return buf.String() +} + +func sha256Hex(data []byte) string { + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]) +} + +func hmacSHA256(key, data []byte) []byte { + h := hmac.New(sha256.New, key) + h.Write(data) + return h.Sum(nil) +} diff --git a/s3/util_test.go b/s3/util_test.go new file mode 100644 index 00000000..d30c5664 --- /dev/null +++ b/s3/util_test.go @@ -0,0 +1,181 @@ +package s3 + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestURIEncode(t *testing.T) { + // Unreserved characters are not encoded + require.Equal(t, "abcdefghijklmnopqrstuvwxyz", uriEncode("abcdefghijklmnopqrstuvwxyz")) + require.Equal(t, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", uriEncode("ABCDEFGHIJKLMNOPQRSTUVWXYZ")) + require.Equal(t, "0123456789", uriEncode("0123456789")) + require.Equal(t, "-_.~", uriEncode("-_.~")) + + // Spaces use %20, not + + require.Equal(t, "hello%20world", uriEncode("hello world")) + + // Slashes are encoded (canonicalURI handles slash splitting separately) + require.Equal(t, "a%2Fb", uriEncode("a/b")) + + // Special characters + require.Equal(t, "%2B", uriEncode("+")) + require.Equal(t, "%3D", uriEncode("=")) + require.Equal(t, "%26", uriEncode("&")) + require.Equal(t, "%40", uriEncode("@")) + require.Equal(t, "%23", uriEncode("#")) + + // Mixed + require.Equal(t, "test~file-name_1.txt", uriEncode("test~file-name_1.txt")) + require.Equal(t, "key%20with%20spaces%2Fand%2Fslashes", uriEncode("key with spaces/and/slashes")) + + // Empty string + require.Equal(t, "", uriEncode("")) +} + +func TestCanonicalURI(t *testing.T) { + // Simple path + u, _ := url.Parse("https://example.com/bucket/key") + require.Equal(t, "/bucket/key", canonicalURI(u)) + + // Root path + u, _ = url.Parse("https://example.com/") + require.Equal(t, "/", canonicalURI(u)) + + // Empty path + u, _ = url.Parse("https://example.com") + require.Equal(t, "/", canonicalURI(u)) + + // Path with special characters + u, _ = url.Parse("https://example.com/bucket/key%20with%20spaces") + require.Equal(t, "/bucket/key%20with%20spaces", canonicalURI(u)) + + // Nested path + u, _ = url.Parse("https://example.com/bucket/a/b/c/file.txt") + require.Equal(t, "/bucket/a/b/c/file.txt", canonicalURI(u)) +} + +func TestCanonicalQueryString(t *testing.T) { + // Multiple keys sorted alphabetically + vals := url.Values{ + "prefix": {"test/"}, + "list-type": {"2"}, + } + require.Equal(t, "list-type=2&prefix=test%2F", canonicalQueryString(vals)) + + // Empty values + require.Equal(t, "", canonicalQueryString(url.Values{})) + + // Single key + require.Equal(t, "key=value", canonicalQueryString(url.Values{"key": {"value"}})) + + // Key with multiple values (sorted) + vals = url.Values{"key": {"b", "a"}} + require.Equal(t, "key=a&key=b", canonicalQueryString(vals)) + + // Keys requiring encoding + vals = url.Values{"continuation-token": {"abc+def"}} + require.Equal(t, "continuation-token=abc%2Bdef", canonicalQueryString(vals)) +} + +func TestSHA256Hex(t *testing.T) { + // SHA-256 of empty string + require.Equal(t, emptyPayloadHash, sha256Hex([]byte(""))) + + // SHA-256 of known value + require.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", sha256Hex([]byte("hello"))) +} + +func TestHmacSHA256(t *testing.T) { + // Known test vector: HMAC-SHA256("key", "message") + result := hmacSHA256([]byte("key"), []byte("message")) + require.Len(t, result, 32) // SHA-256 produces 32 bytes + require.NotEqual(t, make([]byte, 32), result) + + // Same inputs should produce same output + result2 := hmacSHA256([]byte("key"), []byte("message")) + require.Equal(t, result, result2) + + // Different inputs should produce different output + result3 := hmacSHA256([]byte("different-key"), []byte("message")) + require.NotEqual(t, result, result3) +} + +func TestSignV4_SetsRequiredHeaders(t *testing.T) { + c := &Client{ + AccessKey: "AKID", + SecretKey: "SECRET", + Region: "us-east-1", + Endpoint: "s3.us-east-1.amazonaws.com", + Bucket: "my-bucket", + } + + req, _ := http.NewRequest(http.MethodGet, "https://my-bucket.s3.us-east-1.amazonaws.com/test-key", nil) + c.signV4(req, emptyPayloadHash) + + // All required SigV4 headers must be set + require.NotEmpty(t, req.Header.Get("Host")) + require.NotEmpty(t, req.Header.Get("X-Amz-Date")) + require.Equal(t, emptyPayloadHash, req.Header.Get("X-Amz-Content-Sha256")) + + // Authorization header must have correct format + auth := req.Header.Get("Authorization") + require.Contains(t, auth, "AWS4-HMAC-SHA256") + require.Contains(t, auth, "Credential=AKID/") + require.Contains(t, auth, "/us-east-1/s3/aws4_request") + require.Contains(t, auth, "SignedHeaders=") + require.Contains(t, auth, "Signature=") +} + +func TestSignV4_UnsignedPayload(t *testing.T) { + c := &Client{ + AccessKey: "AKID", + SecretKey: "SECRET", + Region: "us-east-1", + Endpoint: "s3.us-east-1.amazonaws.com", + Bucket: "my-bucket", + } + + req, _ := http.NewRequest(http.MethodPut, "https://my-bucket.s3.us-east-1.amazonaws.com/test-key", nil) + c.signV4(req, unsignedPayload) + + require.Equal(t, unsignedPayload, req.Header.Get("X-Amz-Content-Sha256")) +} + +func TestSignV4_DifferentRegions(t *testing.T) { + c1 := &Client{AccessKey: "AKID", SecretKey: "SECRET", Region: "us-east-1", Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "b"} + c2 := &Client{AccessKey: "AKID", SecretKey: "SECRET", Region: "eu-west-1", Endpoint: "s3.eu-west-1.amazonaws.com", Bucket: "b"} + + req1, _ := http.NewRequest(http.MethodGet, "https://b.s3.us-east-1.amazonaws.com/key", nil) + c1.signV4(req1, emptyPayloadHash) + + req2, _ := http.NewRequest(http.MethodGet, "https://b.s3.eu-west-1.amazonaws.com/key", nil) + c2.signV4(req2, emptyPayloadHash) + + // Different regions should produce different signatures + require.NotEqual(t, req1.Header.Get("Authorization"), req2.Header.Get("Authorization")) +} + +func TestParseError_XMLResponse(t *testing.T) { + xmlBody := []byte(`NoSuchKeyThe specified key does not exist.`) + err := parseErrorFromBytes(404, xmlBody) + + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + require.Equal(t, 404, errResp.StatusCode) + require.Equal(t, "NoSuchKey", errResp.Code) + require.Equal(t, "The specified key does not exist.", errResp.Message) +} + +func TestParseError_NonXMLResponse(t *testing.T) { + err := parseErrorFromBytes(500, []byte("internal server error")) + + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + require.Equal(t, 500, errResp.StatusCode) + require.Equal(t, "", errResp.Code) // XML parsing failed, no code + require.Contains(t, errResp.Body, "internal server error") +} diff --git a/tools/s3cli/main.go b/tools/s3cli/main.go new file mode 100644 index 00000000..697d4e71 --- /dev/null +++ b/tools/s3cli/main.go @@ -0,0 +1,164 @@ +// Command s3cli is a minimal CLI for testing the s3 package. It supports put, get, rm, and ls. +// +// Usage: +// +// export S3_URL="s3://ACCESS_KEY:SECRET_KEY@BUCKET/PREFIX?region=REGION&endpoint=ENDPOINT" +// +// s3cli put Upload a file +// s3cli put - Upload from stdin +// s3cli get Download to stdout +// s3cli rm [...] Delete one or more objects +// s3cli ls List all objects +package main + +import ( + "context" + "fmt" + "io" + "os" + "text/tabwriter" + + "heckel.io/ntfy/v2/s3" +) + +func main() { + if len(os.Args) < 2 { + usage() + } + s3URL := os.Getenv("S3_URL") + if s3URL == "" { + fail("S3_URL environment variable is required") + } + cfg, err := s3.ParseURL(s3URL) + if err != nil { + fail("invalid S3_URL: %s", err) + } + client := s3.New(cfg) + ctx := context.Background() + + switch os.Args[1] { + case "put": + cmdPut(ctx, client) + case "get": + cmdGet(ctx, client) + case "rm": + cmdRm(ctx, client) + case "ls": + cmdLs(ctx, client) + default: + usage() + } +} + +func cmdPut(ctx context.Context, client *s3.Client) { + if len(os.Args) != 4 { + fail("usage: s3cli put \n") + } + key := os.Args[2] + path := os.Args[3] + + var r io.Reader + var size int64 + if path == "-" { + // Read stdin into a temp file to get the size + tmp, err := os.CreateTemp("", "s3cli-*") + if err != nil { + fail("create temp file: %s", err) + } + defer os.Remove(tmp.Name()) + n, err := io.Copy(tmp, os.Stdin) + if err != nil { + tmp.Close() + fail("read stdin: %s", err) + } + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + tmp.Close() + fail("seek: %s", err) + } + r = tmp + size = n + defer tmp.Close() + } else { + f, err := os.Open(path) + if err != nil { + fail("open %s: %s", path, err) + } + defer f.Close() + info, err := f.Stat() + if err != nil { + fail("stat %s: %s", path, err) + } + r = f + size = info.Size() + } + + if err := client.PutObject(ctx, key, r, size); err != nil { + fail("put: %s", err) + } + fmt.Fprintf(os.Stderr, "uploaded %s (%d bytes)\n", key, size) +} + +func cmdGet(ctx context.Context, client *s3.Client) { + if len(os.Args) != 3 { + fail("usage: s3cli get \n") + } + key := os.Args[2] + + reader, size, err := client.GetObject(ctx, key) + if err != nil { + fail("get: %s", err) + } + defer reader.Close() + n, err := io.Copy(os.Stdout, reader) + if err != nil { + fail("read: %s", err) + } + fmt.Fprintf(os.Stderr, "downloaded %s (%d bytes, content-length: %d)\n", key, n, size) +} + +func cmdRm(ctx context.Context, client *s3.Client) { + if len(os.Args) < 3 { + fail("usage: s3cli rm [...]\n") + } + keys := os.Args[2:] + if err := client.DeleteObjects(ctx, keys); err != nil { + fail("rm: %s", err) + } + fmt.Fprintf(os.Stderr, "deleted %d object(s)\n", len(keys)) +} + +func cmdLs(ctx context.Context, client *s3.Client) { + objects, err := client.ListAllObjects(ctx) + if err != nil { + fail("ls: %s", err) + } + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + var totalSize int64 + for _, obj := range objects { + fmt.Fprintf(w, "%d\t%s\n", obj.Size, obj.Key) + totalSize += obj.Size + } + w.Flush() + fmt.Fprintf(os.Stderr, "%d object(s), %d bytes total\n", len(objects), totalSize) +} + +func usage() { + fmt.Fprintf(os.Stderr, `Usage: s3cli [args...] + +Commands: + put Upload a file (use - for stdin) + get Download to stdout + rm [keys...] Delete objects + ls List all objects + +Environment: + S3_URL S3 connection URL (required) + s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] +`) + os.Exit(1) +} + +func fail(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} From e8199fa6b54ac445f70b1b1e25b5f0eae0cda1d2 Mon Sep 17 00:00:00 2001 From: ageru Date: Mon, 16 Mar 2026 23:30:21 +0100 Subject: [PATCH 005/126] install.md - Install ntfy server service manually First draft - Add manual steps to install ntfy server service on systemd and OpenRC --- docs/install.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/docs/install.md b/docs/install.md index ed9af639..b123c8cd 100644 --- a/docs/install.md +++ b/docs/install.md @@ -28,6 +28,8 @@ 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 deb/rpm packages. +### Just download and run + === "x86_64/amd64" ```bash wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.1/ntfy_2.19.1_linux_amd64.tar.gz @@ -64,6 +66,51 @@ deb/rpm packages. sudo ntfy serve ``` +### Install permanently and start at boot +The above allows you to quickly run ntfy. If you want to install it permanently 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 +```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 +```bash +sudo mv package/server/ntfy.service /etc/systemd/system/ +sudo chmod 644 /etc/systemd/system/ntfy.service +``` + +Notify systemd the new service exist +```bash +sudo systemctl daemon-reload +``` + +Launch the service on systemd +```bash +sudo systemctl ntfy start +``` + +#### On OpenRC systems + +Install the ntfy server service script +```bash +sudo mv origin/path/ntfy.openrc /etc/init.d/ntfy +sudo chmod 755 /etc/init.d/ntfy +``` + +Launch the ntfy server service +```bash +sudo rc-service ntfy start +``` + +Add ntfy server service to the default runlevel (so that it runs at startup) +```bash +sudo rc-update add ntfy default +``` + + ## Debian/Ubuntu repository !!! info From 86015e100c00b908dbd810f6f93e2a74befbc9ee Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 16 Mar 2026 20:00:19 -0400 Subject: [PATCH 006/126] Multipart upload --- .gitignore | 1 + attachment/store_s3.go | 49 +++++---- attachment/store_s3_test.go | 93 ++++++++++++++++- s3/client.go | 197 ++++++++++++++++++++++++++++++++---- s3/client_test.go | 167 ++++++++++++++++++++++++++---- s3/types.go | 11 ++ s3/util.go | 5 + tools/s3cli/main.go | 29 +----- 8 files changed, 462 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index ed17b2d4..6d5deb67 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ server/site/ tools/fbsend/fbsend tools/pgimport/pgimport tools/loadtest/loadtest +tools/s3cli/s3cli playground/ secrets/ *.iml diff --git a/attachment/store_s3.go b/attachment/store_s3.go index 5c47a81b..38f0353a 100644 --- a/attachment/store_s3.go +++ b/attachment/store_s3.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "io" - "os" "sync" "heckel.io/ntfy/v2/log" @@ -51,33 +50,31 @@ func (c *s3Store) Write(id string, in io.Reader, limiters ...util.Limiter) (int6 } log.Tag(tagS3Store).Field("message_id", id).Debug("Writing attachment to S3") - // Write through limiters into a temp file. This avoids buffering the full attachment in - // memory while still giving us the Content-Length that PutObject requires. + // Stream through limiters via an io.Pipe directly to S3. PutObject supports chunked + // uploads, so no temp file or Content-Length is needed. limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) - tmpFile, err := os.CreateTemp("", "ntfy-s3-upload-*") - if err != nil { - return 0, fmt.Errorf("s3 store: failed to create temp file: %w", err) + pr, pw := io.Pipe() + lw := util.NewLimitWriter(pw, limiters...) + var size int64 + var copyErr error + done := make(chan struct{}) + go func() { + defer close(done) + size, copyErr = io.Copy(lw, in) + if copyErr != nil { + pw.CloseWithError(copyErr) + } else { + pw.Close() + } + }() + putErr := c.client.PutObject(context.Background(), id, pr) + pr.Close() + <-done + if copyErr != nil { + return 0, copyErr } - tmpPath := tmpFile.Name() - defer os.Remove(tmpPath) - limitWriter := util.NewLimitWriter(tmpFile, limiters...) - size, err := io.Copy(limitWriter, in) - if err != nil { - tmpFile.Close() - return 0, err - } - if err := tmpFile.Close(); err != nil { - return 0, err - } - - // Re-open the temp file for reading and stream it to S3 - f, err := os.Open(tmpPath) - if err != nil { - return 0, err - } - defer f.Close() - if err := c.client.PutObject(context.Background(), id, f, size); err != nil { - return 0, err + if putErr != nil { + return 0, putErr } c.mu.Lock() c.totalSizeCurrent += size diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index c898244d..872e8c23 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -167,27 +167,41 @@ func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string // ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory. type mockS3Server struct { - objects map[string][]byte // full key (bucket/key) -> body + objects map[string][]byte // full key (bucket/key) -> body + uploads map[string]map[int][]byte // uploadID -> partNumber -> data + nextID int // counter for generating upload IDs mu sync.RWMutex } func newMockS3Server() *httptest.Server { - m := &mockS3Server{objects: make(map[string][]byte)} + m := &mockS3Server{ + objects: make(map[string][]byte), + uploads: make(map[string]map[int][]byte), + } return httptest.NewTLSServer(m) } func (m *mockS3Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Path is /{bucket}[/{key...}] path := strings.TrimPrefix(r.URL.Path, "/") + q := r.URL.Query() switch { + case r.Method == http.MethodPut && q.Has("partNumber"): + m.handleUploadPart(w, r, path) case r.Method == http.MethodPut: m.handlePut(w, r, path) - case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2": + case r.Method == http.MethodPost && q.Has("uploads"): + m.handleInitiateMultipart(w, r, path) + case r.Method == http.MethodPost && q.Has("uploadId"): + m.handleCompleteMultipart(w, r, path) + case r.Method == http.MethodDelete && q.Has("uploadId"): + m.handleAbortMultipart(w, r, path) + case r.Method == http.MethodGet && q.Get("list-type") == "2": m.handleList(w, r, path) case r.Method == http.MethodGet: m.handleGet(w, r, path) - case r.Method == http.MethodPost && r.URL.Query().Has("delete"): + case r.Method == http.MethodPost && q.Has("delete"): m.handleDelete(w, r, path) default: http.Error(w, "not implemented", http.StatusNotImplemented) @@ -206,6 +220,77 @@ func (m *mockS3Server) handlePut(w http.ResponseWriter, r *http.Request, path st w.WriteHeader(http.StatusOK) } +func (m *mockS3Server) handleInitiateMultipart(w http.ResponseWriter, r *http.Request, path string) { + m.mu.Lock() + m.nextID++ + uploadID := fmt.Sprintf("upload-%d", m.nextID) + m.uploads[uploadID] = make(map[int][]byte) + m.mu.Unlock() + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `%s`, uploadID) +} + +func (m *mockS3Server) handleUploadPart(w http.ResponseWriter, r *http.Request, path string) { + uploadID := r.URL.Query().Get("uploadId") + var partNumber int + fmt.Sscanf(r.URL.Query().Get("partNumber"), "%d", &partNumber) + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + m.mu.Lock() + parts, ok := m.uploads[uploadID] + if !ok { + m.mu.Unlock() + http.Error(w, "NoSuchUpload", http.StatusNotFound) + return + } + parts[partNumber] = body + m.mu.Unlock() + + etag := fmt.Sprintf(`"etag-part-%d"`, partNumber) + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusOK) +} + +func (m *mockS3Server) handleCompleteMultipart(w http.ResponseWriter, r *http.Request, path string) { + uploadID := r.URL.Query().Get("uploadId") + + m.mu.Lock() + parts, ok := m.uploads[uploadID] + if !ok { + m.mu.Unlock() + http.Error(w, "NoSuchUpload", http.StatusNotFound) + return + } + + // Assemble parts in order + var assembled []byte + for i := 1; i <= len(parts); i++ { + assembled = append(assembled, parts[i]...) + } + m.objects[path] = assembled + delete(m.uploads, uploadID) + m.mu.Unlock() + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `%s`, path) +} + +func (m *mockS3Server) handleAbortMultipart(w http.ResponseWriter, r *http.Request, path string) { + uploadID := r.URL.Query().Get("uploadId") + m.mu.Lock() + delete(m.uploads, uploadID) + m.mu.Unlock() + w.WriteHeader(http.StatusNoContent) +} + func (m *mockS3Server) handleGet(w http.ResponseWriter, r *http.Request, path string) { m.mu.RLock() body, ok := m.objects[path] diff --git a/s3/client.go b/s3/client.go index 7fdd8093..29e10d2d 100644 --- a/s3/client.go +++ b/s3/client.go @@ -10,6 +10,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/xml" + "errors" "fmt" "io" "net/http" @@ -50,25 +51,22 @@ func New(config *Config) *Client { } // PutObject uploads body to the given key. The key is automatically prefixed with the client's -// configured prefix. The body size must be known in advance. The payload is sent as -// UNSIGNED-PAYLOAD, which is supported by all major S3-compatible providers over HTTPS. -func (c *Client) PutObject(ctx context.Context, key string, body io.Reader, size int64) error { - fullKey := c.objectKey(key) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.objectURL(fullKey), body) +// configured prefix. The body size does not need to be known in advance. +// +// If the entire body fits in a single part (5 MB), it is uploaded with a simple PUT request. +// Otherwise, the body is uploaded using S3 multipart upload, reading one part at a time +// into memory. +func (c *Client) PutObject(ctx context.Context, key string, body io.Reader) error { + first := make([]byte, partSize) + n, err := io.ReadFull(body, first) + if errors.Is(err, io.ErrUnexpectedEOF) || err == io.EOF { + return c.putObject(ctx, key, bytes.NewReader(first[:n]), int64(n)) + } if err != nil { - return fmt.Errorf("s3: PutObject request: %w", err) + return fmt.Errorf("s3: PutObject read: %w", err) } - req.ContentLength = size - c.signV4(req, unsignedPayload) - resp, err := c.httpClient().Do(req) - if err != nil { - return fmt.Errorf("s3: PutObject: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode/100 != 2 { - return parseError(resp) - } - return nil + combined := io.MultiReader(bytes.NewReader(first), body) + return c.putObjectMultipart(ctx, key, combined) } // GetObject downloads an object. The key is automatically prefixed with the client's configured @@ -216,6 +214,171 @@ func (c *Client) ListAllObjects(ctx context.Context) ([]Object, error) { return nil, fmt.Errorf("s3: ListAllObjects exceeded %d pages", maxPages) } +// putObject uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. +func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size int64) error { + fullKey := c.objectKey(key) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.objectURL(fullKey), body) + if err != nil { + return fmt.Errorf("s3: PutObject request: %w", err) + } + req.ContentLength = size + c.signV4(req, unsignedPayload) + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("s3: PutObject: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return parseError(resp) + } + return nil +} + +// putObjectMultipart uploads body using S3 multipart upload. It reads the body in partSize +// chunks, uploading each as a separate part. This allows uploading without knowing the total +// body size in advance. +func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Reader) error { + fullKey := c.objectKey(key) + + // Step 1: Initiate multipart upload + uploadID, err := c.initiateMultipartUpload(ctx, fullKey) + if err != nil { + return err + } + + // Step 2: Upload parts + var parts []completedPart + buf := make([]byte, partSize) + partNumber := 1 + for { + n, err := io.ReadFull(body, buf) + if n > 0 { + etag, uploadErr := c.uploadPart(ctx, fullKey, uploadID, partNumber, buf[:n]) + if uploadErr != nil { + c.abortMultipartUpload(ctx, fullKey, uploadID) + return uploadErr + } + parts = append(parts, completedPart{PartNumber: partNumber, ETag: etag}) + partNumber++ + } + if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) { + break + } + if err != nil { + c.abortMultipartUpload(ctx, fullKey, uploadID) + return fmt.Errorf("s3: PutObject read: %w", err) + } + } + + // Step 3: Complete multipart upload + return c.completeMultipartUpload(ctx, fullKey, uploadID, parts) +} + +// initiateMultipartUpload starts a new multipart upload and returns the upload ID. +func (c *Client) initiateMultipartUpload(ctx context.Context, fullKey string) (string, error) { + reqURL := c.objectURL(fullKey) + "?uploads" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil) + if err != nil { + return "", fmt.Errorf("s3: InitiateMultipartUpload request: %w", err) + } + req.ContentLength = 0 + c.signV4(req, emptyPayloadHash) + resp, err := c.httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("s3: InitiateMultipartUpload: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", parseError(resp) + } + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return "", fmt.Errorf("s3: InitiateMultipartUpload read: %w", err) + } + var result initiateMultipartUploadResult + if err := xml.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("s3: InitiateMultipartUpload XML: %w", err) + } + return result.UploadID, nil +} + +// uploadPart uploads a single part of a multipart upload and returns the ETag. +func (c *Client) uploadPart(ctx context.Context, fullKey, uploadID string, partNumber int, data []byte) (string, error) { + reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.objectURL(fullKey), partNumber, url.QueryEscape(uploadID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("s3: UploadPart request: %w", err) + } + req.ContentLength = int64(len(data)) + c.signV4(req, unsignedPayload) + resp, err := c.httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("s3: UploadPart: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return "", parseError(resp) + } + etag := resp.Header.Get("ETag") + return etag, nil +} + +// completeMultipartUpload finalizes a multipart upload with the given parts. +func (c *Client) completeMultipartUpload(ctx context.Context, fullKey, uploadID string, parts []completedPart) error { + var body bytes.Buffer + body.WriteString("") + for _, p := range parts { + fmt.Fprintf(&body, "%d%s", p.PartNumber, p.ETag) + } + body.WriteString("") + bodyBytes := body.Bytes() + payloadHash := sha256Hex(bodyBytes) + + reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) + if err != nil { + return fmt.Errorf("s3: CompleteMultipartUpload request: %w", err) + } + req.ContentLength = int64(len(bodyBytes)) + req.Header.Set("Content-Type", "application/xml") + c.signV4(req, payloadHash) + resp, err := c.httpClient().Do(req) + if err != nil { + return fmt.Errorf("s3: CompleteMultipartUpload: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode/100 != 2 { + return parseError(resp) + } + // Read response body to check for errors (S3 can return 200 with an error body) + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return fmt.Errorf("s3: CompleteMultipartUpload read: %w", err) + } + // Check if the response contains an error + var errResp ErrorResponse + if xml.Unmarshal(respBody, &errResp) == nil && errResp.Code != "" { + errResp.StatusCode = resp.StatusCode + return &errResp + } + return nil +} + +// abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. +func (c *Client) abortMultipartUpload(ctx context.Context, fullKey, uploadID string) { + reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) + if err != nil { + return + } + c.signV4(req, emptyPayloadHash) + resp, err := c.httpClient().Do(req) + if err != nil { + return + } + resp.Body.Close() +} + // signV4 signs req in place using AWS Signature V4. payloadHash is the hex-encoded SHA-256 // of the request body, or the literal string "UNSIGNED-PAYLOAD" for streaming uploads. func (c *Client) signV4(req *http.Request, payloadHash string) { diff --git a/s3/client_test.go b/s3/client_test.go index f4a10213..c3a8fe2c 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -23,27 +23,41 @@ import ( // ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory. type mockS3Server struct { - objects map[string][]byte // full key (bucket/key) -> body + objects map[string][]byte // full key (bucket/key) -> body + uploads map[string]map[int][]byte // uploadID -> partNumber -> data + nextID int // counter for generating upload IDs mu sync.RWMutex } func newMockS3Server() (*httptest.Server, *mockS3Server) { - m := &mockS3Server{objects: make(map[string][]byte)} + m := &mockS3Server{ + objects: make(map[string][]byte), + uploads: make(map[string]map[int][]byte), + } return httptest.NewTLSServer(m), m } func (m *mockS3Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Path is /{bucket}[/{key...}] path := strings.TrimPrefix(r.URL.Path, "/") + q := r.URL.Query() switch { + case r.Method == http.MethodPut && q.Has("partNumber"): + m.handleUploadPart(w, r, path) case r.Method == http.MethodPut: m.handlePut(w, r, path) - case r.Method == http.MethodGet && r.URL.Query().Get("list-type") == "2": + case r.Method == http.MethodPost && q.Has("uploads"): + m.handleInitiateMultipart(w, r, path) + case r.Method == http.MethodPost && q.Has("uploadId"): + m.handleCompleteMultipart(w, r, path) + case r.Method == http.MethodDelete && q.Has("uploadId"): + m.handleAbortMultipart(w, r, path) + case r.Method == http.MethodGet && q.Get("list-type") == "2": m.handleList(w, r, path) case r.Method == http.MethodGet: m.handleGet(w, r, path) - case r.Method == http.MethodPost && r.URL.Query().Has("delete"): + case r.Method == http.MethodPost && q.Has("delete"): m.handleDelete(w, r, path) default: http.Error(w, "not implemented", http.StatusNotImplemented) @@ -62,6 +76,77 @@ func (m *mockS3Server) handlePut(w http.ResponseWriter, r *http.Request, path st w.WriteHeader(http.StatusOK) } +func (m *mockS3Server) handleInitiateMultipart(w http.ResponseWriter, r *http.Request, path string) { + m.mu.Lock() + m.nextID++ + uploadID := fmt.Sprintf("upload-%d", m.nextID) + m.uploads[uploadID] = make(map[int][]byte) + m.mu.Unlock() + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `%s`, uploadID) +} + +func (m *mockS3Server) handleUploadPart(w http.ResponseWriter, r *http.Request, path string) { + uploadID := r.URL.Query().Get("uploadId") + var partNumber int + fmt.Sscanf(r.URL.Query().Get("partNumber"), "%d", &partNumber) + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + m.mu.Lock() + parts, ok := m.uploads[uploadID] + if !ok { + m.mu.Unlock() + http.Error(w, "NoSuchUpload", http.StatusNotFound) + return + } + parts[partNumber] = body + m.mu.Unlock() + + etag := fmt.Sprintf(`"etag-part-%d"`, partNumber) + w.Header().Set("ETag", etag) + w.WriteHeader(http.StatusOK) +} + +func (m *mockS3Server) handleCompleteMultipart(w http.ResponseWriter, r *http.Request, path string) { + uploadID := r.URL.Query().Get("uploadId") + + m.mu.Lock() + parts, ok := m.uploads[uploadID] + if !ok { + m.mu.Unlock() + http.Error(w, "NoSuchUpload", http.StatusNotFound) + return + } + + // Assemble parts in order + var assembled []byte + for i := 1; i <= len(parts); i++ { + assembled = append(assembled, parts[i]...) + } + m.objects[path] = assembled + delete(m.uploads, uploadID) + m.mu.Unlock() + + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `%s`, path) +} + +func (m *mockS3Server) handleAbortMultipart(w http.ResponseWriter, r *http.Request, path string) { + uploadID := r.URL.Query().Get("uploadId") + m.mu.Lock() + delete(m.uploads, uploadID) + m.mu.Unlock() + w.WriteHeader(http.StatusNoContent) +} + func (m *mockS3Server) handleGet(w http.ResponseWriter, r *http.Request, path string) { m.mu.RLock() body, ok := m.objects[path] @@ -333,7 +418,7 @@ func TestClient_PutGetObject(t *testing.T) { ctx := context.Background() // Put - err := client.PutObject(ctx, "test-key", strings.NewReader("hello world"), 11) + err := client.PutObject(ctx, "test-key", strings.NewReader("hello world")) require.Nil(t, err) // Get @@ -353,7 +438,7 @@ func TestClient_PutGetObject_WithPrefix(t *testing.T) { ctx := context.Background() - err := client.PutObject(ctx, "test-key", strings.NewReader("hello"), 5) + err := client.PutObject(ctx, "test-key", strings.NewReader("hello")) require.Nil(t, err) reader, _, err := client.GetObject(ctx, "test-key") @@ -385,7 +470,7 @@ func TestClient_DeleteObjects(t *testing.T) { // Put several objects for i := 0; i < 5; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%d", i), bytes.NewReader([]byte("data")), 4) + err := client.PutObject(ctx, fmt.Sprintf("key-%d", i), bytes.NewReader([]byte("data"))) require.Nil(t, err) } require.Equal(t, 5, mock.objectCount()) @@ -416,13 +501,13 @@ func TestClient_ListObjects(t *testing.T) { // Client with prefix "pfx": list should only return objects under pfx/ client := newTestClient(server, "my-bucket", "pfx") for i := 0; i < 3; i++ { - err := client.PutObject(ctx, fmt.Sprintf("%d", i), bytes.NewReader([]byte("x")), 1) + err := client.PutObject(ctx, fmt.Sprintf("%d", i), bytes.NewReader([]byte("x"))) require.Nil(t, err) } // Also put an object outside the prefix using a no-prefix client clientNoPrefix := newTestClient(server, "my-bucket", "") - err := clientNoPrefix.PutObject(ctx, "other", bytes.NewReader([]byte("y")), 1) + err := clientNoPrefix.PutObject(ctx, "other", bytes.NewReader([]byte("y"))) require.Nil(t, err) // List with prefix client: should only see 3 @@ -446,7 +531,7 @@ func TestClient_ListObjects_Pagination(t *testing.T) { // Put 5 objects for i := 0; i < 5; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x")), 1) + err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x"))) require.Nil(t, err) } @@ -478,7 +563,7 @@ func TestClient_ListAllObjects(t *testing.T) { ctx := context.Background() for i := 0; i < 10; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x")), 1) + err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x"))) require.Nil(t, err) } @@ -499,7 +584,7 @@ func TestClient_PutObject_LargeBody(t *testing.T) { for i := range data { data[i] = byte(i % 256) } - err := client.PutObject(ctx, "large", bytes.NewReader(data), int64(len(data))) + err := client.PutObject(ctx, "large", bytes.NewReader(data)) require.Nil(t, err) reader, size, err := client.GetObject(ctx, "large") @@ -511,6 +596,54 @@ func TestClient_PutObject_LargeBody(t *testing.T) { require.Equal(t, data, got) } +func TestClient_PutObject_ChunkedUpload(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "") + + ctx := context.Background() + + // 12 MB object, exceeds 5 MB partSize, triggers multipart upload path + data := make([]byte, 12*1024*1024) + for i := range data { + data[i] = byte(i % 256) + } + err := client.PutObject(ctx, "multipart", bytes.NewReader(data)) + require.Nil(t, err) + + reader, size, err := client.GetObject(ctx, "multipart") + require.Nil(t, err) + require.Equal(t, int64(12*1024*1024), size) + got, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, data, got) +} + +func TestClient_PutObject_ExactPartSize(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "") + + ctx := context.Background() + + // Exactly 5 MB (partSize), should use the simple put path (ReadFull succeeds fully) + data := make([]byte, 5*1024*1024) + for i := range data { + data[i] = byte(i % 256) + } + err := client.PutObject(ctx, "exact", bytes.NewReader(data)) + require.Nil(t, err) + + reader, size, err := client.GetObject(ctx, "exact") + require.Nil(t, err) + require.Equal(t, int64(5*1024*1024), size) + got, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, data, got) +} + func TestClient_PutObject_NestedKey(t *testing.T) { server, _ := newMockS3Server() defer server.Close() @@ -518,7 +651,7 @@ func TestClient_PutObject_NestedKey(t *testing.T) { ctx := context.Background() - err := client.PutObject(ctx, "deep/nested/prefix/file.txt", strings.NewReader("nested"), 6) + err := client.PutObject(ctx, "deep/nested/prefix/file.txt", strings.NewReader("nested")) require.Nil(t, err) reader, _, err := client.GetObject(ctx, "deep/nested/prefix/file.txt") @@ -548,7 +681,7 @@ func TestClient_ListAllObjects_20k(t *testing.T) { for i := 0; i < batchSize; i++ { idx := batch*batchSize + i key := fmt.Sprintf("%08d", idx) - err := client.PutObject(ctx, key, bytes.NewReader([]byte("x")), 1) + err := client.PutObject(ctx, key, bytes.NewReader([]byte("x"))) require.Nil(t, err) } } @@ -647,7 +780,7 @@ func TestClient_RealBucket(t *testing.T) { content := "hello from ntfy s3 test" // Put - err := client.PutObject(ctx, key, strings.NewReader(content), int64(len(content))) + err := client.PutObject(ctx, key, strings.NewReader(content)) require.Nil(t, err) // Get @@ -685,7 +818,7 @@ func TestClient_RealBucket(t *testing.T) { // Put 10 objects for i := 0; i < 10; i++ { - err := listClient.PutObject(ctx, fmt.Sprintf("%d", i), strings.NewReader("x"), 1) + err := listClient.PutObject(ctx, fmt.Sprintf("%d", i), strings.NewReader("x")) require.Nil(t, err) } @@ -710,7 +843,7 @@ func TestClient_RealBucket(t *testing.T) { data[i] = byte(i % 256) } - err := client.PutObject(ctx, key, bytes.NewReader(data), int64(len(data))) + err := client.PutObject(ctx, key, bytes.NewReader(data)) require.Nil(t, err) reader, size, err := client.GetObject(ctx, key) diff --git a/s3/types.go b/s3/types.go index 5929ec6c..201c570b 100644 --- a/s3/types.go +++ b/s3/types.go @@ -63,3 +63,14 @@ type deleteError struct { Code string `xml:"Code"` Message string `xml:"Message"` } + +// initiateMultipartUploadResult is the XML response from S3 InitiateMultipartUpload +type initiateMultipartUploadResult struct { + UploadID string `xml:"UploadId"` +} + +// completedPart represents a successfully uploaded part for CompleteMultipartUpload +type completedPart struct { + PartNumber int + ETag string +} diff --git a/s3/util.go b/s3/util.go index cf9d4ba8..c24c1c5b 100644 --- a/s3/util.go +++ b/s3/util.go @@ -22,6 +22,11 @@ const ( // maxResponseBytes caps the size of S3 response bodies we read into memory (10 MB) maxResponseBytes = 10 * 1024 * 1024 + + // partSize is the size of each part for multipart uploads (5 MB). This is also the threshold + // above which PutObject switches from a simple PUT to multipart upload. S3 requires a minimum + // part size of 5 MB for all parts except the last. + partSize = 5 * 1024 * 1024 ) // ParseURL parses an S3 URL of the form: diff --git a/tools/s3cli/main.go b/tools/s3cli/main.go index 697d4e71..1dbac0cf 100644 --- a/tools/s3cli/main.go +++ b/tools/s3cli/main.go @@ -58,44 +58,21 @@ func cmdPut(ctx context.Context, client *s3.Client) { path := os.Args[3] var r io.Reader - var size int64 if path == "-" { - // Read stdin into a temp file to get the size - tmp, err := os.CreateTemp("", "s3cli-*") - if err != nil { - fail("create temp file: %s", err) - } - defer os.Remove(tmp.Name()) - n, err := io.Copy(tmp, os.Stdin) - if err != nil { - tmp.Close() - fail("read stdin: %s", err) - } - if _, err := tmp.Seek(0, io.SeekStart); err != nil { - tmp.Close() - fail("seek: %s", err) - } - r = tmp - size = n - defer tmp.Close() + r = os.Stdin } else { f, err := os.Open(path) if err != nil { fail("open %s: %s", path, err) } defer f.Close() - info, err := f.Stat() - if err != nil { - fail("stat %s: %s", path, err) - } r = f - size = info.Size() } - if err := client.PutObject(ctx, key, r, size); err != nil { + if err := client.PutObject(ctx, key, r); err != nil { fail("put: %s", err) } - fmt.Fprintf(os.Stderr, "uploaded %s (%d bytes)\n", key, size) + fmt.Fprintf(os.Stderr, "uploaded %s\n", key) } func cmdGet(ctx context.Context, client *s3.Client) { From a47d692cbf0aa8d85b763f8d2d0edf87e9edc253 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 17 Mar 2026 07:50:28 -0400 Subject: [PATCH 007/126] Fix bug --- s3/client.go | 14 +++++++------- s3/util.go | 4 ++++ server/server.go | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/s3/client.go b/s3/client.go index 29e10d2d..ffc4fa8a 100644 --- a/s3/client.go +++ b/s3/client.go @@ -82,7 +82,7 @@ func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int6 if err != nil { return nil, 0, fmt.Errorf("s3: GetObject: %w", err) } - if resp.StatusCode/100 != 2 { + if !isHTTPSuccess(resp) { err := parseError(resp) resp.Body.Close() return nil, 0, err @@ -126,7 +126,7 @@ func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { return fmt.Errorf("s3: DeleteObjects: %w", err) } defer resp.Body.Close() - if resp.StatusCode/100 != 2 { + if !isHTTPSuccess(resp) { return parseError(resp) } @@ -176,7 +176,7 @@ func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxK if err != nil { return nil, fmt.Errorf("s3: ListObjects read: %w", err) } - if resp.StatusCode/100 != 2 { + if !isHTTPSuccess(resp) { return nil, parseErrorFromBytes(resp.StatusCode, respBody) } var result listObjectsV2Response @@ -228,7 +228,7 @@ func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size return fmt.Errorf("s3: PutObject: %w", err) } defer resp.Body.Close() - if resp.StatusCode/100 != 2 { + if !isHTTPSuccess(resp) { return parseError(resp) } return nil @@ -288,7 +288,7 @@ func (c *Client) initiateMultipartUpload(ctx context.Context, fullKey string) (s return "", fmt.Errorf("s3: InitiateMultipartUpload: %w", err) } defer resp.Body.Close() - if resp.StatusCode/100 != 2 { + if !isHTTPSuccess(resp) { return "", parseError(resp) } respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) @@ -316,7 +316,7 @@ func (c *Client) uploadPart(ctx context.Context, fullKey, uploadID string, partN return "", fmt.Errorf("s3: UploadPart: %w", err) } defer resp.Body.Close() - if resp.StatusCode/100 != 2 { + if !isHTTPSuccess(resp) { return "", parseError(resp) } etag := resp.Header.Get("ETag") @@ -347,7 +347,7 @@ func (c *Client) completeMultipartUpload(ctx context.Context, fullKey, uploadID return fmt.Errorf("s3: CompleteMultipartUpload: %w", err) } defer resp.Body.Close() - if resp.StatusCode/100 != 2 { + if !isHTTPSuccess(resp) { return parseError(resp) } // Read response body to check for errors (S3 can return 200 with an error body) diff --git a/s3/util.go b/s3/util.go index c24c1c5b..2e3fc233 100644 --- a/s3/util.go +++ b/s3/util.go @@ -154,6 +154,10 @@ func uriEncode(s string) string { return buf.String() } +func isHTTPSuccess(resp *http.Response) bool { + return resp.StatusCode/100 == 2 +} + func sha256Hex(data []byte) string { h := sha256.Sum256(data) return hex.EncodeToString(h[:]) diff --git a/server/server.go b/server/server.go index f77334de..0972f00d 100644 --- a/server/server.go +++ b/server/server.go @@ -603,7 +603,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleDocs)(w, r, v) - } else if (r.Method == http.MethodGet || r.Method == http.MethodHead) && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { + } else if (r.Method == http.MethodGet || r.Method == http.MethodHead) && fileRegex.MatchString(r.URL.Path) && s.fileCache != nil { return s.limitRequests(s.handleFile)(w, r, v) } else if r.Method == http.MethodOptions { return s.limitRequests(s.handleOptions)(w, r, v) // Should work even if the web app is not enabled, see #598 From cffa57950a50af7572e570e1a743582e6850a8b6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 17 Mar 2026 16:25:45 -0400 Subject: [PATCH 008/126] Logs --- attachment/store.go | 10 +++++----- s3/client.go | 16 +++++++++++++++- s3/util.go | 3 +++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index c48a1e90..302eb585 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -10,6 +10,11 @@ import ( "heckel.io/ntfy/v2/util" ) +var ( + fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength)) + errInvalidFileID = errors.New("invalid file ID") +) + // Store is an interface for storing and retrieving attachment files type Store interface { Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) @@ -18,8 +23,3 @@ type Store interface { Size() int64 Remaining() int64 } - -var ( - fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength)) - errInvalidFileID = errors.New("invalid file ID") -) diff --git a/s3/client.go b/s3/client.go index ffc4fa8a..56f9608e 100644 --- a/s3/client.go +++ b/s3/client.go @@ -19,6 +19,12 @@ import ( "strconv" "strings" "time" + + "heckel.io/ntfy/v2/log" +) + +const ( + tagS3Client = "s3_client" ) // Client is a minimal S3-compatible client. It supports PutObject, GetObject, DeleteObjects, @@ -60,11 +66,13 @@ func (c *Client) PutObject(ctx context.Context, key string, body io.Reader) erro first := make([]byte, partSize) n, err := io.ReadFull(body, first) if errors.Is(err, io.ErrUnexpectedEOF) || err == io.EOF { + log.Tag(tagS3Client).Debug("PutObject key=%s size=%d (simple)", key, n) return c.putObject(ctx, key, bytes.NewReader(first[:n]), int64(n)) } if err != nil { return fmt.Errorf("s3: PutObject read: %w", err) } + log.Tag(tagS3Client).Debug("PutObject key=%s (multipart)", key) combined := io.MultiReader(bytes.NewReader(first), body) return c.putObjectMultipart(ctx, key, combined) } @@ -72,6 +80,7 @@ func (c *Client) PutObject(ctx context.Context, key string, body io.Reader) erro // GetObject downloads an object. The key is automatically prefixed with the client's configured // prefix. The caller must close the returned ReadCloser. func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int64, error) { + log.Tag(tagS3Client).Debug("GetObject key=%s", key) fullKey := c.objectKey(key) req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.objectURL(fullKey), nil) if err != nil { @@ -97,6 +106,7 @@ func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int6 // Even when S3 returns HTTP 200, individual keys may fail. If any per-key errors are present // in the response, they are returned as a combined error. func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { + log.Tag(tagS3Client).Debug("DeleteObjects keys=%d", len(keys)) var body bytes.Buffer body.WriteString("true") for _, key := range keys { @@ -152,6 +162,7 @@ func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { // ListObjects performs a single ListObjectsV2 request using the client's configured prefix. // Use continuationToken for pagination. Set maxKeys to 0 for the server default (typically 1000). func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxKeys int) (*ListResult, error) { + log.Tag(tagS3Client).Debug("ListObjects continuation=%s maxKeys=%d", continuationToken, maxKeys) query := url.Values{"list-type": {"2"}} if prefix := c.prefixForList(); prefix != "" { query.Set("prefix", prefix) @@ -197,7 +208,6 @@ func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxK // ListAllObjects returns all objects under the client's configured prefix by paginating through // ListObjectsV2 results automatically. It stops after 10,000 pages as a safety valve. func (c *Client) ListAllObjects(ctx context.Context) ([]Object, error) { - const maxPages = 10000 var all []Object var token string for page := 0; page < maxPages; page++ { @@ -299,11 +309,13 @@ func (c *Client) initiateMultipartUpload(ctx context.Context, fullKey string) (s if err := xml.Unmarshal(respBody, &result); err != nil { return "", fmt.Errorf("s3: InitiateMultipartUpload XML: %w", err) } + log.Tag(tagS3Client).Debug("InitiateMultipartUpload key=%s uploadId=%s", fullKey, result.UploadID) return result.UploadID, nil } // uploadPart uploads a single part of a multipart upload and returns the ETag. func (c *Client) uploadPart(ctx context.Context, fullKey, uploadID string, partNumber int, data []byte) (string, error) { + log.Tag(tagS3Client).Debug("UploadPart key=%s part=%d size=%d", fullKey, partNumber, len(data)) reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.objectURL(fullKey), partNumber, url.QueryEscape(uploadID)) req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data)) if err != nil { @@ -325,6 +337,7 @@ func (c *Client) uploadPart(ctx context.Context, fullKey, uploadID string, partN // completeMultipartUpload finalizes a multipart upload with the given parts. func (c *Client) completeMultipartUpload(ctx context.Context, fullKey, uploadID string, parts []completedPart) error { + log.Tag(tagS3Client).Debug("CompleteMultipartUpload key=%s uploadId=%s parts=%d", fullKey, uploadID, len(parts)) var body bytes.Buffer body.WriteString("") for _, p := range parts { @@ -366,6 +379,7 @@ func (c *Client) completeMultipartUpload(ctx context.Context, fullKey, uploadID // abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. func (c *Client) abortMultipartUpload(ctx context.Context, fullKey, uploadID string) { + log.Tag(tagS3Client).Debug("AbortMultipartUpload key=%s uploadId=%s", fullKey, uploadID) reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) if err != nil { diff --git a/s3/util.go b/s3/util.go index 2e3fc233..546a940a 100644 --- a/s3/util.go +++ b/s3/util.go @@ -27,6 +27,9 @@ const ( // above which PutObject switches from a simple PUT to multipart upload. S3 requires a minimum // part size of 5 MB for all parts except the last. partSize = 5 * 1024 * 1024 + + // maxPages is the max number of pages to iterate through when listing objects + maxPages = 10000 ) // ParseURL parses an S3 URL of the form: From ef314960d015d12fcab803848f4f8a6865d0ae50 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 17 Mar 2026 20:53:41 -0400 Subject: [PATCH 009/126] Refactor --- attachment/backend.go | 22 ++++ attachment/backend_file.go | 85 ++++++++++++++++ attachment/backend_s3.go | 70 +++++++++++++ attachment/store.go | 186 ++++++++++++++++++++++++++++++++-- attachment/store_file.go | 139 ------------------------- attachment/store_file_test.go | 105 ++++++++++++++----- attachment/store_s3.go | 150 --------------------------- attachment/store_s3_test.go | 180 +++++++++++++++++++++----------- message/cache.go | 76 ++++++-------- message/cache_postgres.go | 3 + message/cache_sqlite.go | 3 + s3/client.go | 10 +- s3/client_test.go | 3 +- s3/types.go | 15 ++- server/server.go | 16 ++- util/limit.go | 55 ++++++++++ 16 files changed, 682 insertions(+), 436 deletions(-) create mode 100644 attachment/backend.go create mode 100644 attachment/backend_file.go create mode 100644 attachment/backend_s3.go delete mode 100644 attachment/store_file.go delete mode 100644 attachment/store_s3.go diff --git a/attachment/backend.go b/attachment/backend.go new file mode 100644 index 00000000..8989b890 --- /dev/null +++ b/attachment/backend.go @@ -0,0 +1,22 @@ +package attachment + +import ( + "io" + "time" +) + +// backendObject represents an object stored in a backend. +type object struct { + ID string + Size int64 + LastModified time.Time +} + +// backend is a minimal I/O interface for storing and retrieving attachment files. +// It has no knowledge of size tracking, limiting, or ID validation. +type backend interface { + Put(id string, in io.Reader) error + Get(id string) (io.ReadCloser, int64, error) + Delete(ids ...string) error + List() ([]object, error) +} diff --git a/attachment/backend_file.go b/attachment/backend_file.go new file mode 100644 index 00000000..b0afb2ca --- /dev/null +++ b/attachment/backend_file.go @@ -0,0 +1,85 @@ +package attachment + +import ( + "io" + "os" + "path/filepath" + + "heckel.io/ntfy/v2/log" +) + +const tagFileBackend = "file_backend" + +type fileBackend struct { + dir string +} + +var _ backend = (*fileBackend)(nil) + +func newFileBackend(dir string) (*fileBackend, error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + return &fileBackend{dir: dir}, nil +} + +func (b *fileBackend) Put(id string, in io.Reader) error { + file := filepath.Join(b.dir, id) + f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(f, in); err != nil { + os.Remove(file) + return err + } + if err := f.Close(); err != nil { + os.Remove(file) + return err + } + return nil +} + +func (b *fileBackend) Get(id string) (io.ReadCloser, int64, error) { + file := filepath.Join(b.dir, id) + stat, err := os.Stat(file) + if err != nil { + return nil, 0, err + } + f, err := os.Open(file) + if err != nil { + return nil, 0, err + } + return f, stat.Size(), nil +} + +func (b *fileBackend) Delete(ids ...string) error { + for _, id := range ids { + file := filepath.Join(b.dir, id) + if err := os.Remove(file); err != nil { + log.Tag(tagFileBackend).Field("message_id", id).Err(err).Debug("Error deleting attachment") + } + } + return nil +} + +func (b *fileBackend) List() ([]object, error) { + entries, err := os.ReadDir(b.dir) + if err != nil { + return nil, err + } + objects := make([]object, 0, len(entries)) + for _, e := range entries { + info, err := e.Info() + if err != nil { + return nil, err + } + objects = append(objects, object{ + ID: e.Name(), + Size: info.Size(), + LastModified: info.ModTime(), + }) + } + return objects, nil +} diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go new file mode 100644 index 00000000..8fcd8ccb --- /dev/null +++ b/attachment/backend_s3.go @@ -0,0 +1,70 @@ +package attachment + +import ( + "context" + "io" + "strings" + + "heckel.io/ntfy/v2/s3" + + "heckel.io/ntfy/v2/log" +) + +const tagS3Backend = "s3_backend" + +type s3Backend struct { + client *s3.Client +} + +var _ backend = (*s3Backend)(nil) + +func newS3Backend(client *s3.Client) *s3Backend { + return &s3Backend{client: client} +} + +func (b *s3Backend) Put(id string, in io.Reader) error { + return b.client.PutObject(context.Background(), id, in) +} + +func (b *s3Backend) Get(id string) (io.ReadCloser, int64, error) { + return b.client.GetObject(context.Background(), id) +} + +func (b *s3Backend) Delete(ids ...string) error { + // S3 DeleteObjects supports up to 1000 keys per call + for i := 0; i < len(ids); i += 1000 { + end := i + 1000 + if end > len(ids) { + end = len(ids) + } + batch := ids[i:end] + for _, id := range batch { + log.Tag(tagS3Backend).Field("message_id", id).Debug("Deleting attachment from S3") + } + if err := b.client.DeleteObjects(context.Background(), batch); err != nil { + return err + } + } + return nil +} + +func (b *s3Backend) List() ([]object, error) { + objects, err := b.client.ListAllObjects(context.Background()) + if err != nil { + return nil, err + } + prefix := b.client.Prefix + result := make([]object, 0, len(objects)) + for _, obj := range objects { + id := obj.Key + if prefix != "" { + id = strings.TrimPrefix(id, prefix+"/") + } + result = append(result, object{ + ID: id, + Size: obj.Size, + LastModified: obj.LastModified, + }) + } + return result, nil +} diff --git a/attachment/store.go b/attachment/store.go index 302eb585..f66cd35c 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -5,21 +5,193 @@ import ( "fmt" "io" "regexp" + "sync" + "time" + "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/model" + "heckel.io/ntfy/v2/s3" "heckel.io/ntfy/v2/util" ) +const ( + tagStore = "attachment_cache" + 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 ( fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength)) errInvalidFileID = errors.New("invalid file ID") ) -// Store is an interface for storing and retrieving attachment files -type Store interface { - Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) - Read(id string) (io.ReadCloser, int64, error) - Remove(ids ...string) error - Size() int64 - Remaining() int64 +// Store manages attachment storage with shared logic for size tracking, limiting, +// ID validation, and background sync to reconcile storage with the database. +type Store struct { + backend backend + totalSizeCurrent int64 + totalSizeLimit int64 + localIDs func() ([]string, error) // returns IDs that should exist + closeChan chan struct{} + mu sync.Mutex // Protects totalSizeCurrent +} + +// NewFileStore creates a new file-system backed attachment cache +func NewFileStore(dir string, totalSizeLimit int64, localIDsFn func() ([]string, error)) (*Store, error) { + backend, err := newFileBackend(dir) + if err != nil { + return nil, err + } + return newStore(backend, totalSizeLimit, localIDsFn) +} + +// 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] +func NewS3Store(s3URL string, totalSizeLimit int64, localIDs func() ([]string, error)) (*Store, error) { + config, err := s3.ParseURL(s3URL) + if err != nil { + return nil, err + } + return newStore(newS3Backend(s3.New(config)), totalSizeLimit, localIDs) +} + +func newStore(backend backend, totalSizeLimit int64, localIDs func() ([]string, error)) (*Store, error) { + c := &Store{ + backend: backend, + totalSizeLimit: totalSizeLimit, + localIDs: localIDs, + closeChan: make(chan struct{}), + } + if localIDs != nil { + go c.syncLoop() + } + return c, nil +} + +// Write stores an attachment file. The id is validated, and the write is subject to +// the total size limit and any additional limiters. +func (c *Store) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { + if !fileIDRegex.MatchString(id) { + return 0, errInvalidFileID + } + log.Tag(tagStore).Field("message_id", id).Debug("Writing attachment") + limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) + cr := util.NewCountingReader(in) + lr := util.NewLimitReader(cr, limiters...) + if err := c.backend.Put(id, lr); err != nil { + c.backend.Delete(id) //nolint:errcheck + return 0, err + } + size := cr.Total() + c.mu.Lock() + c.totalSizeCurrent += size + c.mu.Unlock() + return size, nil +} + +// Read retrieves an attachment file by ID +func (c *Store) Read(id string) (io.ReadCloser, int64, error) { + if !fileIDRegex.MatchString(id) { + return nil, 0, errInvalidFileID + } + return c.backend.Get(id) +} + +// Remove deletes attachment files by ID. It does NOT recompute the total size; +// the next sync() call will correct it. +func (c *Store) Remove(ids ...string) error { + for _, id := range ids { + if !fileIDRegex.MatchString(id) { + return errInvalidFileID + } + } + return c.backend.Delete(ids...) +} + +// 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 +// the total size from the remaining objects. +func (c *Store) sync() error { + log.Tag(tagStore).Debug("Sync: starting sync loop") + localIDs, err := c.localIDs() + if err != nil { + return fmt.Errorf("attachment sync: failed to get valid IDs: %w", err) + } + localIDMap := make(map[string]struct{}, len(localIDs)) + for _, id := range localIDs { + localIDMap[id] = struct{}{} + } + remoteObjects, err := c.backend.List() + if err != nil { + return fmt.Errorf("attachment sync: failed to list objects: %w", err) + } + // 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. + cutoff := time.Now().Add(-orphanGracePeriod) + var orphanIDs []string + var totalSize int64 + for _, obj := range remoteObjects { + if !fileIDRegex.MatchString(obj.ID) { + continue + } + if _, ok := localIDMap[obj.ID]; !ok && obj.LastModified.Before(cutoff) { + orphanIDs = append(orphanIDs, obj.ID) + } else { + totalSize += obj.Size + } + } + log.Tag(tagStore).Debug("Sync: cache size updated to %s", util.FormatSizeHuman(totalSize)) + c.mu.Lock() + c.totalSizeCurrent = totalSize + c.mu.Unlock() + // Delete orphaned attachments + if len(orphanIDs) > 0 { + log.Tag(tagStore).Debug("Sync: deleting %d orphaned attachment(s)", len(orphanIDs)) + if err := c.backend.Delete(orphanIDs...); err != nil { + return fmt.Errorf("attachment sync: failed to delete orphaned objects: %w", err) + } + } + return nil +} + +// Size returns the current total size of all attachments +func (c *Store) Size() int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.totalSizeCurrent +} + +// Remaining returns the remaining capacity for attachments +func (c *Store) Remaining() int64 { + c.mu.Lock() + defer c.mu.Unlock() + remaining := c.totalSizeLimit - c.totalSizeCurrent + if remaining < 0 { + return 0 + } + return remaining +} + +// Close stops the background sync goroutine +func (c *Store) Close() { + close(c.closeChan) +} + +func (c *Store) syncLoop() { + if err := c.sync(); err != nil { + log.Tag(tagStore).Err(err).Warn("Attachment sync failed") + } + ticker := time.NewTicker(syncInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := c.sync(); err != nil { + log.Tag(tagStore).Err(err).Warn("Attachment sync failed") + } + case <-c.closeChan: + return + } + } } diff --git a/attachment/store_file.go b/attachment/store_file.go deleted file mode 100644 index b26e86f0..00000000 --- a/attachment/store_file.go +++ /dev/null @@ -1,139 +0,0 @@ -package attachment - -import ( - "errors" - "io" - "os" - "path/filepath" - "sync" - - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/util" -) - -const tagFileStore = "file_store" - -var errFileExists = errors.New("file exists") - -type fileStore struct { - dir string - totalSizeCurrent int64 - totalSizeLimit int64 - mu sync.Mutex -} - -// NewFileStore creates a new file-system backed attachment store -func NewFileStore(dir string, totalSizeLimit int64) (Store, error) { - if err := os.MkdirAll(dir, 0700); err != nil { - return nil, err - } - size, err := dirSize(dir) - if err != nil { - return nil, err - } - return &fileStore{ - dir: dir, - totalSizeCurrent: size, - totalSizeLimit: totalSizeLimit, - }, nil -} - -func (c *fileStore) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { - if !fileIDRegex.MatchString(id) { - return 0, errInvalidFileID - } - log.Tag(tagFileStore).Field("message_id", id).Debug("Writing attachment") - file := filepath.Join(c.dir, id) - if _, err := os.Stat(file); err == nil { - return 0, errFileExists - } - f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - return 0, err - } - defer f.Close() - limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) - limitWriter := util.NewLimitWriter(f, limiters...) - size, err := io.Copy(limitWriter, in) - if err != nil { - os.Remove(file) - return 0, err - } - if err := f.Close(); err != nil { - os.Remove(file) - return 0, err - } - c.mu.Lock() - c.totalSizeCurrent += size - c.mu.Unlock() - return size, nil -} - -func (c *fileStore) Read(id string) (io.ReadCloser, int64, error) { - if !fileIDRegex.MatchString(id) { - return nil, 0, errInvalidFileID - } - file := filepath.Join(c.dir, id) - stat, err := os.Stat(file) - if err != nil { - return nil, 0, err - } - f, err := os.Open(file) - if err != nil { - return nil, 0, err - } - return f, stat.Size(), nil -} - -func (c *fileStore) Remove(ids ...string) error { - for _, id := range ids { - if !fileIDRegex.MatchString(id) { - return errInvalidFileID - } - log.Tag(tagFileStore).Field("message_id", id).Debug("Deleting attachment") - file := filepath.Join(c.dir, id) - if err := os.Remove(file); err != nil { - log.Tag(tagFileStore).Field("message_id", id).Err(err).Debug("Error deleting attachment") - } - } - size, err := dirSize(c.dir) - if err != nil { - return err - } - c.mu.Lock() - c.totalSizeCurrent = size - c.mu.Unlock() - return nil -} - -func (c *fileStore) Size() int64 { - c.mu.Lock() - defer c.mu.Unlock() - return c.totalSizeCurrent -} - -func (c *fileStore) Remaining() int64 { - c.mu.Lock() - defer c.mu.Unlock() - remaining := c.totalSizeLimit - c.totalSizeCurrent - if remaining < 0 { - return 0 - } - return remaining -} - -func dirSize(dir string) (int64, error) { - entries, err := os.ReadDir(dir) - if err != nil { - return 0, err - } - var size int64 - for _, e := range entries { - info, err := e.Info() - if err != nil { - return 0, err - } - size += info.Size() - } - return size, nil -} diff --git a/attachment/store_file_test.go b/attachment/store_file_test.go index 5cfe0db4..ceac09d7 100644 --- a/attachment/store_file_test.go +++ b/attachment/store_file_test.go @@ -7,6 +7,7 @@ import ( "os" "strings" "testing" + "time" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/util" @@ -17,22 +18,22 @@ var ( ) func TestFileStore_Write_Success(t *testing.T) { - dir, s := newTestFileStore(t) - size, err := s.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999)) + dir, c := newTestFileStore(t) + size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999)) require.Nil(t, err) require.Equal(t, int64(11), size) require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl")) - require.Equal(t, int64(11), s.Size()) - require.Equal(t, int64(10229), s.Remaining()) + require.Equal(t, int64(11), c.Size()) + require.Equal(t, int64(10229), c.Remaining()) } func TestFileStore_Write_Read_Success(t *testing.T) { - _, s := newTestFileStore(t) - size, err := s.Write("abcdefghijkl", strings.NewReader("hello world")) + _, c := newTestFileStore(t) + size, err := c.Write("abcdefghijkl", strings.NewReader("hello world")) require.Nil(t, err) require.Equal(t, int64(11), size) - reader, readSize, err := s.Read("abcdefghijkl") + reader, readSize, err := c.Read("abcdefghijkl") require.Nil(t, err) require.Equal(t, int64(11), readSize) defer reader.Close() @@ -42,57 +43,111 @@ func TestFileStore_Write_Read_Success(t *testing.T) { } func TestFileStore_Write_Remove_Success(t *testing.T) { - dir, s := newTestFileStore(t) // max = 10k (10240), each = 1k (1024) + dir, c := newTestFileStore(t) // max = 10k (10240), each = 1k (1024) for i := 0; i < 10; i++ { // 10x999 = 9990 - size, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999))) + size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999))) require.Nil(t, err) require.Equal(t, int64(999), size) } - require.Equal(t, int64(9990), s.Size()) - require.Equal(t, int64(250), s.Remaining()) + require.Equal(t, int64(9990), c.Size()) + require.Equal(t, int64(250), c.Remaining()) require.FileExists(t, dir+"/abcdefghijk1") require.FileExists(t, dir+"/abcdefghijk5") - require.Nil(t, s.Remove("abcdefghijk1", "abcdefghijk5")) + require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5")) require.NoFileExists(t, dir+"/abcdefghijk1") require.NoFileExists(t, dir+"/abcdefghijk5") - require.Equal(t, int64(7992), s.Size()) - require.Equal(t, int64(2248), s.Remaining()) + // Size is not recomputed by Remove; it stays stale until next sync + require.Equal(t, int64(9990), c.Size()) } func TestFileStore_Write_FailedTotalSizeLimit(t *testing.T) { - dir, s := newTestFileStore(t) + dir, c := newTestFileStore(t) for i := 0; i < 10; i++ { - size, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray)) + size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray)) require.Nil(t, err) require.Equal(t, int64(1024), size) } - _, err := s.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray)) + _, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray)) require.Equal(t, util.ErrLimitReached, err) require.NoFileExists(t, dir+"/abcdefghijkX") } func TestFileStore_Write_FailedAdditionalLimiter(t *testing.T) { - dir, s := newTestFileStore(t) - _, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) + dir, c := newTestFileStore(t) + _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) require.Equal(t, util.ErrLimitReached, err) require.NoFileExists(t, dir+"/abcdefghijkl") } func TestFileStore_Read_NotFound(t *testing.T) { - _, s := newTestFileStore(t) - _, _, err := s.Read("abcdefghijkl") + _, c := newTestFileStore(t) + _, _, err := c.Read("abcdefghijkl") require.Error(t, err) } -func newTestFileStore(t *testing.T) (dir string, store Store) { - dir = t.TempDir() - store, err := NewFileStore(dir, 10*1024) +func TestFileStore_Sync(t *testing.T) { + dir, c := newTestFileStore(t) + + // Write some files + _, err := c.Write("abcdefghijk0", strings.NewReader("file0")) require.Nil(t, err) - return dir, store + _, err = c.Write("abcdefghijk1", strings.NewReader("file1")) + require.Nil(t, err) + _, err = c.Write("abcdefghijk2", strings.NewReader("file2")) + require.Nil(t, err) + + require.Equal(t, int64(15), c.Size()) + + // Set the ID provider to only know about file 0 and 2 + c.localIDs = func() ([]string, error) { + return []string{"abcdefghijk0", "abcdefghijk2"}, nil + } + + // Make file 1's mod time old enough to be cleaned up (> 1 hour) + oldTime := time.Unix(1, 0) + os.Chtimes(dir+"/abcdefghijk1", oldTime, oldTime) + + // Run sync + require.Nil(t, c.sync()) + + // File 1 should be deleted (orphan, old enough) + require.NoFileExists(t, dir+"/abcdefghijk1") + require.FileExists(t, dir+"/abcdefghijk0") + require.FileExists(t, dir+"/abcdefghijk2") + + // Size should be updated + require.Equal(t, int64(10), c.Size()) +} + +func TestFileStore_Sync_SkipsRecentFiles(t *testing.T) { + dir, c := newTestFileStore(t) + + // Write a file + _, err := c.Write("abcdefghijk0", strings.NewReader("file0")) + require.Nil(t, err) + + // Set the ID provider to return empty (no valid IDs) + c.localIDs = func() ([]string, error) { + return []string{}, nil + } + + // File was just created, so it should NOT be deleted (< 1 hour old) + require.Nil(t, c.sync()) + require.FileExists(t, dir+"/abcdefghijk0") +} + +func newTestFileStore(t *testing.T) (dir string, cache *Store) { + t.Helper() + dir = t.TempDir() + cache, err := NewFileStore(dir, 10*1024, nil) + require.Nil(t, err) + t.Cleanup(func() { cache.Close() }) + return dir, cache } func readFile(t *testing.T, f string) string { + t.Helper() b, err := os.ReadFile(f) require.Nil(t, err) return string(b) diff --git a/attachment/store_s3.go b/attachment/store_s3.go deleted file mode 100644 index 38f0353a..00000000 --- a/attachment/store_s3.go +++ /dev/null @@ -1,150 +0,0 @@ -package attachment - -import ( - "context" - "fmt" - "io" - "sync" - - "heckel.io/ntfy/v2/log" - "heckel.io/ntfy/v2/s3" - "heckel.io/ntfy/v2/util" -) - -const ( - tagS3Store = "s3_store" -) - -type s3Store struct { - client *s3.Client - totalSizeCurrent int64 - totalSizeLimit int64 - mu sync.Mutex -} - -// NewS3Store creates a new S3-backed attachment store. The s3URL must be in the format: -// -// s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] -func NewS3Store(s3URL string, totalSizeLimit int64) (Store, error) { - cfg, err := s3.ParseURL(s3URL) - if err != nil { - return nil, err - } - store := &s3Store{ - client: s3.New(cfg), - totalSizeLimit: totalSizeLimit, - } - if totalSizeLimit > 0 { - size, err := store.computeSize() - if err != nil { - return nil, fmt.Errorf("s3 store: failed to compute initial size: %w", err) - } - store.totalSizeCurrent = size - } - return store, nil -} - -func (c *s3Store) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { - if !fileIDRegex.MatchString(id) { - return 0, errInvalidFileID - } - log.Tag(tagS3Store).Field("message_id", id).Debug("Writing attachment to S3") - - // Stream through limiters via an io.Pipe directly to S3. PutObject supports chunked - // uploads, so no temp file or Content-Length is needed. - limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) - pr, pw := io.Pipe() - lw := util.NewLimitWriter(pw, limiters...) - var size int64 - var copyErr error - done := make(chan struct{}) - go func() { - defer close(done) - size, copyErr = io.Copy(lw, in) - if copyErr != nil { - pw.CloseWithError(copyErr) - } else { - pw.Close() - } - }() - putErr := c.client.PutObject(context.Background(), id, pr) - pr.Close() - <-done - if copyErr != nil { - return 0, copyErr - } - if putErr != nil { - return 0, putErr - } - c.mu.Lock() - c.totalSizeCurrent += size - c.mu.Unlock() - return size, nil -} - -func (c *s3Store) Read(id string) (io.ReadCloser, int64, error) { - if !fileIDRegex.MatchString(id) { - return nil, 0, errInvalidFileID - } - return c.client.GetObject(context.Background(), id) -} - -func (c *s3Store) Remove(ids ...string) error { - for _, id := range ids { - if !fileIDRegex.MatchString(id) { - return errInvalidFileID - } - } - // S3 DeleteObjects supports up to 1000 keys per call - for i := 0; i < len(ids); i += 1000 { - end := i + 1000 - if end > len(ids) { - end = len(ids) - } - batch := ids[i:end] - for _, id := range batch { - log.Tag(tagS3Store).Field("message_id", id).Debug("Deleting attachment from S3") - } - if err := c.client.DeleteObjects(context.Background(), batch); err != nil { - return err - } - } - // Recalculate totalSizeCurrent via ListObjectsV2 (matches fileStore's dirSize rescan pattern) - size, err := c.computeSize() - if err != nil { - return fmt.Errorf("s3 store: failed to compute size after remove: %w", err) - } - c.mu.Lock() - c.totalSizeCurrent = size - c.mu.Unlock() - return nil -} - -func (c *s3Store) Size() int64 { - c.mu.Lock() - defer c.mu.Unlock() - return c.totalSizeCurrent -} - -func (c *s3Store) Remaining() int64 { - c.mu.Lock() - defer c.mu.Unlock() - remaining := c.totalSizeLimit - c.totalSizeCurrent - if remaining < 0 { - return 0 - } - return remaining -} - -// computeSize uses ListAllObjects to sum up the total size of all objects with our prefix. -func (c *s3Store) computeSize() (int64, error) { - objects, err := c.client.ListAllObjects(context.Background()) - if err != nil { - return 0, err - } - var totalSize int64 - for _, obj := range objects { - totalSize += obj.Size - } - return totalSize, nil -} diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index 872e8c23..e29f9ed6 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/s3" @@ -22,16 +23,16 @@ func TestS3Store_WriteReadRemove(t *testing.T) { server := newMockS3Server() defer server.Close() - store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) // Write - size, err := store.Write("abcdefghijkl", strings.NewReader("hello world")) + size, err := cache.Write("abcdefghijkl", strings.NewReader("hello world")) require.Nil(t, err) require.Equal(t, int64(11), size) - require.Equal(t, int64(11), store.Size()) + require.Equal(t, int64(11), cache.Size()) // Read back - reader, readSize, err := store.Read("abcdefghijkl") + reader, readSize, err := cache.Read("abcdefghijkl") require.Nil(t, err) require.Equal(t, int64(11), readSize) data, err := io.ReadAll(reader) @@ -40,11 +41,11 @@ func TestS3Store_WriteReadRemove(t *testing.T) { require.Equal(t, "hello world", string(data)) // Remove - require.Nil(t, store.Remove("abcdefghijkl")) - require.Equal(t, int64(0), store.Size()) + require.Nil(t, cache.Remove("abcdefghijkl")) + // Size is not recomputed by Remove; stays stale until next sync // Read after remove should fail - _, _, err = store.Read("abcdefghijkl") + _, _, err = cache.Read("abcdefghijkl") require.Error(t, err) } @@ -52,13 +53,13 @@ func TestS3Store_WriteNoPrefix(t *testing.T) { server := newMockS3Server() defer server.Close() - store := newTestS3Store(t, server, "my-bucket", "", 10*1024) + cache := newTestS3Store(t, server, "my-bucket", "", 10*1024) - size, err := store.Write("abcdefghijkl", strings.NewReader("test")) + size, err := cache.Write("abcdefghijkl", strings.NewReader("test")) require.Nil(t, err) require.Equal(t, int64(4), size) - reader, _, err := store.Read("abcdefghijkl") + reader, _, err := cache.Read("abcdefghijkl") require.Nil(t, err) data, err := io.ReadAll(reader) reader.Close() @@ -70,52 +71,53 @@ func TestS3Store_WriteTotalSizeLimit(t *testing.T) { server := newMockS3Server() defer server.Close() - store := newTestS3Store(t, server, "my-bucket", "pfx", 100) + cache := newTestS3Store(t, server, "my-bucket", "pfx", 100) // First write fits - _, err := store.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80))) + _, err := cache.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80))) require.Nil(t, err) - require.Equal(t, int64(80), store.Size()) - require.Equal(t, int64(20), store.Remaining()) + require.Equal(t, int64(80), cache.Size()) + require.Equal(t, int64(20), cache.Remaining()) // Second write exceeds total limit - _, err = store.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50))) - require.Equal(t, util.ErrLimitReached, err) + _, err = cache.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50))) + require.ErrorIs(t, err, util.ErrLimitReached) } func TestS3Store_WriteFileSizeLimit(t *testing.T) { server := newMockS3Server() defer server.Close() - store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - _, err := store.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), util.NewFixedLimiter(100)) - require.Equal(t, util.ErrLimitReached, err) + _, err := cache.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), util.NewFixedLimiter(100)) + require.ErrorIs(t, err, util.ErrLimitReached) } func TestS3Store_WriteRemoveMultiple(t *testing.T) { server := newMockS3Server() defer server.Close() - store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) for i := 0; i < 5; i++ { - _, err := store.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100))) + _, err := cache.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100))) require.Nil(t, err) } - require.Equal(t, int64(500), store.Size()) + require.Equal(t, int64(500), cache.Size()) - require.Nil(t, store.Remove("abcdefghijk1", "abcdefghijk3")) - require.Equal(t, int64(300), store.Size()) + require.Nil(t, cache.Remove("abcdefghijk1", "abcdefghijk3")) + // Size not recomputed by Remove + require.Equal(t, int64(500), cache.Size()) } func TestS3Store_ReadNotFound(t *testing.T) { server := newMockS3Server() defer server.Close() - store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - _, _, err := store.Read("abcdefghijkl") + _, _, err := cache.Read("abcdefghijkl") require.Error(t, err) } @@ -123,42 +125,93 @@ func TestS3Store_InvalidID(t *testing.T) { server := newMockS3Server() defer server.Close() - store := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - _, err := store.Write("bad", strings.NewReader("x")) + _, err := cache.Write("bad", strings.NewReader("x")) require.Equal(t, errInvalidFileID, err) - _, _, err = store.Read("bad") + _, _, err = cache.Read("bad") require.Equal(t, errInvalidFileID, err) - err = store.Remove("bad") + err = cache.Remove("bad") require.Equal(t, errInvalidFileID, err) } +func TestS3Store_Sync(t *testing.T) { + server := newMockS3Server() + defer server.Close() + + cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) + + // Write some files + _, err := cache.Write("abcdefghijk0", strings.NewReader("file0")) + require.Nil(t, err) + _, err = cache.Write("abcdefghijk1", strings.NewReader("file1")) + require.Nil(t, err) + _, err = cache.Write("abcdefghijk2", strings.NewReader("file2")) + require.Nil(t, err) + + require.Equal(t, int64(15), cache.Size()) + + // Set the ID provider to only know about file 0 and 2 + // All mock objects have LastModified set to 2 hours ago, so orphans are eligible for deletion + cache.localIDs = func() ([]string, error) { + return []string{"abcdefghijk0", "abcdefghijk2"}, nil + } + + // Run sync + require.Nil(t, cache.sync()) + + // File 1 should be deleted (orphan) + _, _, err = cache.Read("abcdefghijk1") + require.Error(t, err) + + // Size should be updated + require.Equal(t, int64(10), cache.Size()) +} + +func TestS3Store_Sync_SkipsRecentFiles(t *testing.T) { + mockServer := newMockS3ServerWithModTime(time.Now()) + defer mockServer.Close() + + cache := newTestS3Store(t, mockServer, "my-bucket", "pfx", 10*1024) + + _, err := cache.Write("abcdefghijk0", strings.NewReader("file0")) + require.Nil(t, err) + + // Set the ID provider to return empty (no valid IDs) + cache.localIDs = func() ([]string, error) { + return []string{}, nil + } + + // File was "just created" (mock returns recent time), so it should NOT be deleted + require.Nil(t, cache.sync()) + + // File should still exist + reader, _, err := cache.Read("abcdefghijk0") + require.Nil(t, err) + reader.Close() +} + // --- Helpers --- -func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string, totalSizeLimit int64) Store { +func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string, totalSizeLimit int64) *Store { t.Helper() - // httptest.NewTLSServer URL is like "https://127.0.0.1:PORT" host := strings.TrimPrefix(server.URL, "https://") - s := &s3Store{ - client: &s3.Client{ - AccessKey: "AKID", - SecretKey: "SECRET", - Region: "us-east-1", - Endpoint: host, - Bucket: bucket, - Prefix: prefix, - PathStyle: true, - HTTPClient: server.Client(), - }, - totalSizeLimit: totalSizeLimit, - } - // Compute initial size (should be 0 for fresh mock) - size, err := s.computeSize() + backend := newS3Backend(&s3.Client{ + AccessKey: "AKID", + SecretKey: "SECRET", + Region: "us-east-1", + Endpoint: host, + Bucket: bucket, + Prefix: prefix, + PathStyle: true, + HTTPClient: server.Client(), + }) + cache, err := newStore(backend, totalSizeLimit, nil) require.Nil(t, err) - s.totalSizeCurrent = size - return s + t.Cleanup(func() { cache.Close() }) + return cache } // --- Mock S3 server --- @@ -167,16 +220,22 @@ func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string // ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory. type mockS3Server struct { - objects map[string][]byte // full key (bucket/key) -> body - uploads map[string]map[int][]byte // uploadID -> partNumber -> data - nextID int // counter for generating upload IDs - mu sync.RWMutex + objects map[string][]byte // full key (bucket/key) -> body + uploads map[string]map[int][]byte // uploadID -> partNumber -> data + nextID int // counter for generating upload IDs + lastModTime time.Time // time to return for LastModified in list responses + mu sync.RWMutex } func newMockS3Server() *httptest.Server { + return newMockS3ServerWithModTime(time.Now().Add(-2 * time.Hour)) +} + +func newMockS3ServerWithModTime(modTime time.Time) *httptest.Server { m := &mockS3Server{ - objects: make(map[string][]byte), - uploads: make(map[string]map[int][]byte), + objects: make(map[string][]byte), + uploads: make(map[string]map[int][]byte), + lastModTime: modTime, } return httptest.NewTLSServer(m) } @@ -341,7 +400,11 @@ func (m *mockS3Server) handleList(w http.ResponseWriter, r *http.Request, bucket continue // different bucket } if prefix == "" || strings.HasPrefix(objKey, prefix) { - contents = append(contents, s3ListObject{Key: objKey, Size: int64(len(body))}) + contents = append(contents, s3ListObject{ + Key: objKey, + Size: int64(len(body)), + LastModified: m.lastModTime.Format(time.RFC3339), + }) } } m.mu.RUnlock() @@ -362,6 +425,7 @@ type s3ListResponse struct { } type s3ListObject struct { - Key string `xml:"Key"` - Size int64 `xml:"Size"` + Key string `xml:"Key"` + Size int64 `xml:"Size"` + LastModified string `xml:"LastModified"` } diff --git a/message/cache.go b/message/cache.go index 76aba4be..dd4ef0a4 100644 --- a/message/cache.go +++ b/message/cache.go @@ -46,6 +46,7 @@ type queries struct { selectStats string updateStats string updateMessageTime string + selectAttachmentIDs string } // Cache stores published messages @@ -252,18 +253,7 @@ func (c *Cache) MessagesExpired() ([]string, error) { return nil, err } defer rows.Close() - ids := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return ids, nil + return readStrings(rows) } // Message returns the message with the given ID, or ErrMessageNotFound if not found @@ -319,18 +309,7 @@ func (c *Cache) Topics() ([]string, error) { return nil, err } defer rows.Close() - topics := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - topics = append(topics, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return topics, nil + return readStrings(rows) } // DeleteMessages deletes the messages with the given IDs @@ -358,15 +337,8 @@ func (c *Cache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, return nil, err } defer rows.Close() - ids := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { + ids, err := readStrings(rows) + if err != nil { return nil, err } rows.Close() // Close rows before executing delete in same transaction @@ -391,6 +363,16 @@ func (c *Cache) ExpireMessages(topics ...string) error { }) } +// AttachmentIDs returns message IDs with active (non-expired, non-deleted) attachments +func (c *Cache) AttachmentIDs() ([]string, error) { + rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentIDs, time.Now().Unix()) + 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()) @@ -398,18 +380,7 @@ func (c *Cache) AttachmentsExpired() ([]string, error) { return nil, err } defer rows.Close() - ids := make([]string, 0) - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, err - } - ids = append(ids, id) - } - if err := rows.Err(); err != nil { - return nil, err - } - return ids, nil + return readStrings(rows) } // MarkAttachmentsDeleted marks the attachments for the given message IDs as deleted @@ -590,3 +561,18 @@ func readMessage(rows *sql.Rows) (*model.Message, error) { Encoding: encoding, }, nil } + +func readStrings(rows *sql.Rows) ([]string, error) { + strs := make([]string, 0) + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return nil, err + } + strs = append(strs, s) + } + if err := rows.Err(); err != nil { + return nil, err + } + return strs, nil +} diff --git a/message/cache_postgres.go b/message/cache_postgres.go index ba162da2..d59b2590 100644 --- a/message/cache_postgres.go +++ b/message/cache_postgres.go @@ -74,6 +74,8 @@ const ( postgresSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` postgresUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` 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{ @@ -100,6 +102,7 @@ var postgresQueries = queries{ selectStats: postgresSelectStatsQuery, updateStats: postgresUpdateStatsQuery, updateMessageTime: postgresUpdateMessageTimeQuery, + selectAttachmentIDs: postgresSelectAttachmentIDsQuery, } // NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. diff --git a/message/cache_sqlite.go b/message/cache_sqlite.go index a36aba0e..6126f1e1 100644 --- a/message/cache_sqlite.go +++ b/message/cache_sqlite.go @@ -77,6 +77,8 @@ const ( sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` sqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?` + + sqliteSelectAttachmentIDsQuery = `SELECT mid FROM messages WHERE attachment_expires > ? AND attachment_deleted = 0` ) var sqliteQueries = queries{ @@ -103,6 +105,7 @@ var sqliteQueries = queries{ selectStats: sqliteSelectStatsQuery, updateStats: sqliteUpdateStatsQuery, updateMessageTime: sqliteUpdateMessageTimeQuery, + selectAttachmentIDs: sqliteSelectAttachmentIDsQuery, } // NewSQLiteStore creates a SQLite file-backed cache diff --git a/s3/client.go b/s3/client.go index 56f9608e..5ec8caf6 100644 --- a/s3/client.go +++ b/s3/client.go @@ -196,7 +196,15 @@ func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxK } objects := make([]Object, len(result.Contents)) for i, obj := range result.Contents { - objects[i] = Object(obj) + var lastModified time.Time + if obj.LastModified != "" { + lastModified, _ = time.Parse(time.RFC3339, obj.LastModified) + } + objects[i] = Object{ + Key: obj.Key, + Size: obj.Size, + LastModified: lastModified, + } } return &ListResult{ Objects: objects, diff --git a/s3/client_test.go b/s3/client_test.go index c3a8fe2c..8007601c 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -13,6 +13,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -243,7 +244,7 @@ func (m *mockS3Server) handleList(w http.ResponseWriter, r *http.Request, bucket var contents []listObject for _, objKey := range allKeys { body := m.objects[bucketPath+"/"+objKey] - contents = append(contents, listObject{Key: objKey, Size: int64(len(body))}) + contents = append(contents, listObject{Key: objKey, Size: int64(len(body)), LastModified: time.Now().Format(time.RFC3339)}) } m.mu.RUnlock() diff --git a/s3/types.go b/s3/types.go index 201c570b..65615fcd 100644 --- a/s3/types.go +++ b/s3/types.go @@ -1,6 +1,9 @@ package s3 -import "fmt" +import ( + "fmt" + "time" +) // Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string. type Config struct { @@ -15,8 +18,9 @@ type Config struct { // Object represents an S3 object returned by list operations. type Object struct { - Key string - Size int64 + Key string + Size int64 + LastModified time.Time } // ListResult holds the response from a ListObjectsV2 call. @@ -49,8 +53,9 @@ type listObjectsV2Response struct { } type listObject struct { - Key string `xml:"Key"` - Size int64 `xml:"Size"` + Key string `xml:"Key"` + Size int64 `xml:"Size"` + LastModified string `xml:"LastModified"` } // deleteResult is the XML response from S3 DeleteObjects diff --git a/server/server.go b/server/server.go index 0972f00d..b43b71ef 100644 --- a/server/server.go +++ b/server/server.go @@ -65,7 +65,7 @@ type Server struct { userManager *user.Manager // Might be nil! messageCache *message.Cache // Database that stores the messages webPush *webpush.Store // Database that stores web push subscriptions - fileCache attachment.Store // Attachment store (file system or S3) + fileCache *attachment.Store // Attachment store (file system or S3) stripe stripeAPI // Stripe API, can be replaced with a mock priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set @@ -229,7 +229,7 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } - fileCache, err := createAttachmentStore(conf) + fileCache, err := createAttachmentStore(conf, messageCache) if err != nil { return nil, err } @@ -300,11 +300,14 @@ func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) { return message.NewMemStore() } -func createAttachmentStore(conf *Config) (attachment.Store, error) { +func createAttachmentStore(conf *Config, messageCache *message.Cache) (*attachment.Store, error) { + idProvider := func() ([]string, error) { + return messageCache.AttachmentIDs() + } if conf.AttachmentS3URL != "" { - return attachment.NewS3Store(conf.AttachmentS3URL, conf.AttachmentTotalSizeLimit) + return attachment.NewS3Store(conf.AttachmentS3URL, conf.AttachmentTotalSizeLimit, idProvider) } else if conf.AttachmentCacheDir != "" { - return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit) + return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, idProvider) } return nil, nil } @@ -429,6 +432,9 @@ func (s *Server) Stop() { if s.smtpServer != nil { s.smtpServer.Close() } + if s.fileCache != nil { + s.fileCache.Close() + } s.closeDatabases() close(s.closeChan) } diff --git a/util/limit.go b/util/limit.go index ad2118c7..9c39d3dc 100644 --- a/util/limit.go +++ b/util/limit.go @@ -152,6 +152,61 @@ func (l *RateLimiter) Reset() { l.value = 0 } +// CountingReader wraps an io.Reader and counts the number of bytes read through it. +type CountingReader struct { + r io.Reader + total int64 +} + +// NewCountingReader creates a new CountingReader +func NewCountingReader(r io.Reader) *CountingReader { + return &CountingReader{r: r} +} + +// Read passes through to the underlying reader and counts the bytes read +func (r *CountingReader) Read(p []byte) (n int, err error) { + n, err = r.r.Read(p) + r.total += int64(n) + return +} + +// Total returns the total number of bytes read so far +func (r *CountingReader) Total() int64 { + return r.total +} + +// LimitReader implements an io.Reader that will pass through all Read calls to the underlying +// reader r until any of the limiter's limit is reached, at which point a Read will return ErrLimitReached. +// Each limiter's value is increased after every read based on the number of bytes actually read. +type LimitReader struct { + r io.Reader + limiters []Limiter +} + +// NewLimitReader creates a new LimitReader +func NewLimitReader(r io.Reader, limiters ...Limiter) *LimitReader { + return &LimitReader{ + r: r, + limiters: limiters, + } +} + +// Read passes through all reads to the underlying reader until any of the given limiter's limit is reached +func (r *LimitReader) Read(p []byte) (n int, err error) { + n, err = r.r.Read(p) + if n > 0 { + for i := 0; i < len(r.limiters); i++ { + if !r.limiters[i].AllowN(int64(n)) { + for j := i - 1; j >= 0; j-- { + r.limiters[j].AllowN(-int64(n)) // Revert limiters if not allowed + } + return 0, ErrLimitReached + } + } + } + return +} + // LimitWriter implements an io.Writer that will pass through all Write calls to the underlying // writer w until any of the limiter's limit is reached, at which point a Write will return ErrLimitReached. // Each limiter's value is increased with every write. From a1b403d23cf1a478e52c4c99dd79f9cc92bfa36a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 19 Mar 2026 21:11:36 -0400 Subject: [PATCH 010/126] Remove s3 config option, reduce size when removing files --- attachment/backend.go | 3 +- attachment/backend_file.go | 41 +++++++++++--------- attachment/backend_s3.go | 49 +++++++++++++----------- attachment/store.go | 69 +++++++++++++++++++++++----------- attachment/store_file_test.go | 4 +- attachment/store_s3_test.go | 5 +-- cmd/serve.go | 9 +---- docs/config.md | 17 ++++----- docs/releases.md | 2 +- s3/client.go | 71 +++++++++++++++++++++++++++++++++++ s3/types.go | 21 +++++++++++ server/config.go | 1 - server/server.go | 8 ++-- server/server.yml | 4 +- 14 files changed, 211 insertions(+), 93 deletions(-) diff --git a/attachment/backend.go b/attachment/backend.go index 8989b890..e95fc91e 100644 --- a/attachment/backend.go +++ b/attachment/backend.go @@ -17,6 +17,7 @@ type object struct { type backend interface { Put(id string, in io.Reader) error Get(id string) (io.ReadCloser, int64, error) - Delete(ids ...string) error List() ([]object, error) + Delete(ids ...string) error + DeleteIncomplete(cutoff time.Time) error } diff --git a/attachment/backend_file.go b/attachment/backend_file.go index b0afb2ca..8aaf20b9 100644 --- a/attachment/backend_file.go +++ b/attachment/backend_file.go @@ -4,6 +4,7 @@ import ( "io" "os" "path/filepath" + "time" "heckel.io/ntfy/v2/log" ) @@ -41,6 +42,26 @@ func (b *fileBackend) Put(id string, in io.Reader) error { return nil } +func (b *fileBackend) List() ([]object, error) { + entries, err := os.ReadDir(b.dir) + if err != nil { + return nil, err + } + objects := make([]object, 0, len(entries)) + for _, e := range entries { + info, err := e.Info() + if err != nil { + return nil, err + } + objects = append(objects, object{ + ID: e.Name(), + Size: info.Size(), + LastModified: info.ModTime(), + }) + } + return objects, nil +} + func (b *fileBackend) Get(id string) (io.ReadCloser, int64, error) { file := filepath.Join(b.dir, id) stat, err := os.Stat(file) @@ -64,22 +85,6 @@ func (b *fileBackend) Delete(ids ...string) error { return nil } -func (b *fileBackend) List() ([]object, error) { - entries, err := os.ReadDir(b.dir) - if err != nil { - return nil, err - } - objects := make([]object, 0, len(entries)) - for _, e := range entries { - info, err := e.Info() - if err != nil { - return nil, err - } - objects = append(objects, object{ - ID: e.Name(), - Size: info.Size(), - LastModified: info.ModTime(), - }) - } - return objects, nil +func (b *fileBackend) DeleteIncomplete(_ time.Time) error { + return nil } diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go index 8fcd8ccb..11d2254b 100644 --- a/attachment/backend_s3.go +++ b/attachment/backend_s3.go @@ -4,13 +4,16 @@ import ( "context" "io" "strings" - - "heckel.io/ntfy/v2/s3" + "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/s3" ) -const tagS3Backend = "s3_backend" +const ( + tagS3Backend = "s3_backend" + deleteBatchSize = 1000 +) type s3Backend struct { client *s3.Client @@ -30,24 +33,6 @@ func (b *s3Backend) Get(id string) (io.ReadCloser, int64, error) { return b.client.GetObject(context.Background(), id) } -func (b *s3Backend) Delete(ids ...string) error { - // S3 DeleteObjects supports up to 1000 keys per call - for i := 0; i < len(ids); i += 1000 { - end := i + 1000 - if end > len(ids) { - end = len(ids) - } - batch := ids[i:end] - for _, id := range batch { - log.Tag(tagS3Backend).Field("message_id", id).Debug("Deleting attachment from S3") - } - if err := b.client.DeleteObjects(context.Background(), batch); err != nil { - return err - } - } - return nil -} - func (b *s3Backend) List() ([]object, error) { objects, err := b.client.ListAllObjects(context.Background()) if err != nil { @@ -68,3 +53,25 @@ func (b *s3Backend) List() ([]object, error) { } return result, nil } + +func (b *s3Backend) Delete(ids ...string) error { + // S3 DeleteObjects supports up to 1000 keys per call + for i := 0; i < len(ids); i += deleteBatchSize { + end := i + deleteBatchSize + if end > len(ids) { + end = len(ids) + } + batch := ids[i:end] + for _, id := range batch { + log.Tag(tagS3Backend).Field("message_id", id).Debug("Deleting attachment from S3") + } + if err := b.client.DeleteObjects(context.Background(), batch); err != nil { + return err + } + } + return nil +} + +func (b *s3Backend) DeleteIncomplete(cutoff time.Time) error { + return b.client.AbortIncompleteUploads(context.Background(), cutoff) +} diff --git a/attachment/store.go b/attachment/store.go index f66cd35c..78b8a7cc 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -28,21 +28,22 @@ var ( // Store manages attachment storage with shared logic for size tracking, limiting, // ID validation, and background sync to reconcile storage with the database. type Store struct { - backend backend - totalSizeCurrent int64 - totalSizeLimit int64 - localIDs func() ([]string, error) // returns IDs that should exist - closeChan chan struct{} - mu sync.Mutex // Protects totalSizeCurrent + backend backend + limit int64 // Defined limit 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 + localIDs func() ([]string, error) // Returns file IDs that should exist locally, used for sync() + closeChan chan struct{} + mu sync.Mutex // Protects size and sizes } // NewFileStore creates a new file-system backed attachment cache func NewFileStore(dir string, totalSizeLimit int64, localIDsFn func() ([]string, error)) (*Store, error) { - backend, err := newFileBackend(dir) + b, err := newFileBackend(dir) if err != nil { return nil, err } - return newStore(backend, totalSizeLimit, localIDsFn) + return newStore(b, totalSizeLimit, localIDsFn) } // NewS3Store creates a new S3-backed attachment cache. The s3URL must be in the format: @@ -58,10 +59,11 @@ func NewS3Store(s3URL string, totalSizeLimit int64, localIDs func() ([]string, e func newStore(backend backend, totalSizeLimit int64, localIDs func() ([]string, error)) (*Store, error) { c := &Store{ - backend: backend, - totalSizeLimit: totalSizeLimit, - localIDs: localIDs, - closeChan: make(chan struct{}), + backend: backend, + limit: totalSizeLimit, + sizes: make(map[string]int64), + localIDs: localIDs, + closeChan: make(chan struct{}), } if localIDs != nil { go c.syncLoop() @@ -85,7 +87,8 @@ func (c *Store) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, } size := cr.Total() c.mu.Lock() - c.totalSizeCurrent += size + c.size += size + c.sizes[id] = size c.mu.Unlock() return size, nil } @@ -98,15 +101,30 @@ func (c *Store) Read(id string) (io.ReadCloser, int64, error) { return c.backend.Get(id) } -// Remove deletes attachment files by ID. It does NOT recompute the total size; -// the next sync() call will correct it. +// Remove deletes attachment files by ID and subtracts their known sizes from +// the total. Sizes for objects not tracked (e.g. written before this process +// started and before the first sync) are corrected by the next sync() call. func (c *Store) Remove(ids ...string) error { for _, id := range ids { if !fileIDRegex.MatchString(id) { return errInvalidFileID } } - return c.backend.Delete(ids...) + if err := c.backend.Delete(ids...); err != nil { + return err + } + c.mu.Lock() + for _, id := range ids { + if size, ok := c.sizes[id]; ok { + c.size -= size + delete(c.sizes, id) + } + } + if c.size < 0 { + c.size = 0 + } + c.mu.Unlock() + return nil } // sync reconciles the backend storage with the database. It lists all objects, @@ -130,7 +148,8 @@ func (c *Store) sync() error { // than the grace period to account for races, and skipping objects with invalid IDs. cutoff := time.Now().Add(-orphanGracePeriod) var orphanIDs []string - var totalSize int64 + var size int64 + sizes := make(map[string]int64, len(remoteObjects)) for _, obj := range remoteObjects { if !fileIDRegex.MatchString(obj.ID) { continue @@ -138,12 +157,14 @@ func (c *Store) sync() error { if _, ok := localIDMap[obj.ID]; !ok && obj.LastModified.Before(cutoff) { orphanIDs = append(orphanIDs, obj.ID) } else { - totalSize += obj.Size + size += obj.Size + sizes[obj.ID] = obj.Size } } - log.Tag(tagStore).Debug("Sync: cache size updated to %s", util.FormatSizeHuman(totalSize)) + log.Tag(tagStore).Debug("Sync: cache size updated to %s", util.FormatSizeHuman(size)) c.mu.Lock() - c.totalSizeCurrent = totalSize + c.size = size + c.sizes = sizes c.mu.Unlock() // Delete orphaned attachments if len(orphanIDs) > 0 { @@ -152,6 +173,10 @@ func (c *Store) sync() error { return fmt.Errorf("attachment sync: failed to delete orphaned objects: %w", err) } } + // Clean up incomplete uploads (S3 only) + if err := c.backend.DeleteIncomplete(cutoff); err != nil { + log.Tag(tagStore).Err(err).Warn("Sync: failed to abort incomplete uploads") + } return nil } @@ -159,14 +184,14 @@ func (c *Store) sync() error { func (c *Store) Size() int64 { c.mu.Lock() defer c.mu.Unlock() - return c.totalSizeCurrent + return c.size } // Remaining returns the remaining capacity for attachments func (c *Store) Remaining() int64 { c.mu.Lock() defer c.mu.Unlock() - remaining := c.totalSizeLimit - c.totalSizeCurrent + remaining := c.limit - c.size if remaining < 0 { return 0 } diff --git a/attachment/store_file_test.go b/attachment/store_file_test.go index ceac09d7..c65bad92 100644 --- a/attachment/store_file_test.go +++ b/attachment/store_file_test.go @@ -57,8 +57,8 @@ func TestFileStore_Write_Remove_Success(t *testing.T) { require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5")) require.NoFileExists(t, dir+"/abcdefghijk1") require.NoFileExists(t, dir+"/abcdefghijk5") - // Size is not recomputed by Remove; it stays stale until next sync - require.Equal(t, int64(9990), c.Size()) + require.Equal(t, int64(8*999), c.Size()) + require.Equal(t, int64(10240-8*999), c.Remaining()) } func TestFileStore_Write_FailedTotalSizeLimit(t *testing.T) { diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index e29f9ed6..7ae122ae 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -42,7 +42,7 @@ func TestS3Store_WriteReadRemove(t *testing.T) { // Remove require.Nil(t, cache.Remove("abcdefghijkl")) - // Size is not recomputed by Remove; stays stale until next sync + require.Equal(t, int64(0), cache.Size()) // Read after remove should fail _, _, err = cache.Read("abcdefghijkl") @@ -107,8 +107,7 @@ func TestS3Store_WriteRemoveMultiple(t *testing.T) { require.Equal(t, int64(500), cache.Size()) require.Nil(t, cache.Remove("abcdefghijk1", "abcdefghijk3")) - // Size not recomputed by Remove - require.Equal(t, int64(500), cache.Size()) + require.Equal(t, int64(300), cache.Size()) } func TestS3Store_ReadNotFound(t *testing.T) { diff --git a/cmd/serve.go b/cmd/serve.go index 26a08c81..52794a07 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,8 +52,7 @@ var flagsServe = append( altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-s3-url", Aliases: []string{"attachment_s3_url"}, EnvVars: []string{"NTFY_ATTACHMENT_S3_URL"}, Usage: "S3 URL for attachment storage (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), @@ -167,7 +166,6 @@ func execServe(c *cli.Context) error { authAccessRaw := c.StringSlice("auth-access") authTokensRaw := c.StringSlice("auth-tokens") attachmentCacheDir := c.String("attachment-cache-dir") - attachmentS3URL := c.String("attachment-s3-url") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") attachmentExpiryDurationStr := c.String("attachment-expiry-duration") @@ -316,10 +314,6 @@ func execServe(c *cli.Context) error { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { return errors.New("if attachment-cache-dir is set, base-url must also be set") - } else if attachmentS3URL != "" && baseURL == "" { - return errors.New("if attachment-s3-url is set, base-url must also be set") - } else if attachmentS3URL != "" && attachmentCacheDir != "" { - return errors.New("attachment-cache-dir and attachment-s3-url are mutually exclusive") } else if baseURL != "" { u, err := url.Parse(baseURL) if err != nil { @@ -463,7 +457,6 @@ func execServe(c *cli.Context) error { conf.AuthAccess = authAccess conf.AuthTokens = authTokens conf.AttachmentCacheDir = attachmentCacheDir - conf.AttachmentS3URL = attachmentS3URL conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.AttachmentExpiryDuration = attachmentExpiryDuration diff --git a/docs/config.md b/docs/config.md index 34484a51..edfa43ff 100644 --- a/docs/config.md +++ b/docs/config.md @@ -490,7 +490,7 @@ Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscri ## Attachments If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable this feature, you have to configure an attachment storage backend and a base URL (`base-url`). Attachments can be stored -either on the local filesystem (`attachment-cache-dir`) or in an S3-compatible object store (`attachment-s3-url`). +either on the local filesystem or in an S3-compatible object store, both using the `attachment-cache-dir` option. Once configured, you can upload attachments via PUT. By default, attachments are stored **for only 3 hours**. The main reason for this is to avoid legal issues @@ -498,8 +498,7 @@ and such when hosting user controlled content. Typically, this is more than enou feature) to download the file. The following config options are relevant to attachments: * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs -* `attachment-cache-dir` is the cache directory for attached files (mutually exclusive with `attachment-s3-url`) -* `attachment-s3-url` is the S3 URL for attachment storage (mutually exclusive with `attachment-cache-dir`) +* `attachment-cache-dir` is the cache directory for attached files, or an S3 URL for object storage * `attachment-total-size-limit` is the size limit of the attachment storage (default: 5G) * `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M) * `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h) @@ -528,7 +527,7 @@ Here's an example config using the local filesystem for attachment storage: As an alternative to the local filesystem, you can store attachments in an S3-compatible object store (e.g. AWS S3, MinIO, DigitalOcean Spaces). This is useful for HA/cloud deployments where you don't want to rely on local disk storage. -The `attachment-s3-url` option uses the following format: +To use S3, set `attachment-cache-dir` to an S3 URL with the following format: ``` s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] @@ -539,13 +538,13 @@ When `endpoint` is specified, path-style addressing is enabled automatically (us === "/etc/ntfy/server.yml (AWS S3)" ``` yaml base-url: "https://ntfy.sh" - attachment-s3-url: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1" + attachment-cache-dir: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1" ``` === "/etc/ntfy/server.yml (MinIO/custom endpoint)" ``` yaml base-url: "https://ntfy.sh" - attachment-s3-url: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" + attachment-cache-dir: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" ``` Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` @@ -2143,8 +2142,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) | | `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 | -| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. Mutually exclusive with `attachment-s3-url`. | -| `attachment-s3-url` | `NTFY_ATTACHMENT_S3_URL` | *URL* | - | S3 URL for attachment storage (format: `s3://KEY:SECRET@BUCKET[/PREFIX]?region=REGION`). Mutually exclusive with `attachment-cache-dir`. | +| `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`). | | `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-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`. | @@ -2246,8 +2244,7 @@ OPTIONS: --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-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 [$NTFY_ATTACHMENT_CACHE_DIR] - --attachment-s3-url value, --attachment_s3_url value S3 URL for attachment storage (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION) [$NTFY_ATTACHMENT_S3_URL] + --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) [$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-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] diff --git a/docs/releases.md b/docs/releases.md index dbf9bd41..b16608c6 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1802,7 +1802,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Add S3-compatible object storage as an alternative attachment backend via `attachment-s3-url` config option +* Add S3-compatible object storage as an alternative attachment backend via `attachment-cache-dir` config option **Bug fixes + maintenance:** diff --git a/s3/client.go b/s3/client.go index 5ec8caf6..41d940f3 100644 --- a/s3/client.go +++ b/s3/client.go @@ -232,6 +232,77 @@ func (c *Client) ListAllObjects(ctx context.Context) ([]Object, error) { return nil, fmt.Errorf("s3: ListAllObjects exceeded %d pages", maxPages) } +// ListMultipartUploads returns in-progress multipart uploads for the client's prefix. +// It paginates automatically, stopping after 10,000 pages as a safety valve. +func (c *Client) ListMultipartUploads(ctx context.Context) ([]MultipartUpload, error) { + var all []MultipartUpload + var keyMarker, uploadIDMarker string + for page := 0; page < maxPages; page++ { + query := url.Values{"uploads": {""}} + if prefix := c.prefixForList(); prefix != "" { + query.Set("prefix", prefix) + } + if keyMarker != "" { + query.Set("key-marker", keyMarker) + query.Set("upload-id-marker", uploadIDMarker) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.bucketURL()+"?"+query.Encode(), nil) + if err != nil { + return nil, fmt.Errorf("s3: ListMultipartUploads request: %w", err) + } + c.signV4(req, emptyPayloadHash) + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("s3: ListMultipartUploads: %w", err) + } + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("s3: ListMultipartUploads read: %w", err) + } + if !isHTTPSuccess(resp) { + return nil, parseErrorFromBytes(resp.StatusCode, respBody) + } + var result listMultipartUploadsResult + if err := xml.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("s3: ListMultipartUploads XML: %w", err) + } + for _, u := range result.Uploads { + var initiated time.Time + if u.Initiated != "" { + initiated, _ = time.Parse(time.RFC3339, u.Initiated) + } + all = append(all, MultipartUpload{ + Key: u.Key, + UploadID: u.UploadID, + Initiated: initiated, + }) + } + if !result.IsTruncated { + return all, nil + } + keyMarker = result.NextKeyMarker + uploadIDMarker = result.NextUploadIDMarker + } + return nil, fmt.Errorf("s3: ListMultipartUploads exceeded %d pages", maxPages) +} + +// AbortIncompleteUploads lists all in-progress multipart uploads and aborts those initiated +// before the given cutoff time. This cleans up orphaned upload parts from interrupted uploads. +func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) error { + uploads, err := c.ListMultipartUploads(ctx) + if err != nil { + return err + } + for _, u := range uploads { + if !u.Initiated.IsZero() && u.Initiated.Before(cutoff) { + log.Tag(tagS3Client).Debug("DeleteIncomplete key=%s uploadId=%s initiated=%s", u.Key, u.UploadID, u.Initiated) + c.abortMultipartUpload(ctx, u.Key, u.UploadID) + } + } + return nil +} + // putObject uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size int64) error { fullKey := c.objectKey(key) diff --git a/s3/types.go b/s3/types.go index 65615fcd..f78c4a8b 100644 --- a/s3/types.go +++ b/s3/types.go @@ -69,6 +69,27 @@ type deleteError struct { Message string `xml:"Message"` } +// MultipartUpload represents an in-progress multipart upload returned by ListMultipartUploads. +type MultipartUpload struct { + Key string + UploadID string + Initiated time.Time +} + +// listMultipartUploadsResult is the XML response from S3 ListMultipartUploads +type listMultipartUploadsResult struct { + Uploads []listUpload `xml:"Upload"` + IsTruncated bool `xml:"IsTruncated"` + NextKeyMarker string `xml:"NextKeyMarker"` + NextUploadIDMarker string `xml:"NextUploadIdMarker"` +} + +type listUpload struct { + Key string `xml:"Key"` + UploadID string `xml:"UploadId"` + Initiated string `xml:"Initiated"` +} + // initiateMultipartUploadResult is the XML response from S3 InitiateMultipartUpload type initiateMultipartUploadResult struct { UploadID string `xml:"UploadId"` diff --git a/server/config.go b/server/config.go index 97f72a1c..8ead312c 100644 --- a/server/config.go +++ b/server/config.go @@ -112,7 +112,6 @@ type Config struct { AuthBcryptCost int AuthStatsQueueWriterInterval time.Duration AttachmentCacheDir string - AttachmentS3URL string AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 AttachmentExpiryDuration time.Duration diff --git a/server/server.go b/server/server.go index b43b71ef..99a61906 100644 --- a/server/server.go +++ b/server/server.go @@ -301,13 +301,13 @@ func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) { } func createAttachmentStore(conf *Config, messageCache *message.Cache) (*attachment.Store, error) { - idProvider := func() ([]string, error) { + attachmentIDs := func() ([]string, error) { return messageCache.AttachmentIDs() } - if conf.AttachmentS3URL != "" { - return attachment.NewS3Store(conf.AttachmentS3URL, conf.AttachmentTotalSizeLimit, idProvider) + if strings.HasPrefix(conf.AttachmentCacheDir, "s3://") { + return attachment.NewS3Store(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, attachmentIDs) } else if conf.AttachmentCacheDir != "" { - return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, idProvider) + return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, attachmentIDs) } return nil, nil } diff --git a/server/server.yml b/server/server.yml index e6f7afee..9dc92968 100644 --- a/server/server.yml +++ b/server/server.yml @@ -153,13 +153,13 @@ # If enabled, clients can attach files to notifications as attachments. Minimum settings to enable attachments # are "attachment-cache-dir" and "base-url". # -# - attachment-cache-dir is the cache directory for attached files +# - attachment-cache-dir is the cache directory for attached files, or an S3 URL for object storage +# e.g. /var/cache/ntfy/attachments, or s3://ACCESS_KEY:SECRET_KEY@bucket/prefix?region=us-east-1&endpoint=https://... # - attachment-total-size-limit is the limit of the on-disk attachment cache directory (total size) # - attachment-file-size-limit is the per-file attachment size limit (e.g. 300k, 2M, 100M) # - attachment-expiry-duration is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h) # # attachment-cache-dir: -# attachment-s3-url: "s3://ACCESS_KEY:SECRET_KEY@bucket/prefix?region=us-east-1" # attachment-total-size-limit: "5G" # attachment-file-size-limit: "15M" # attachment-expiry-duration: "3h" From d86e20173cc040596f02598ee104e8ab4f498a7a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 19 Mar 2026 21:46:52 -0400 Subject: [PATCH 011/126] Move stuff around --- attachment/backend_s3.go | 8 +- attachment/store_s3_test.go | 4 +- s3/client.go | 407 +++++++----------------------------- s3/client_auth.go | 68 ++++++ s3/client_multipart.go | 188 +++++++++++++++++ s3/client_test.go | 61 +++--- s3/types.go | 44 ++-- s3/util_test.go | 12 +- 8 files changed, 396 insertions(+), 396 deletions(-) create mode 100644 s3/client_auth.go create mode 100644 s3/client_multipart.go diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go index 11d2254b..6603bb91 100644 --- a/attachment/backend_s3.go +++ b/attachment/backend_s3.go @@ -3,7 +3,6 @@ package attachment import ( "context" "io" - "strings" "time" "heckel.io/ntfy/v2/log" @@ -38,15 +37,10 @@ func (b *s3Backend) List() ([]object, error) { if err != nil { return nil, err } - prefix := b.client.Prefix result := make([]object, 0, len(objects)) for _, obj := range objects { - id := obj.Key - if prefix != "" { - id = strings.TrimPrefix(id, prefix+"/") - } result = append(result, object{ - ID: id, + ID: obj.Key, Size: obj.Size, LastModified: obj.LastModified, }) diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index 7ae122ae..3ad5a93c 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -197,7 +197,7 @@ func TestS3Store_Sync_SkipsRecentFiles(t *testing.T) { func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string, totalSizeLimit int64) *Store { t.Helper() host := strings.TrimPrefix(server.URL, "https://") - backend := newS3Backend(&s3.Client{ + backend := newS3Backend(s3.New(&s3.Config{ AccessKey: "AKID", SecretKey: "SECRET", Region: "us-east-1", @@ -206,7 +206,7 @@ func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string Prefix: prefix, PathStyle: true, HTTPClient: server.Client(), - }) + })) cache, err := newStore(backend, totalSizeLimit, nil) require.Nil(t, err) t.Cleanup(func() { cache.Close() }) diff --git a/s3/client.go b/s3/client.go index 41d940f3..5c43af05 100644 --- a/s3/client.go +++ b/s3/client.go @@ -8,14 +8,12 @@ import ( "context" "crypto/md5" //nolint:gosec // MD5 is required by the S3 protocol for Content-MD5 headers "encoding/base64" - "encoding/hex" "encoding/xml" "errors" "fmt" "io" "net/http" "net/url" - "sort" "strconv" "strings" "time" @@ -33,26 +31,19 @@ const ( // // Fields must not be modified after the Client is passed to any method or goroutine. type Client struct { - AccessKey string // AWS access key ID - SecretKey string // AWS secret access key - Region string // e.g. "us-east-1" - Endpoint string // host[:port] only, e.g. "s3.amazonaws.com" or "nyc3.digitaloceanspaces.com" - Bucket string // S3 bucket name - Prefix string // optional key prefix (e.g. "attachments"); prepended to all keys automatically - PathStyle bool // if true, use path-style addressing; otherwise virtual-hosted-style - HTTPClient *http.Client // if nil, http.DefaultClient is used + config *Config + http *http.Client } // New creates a new S3 client from the given Config. func New(config *Config) *Client { + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } return &Client{ - AccessKey: config.AccessKey, - SecretKey: config.SecretKey, - Region: config.Region, - Endpoint: config.Endpoint, - Bucket: config.Bucket, - Prefix: config.Prefix, - PathStyle: config.PathStyle, + config: config, + http: httpClient, } } @@ -87,7 +78,7 @@ func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int6 return nil, 0, fmt.Errorf("s3: GetObject request: %w", err) } c.signV4(req, emptyPayloadHash) - resp, err := c.httpClient().Do(req) + resp, err := c.http.Do(req) if err != nil { return nil, 0, fmt.Errorf("s3: GetObject: %w", err) } @@ -116,35 +107,18 @@ func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { } body.WriteString("") bodyBytes := body.Bytes() - payloadHash := sha256Hex(bodyBytes) // Content-MD5 is required by the S3 protocol for DeleteObjects requests. md5Sum := md5.Sum(bodyBytes) //nolint:gosec contentMD5 := base64.StdEncoding.EncodeToString(md5Sum[:]) - reqURL := c.bucketURL() + "?delete=" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) + respBody, err := c.doWithBodyAndHeaders(ctx, http.MethodPost, c.config.BucketURL()+"?delete=", bodyBytes, + map[string]string{"Content-MD5": contentMD5}, "DeleteObjects") if err != nil { - return fmt.Errorf("s3: DeleteObjects request: %w", err) - } - req.ContentLength = int64(len(bodyBytes)) - req.Header.Set("Content-Type", "application/xml") - req.Header.Set("Content-MD5", contentMD5) - c.signV4(req, payloadHash) - resp, err := c.httpClient().Do(req) - if err != nil { - return fmt.Errorf("s3: DeleteObjects: %w", err) - } - defer resp.Body.Close() - if !isHTTPSuccess(resp) { - return parseError(resp) + return err } // S3 may return HTTP 200 with per-key errors in the response body - respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) - if err != nil { - return fmt.Errorf("s3: DeleteObjects read response: %w", err) - } var result deleteResult if err := xml.Unmarshal(respBody, &result); err != nil { return nil // If we can't parse, assume success (Quiet mode returns empty body on success) @@ -159,9 +133,9 @@ func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { return nil } -// ListObjects performs a single ListObjectsV2 request using the client's configured prefix. +// listObjects performs a single ListObjectsV2 request using the client's configured prefix. // Use continuationToken for pagination. Set maxKeys to 0 for the server default (typically 1000). -func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxKeys int) (*ListResult, error) { +func (c *Client) listObjects(ctx context.Context, continuationToken string, maxKeys int) (*listResult, error) { log.Tag(tagS3Client).Debug("ListObjects continuation=%s maxKeys=%d", continuationToken, maxKeys) query := url.Values{"list-type": {"2"}} if prefix := c.prefixForList(); prefix != "" { @@ -173,22 +147,9 @@ func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxK if maxKeys > 0 { query.Set("max-keys", strconv.Itoa(maxKeys)) } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.bucketURL()+"?"+query.Encode(), nil) + respBody, err := c.do(ctx, http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, "ListObjects") if err != nil { - return nil, fmt.Errorf("s3: ListObjects request: %w", err) - } - c.signV4(req, emptyPayloadHash) - resp, err := c.httpClient().Do(req) - if err != nil { - return nil, fmt.Errorf("s3: ListObjects: %w", err) - } - respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("s3: ListObjects read: %w", err) - } - if !isHTTPSuccess(resp) { - return nil, parseErrorFromBytes(resp.StatusCode, respBody) + return nil, err } var result listObjectsV2Response if err := xml.Unmarshal(respBody, &result); err != nil { @@ -206,7 +167,7 @@ func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxK LastModified: lastModified, } } - return &ListResult{ + return &listResult{ Objects: objects, IsTruncated: result.IsTruncated, NextContinuationToken: result.NextContinuationToken, @@ -214,16 +175,21 @@ func (c *Client) ListObjects(ctx context.Context, continuationToken string, maxK } // ListAllObjects returns all objects under the client's configured prefix by paginating through -// ListObjectsV2 results automatically. It stops after 10,000 pages as a safety valve. +// ListObjectsV2 results automatically. Keys in the returned objects have the prefix stripped, +// so they match the keys used with PutObject/GetObject/DeleteObjects. It stops after 10,000 +// pages as a safety valve. func (c *Client) ListAllObjects(ctx context.Context) ([]Object, error) { var all []Object var token string for page := 0; page < maxPages; page++ { - result, err := c.ListObjects(ctx, token, 0) + result, err := c.listObjects(ctx, token, 0) if err != nil { return nil, err } - all = append(all, result.Objects...) + for _, obj := range result.Objects { + obj.Key = c.stripPrefix(obj.Key) + all = append(all, obj) + } if !result.IsTruncated { return all, nil } @@ -232,77 +198,6 @@ func (c *Client) ListAllObjects(ctx context.Context) ([]Object, error) { return nil, fmt.Errorf("s3: ListAllObjects exceeded %d pages", maxPages) } -// ListMultipartUploads returns in-progress multipart uploads for the client's prefix. -// It paginates automatically, stopping after 10,000 pages as a safety valve. -func (c *Client) ListMultipartUploads(ctx context.Context) ([]MultipartUpload, error) { - var all []MultipartUpload - var keyMarker, uploadIDMarker string - for page := 0; page < maxPages; page++ { - query := url.Values{"uploads": {""}} - if prefix := c.prefixForList(); prefix != "" { - query.Set("prefix", prefix) - } - if keyMarker != "" { - query.Set("key-marker", keyMarker) - query.Set("upload-id-marker", uploadIDMarker) - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.bucketURL()+"?"+query.Encode(), nil) - if err != nil { - return nil, fmt.Errorf("s3: ListMultipartUploads request: %w", err) - } - c.signV4(req, emptyPayloadHash) - resp, err := c.httpClient().Do(req) - if err != nil { - return nil, fmt.Errorf("s3: ListMultipartUploads: %w", err) - } - respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("s3: ListMultipartUploads read: %w", err) - } - if !isHTTPSuccess(resp) { - return nil, parseErrorFromBytes(resp.StatusCode, respBody) - } - var result listMultipartUploadsResult - if err := xml.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("s3: ListMultipartUploads XML: %w", err) - } - for _, u := range result.Uploads { - var initiated time.Time - if u.Initiated != "" { - initiated, _ = time.Parse(time.RFC3339, u.Initiated) - } - all = append(all, MultipartUpload{ - Key: u.Key, - UploadID: u.UploadID, - Initiated: initiated, - }) - } - if !result.IsTruncated { - return all, nil - } - keyMarker = result.NextKeyMarker - uploadIDMarker = result.NextUploadIDMarker - } - return nil, fmt.Errorf("s3: ListMultipartUploads exceeded %d pages", maxPages) -} - -// AbortIncompleteUploads lists all in-progress multipart uploads and aborts those initiated -// before the given cutoff time. This cleans up orphaned upload parts from interrupted uploads. -func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) error { - uploads, err := c.ListMultipartUploads(ctx) - if err != nil { - return err - } - for _, u := range uploads { - if !u.Initiated.IsZero() && u.Initiated.Before(cutoff) { - log.Tag(tagS3Client).Debug("DeleteIncomplete key=%s uploadId=%s initiated=%s", u.Key, u.UploadID, u.Initiated) - c.abortMultipartUpload(ctx, u.Key, u.UploadID) - } - } - return nil -} - // putObject uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size int64) error { fullKey := c.objectKey(key) @@ -312,235 +207,80 @@ func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size } req.ContentLength = size c.signV4(req, unsignedPayload) - resp, err := c.httpClient().Do(req) + resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("s3: PutObject: %w", err) } - defer resp.Body.Close() + resp.Body.Close() if !isHTTPSuccess(resp) { return parseError(resp) } return nil } -// putObjectMultipart uploads body using S3 multipart upload. It reads the body in partSize -// chunks, uploading each as a separate part. This allows uploading without knowing the total -// body size in advance. -func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Reader) error { - fullKey := c.objectKey(key) - - // Step 1: Initiate multipart upload - uploadID, err := c.initiateMultipartUpload(ctx, fullKey) +// do creates a request, signs it with an empty payload, executes it, reads the response body, +// and checks for errors. It is used for bodiless GET/POST requests. +func (c *Client) do(ctx context.Context, method, reqURL string, body io.Reader, op string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, method, reqURL, body) if err != nil { - return err + return nil, fmt.Errorf("s3: %s request: %w", op, err) } - - // Step 2: Upload parts - var parts []completedPart - buf := make([]byte, partSize) - partNumber := 1 - for { - n, err := io.ReadFull(body, buf) - if n > 0 { - etag, uploadErr := c.uploadPart(ctx, fullKey, uploadID, partNumber, buf[:n]) - if uploadErr != nil { - c.abortMultipartUpload(ctx, fullKey, uploadID) - return uploadErr - } - parts = append(parts, completedPart{PartNumber: partNumber, ETag: etag}) - partNumber++ - } - if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) { - break - } - if err != nil { - c.abortMultipartUpload(ctx, fullKey, uploadID) - return fmt.Errorf("s3: PutObject read: %w", err) - } + if body == nil { + req.ContentLength = 0 } - - // Step 3: Complete multipart upload - return c.completeMultipartUpload(ctx, fullKey, uploadID, parts) -} - -// initiateMultipartUpload starts a new multipart upload and returns the upload ID. -func (c *Client) initiateMultipartUpload(ctx context.Context, fullKey string) (string, error) { - reqURL := c.objectURL(fullKey) + "?uploads" - req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil) - if err != nil { - return "", fmt.Errorf("s3: InitiateMultipartUpload request: %w", err) - } - req.ContentLength = 0 c.signV4(req, emptyPayloadHash) - resp, err := c.httpClient().Do(req) + resp, err := c.http.Do(req) if err != nil { - return "", fmt.Errorf("s3: InitiateMultipartUpload: %w", err) - } - defer resp.Body.Close() - if !isHTTPSuccess(resp) { - return "", parseError(resp) + return nil, fmt.Errorf("s3: %s: %w", op, err) } respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + resp.Body.Close() if err != nil { - return "", fmt.Errorf("s3: InitiateMultipartUpload read: %w", err) + return nil, fmt.Errorf("s3: %s read: %w", op, err) } - var result initiateMultipartUploadResult - if err := xml.Unmarshal(respBody, &result); err != nil { - return "", fmt.Errorf("s3: InitiateMultipartUpload XML: %w", err) - } - log.Tag(tagS3Client).Debug("InitiateMultipartUpload key=%s uploadId=%s", fullKey, result.UploadID) - return result.UploadID, nil -} - -// uploadPart uploads a single part of a multipart upload and returns the ETag. -func (c *Client) uploadPart(ctx context.Context, fullKey, uploadID string, partNumber int, data []byte) (string, error) { - log.Tag(tagS3Client).Debug("UploadPart key=%s part=%d size=%d", fullKey, partNumber, len(data)) - reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.objectURL(fullKey), partNumber, url.QueryEscape(uploadID)) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data)) - if err != nil { - return "", fmt.Errorf("s3: UploadPart request: %w", err) - } - req.ContentLength = int64(len(data)) - c.signV4(req, unsignedPayload) - resp, err := c.httpClient().Do(req) - if err != nil { - return "", fmt.Errorf("s3: UploadPart: %w", err) - } - defer resp.Body.Close() if !isHTTPSuccess(resp) { - return "", parseError(resp) + return nil, parseErrorFromBytes(resp.StatusCode, respBody) } - etag := resp.Header.Get("ETag") - return etag, nil + return respBody, nil } -// completeMultipartUpload finalizes a multipart upload with the given parts. -func (c *Client) completeMultipartUpload(ctx context.Context, fullKey, uploadID string, parts []completedPart) error { - log.Tag(tagS3Client).Debug("CompleteMultipartUpload key=%s uploadId=%s parts=%d", fullKey, uploadID, len(parts)) - var body bytes.Buffer - body.WriteString("") - for _, p := range parts { - fmt.Fprintf(&body, "%d%s", p.PartNumber, p.ETag) - } - body.WriteString("") - bodyBytes := body.Bytes() - payloadHash := sha256Hex(bodyBytes) +// doWithBody is like do, but sends a body with a computed SHA-256 payload hash and Content-Type: application/xml. +func (c *Client) doWithBody(ctx context.Context, method, reqURL string, bodyBytes []byte, op string) ([]byte, error) { + return c.doWithBodyAndHeaders(ctx, method, reqURL, bodyBytes, nil, op) +} - reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(bodyBytes)) +// doWithBodyAndHeaders is like doWithBody, but allows setting additional headers (e.g. Content-MD5). +func (c *Client) doWithBodyAndHeaders(ctx context.Context, method, reqURL string, bodyBytes []byte, headers map[string]string, op string) ([]byte, error) { + payloadHash := sha256Hex(bodyBytes) + req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(bodyBytes)) if err != nil { - return fmt.Errorf("s3: CompleteMultipartUpload request: %w", err) + return nil, fmt.Errorf("s3: %s request: %w", op, err) } req.ContentLength = int64(len(bodyBytes)) req.Header.Set("Content-Type", "application/xml") + for k, v := range headers { + req.Header.Set(k, v) + } c.signV4(req, payloadHash) - resp, err := c.httpClient().Do(req) + resp, err := c.http.Do(req) if err != nil { - return fmt.Errorf("s3: CompleteMultipartUpload: %w", err) + return nil, fmt.Errorf("s3: %s: %w", op, err) } - defer resp.Body.Close() - if !isHTTPSuccess(resp) { - return parseError(resp) - } - // Read response body to check for errors (S3 can return 200 with an error body) respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) - if err != nil { - return fmt.Errorf("s3: CompleteMultipartUpload read: %w", err) - } - // Check if the response contains an error - var errResp ErrorResponse - if xml.Unmarshal(respBody, &errResp) == nil && errResp.Code != "" { - errResp.StatusCode = resp.StatusCode - return &errResp - } - return nil -} - -// abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. -func (c *Client) abortMultipartUpload(ctx context.Context, fullKey, uploadID string) { - log.Tag(tagS3Client).Debug("AbortMultipartUpload key=%s uploadId=%s", fullKey, uploadID) - reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)) - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) - if err != nil { - return - } - c.signV4(req, emptyPayloadHash) - resp, err := c.httpClient().Do(req) - if err != nil { - return - } resp.Body.Close() -} - -// signV4 signs req in place using AWS Signature V4. payloadHash is the hex-encoded SHA-256 -// of the request body, or the literal string "UNSIGNED-PAYLOAD" for streaming uploads. -func (c *Client) signV4(req *http.Request, payloadHash string) { - now := time.Now().UTC() - datestamp := now.Format("20060102") - amzDate := now.Format("20060102T150405Z") - - // Required headers - req.Header.Set("Host", c.hostHeader()) - req.Header.Set("X-Amz-Date", amzDate) - req.Header.Set("X-Amz-Content-Sha256", payloadHash) - - // Canonical headers (all headers we set, sorted by lowercase key) - signedKeys := make([]string, 0, len(req.Header)) - canonHeaders := make(map[string]string, len(req.Header)) - for k := range req.Header { - lk := strings.ToLower(k) - signedKeys = append(signedKeys, lk) - canonHeaders[lk] = strings.TrimSpace(req.Header.Get(k)) + if err != nil { + return nil, fmt.Errorf("s3: %s read: %w", op, err) } - sort.Strings(signedKeys) - signedHeadersStr := strings.Join(signedKeys, ";") - var chBuf strings.Builder - for _, k := range signedKeys { - chBuf.WriteString(k) - chBuf.WriteByte(':') - chBuf.WriteString(canonHeaders[k]) - chBuf.WriteByte('\n') + if !isHTTPSuccess(resp) { + return nil, parseErrorFromBytes(resp.StatusCode, respBody) } - - // Canonical request - canonicalRequest := strings.Join([]string{ - req.Method, - canonicalURI(req.URL), - canonicalQueryString(req.URL.Query()), - chBuf.String(), - signedHeadersStr, - payloadHash, - }, "\n") - - // String to sign - credentialScope := datestamp + "/" + c.Region + "/s3/aws4_request" - stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex([]byte(canonicalRequest)) - - // Signing key - signingKey := hmacSHA256(hmacSHA256(hmacSHA256(hmacSHA256( - []byte("AWS4"+c.SecretKey), []byte(datestamp)), - []byte(c.Region)), - []byte("s3")), - []byte("aws4_request")) - - signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign))) - req.Header.Set("Authorization", fmt.Sprintf( - "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", - c.AccessKey, credentialScope, signedHeadersStr, signature, - )) -} - -func (c *Client) httpClient() *http.Client { - if c.HTTPClient != nil { - return c.HTTPClient - } - return http.DefaultClient + return respBody, nil } // objectKey prepends the configured prefix to the given key. func (c *Client) objectKey(key string) string { - if c.Prefix != "" { - return c.Prefix + "/" + key + if c.config.Prefix != "" { + return c.config.Prefix + "/" + key } return key } @@ -548,18 +288,19 @@ func (c *Client) objectKey(key string) string { // prefixForList returns the prefix to use in ListObjectsV2 requests, // with a trailing slash so that only objects under the prefix directory are returned. func (c *Client) prefixForList() string { - if c.Prefix != "" { - return c.Prefix + "/" + if c.config.Prefix != "" { + return c.config.Prefix + "/" } return "" } -// bucketURL returns the base URL for bucket-level operations. -func (c *Client) bucketURL() string { - if c.PathStyle { - return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket) +// stripPrefix removes the configured prefix from a key returned by ListObjectsV2, +// so keys match what was passed to PutObject/GetObject/DeleteObjects. +func (c *Client) stripPrefix(key string) string { + if c.config.Prefix != "" { + return strings.TrimPrefix(key, c.config.Prefix+"/") } - return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint) + return key } // objectURL returns the full URL for an object (key should already include the prefix). @@ -569,13 +310,5 @@ func (c *Client) objectURL(key string) string { for i, seg := range segments { segments[i] = uriEncode(seg) } - return c.bucketURL() + "/" + strings.Join(segments, "/") -} - -// hostHeader returns the value for the Host header. -func (c *Client) hostHeader() string { - if c.PathStyle { - return c.Endpoint - } - return c.Bucket + "." + c.Endpoint + return c.config.BucketURL() + "/" + strings.Join(segments, "/") } diff --git a/s3/client_auth.go b/s3/client_auth.go new file mode 100644 index 00000000..ede971f3 --- /dev/null +++ b/s3/client_auth.go @@ -0,0 +1,68 @@ +package s3 + +import ( + "encoding/hex" + "fmt" + "net/http" + "sort" + "strings" + "time" +) + +// signV4 signs req in place using AWS Signature V4. payloadHash is the hex-encoded SHA-256 +// of the request body, or the literal string "UNSIGNED-PAYLOAD" for streaming uploads. +func (c *Client) signV4(req *http.Request, payloadHash string) { + now := time.Now().UTC() + datestamp := now.Format("20060102") + amzDate := now.Format("20060102T150405Z") + + // Required headers + req.Header.Set("Host", c.config.HostHeader()) + req.Header.Set("X-Amz-Date", amzDate) + req.Header.Set("X-Amz-Content-Sha256", payloadHash) + + // Canonical headers (all headers we set, sorted by lowercase key) + signedKeys := make([]string, 0, len(req.Header)) + canonHeaders := make(map[string]string, len(req.Header)) + for k := range req.Header { + lk := strings.ToLower(k) + signedKeys = append(signedKeys, lk) + canonHeaders[lk] = strings.TrimSpace(req.Header.Get(k)) + } + sort.Strings(signedKeys) + signedHeadersStr := strings.Join(signedKeys, ";") + var chBuf strings.Builder + for _, k := range signedKeys { + chBuf.WriteString(k) + chBuf.WriteByte(':') + chBuf.WriteString(canonHeaders[k]) + chBuf.WriteByte('\n') + } + + // Canonical request + canonicalRequest := strings.Join([]string{ + req.Method, + canonicalURI(req.URL), + canonicalQueryString(req.URL.Query()), + chBuf.String(), + signedHeadersStr, + payloadHash, + }, "\n") + + // String to sign + credentialScope := datestamp + "/" + c.config.Region + "/s3/aws4_request" + stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex([]byte(canonicalRequest)) + + // Signing key + signingKey := hmacSHA256(hmacSHA256(hmacSHA256(hmacSHA256( + []byte("AWS4"+c.config.SecretKey), []byte(datestamp)), + []byte(c.config.Region)), + []byte("s3")), + []byte("aws4_request")) + + signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign))) + req.Header.Set("Authorization", fmt.Sprintf( + "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", + c.config.AccessKey, credentialScope, signedHeadersStr, signature, + )) +} diff --git a/s3/client_multipart.go b/s3/client_multipart.go new file mode 100644 index 00000000..61d206b6 --- /dev/null +++ b/s3/client_multipart.go @@ -0,0 +1,188 @@ +package s3 + +import ( + "bytes" + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "heckel.io/ntfy/v2/log" +) + +// ListMultipartUploads returns in-progress multipart uploads for the client's prefix. +// It paginates automatically, stopping after 10,000 pages as a safety valve. +func (c *Client) ListMultipartUploads(ctx context.Context) ([]MultipartUpload, error) { + var all []MultipartUpload + var keyMarker, uploadIDMarker string + for page := 0; page < maxPages; page++ { + query := url.Values{"uploads": {""}} + if prefix := c.prefixForList(); prefix != "" { + query.Set("prefix", prefix) + } + if keyMarker != "" { + query.Set("key-marker", keyMarker) + query.Set("upload-id-marker", uploadIDMarker) + } + respBody, err := c.do(ctx, http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, "ListMultipartUploads") + if err != nil { + return nil, err + } + var result listMultipartUploadsResult + if err := xml.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("s3: ListMultipartUploads XML: %w", err) + } + for _, u := range result.Uploads { + var initiated time.Time + if u.Initiated != "" { + initiated, _ = time.Parse(time.RFC3339, u.Initiated) + } + all = append(all, MultipartUpload{ + Key: u.Key, + UploadID: u.UploadID, + Initiated: initiated, + }) + } + if !result.IsTruncated { + return all, nil + } + keyMarker = result.NextKeyMarker + uploadIDMarker = result.NextUploadIDMarker + } + return nil, fmt.Errorf("s3: ListMultipartUploads exceeded %d pages", maxPages) +} + +// AbortIncompleteUploads lists all in-progress multipart uploads and aborts those initiated +// before the given cutoff time. This cleans up orphaned upload parts from interrupted uploads. +func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) error { + uploads, err := c.ListMultipartUploads(ctx) + if err != nil { + return err + } + for _, u := range uploads { + if !u.Initiated.IsZero() && u.Initiated.Before(cutoff) { + log.Tag(tagS3Client).Debug("DeleteIncomplete key=%s uploadId=%s initiated=%s", u.Key, u.UploadID, u.Initiated) + c.abortMultipartUpload(ctx, u.Key, u.UploadID) + } + } + return nil +} + +// putObjectMultipart uploads body using S3 multipart upload. It reads the body in partSize +// chunks, uploading each as a separate part. This allows uploading without knowing the total +// body size in advance. +func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Reader) error { + fullKey := c.objectKey(key) + + // Step 1: Initiate multipart upload + uploadID, err := c.initiateMultipartUpload(ctx, fullKey) + if err != nil { + return err + } + + // Step 2: Upload parts + var parts []completedPart + buf := make([]byte, partSize) + partNumber := 1 + for { + n, err := io.ReadFull(body, buf) + if n > 0 { + etag, uploadErr := c.uploadPart(ctx, fullKey, uploadID, partNumber, buf[:n]) + if uploadErr != nil { + c.abortMultipartUpload(ctx, fullKey, uploadID) + return uploadErr + } + parts = append(parts, completedPart{PartNumber: partNumber, ETag: etag}) + partNumber++ + } + if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) { + break + } + if err != nil { + c.abortMultipartUpload(ctx, fullKey, uploadID) + return fmt.Errorf("s3: PutObject read: %w", err) + } + } + + // Step 3: Complete multipart upload + return c.completeMultipartUpload(ctx, fullKey, uploadID, parts) +} + +// initiateMultipartUpload starts a new multipart upload and returns the upload ID. +func (c *Client) initiateMultipartUpload(ctx context.Context, fullKey string) (string, error) { + respBody, err := c.do(ctx, http.MethodPost, c.objectURL(fullKey)+"?uploads", nil, "InitiateMultipartUpload") + if err != nil { + return "", err + } + var result initiateMultipartUploadResult + if err := xml.Unmarshal(respBody, &result); err != nil { + return "", fmt.Errorf("s3: InitiateMultipartUpload XML: %w", err) + } + log.Tag(tagS3Client).Debug("InitiateMultipartUpload key=%s uploadId=%s", fullKey, result.UploadID) + return result.UploadID, nil +} + +// uploadPart uploads a single part of a multipart upload and returns the ETag. +func (c *Client) uploadPart(ctx context.Context, fullKey, uploadID string, partNumber int, data []byte) (string, error) { + log.Tag(tagS3Client).Debug("UploadPart key=%s part=%d size=%d", fullKey, partNumber, len(data)) + reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.objectURL(fullKey), partNumber, url.QueryEscape(uploadID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("s3: UploadPart request: %w", err) + } + req.ContentLength = int64(len(data)) + c.signV4(req, unsignedPayload) + resp, err := c.http.Do(req) + if err != nil { + return "", fmt.Errorf("s3: UploadPart: %w", err) + } + defer resp.Body.Close() + if !isHTTPSuccess(resp) { + return "", parseError(resp) + } + etag := resp.Header.Get("ETag") + return etag, nil +} + +// completeMultipartUpload finalizes a multipart upload with the given parts. +func (c *Client) completeMultipartUpload(ctx context.Context, fullKey, uploadID string, parts []completedPart) error { + log.Tag(tagS3Client).Debug("CompleteMultipartUpload key=%s uploadId=%s parts=%d", fullKey, uploadID, len(parts)) + var body bytes.Buffer + body.WriteString("") + for _, p := range parts { + fmt.Fprintf(&body, "%d%s", p.PartNumber, p.ETag) + } + body.WriteString("") + respBody, err := c.doWithBody(ctx, http.MethodPost, + fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)), + body.Bytes(), "CompleteMultipartUpload") + if err != nil { + return err + } + // Check if the response contains an error (S3 can return 200 with an error body) + var errResp ErrorResponse + if xml.Unmarshal(respBody, &errResp) == nil && errResp.Code != "" { + return &errResp + } + return nil +} + +// abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. +func (c *Client) abortMultipartUpload(ctx context.Context, fullKey, uploadID string) { + log.Tag(tagS3Client).Debug("AbortMultipartUpload key=%s uploadId=%s", fullKey, uploadID) + reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) + if err != nil { + return + } + c.signV4(req, emptyPayloadHash) + resp, err := c.http.Do(req) + if err != nil { + return + } + resp.Body.Close() +} diff --git a/s3/client_test.go b/s3/client_test.go index 8007601c..447f36d0 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -269,7 +269,7 @@ func (m *mockS3Server) objectCount() int { func newTestClient(server *httptest.Server, bucket, prefix string) *Client { // httptest.NewTLSServer URL is like "https://127.0.0.1:PORT" host := strings.TrimPrefix(server.URL, "https://") - return &Client{ + return New(&Config{ AccessKey: "AKID", SecretKey: "SECRET", Region: "us-east-1", @@ -278,7 +278,7 @@ func newTestClient(server *httptest.Server, bucket, prefix string) *Client { Prefix: prefix, PathStyle: true, HTTPClient: server.Client(), - } + }) } // --- URL parsing tests --- @@ -363,49 +363,49 @@ func TestParseURL_EmptyBucket(t *testing.T) { // --- Unit tests: URL construction --- -func TestClient_BucketURL_PathStyle(t *testing.T) { - c := &Client{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true} - require.Equal(t, "https://s3.example.com/my-bucket", c.bucketURL()) +func TestConfig_BucketURL_PathStyle(t *testing.T) { + c := &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true} + require.Equal(t, "https://s3.example.com/my-bucket", c.BucketURL()) } -func TestClient_BucketURL_VirtualHosted(t *testing.T) { - c := &Client{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} - require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.bucketURL()) +func TestConfig_BucketURL_VirtualHosted(t *testing.T) { + c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} + require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.BucketURL()) } func TestClient_ObjectURL_PathStyle(t *testing.T) { - c := &Client{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true} + c := &Client{config: &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true}} require.Equal(t, "https://s3.example.com/my-bucket/prefix/obj", c.objectURL("prefix/obj")) } func TestClient_ObjectURL_VirtualHosted(t *testing.T) { - c := &Client{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} + c := &Client{config: &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false}} require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com/prefix/obj", c.objectURL("prefix/obj")) } -func TestClient_HostHeader_PathStyle(t *testing.T) { - c := &Client{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true} - require.Equal(t, "s3.example.com", c.hostHeader()) +func TestConfig_HostHeader_PathStyle(t *testing.T) { + c := &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true} + require.Equal(t, "s3.example.com", c.HostHeader()) } -func TestClient_HostHeader_VirtualHosted(t *testing.T) { - c := &Client{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} - require.Equal(t, "my-bucket.s3.us-east-1.amazonaws.com", c.hostHeader()) +func TestConfig_HostHeader_VirtualHosted(t *testing.T) { + c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false} + require.Equal(t, "my-bucket.s3.us-east-1.amazonaws.com", c.HostHeader()) } func TestClient_ObjectKey(t *testing.T) { - c := &Client{Prefix: "attachments"} + c := &Client{config: &Config{Prefix: "attachments"}} require.Equal(t, "attachments/file123", c.objectKey("file123")) - c2 := &Client{Prefix: ""} + c2 := &Client{config: &Config{Prefix: ""}} require.Equal(t, "file123", c2.objectKey("file123")) } func TestClient_PrefixForList(t *testing.T) { - c := &Client{Prefix: "attachments"} + c := &Client{config: &Config{Prefix: "attachments"}} require.Equal(t, "attachments/", c.prefixForList()) - c2 := &Client{Prefix: ""} + c2 := &Client{config: &Config{Prefix: ""}} require.Equal(t, "", c2.prefixForList()) } @@ -512,13 +512,13 @@ func TestClient_ListObjects(t *testing.T) { require.Nil(t, err) // List with prefix client: should only see 3 - result, err := client.ListObjects(ctx, "", 0) + result, err := client.listObjects(ctx, "", 0) require.Nil(t, err) require.Len(t, result.Objects, 3) require.False(t, result.IsTruncated) // List with no-prefix client: should see all 4 - result, err = clientNoPrefix.ListObjects(ctx, "", 0) + result, err = clientNoPrefix.listObjects(ctx, "", 0) require.Nil(t, err) require.Len(t, result.Objects, 4) } @@ -537,20 +537,20 @@ func TestClient_ListObjects_Pagination(t *testing.T) { } // List with max-keys=2 - result, err := client.ListObjects(ctx, "", 2) + result, err := client.listObjects(ctx, "", 2) require.Nil(t, err) require.Len(t, result.Objects, 2) require.True(t, result.IsTruncated) require.NotEmpty(t, result.NextContinuationToken) // Get next page - result2, err := client.ListObjects(ctx, result.NextContinuationToken, 2) + result2, err := client.listObjects(ctx, result.NextContinuationToken, 2) require.Nil(t, err) require.Len(t, result2.Objects, 2) require.True(t, result2.IsTruncated) // Get last page - result3, err := client.ListObjects(ctx, result2.NextContinuationToken, 2) + result3, err := client.listObjects(ctx, result2.NextContinuationToken, 2) require.Nil(t, err) require.Len(t, result3.Objects, 1) require.False(t, result3.IsTruncated) @@ -744,7 +744,7 @@ func TestClient_RealBucket(t *testing.T) { prefix = "ntfy-s3-test" } - client := &Client{ + client := New(&Config{ AccessKey: accessKey, SecretKey: secretKey, Region: region, @@ -752,7 +752,7 @@ func TestClient_RealBucket(t *testing.T) { Bucket: bucket, Prefix: prefix, PathStyle: pathStyle, - } + }) ctx := context.Background() @@ -762,8 +762,7 @@ func TestClient_RealBucket(t *testing.T) { if len(existing) > 0 { keys := make([]string, len(existing)) for i, obj := range existing { - // Strip the prefix since DeleteObjects will re-add it - keys[i] = strings.TrimPrefix(obj.Key, prefix+"/") + keys[i] = obj.Key } // Batch delete in groups of 1000 for i := 0; i < len(keys); i += 1000 { @@ -807,7 +806,7 @@ func TestClient_RealBucket(t *testing.T) { t.Run("ListObjects", func(t *testing.T) { // Use a sub-prefix client for isolation - listClient := &Client{ + listClient := New(&Config{ AccessKey: accessKey, SecretKey: secretKey, Region: region, @@ -815,7 +814,7 @@ func TestClient_RealBucket(t *testing.T) { Bucket: bucket, Prefix: prefix + "/list-test", PathStyle: pathStyle, - } + }) // Put 10 objects for i := 0; i < 10; i++ { diff --git a/s3/types.go b/s3/types.go index f78c4a8b..5fec7c78 100644 --- a/s3/types.go +++ b/s3/types.go @@ -2,18 +2,36 @@ package s3 import ( "fmt" + "net/http" "time" ) // Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string. type Config struct { - Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com" - PathStyle bool - Bucket string - Prefix string - Region string - AccessKey string - SecretKey string + Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com" + PathStyle bool + Bucket string + Prefix string + Region string + AccessKey string + SecretKey string + HTTPClient *http.Client // if nil, http.DefaultClient is used +} + +// bucketURL returns the base URL for bucket-level operations. +func (c *Config) BucketURL() string { + if c.PathStyle { + return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket) + } + return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint) +} + +// hostHeader returns the value for the Host header. +func (c *Config) HostHeader() string { + if c.PathStyle { + return c.Endpoint + } + return c.Bucket + "." + c.Endpoint } // Object represents an S3 object returned by list operations. @@ -23,8 +41,8 @@ type Object struct { LastModified time.Time } -// ListResult holds the response from a ListObjectsV2 call. -type ListResult struct { +// listResult holds the response from a single ListObjectsV2 page. +type listResult struct { Objects []Object IsTruncated bool NextContinuationToken string @@ -78,10 +96,10 @@ type MultipartUpload struct { // listMultipartUploadsResult is the XML response from S3 ListMultipartUploads type listMultipartUploadsResult struct { - Uploads []listUpload `xml:"Upload"` - IsTruncated bool `xml:"IsTruncated"` - NextKeyMarker string `xml:"NextKeyMarker"` - NextUploadIDMarker string `xml:"NextUploadIdMarker"` + Uploads []listUpload `xml:"Upload"` + IsTruncated bool `xml:"IsTruncated"` + NextKeyMarker string `xml:"NextKeyMarker"` + NextUploadIDMarker string `xml:"NextUploadIdMarker"` } type listUpload struct { diff --git a/s3/util_test.go b/s3/util_test.go index d30c5664..3f08911d 100644 --- a/s3/util_test.go +++ b/s3/util_test.go @@ -105,13 +105,13 @@ func TestHmacSHA256(t *testing.T) { } func TestSignV4_SetsRequiredHeaders(t *testing.T) { - c := &Client{ + c := &Client{config: &Config{ AccessKey: "AKID", SecretKey: "SECRET", Region: "us-east-1", Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", - } + }} req, _ := http.NewRequest(http.MethodGet, "https://my-bucket.s3.us-east-1.amazonaws.com/test-key", nil) c.signV4(req, emptyPayloadHash) @@ -131,13 +131,13 @@ func TestSignV4_SetsRequiredHeaders(t *testing.T) { } func TestSignV4_UnsignedPayload(t *testing.T) { - c := &Client{ + c := &Client{config: &Config{ AccessKey: "AKID", SecretKey: "SECRET", Region: "us-east-1", Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", - } + }} req, _ := http.NewRequest(http.MethodPut, "https://my-bucket.s3.us-east-1.amazonaws.com/test-key", nil) c.signV4(req, unsignedPayload) @@ -146,8 +146,8 @@ func TestSignV4_UnsignedPayload(t *testing.T) { } func TestSignV4_DifferentRegions(t *testing.T) { - c1 := &Client{AccessKey: "AKID", SecretKey: "SECRET", Region: "us-east-1", Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "b"} - c2 := &Client{AccessKey: "AKID", SecretKey: "SECRET", Region: "eu-west-1", Endpoint: "s3.eu-west-1.amazonaws.com", Bucket: "b"} + c1 := &Client{config: &Config{AccessKey: "AKID", SecretKey: "SECRET", Region: "us-east-1", Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "b"}} + c2 := &Client{config: &Config{AccessKey: "AKID", SecretKey: "SECRET", Region: "eu-west-1", Endpoint: "s3.eu-west-1.amazonaws.com", Bucket: "b"}} req1, _ := http.NewRequest(http.MethodGet, "https://b.s3.us-east-1.amazonaws.com/key", nil) c1.signV4(req1, emptyPayloadHash) From 1f270b68e0a9e9c1e33750d968eb640db9c9f13a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 19 Mar 2026 22:42:38 -0400 Subject: [PATCH 012/126] Simplify a little, manual review --- attachment/store.go | 7 +- s3/client.go | 151 +++++++++++++++++------------------------ s3/client_auth.go | 11 +-- s3/client_multipart.go | 90 ++++++++++++------------ s3/client_test.go | 8 +-- s3/types.go | 26 +++++-- 6 files changed, 140 insertions(+), 153 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index 78b8a7cc..0192b09a 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -131,7 +131,6 @@ func (c *Store) Remove(ids ...string) error { // deletes orphans (not in the valid ID set and older than 1 hour), and recomputes // the total size from the remaining objects. func (c *Store) sync() error { - log.Tag(tagStore).Debug("Sync: starting sync loop") localIDs, err := c.localIDs() if err != nil { return fmt.Errorf("attachment sync: failed to get valid IDs: %w", err) @@ -161,21 +160,21 @@ func (c *Store) sync() error { sizes[obj.ID] = obj.Size } } - log.Tag(tagStore).Debug("Sync: cache size updated to %s", util.FormatSizeHuman(size)) + log.Tag(tagStore).Debug("Attachment cache size updated to %s", util.FormatSizeHuman(size)) c.mu.Lock() c.size = size c.sizes = sizes c.mu.Unlock() // Delete orphaned attachments if len(orphanIDs) > 0 { - log.Tag(tagStore).Debug("Sync: deleting %d orphaned attachment(s)", len(orphanIDs)) + log.Tag(tagStore).Debug("Deleting %d orphaned attachment(s)", len(orphanIDs)) if err := c.backend.Delete(orphanIDs...); err != nil { return fmt.Errorf("attachment sync: failed to delete orphaned objects: %w", err) } } // Clean up incomplete uploads (S3 only) if err := c.backend.DeleteIncomplete(cutoff); err != nil { - log.Tag(tagStore).Err(err).Warn("Sync: failed to abort incomplete uploads") + log.Tag(tagStore).Err(err).Warn("Failed to abort incomplete uploads from attachment cache") } return nil } diff --git a/s3/client.go b/s3/client.go index 5c43af05..65316fb2 100644 --- a/s3/client.go +++ b/s3/client.go @@ -1,6 +1,3 @@ -// Package s3 provides a minimal S3-compatible client that works with AWS S3, DigitalOcean Spaces, -// GCP Cloud Storage, MinIO, Backblaze B2, and other S3-compatible providers. It uses raw HTTP -// requests with AWS Signature V4 signing, no AWS SDK dependency required. package s3 import ( @@ -57,32 +54,26 @@ func (c *Client) PutObject(ctx context.Context, key string, body io.Reader) erro first := make([]byte, partSize) n, err := io.ReadFull(body, first) if errors.Is(err, io.ErrUnexpectedEOF) || err == io.EOF { - log.Tag(tagS3Client).Debug("PutObject key=%s size=%d (simple)", key, n) - return c.putObject(ctx, key, bytes.NewReader(first[:n]), int64(n)) + return c.putObjectSimple(ctx, key, bytes.NewReader(first[:n]), int64(n)) + } else if err != nil { + return fmt.Errorf("error reading object %s from client: %w", key, err) } - if err != nil { - return fmt.Errorf("s3: PutObject read: %w", err) - } - log.Tag(tagS3Client).Debug("PutObject key=%s (multipart)", key) - combined := io.MultiReader(bytes.NewReader(first), body) - return c.putObjectMultipart(ctx, key, combined) + return c.putObjectMultipart(ctx, key, io.MultiReader(bytes.NewReader(first), body)) } // GetObject downloads an object. The key is automatically prefixed with the client's configured // prefix. The caller must close the returned ReadCloser. func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int64, error) { - log.Tag(tagS3Client).Debug("GetObject key=%s", key) - fullKey := c.objectKey(key) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.objectURL(fullKey), nil) + log.Tag(tagS3Client).Debug("Fetching object %s from backend", key) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.objectURL(key), nil) if err != nil { - return nil, 0, fmt.Errorf("s3: GetObject request: %w", err) + return nil, 0, fmt.Errorf("error creating HTTP GET request for %s: %w", key, err) } c.signV4(req, emptyPayloadHash) resp, err := c.http.Do(req) if err != nil { - return nil, 0, fmt.Errorf("s3: GetObject: %w", err) - } - if !isHTTPSuccess(resp) { + return nil, 0, fmt.Errorf("error fetching object %s: %w", key, err) + } else if !isHTTPSuccess(resp) { err := parseError(resp) resp.Body.Close() return nil, 0, err @@ -97,25 +88,27 @@ func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int6 // Even when S3 returns HTTP 200, individual keys may fail. If any per-key errors are present // in the response, they are returned as a combined error. func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { - log.Tag(tagS3Client).Debug("DeleteObjects keys=%d", len(keys)) - var body bytes.Buffer - body.WriteString("true") + log.Tag(tagS3Client).Debug("Deleting %d object(s)", len(keys)) + req := &deleteRequest{ + Quiet: true, + } for _, key := range keys { - body.WriteString("") - xml.EscapeText(&body, []byte(c.objectKey(key))) - body.WriteString("") + req.Objects = append(req.Objects, &deleteObject{Key: c.objectKey(key)}) + } + body, err := xml.Marshal(req) + if err != nil { + return fmt.Errorf("error marshalling XML for deleting objects: %w", err) } - body.WriteString("") - bodyBytes := body.Bytes() // Content-MD5 is required by the S3 protocol for DeleteObjects requests. - md5Sum := md5.Sum(bodyBytes) //nolint:gosec - contentMD5 := base64.StdEncoding.EncodeToString(md5Sum[:]) - - respBody, err := c.doWithBodyAndHeaders(ctx, http.MethodPost, c.config.BucketURL()+"?delete=", bodyBytes, - map[string]string{"Content-MD5": contentMD5}, "DeleteObjects") + md5Sum := md5.Sum(body) //nolint:gosec + headers := map[string]string{ + "Content-MD5": base64.StdEncoding.EncodeToString(md5Sum[:]), + } + reqURL := c.config.BucketURL() + "?delete=" + respBody, err := c.do(ctx, http.MethodPost, reqURL, body, headers, "DeleteObjects") if err != nil { - return err + return fmt.Errorf("error deleting objects: %w", err) } // S3 may return HTTP 200 with per-key errors in the response body @@ -128,7 +121,7 @@ func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { for _, e := range result.Errors { msgs = append(msgs, fmt.Sprintf("%s: %s", e.Key, e.Message)) } - return fmt.Errorf("s3: DeleteObjects partial failure: %s", strings.Join(msgs, "; ")) + return fmt.Errorf("error deleting objects, partial failure: %s", strings.Join(msgs, "; ")) } return nil } @@ -147,7 +140,7 @@ func (c *Client) listObjects(ctx context.Context, continuationToken string, maxK if maxKeys > 0 { query.Set("max-keys", strconv.Itoa(maxKeys)) } - respBody, err := c.do(ctx, http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, "ListObjects") + respBody, err := c.do(ctx, http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil, "ListObjects") if err != nil { return nil, err } @@ -198,12 +191,12 @@ func (c *Client) ListAllObjects(ctx context.Context) ([]Object, error) { return nil, fmt.Errorf("s3: ListAllObjects exceeded %d pages", maxPages) } -// putObject uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. -func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size int64) error { - fullKey := c.objectKey(key) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.objectURL(fullKey), body) +// putObjectSimple uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. +func (c *Client) putObjectSimple(ctx context.Context, key string, body io.Reader, size int64) error { + log.Tag(tagS3Client).Debug("Uploading object %s (%d bytes)", key, size) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.objectURL(key), body) if err != nil { - return fmt.Errorf("s3: PutObject request: %w", err) + return fmt.Errorf("uploading object %s failed: %w", key, err) } req.ContentLength = size c.signV4(req, unsignedPayload) @@ -218,50 +211,32 @@ func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size return nil } -// do creates a request, signs it with an empty payload, executes it, reads the response body, -// and checks for errors. It is used for bodiless GET/POST requests. -func (c *Client) do(ctx context.Context, method, reqURL string, body io.Reader, op string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, method, reqURL, body) +// do creates a signed request, executes it, reads the response body, and checks for errors. +// If body is nil, the request is sent with an empty payload. If body is non-nil, it is sent +// with a computed SHA-256 payload hash and Content-Type: application/xml. +func (c *Client) do(ctx context.Context, method, reqURL string, body []byte, headers map[string]string, op string) ([]byte, error) { + var reader io.Reader + var hash string + if body != nil { + reader = bytes.NewReader(body) + hash = sha256Hex(body) + } else { + hash = emptyPayloadHash + } + req, err := http.NewRequestWithContext(ctx, method, reqURL, reader) if err != nil { return nil, fmt.Errorf("s3: %s request: %w", op, err) } - if body == nil { + if body != nil { + req.ContentLength = int64(len(body)) + req.Header.Set("Content-Type", "application/xml") + } else { req.ContentLength = 0 } - c.signV4(req, emptyPayloadHash) - resp, err := c.http.Do(req) - if err != nil { - return nil, fmt.Errorf("s3: %s: %w", op, err) - } - respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("s3: %s read: %w", op, err) - } - if !isHTTPSuccess(resp) { - return nil, parseErrorFromBytes(resp.StatusCode, respBody) - } - return respBody, nil -} - -// doWithBody is like do, but sends a body with a computed SHA-256 payload hash and Content-Type: application/xml. -func (c *Client) doWithBody(ctx context.Context, method, reqURL string, bodyBytes []byte, op string) ([]byte, error) { - return c.doWithBodyAndHeaders(ctx, method, reqURL, bodyBytes, nil, op) -} - -// doWithBodyAndHeaders is like doWithBody, but allows setting additional headers (e.g. Content-MD5). -func (c *Client) doWithBodyAndHeaders(ctx context.Context, method, reqURL string, bodyBytes []byte, headers map[string]string, op string) ([]byte, error) { - payloadHash := sha256Hex(bodyBytes) - req, err := http.NewRequestWithContext(ctx, method, reqURL, bytes.NewReader(bodyBytes)) - if err != nil { - return nil, fmt.Errorf("s3: %s request: %w", op, err) - } - req.ContentLength = int64(len(bodyBytes)) - req.Header.Set("Content-Type", "application/xml") for k, v := range headers { req.Header.Set(k, v) } - c.signV4(req, payloadHash) + c.signV4(req, hash) resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("s3: %s: %w", op, err) @@ -277,14 +252,6 @@ func (c *Client) doWithBodyAndHeaders(ctx context.Context, method, reqURL string return respBody, nil } -// objectKey prepends the configured prefix to the given key. -func (c *Client) objectKey(key string) string { - if c.config.Prefix != "" { - return c.config.Prefix + "/" + key - } - return key -} - // prefixForList returns the prefix to use in ListObjectsV2 requests, // with a trailing slash so that only objects under the prefix directory are returned. func (c *Client) prefixForList() string { @@ -303,12 +270,16 @@ func (c *Client) stripPrefix(key string) string { return key } -// objectURL returns the full URL for an object (key should already include the prefix). -// Each path segment is URI-encoded to handle special characters in keys. -func (c *Client) objectURL(key string) string { - segments := strings.Split(key, "/") - for i, seg := range segments { - segments[i] = uriEncode(seg) +// objectKey prepends the configured prefix to the given key. +func (c *Client) objectKey(key string) string { + if c.config.Prefix != "" { + return c.config.Prefix + "/" + key } - return c.config.BucketURL() + "/" + strings.Join(segments, "/") + return key +} + +// objectURL returns the full URL for an object, automatically prepending the configured prefix. +func (c *Client) objectURL(key string) string { + u, _ := url.JoinPath(c.config.BucketURL(), c.objectKey(key)) + return u } diff --git a/s3/client_auth.go b/s3/client_auth.go index ede971f3..8b7e6053 100644 --- a/s3/client_auth.go +++ b/s3/client_auth.go @@ -11,7 +11,7 @@ import ( // signV4 signs req in place using AWS Signature V4. payloadHash is the hex-encoded SHA-256 // of the request body, or the literal string "UNSIGNED-PAYLOAD" for streaming uploads. -func (c *Client) signV4(req *http.Request, payloadHash string) { +func (c *Client) signV4(req *http.Request, hash string) { now := time.Now().UTC() datestamp := now.Format("20060102") amzDate := now.Format("20060102T150405Z") @@ -19,7 +19,7 @@ func (c *Client) signV4(req *http.Request, payloadHash string) { // Required headers req.Header.Set("Host", c.config.HostHeader()) req.Header.Set("X-Amz-Date", amzDate) - req.Header.Set("X-Amz-Content-Sha256", payloadHash) + req.Header.Set("X-Amz-Content-Sha256", hash) // Canonical headers (all headers we set, sorted by lowercase key) signedKeys := make([]string, 0, len(req.Header)) @@ -46,7 +46,7 @@ func (c *Client) signV4(req *http.Request, payloadHash string) { canonicalQueryString(req.URL.Query()), chBuf.String(), signedHeadersStr, - payloadHash, + hash, }, "\n") // String to sign @@ -61,8 +61,9 @@ func (c *Client) signV4(req *http.Request, payloadHash string) { []byte("aws4_request")) signature := hex.EncodeToString(hmacSHA256(signingKey, []byte(stringToSign))) - req.Header.Set("Authorization", fmt.Sprintf( + header := fmt.Sprintf( "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", c.config.AccessKey, credentialScope, signedHeadersStr, signature, - )) + ) + req.Header.Set("Authorization", header) } diff --git a/s3/client_multipart.go b/s3/client_multipart.go index 61d206b6..d58a9337 100644 --- a/s3/client_multipart.go +++ b/s3/client_multipart.go @@ -14,9 +14,25 @@ import ( "heckel.io/ntfy/v2/log" ) -// ListMultipartUploads returns in-progress multipart uploads for the client's prefix. +// AbortIncompleteUploads lists all in-progress multipart uploads and aborts those initiated +// before the given cutoff time. This cleans up orphaned upload parts from interrupted uploads. +func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) error { + uploads, err := c.listMultipartUploads(ctx) + if err != nil { + return err + } + for _, u := range uploads { + if !u.Initiated.IsZero() && u.Initiated.Before(cutoff) { + log.Tag(tagS3Client).Debug("DeleteIncomplete key=%s uploadId=%s initiated=%s", u.Key, u.UploadID, u.Initiated) + c.abortMultipartUpload(ctx, u.Key, u.UploadID) + } + } + return nil +} + +// listMultipartUploads returns in-progress multipart uploads for the client's prefix. // It paginates automatically, stopping after 10,000 pages as a safety valve. -func (c *Client) ListMultipartUploads(ctx context.Context) ([]MultipartUpload, error) { +func (c *Client) listMultipartUploads(ctx context.Context) ([]MultipartUpload, error) { var all []MultipartUpload var keyMarker, uploadIDMarker string for page := 0; page < maxPages; page++ { @@ -28,13 +44,13 @@ func (c *Client) ListMultipartUploads(ctx context.Context) ([]MultipartUpload, e query.Set("key-marker", keyMarker) query.Set("upload-id-marker", uploadIDMarker) } - respBody, err := c.do(ctx, http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, "ListMultipartUploads") + respBody, err := c.do(ctx, http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil, "listMultipartUploads") if err != nil { return nil, err } var result listMultipartUploadsResult if err := xml.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("s3: ListMultipartUploads XML: %w", err) + return nil, fmt.Errorf("s3: listMultipartUploads XML: %w", err) } for _, u := range result.Uploads { var initiated time.Time @@ -53,33 +69,17 @@ func (c *Client) ListMultipartUploads(ctx context.Context) ([]MultipartUpload, e keyMarker = result.NextKeyMarker uploadIDMarker = result.NextUploadIDMarker } - return nil, fmt.Errorf("s3: ListMultipartUploads exceeded %d pages", maxPages) -} - -// AbortIncompleteUploads lists all in-progress multipart uploads and aborts those initiated -// before the given cutoff time. This cleans up orphaned upload parts from interrupted uploads. -func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) error { - uploads, err := c.ListMultipartUploads(ctx) - if err != nil { - return err - } - for _, u := range uploads { - if !u.Initiated.IsZero() && u.Initiated.Before(cutoff) { - log.Tag(tagS3Client).Debug("DeleteIncomplete key=%s uploadId=%s initiated=%s", u.Key, u.UploadID, u.Initiated) - c.abortMultipartUpload(ctx, u.Key, u.UploadID) - } - } - return nil + return nil, fmt.Errorf("s3: listMultipartUploads exceeded %d pages", maxPages) } // putObjectMultipart uploads body using S3 multipart upload. It reads the body in partSize // chunks, uploading each as a separate part. This allows uploading without knowing the total // body size in advance. func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Reader) error { - fullKey := c.objectKey(key) + log.Tag(tagS3Client).Debug("Uploading multipart object %s", key) // Step 1: Initiate multipart upload - uploadID, err := c.initiateMultipartUpload(ctx, fullKey) + uploadID, err := c.initiateMultipartUpload(ctx, key) if err != nil { return err } @@ -91,9 +91,9 @@ func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Rea for { n, err := io.ReadFull(body, buf) if n > 0 { - etag, uploadErr := c.uploadPart(ctx, fullKey, uploadID, partNumber, buf[:n]) + etag, uploadErr := c.uploadPart(ctx, key, uploadID, partNumber, buf[:n]) if uploadErr != nil { - c.abortMultipartUpload(ctx, fullKey, uploadID) + c.abortMultipartUpload(ctx, key, uploadID) return uploadErr } parts = append(parts, completedPart{PartNumber: partNumber, ETag: etag}) @@ -103,18 +103,18 @@ func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Rea break } if err != nil { - c.abortMultipartUpload(ctx, fullKey, uploadID) + c.abortMultipartUpload(ctx, key, uploadID) return fmt.Errorf("s3: PutObject read: %w", err) } } // Step 3: Complete multipart upload - return c.completeMultipartUpload(ctx, fullKey, uploadID, parts) + return c.completeMultipartUpload(ctx, key, uploadID, parts) } // initiateMultipartUpload starts a new multipart upload and returns the upload ID. -func (c *Client) initiateMultipartUpload(ctx context.Context, fullKey string) (string, error) { - respBody, err := c.do(ctx, http.MethodPost, c.objectURL(fullKey)+"?uploads", nil, "InitiateMultipartUpload") +func (c *Client) initiateMultipartUpload(ctx context.Context, key string) (string, error) { + respBody, err := c.do(ctx, http.MethodPost, c.objectURL(key)+"?uploads", nil, nil, "InitiateMultipartUpload") if err != nil { return "", err } @@ -122,14 +122,14 @@ func (c *Client) initiateMultipartUpload(ctx context.Context, fullKey string) (s if err := xml.Unmarshal(respBody, &result); err != nil { return "", fmt.Errorf("s3: InitiateMultipartUpload XML: %w", err) } - log.Tag(tagS3Client).Debug("InitiateMultipartUpload key=%s uploadId=%s", fullKey, result.UploadID) + log.Tag(tagS3Client).Debug("InitiateMultipartUpload key=%s uploadId=%s", key, result.UploadID) return result.UploadID, nil } // uploadPart uploads a single part of a multipart upload and returns the ETag. -func (c *Client) uploadPart(ctx context.Context, fullKey, uploadID string, partNumber int, data []byte) (string, error) { - log.Tag(tagS3Client).Debug("UploadPart key=%s part=%d size=%d", fullKey, partNumber, len(data)) - reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.objectURL(fullKey), partNumber, url.QueryEscape(uploadID)) +func (c *Client) uploadPart(ctx context.Context, key, uploadID string, partNumber int, data []byte) (string, error) { + log.Tag(tagS3Client).Debug("UploadPart key=%s part=%d size=%d", key, partNumber, len(data)) + reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.objectURL(key), partNumber, url.QueryEscape(uploadID)) req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data)) if err != nil { return "", fmt.Errorf("s3: UploadPart request: %w", err) @@ -149,17 +149,15 @@ func (c *Client) uploadPart(ctx context.Context, fullKey, uploadID string, partN } // completeMultipartUpload finalizes a multipart upload with the given parts. -func (c *Client) completeMultipartUpload(ctx context.Context, fullKey, uploadID string, parts []completedPart) error { - log.Tag(tagS3Client).Debug("CompleteMultipartUpload key=%s uploadId=%s parts=%d", fullKey, uploadID, len(parts)) - var body bytes.Buffer - body.WriteString("") - for _, p := range parts { - fmt.Fprintf(&body, "%d%s", p.PartNumber, p.ETag) +func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID string, parts []completedPart) error { + log.Tag(tagS3Client).Debug("CompleteMultipartUpload key=%s uploadId=%s parts=%d", key, uploadID, len(parts)) + bodyBytes, err := xml.Marshal(completeMultipartUploadRequest{Parts: parts}) + if err != nil { + return fmt.Errorf("s3: CompleteMultipartUpload marshal: %w", err) } - body.WriteString("") - respBody, err := c.doWithBody(ctx, http.MethodPost, - fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)), - body.Bytes(), "CompleteMultipartUpload") + respBody, err := c.do(ctx, http.MethodPost, + fmt.Sprintf("%s?uploadId=%s", c.objectURL(key), url.QueryEscape(uploadID)), + bodyBytes, nil, "CompleteMultipartUpload") if err != nil { return err } @@ -172,9 +170,9 @@ func (c *Client) completeMultipartUpload(ctx context.Context, fullKey, uploadID } // abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. -func (c *Client) abortMultipartUpload(ctx context.Context, fullKey, uploadID string) { - log.Tag(tagS3Client).Debug("AbortMultipartUpload key=%s uploadId=%s", fullKey, uploadID) - reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(fullKey), url.QueryEscape(uploadID)) +func (c *Client) abortMultipartUpload(ctx context.Context, key, uploadID string) { + log.Tag(tagS3Client).Debug("AbortMultipartUpload key=%s uploadId=%s", key, uploadID) + reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(key), url.QueryEscape(uploadID)) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) if err != nil { return diff --git a/s3/client_test.go b/s3/client_test.go index 447f36d0..2568bf28 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -374,13 +374,13 @@ func TestConfig_BucketURL_VirtualHosted(t *testing.T) { } func TestClient_ObjectURL_PathStyle(t *testing.T) { - c := &Client{config: &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", PathStyle: true}} - require.Equal(t, "https://s3.example.com/my-bucket/prefix/obj", c.objectURL("prefix/obj")) + c := &Client{config: &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", Prefix: "prefix", PathStyle: true}} + require.Equal(t, "https://s3.example.com/my-bucket/prefix/obj", c.objectURL("obj")) } func TestClient_ObjectURL_VirtualHosted(t *testing.T) { - c := &Client{config: &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", PathStyle: false}} - require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com/prefix/obj", c.objectURL("prefix/obj")) + c := &Client{config: &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", Prefix: "prefix", PathStyle: false}} + require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com/prefix/obj", c.objectURL("obj")) } func TestConfig_HostHeader_PathStyle(t *testing.T) { diff --git a/s3/types.go b/s3/types.go index 5fec7c78..23ccb15b 100644 --- a/s3/types.go +++ b/s3/types.go @@ -1,6 +1,7 @@ package s3 import ( + "encoding/xml" "fmt" "net/http" "time" @@ -76,6 +77,17 @@ type listObject struct { LastModified string `xml:"LastModified"` } +// deleteRequest is the XML request body for S3 DeleteObjects +type deleteRequest struct { + XMLName xml.Name `xml:"Delete"` + Quiet bool `xml:"Quiet"` + Objects []*deleteObject `xml:"Object"` +} + +type deleteObject struct { + Key string `xml:"Key"` +} + // deleteResult is the XML response from S3 DeleteObjects type deleteResult struct { Errors []deleteError `xml:"Error"` @@ -87,14 +99,14 @@ type deleteError struct { Message string `xml:"Message"` } -// MultipartUpload represents an in-progress multipart upload returned by ListMultipartUploads. +// MultipartUpload represents an in-progress multipart upload returned by listMultipartUploads. type MultipartUpload struct { Key string UploadID string Initiated time.Time } -// listMultipartUploadsResult is the XML response from S3 ListMultipartUploads +// listMultipartUploadsResult is the XML response from S3 listMultipartUploads type listMultipartUploadsResult struct { Uploads []listUpload `xml:"Upload"` IsTruncated bool `xml:"IsTruncated"` @@ -113,8 +125,14 @@ type initiateMultipartUploadResult struct { UploadID string `xml:"UploadId"` } +// completeMultipartUploadRequest is the XML request body for S3 CompleteMultipartUpload +type completeMultipartUploadRequest struct { + XMLName xml.Name `xml:"CompleteMultipartUpload"` + Parts []completedPart `xml:"Part"` +} + // completedPart represents a successfully uploaded part for CompleteMultipartUpload type completedPart struct { - PartNumber int - ETag string + PartNumber int `xml:"PartNumber"` + ETag string `xml:"ETag"` } From 02ea09ab0ff572ea759d090f7a7c33be7c84f7b5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Mar 2026 15:52:45 -0400 Subject: [PATCH 013/126] Refine, manual review, re-org --- attachment/backend_s3.go | 2 +- s3/client.go | 231 +++++++++++++++++---------------------- s3/client_auth.go | 2 + s3/client_multipart.go | 24 ++-- s3/client_test.go | 52 ++++----- s3/types.go | 78 +++++++++---- s3/util.go | 8 +- tools/s3cli/main.go | 2 +- 8 files changed, 206 insertions(+), 193 deletions(-) diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go index 6603bb91..eb911edc 100644 --- a/attachment/backend_s3.go +++ b/attachment/backend_s3.go @@ -33,7 +33,7 @@ func (b *s3Backend) Get(id string) (io.ReadCloser, int64, error) { } func (b *s3Backend) List() ([]object, error) { - objects, err := b.client.ListAllObjects(context.Background()) + objects, err := b.client.ListObjectsV2(context.Background()) if err != nil { return nil, err } diff --git a/s3/client.go b/s3/client.go index 65316fb2..754a1bfb 100644 --- a/s3/client.go +++ b/s3/client.go @@ -47,9 +47,10 @@ func New(config *Config) *Client { // PutObject uploads body to the given key. The key is automatically prefixed with the client's // configured prefix. The body size does not need to be known in advance. // -// If the entire body fits in a single part (5 MB), it is uploaded with a simple PUT request. -// Otherwise, the body is uploaded using S3 multipart upload, reading one part at a time -// into memory. +// If the entire body fits in a single part (5 MB), it is uploaded with a simple PUT request +// (https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html). Otherwise, the body +// is uploaded using S3 multipart upload, reading one part at a time into memory +// (https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html). func (c *Client) PutObject(ctx context.Context, key string, body io.Reader) error { first := make([]byte, partSize) n, err := io.ReadFull(body, first) @@ -61,11 +62,33 @@ func (c *Client) PutObject(ctx context.Context, key string, body io.Reader) erro return c.putObjectMultipart(ctx, key, io.MultiReader(bytes.NewReader(first), body)) } +// putObjectSimple uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. +func (c *Client) putObjectSimple(ctx context.Context, key string, body io.Reader, size int64) error { + log.Tag(tagS3Client).Debug("Uploading object %s (%d bytes)", key, size) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.config.ObjectURL(key), body) + if err != nil { + return fmt.Errorf("creating upload request object %s failed: %w", key, err) + } + req.ContentLength = size + c.signV4(req, unsignedPayload) + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("uploading object %s failed: %w", key, err) + } + resp.Body.Close() + if !isHTTPSuccess(resp) { + return parseError(resp) + } + return nil +} + // GetObject downloads an object. The key is automatically prefixed with the client's configured // prefix. The caller must close the returned ReadCloser. +// +// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int64, error) { - log.Tag(tagS3Client).Debug("Fetching object %s from backend", key) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.objectURL(key), nil) + log.Tag(tagS3Client).Debug("Fetching object %s", key) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.ObjectURL(key), nil) if err != nil { return nil, 0, fmt.Errorf("error creating HTTP GET request for %s: %w", key, err) } @@ -81,19 +104,88 @@ func (c *Client) GetObject(ctx context.Context, key string) (io.ReadCloser, int6 return resp.Body, resp.ContentLength, nil } +// ListObjectsV2 returns all objects under the client's configured prefix by paginating through +// ListObjectsV2 results automatically. Keys in the returned objects have the prefix stripped, +// so they match the keys used with PutObject/GetObject/DeleteObjects. It stops after 10,000 +// pages as a safety valve. +// +// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html +func (c *Client) ListObjectsV2(ctx context.Context) ([]*Object, error) { + var all []*Object + var token string + for page := 0; page < maxPages; page++ { + result, err := c.listObjectsV2(ctx, token, 0) + if err != nil { + return nil, err + } + for _, obj := range result.Objects { + obj.Key = c.config.StripPrefix(obj.Key) + all = append(all, obj) + } + if !result.IsTruncated { + return all, nil + } + token = result.NextContinuationToken + } + return nil, fmt.Errorf("listing objects exceeded %d pages", maxPages) +} + +// listObjectsV2 performs a single ListObjectsV2 request using the client's configured prefix. +// Use continuationToken for pagination. Set maxKeys to 0 for the server default (typically 1000). +func (c *Client) listObjectsV2(ctx context.Context, continuationToken string, maxKeys int) (*listObjectsV2Result, error) { + log.Tag(tagS3Client).Debug("Listing remote objects with continuation token '%s'", continuationToken) + query := url.Values{"list-type": {"2"}} + if prefix := c.config.ListPrefix(); prefix != "" { + query.Set("prefix", prefix) + } + if continuationToken != "" { + query.Set("continuation-token", continuationToken) + } + if maxKeys > 0 { + query.Set("max-keys", strconv.Itoa(maxKeys)) + } + respBody, err := c.do(ctx, "ListObjects", http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil) + if err != nil { + return nil, err + } + var result listObjectsV2Response + if err := xml.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal list object response: %w", err) + } + objects := make([]*Object, len(result.Contents)) + for i, obj := range result.Contents { + var lastModified time.Time + if obj.LastModified != "" { + lastModified, _ = time.Parse(time.RFC3339, obj.LastModified) + } + objects[i] = &Object{ + Key: obj.Key, + Size: obj.Size, + LastModified: lastModified, + } + } + return &listObjectsV2Result{ + Objects: objects, + IsTruncated: result.IsTruncated, + NextContinuationToken: result.NextContinuationToken, + }, nil +} + // DeleteObjects removes multiple objects in a single batch request. Keys are automatically // prefixed with the client's configured prefix. S3 supports up to 1000 keys per call; the // caller is responsible for batching if needed. // // Even when S3 returns HTTP 200, individual keys may fail. If any per-key errors are present // in the response, they are returned as a combined error. +// +// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { log.Tag(tagS3Client).Debug("Deleting %d object(s)", len(keys)) - req := &deleteRequest{ + req := &deleteObjectsRequest{ Quiet: true, } for _, key := range keys { - req.Objects = append(req.Objects, &deleteObject{Key: c.objectKey(key)}) + req.Objects = append(req.Objects, &deleteObject{Key: c.config.ObjectKey(key)}) } body, err := xml.Marshal(req) if err != nil { @@ -105,14 +197,14 @@ func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { headers := map[string]string{ "Content-MD5": base64.StdEncoding.EncodeToString(md5Sum[:]), } - reqURL := c.config.BucketURL() + "?delete=" - respBody, err := c.do(ctx, http.MethodPost, reqURL, body, headers, "DeleteObjects") + reqURL := c.config.BucketURL() + "?delete" + respBody, err := c.do(ctx, "DeleteObjects", http.MethodPost, reqURL, body, headers) if err != nil { return fmt.Errorf("error deleting objects: %w", err) } // S3 may return HTTP 200 with per-key errors in the response body - var result deleteResult + var result deleteObjectsResult if err := xml.Unmarshal(respBody, &result); err != nil { return nil // If we can't parse, assume success (Quiet mode returns empty body on success) } @@ -126,95 +218,10 @@ func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { return nil } -// listObjects performs a single ListObjectsV2 request using the client's configured prefix. -// Use continuationToken for pagination. Set maxKeys to 0 for the server default (typically 1000). -func (c *Client) listObjects(ctx context.Context, continuationToken string, maxKeys int) (*listResult, error) { - log.Tag(tagS3Client).Debug("ListObjects continuation=%s maxKeys=%d", continuationToken, maxKeys) - query := url.Values{"list-type": {"2"}} - if prefix := c.prefixForList(); prefix != "" { - query.Set("prefix", prefix) - } - if continuationToken != "" { - query.Set("continuation-token", continuationToken) - } - if maxKeys > 0 { - query.Set("max-keys", strconv.Itoa(maxKeys)) - } - respBody, err := c.do(ctx, http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil, "ListObjects") - if err != nil { - return nil, err - } - var result listObjectsV2Response - if err := xml.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("s3: ListObjects XML: %w", err) - } - objects := make([]Object, len(result.Contents)) - for i, obj := range result.Contents { - var lastModified time.Time - if obj.LastModified != "" { - lastModified, _ = time.Parse(time.RFC3339, obj.LastModified) - } - objects[i] = Object{ - Key: obj.Key, - Size: obj.Size, - LastModified: lastModified, - } - } - return &listResult{ - Objects: objects, - IsTruncated: result.IsTruncated, - NextContinuationToken: result.NextContinuationToken, - }, nil -} - -// ListAllObjects returns all objects under the client's configured prefix by paginating through -// ListObjectsV2 results automatically. Keys in the returned objects have the prefix stripped, -// so they match the keys used with PutObject/GetObject/DeleteObjects. It stops after 10,000 -// pages as a safety valve. -func (c *Client) ListAllObjects(ctx context.Context) ([]Object, error) { - var all []Object - var token string - for page := 0; page < maxPages; page++ { - result, err := c.listObjects(ctx, token, 0) - if err != nil { - return nil, err - } - for _, obj := range result.Objects { - obj.Key = c.stripPrefix(obj.Key) - all = append(all, obj) - } - if !result.IsTruncated { - return all, nil - } - token = result.NextContinuationToken - } - return nil, fmt.Errorf("s3: ListAllObjects exceeded %d pages", maxPages) -} - -// putObjectSimple uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. -func (c *Client) putObjectSimple(ctx context.Context, key string, body io.Reader, size int64) error { - log.Tag(tagS3Client).Debug("Uploading object %s (%d bytes)", key, size) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.objectURL(key), body) - if err != nil { - return fmt.Errorf("uploading object %s failed: %w", key, err) - } - req.ContentLength = size - c.signV4(req, unsignedPayload) - resp, err := c.http.Do(req) - if err != nil { - return fmt.Errorf("s3: PutObject: %w", err) - } - resp.Body.Close() - if !isHTTPSuccess(resp) { - return parseError(resp) - } - return nil -} - // do creates a signed request, executes it, reads the response body, and checks for errors. // If body is nil, the request is sent with an empty payload. If body is non-nil, it is sent // with a computed SHA-256 payload hash and Content-Type: application/xml. -func (c *Client) do(ctx context.Context, method, reqURL string, body []byte, headers map[string]string, op string) ([]byte, error) { +func (c *Client) do(ctx context.Context, op, method, reqURL string, body []byte, headers map[string]string) ([]byte, error) { var reader io.Reader var hash string if body != nil { @@ -251,35 +258,3 @@ func (c *Client) do(ctx context.Context, method, reqURL string, body []byte, hea } return respBody, nil } - -// prefixForList returns the prefix to use in ListObjectsV2 requests, -// with a trailing slash so that only objects under the prefix directory are returned. -func (c *Client) prefixForList() string { - if c.config.Prefix != "" { - return c.config.Prefix + "/" - } - return "" -} - -// stripPrefix removes the configured prefix from a key returned by ListObjectsV2, -// so keys match what was passed to PutObject/GetObject/DeleteObjects. -func (c *Client) stripPrefix(key string) string { - if c.config.Prefix != "" { - return strings.TrimPrefix(key, c.config.Prefix+"/") - } - return key -} - -// objectKey prepends the configured prefix to the given key. -func (c *Client) objectKey(key string) string { - if c.config.Prefix != "" { - return c.config.Prefix + "/" + key - } - return key -} - -// objectURL returns the full URL for an object, automatically prepending the configured prefix. -func (c *Client) objectURL(key string) string { - u, _ := url.JoinPath(c.config.BucketURL(), c.objectKey(key)) - return u -} diff --git a/s3/client_auth.go b/s3/client_auth.go index 8b7e6053..61aba73c 100644 --- a/s3/client_auth.go +++ b/s3/client_auth.go @@ -11,6 +11,8 @@ import ( // signV4 signs req in place using AWS Signature V4. payloadHash is the hex-encoded SHA-256 // of the request body, or the literal string "UNSIGNED-PAYLOAD" for streaming uploads. +// +// See https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html func (c *Client) signV4(req *http.Request, hash string) { now := time.Now().UTC() datestamp := now.Format("20060102") diff --git a/s3/client_multipart.go b/s3/client_multipart.go index d58a9337..f6b68784 100644 --- a/s3/client_multipart.go +++ b/s3/client_multipart.go @@ -16,6 +16,9 @@ import ( // AbortIncompleteUploads lists all in-progress multipart uploads and aborts those initiated // before the given cutoff time. This cleans up orphaned upload parts from interrupted uploads. +// +// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListMultipartUploads.html +// and https://docs.aws.amazon.com/AmazonS3/latest/API/API_AbortMultipartUpload.html func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) error { uploads, err := c.listMultipartUploads(ctx) if err != nil { @@ -32,19 +35,19 @@ func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) e // listMultipartUploads returns in-progress multipart uploads for the client's prefix. // It paginates automatically, stopping after 10,000 pages as a safety valve. -func (c *Client) listMultipartUploads(ctx context.Context) ([]MultipartUpload, error) { - var all []MultipartUpload +func (c *Client) listMultipartUploads(ctx context.Context) ([]*multipartUpload, error) { + var all []*multipartUpload var keyMarker, uploadIDMarker string for page := 0; page < maxPages; page++ { query := url.Values{"uploads": {""}} - if prefix := c.prefixForList(); prefix != "" { + if prefix := c.config.ListPrefix(); prefix != "" { query.Set("prefix", prefix) } if keyMarker != "" { query.Set("key-marker", keyMarker) query.Set("upload-id-marker", uploadIDMarker) } - respBody, err := c.do(ctx, http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil, "listMultipartUploads") + respBody, err := c.do(ctx, "listMultipartUploads", http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil) if err != nil { return nil, err } @@ -57,7 +60,7 @@ func (c *Client) listMultipartUploads(ctx context.Context) ([]MultipartUpload, e if u.Initiated != "" { initiated, _ = time.Parse(time.RFC3339, u.Initiated) } - all = append(all, MultipartUpload{ + all = append(all, &multipartUpload{ Key: u.Key, UploadID: u.UploadID, Initiated: initiated, @@ -114,7 +117,7 @@ func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Rea // initiateMultipartUpload starts a new multipart upload and returns the upload ID. func (c *Client) initiateMultipartUpload(ctx context.Context, key string) (string, error) { - respBody, err := c.do(ctx, http.MethodPost, c.objectURL(key)+"?uploads", nil, nil, "InitiateMultipartUpload") + respBody, err := c.do(ctx, "InitiateMultipartUpload", http.MethodPost, c.config.ObjectURL(key)+"?uploads", nil, nil) if err != nil { return "", err } @@ -129,7 +132,7 @@ func (c *Client) initiateMultipartUpload(ctx context.Context, key string) (strin // uploadPart uploads a single part of a multipart upload and returns the ETag. func (c *Client) uploadPart(ctx context.Context, key, uploadID string, partNumber int, data []byte) (string, error) { log.Tag(tagS3Client).Debug("UploadPart key=%s part=%d size=%d", key, partNumber, len(data)) - reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.objectURL(key), partNumber, url.QueryEscape(uploadID)) + reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.config.ObjectURL(key), partNumber, url.QueryEscape(uploadID)) req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data)) if err != nil { return "", fmt.Errorf("s3: UploadPart request: %w", err) @@ -155,9 +158,8 @@ func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID stri if err != nil { return fmt.Errorf("s3: CompleteMultipartUpload marshal: %w", err) } - respBody, err := c.do(ctx, http.MethodPost, - fmt.Sprintf("%s?uploadId=%s", c.objectURL(key), url.QueryEscape(uploadID)), - bodyBytes, nil, "CompleteMultipartUpload") + reqURL := fmt.Sprintf("%s?uploadId=%s", c.config.ObjectURL(key), url.QueryEscape(uploadID)) + respBody, err := c.do(ctx, "CompleteMultipartUpload", http.MethodPost, reqURL, bodyBytes, nil) if err != nil { return err } @@ -172,7 +174,7 @@ func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID stri // abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. func (c *Client) abortMultipartUpload(ctx context.Context, key, uploadID string) { log.Tag(tagS3Client).Debug("AbortMultipartUpload key=%s uploadId=%s", key, uploadID) - reqURL := fmt.Sprintf("%s?uploadId=%s", c.objectURL(key), url.QueryEscape(uploadID)) + reqURL := fmt.Sprintf("%s?uploadId=%s", c.config.ObjectURL(key), url.QueryEscape(uploadID)) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) if err != nil { return diff --git a/s3/client_test.go b/s3/client_test.go index 2568bf28..d488c832 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -373,14 +373,14 @@ func TestConfig_BucketURL_VirtualHosted(t *testing.T) { require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com", c.BucketURL()) } -func TestClient_ObjectURL_PathStyle(t *testing.T) { - c := &Client{config: &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", Prefix: "prefix", PathStyle: true}} - require.Equal(t, "https://s3.example.com/my-bucket/prefix/obj", c.objectURL("obj")) +func TestConfig_ObjectURL_PathStyle(t *testing.T) { + c := &Config{Endpoint: "s3.example.com", Bucket: "my-bucket", Prefix: "prefix", PathStyle: true} + require.Equal(t, "https://s3.example.com/my-bucket/prefix/obj", c.ObjectURL("obj")) } -func TestClient_ObjectURL_VirtualHosted(t *testing.T) { - c := &Client{config: &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", Prefix: "prefix", PathStyle: false}} - require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com/prefix/obj", c.objectURL("obj")) +func TestConfig_ObjectURL_VirtualHosted(t *testing.T) { + c := &Config{Endpoint: "s3.us-east-1.amazonaws.com", Bucket: "my-bucket", Prefix: "prefix", PathStyle: false} + require.Equal(t, "https://my-bucket.s3.us-east-1.amazonaws.com/prefix/obj", c.ObjectURL("obj")) } func TestConfig_HostHeader_PathStyle(t *testing.T) { @@ -393,20 +393,20 @@ func TestConfig_HostHeader_VirtualHosted(t *testing.T) { require.Equal(t, "my-bucket.s3.us-east-1.amazonaws.com", c.HostHeader()) } -func TestClient_ObjectKey(t *testing.T) { - c := &Client{config: &Config{Prefix: "attachments"}} - require.Equal(t, "attachments/file123", c.objectKey("file123")) +func TestConfig_ObjectKey(t *testing.T) { + c := &Config{Prefix: "attachments"} + require.Equal(t, "attachments/file123", c.ObjectKey("file123")) - c2 := &Client{config: &Config{Prefix: ""}} - require.Equal(t, "file123", c2.objectKey("file123")) + c2 := &Config{Prefix: ""} + require.Equal(t, "file123", c2.ObjectKey("file123")) } -func TestClient_PrefixForList(t *testing.T) { - c := &Client{config: &Config{Prefix: "attachments"}} - require.Equal(t, "attachments/", c.prefixForList()) +func TestConfig_ListPrefix(t *testing.T) { + c := &Config{Prefix: "attachments"} + require.Equal(t, "attachments/", c.ListPrefix()) - c2 := &Client{config: &Config{Prefix: ""}} - require.Equal(t, "", c2.prefixForList()) + c2 := &Config{Prefix: ""} + require.Equal(t, "", c2.ListPrefix()) } // --- Integration tests using mock S3 server --- @@ -512,13 +512,13 @@ func TestClient_ListObjects(t *testing.T) { require.Nil(t, err) // List with prefix client: should only see 3 - result, err := client.listObjects(ctx, "", 0) + result, err := client.listObjectsV2(ctx, "", 0) require.Nil(t, err) require.Len(t, result.Objects, 3) require.False(t, result.IsTruncated) // List with no-prefix client: should see all 4 - result, err = clientNoPrefix.listObjects(ctx, "", 0) + result, err = clientNoPrefix.listObjectsV2(ctx, "", 0) require.Nil(t, err) require.Len(t, result.Objects, 4) } @@ -537,20 +537,20 @@ func TestClient_ListObjects_Pagination(t *testing.T) { } // List with max-keys=2 - result, err := client.listObjects(ctx, "", 2) + result, err := client.listObjectsV2(ctx, "", 2) require.Nil(t, err) require.Len(t, result.Objects, 2) require.True(t, result.IsTruncated) require.NotEmpty(t, result.NextContinuationToken) // Get next page - result2, err := client.listObjects(ctx, result.NextContinuationToken, 2) + result2, err := client.listObjectsV2(ctx, result.NextContinuationToken, 2) require.Nil(t, err) require.Len(t, result2.Objects, 2) require.True(t, result2.IsTruncated) // Get last page - result3, err := client.listObjects(ctx, result2.NextContinuationToken, 2) + result3, err := client.listObjectsV2(ctx, result2.NextContinuationToken, 2) require.Nil(t, err) require.Len(t, result3.Objects, 1) require.False(t, result3.IsTruncated) @@ -568,7 +568,7 @@ func TestClient_ListAllObjects(t *testing.T) { require.Nil(t, err) } - objects, err := client.ListAllObjects(ctx) + objects, err := client.ListObjectsV2(ctx) require.Nil(t, err) require.Len(t, objects, 10) } @@ -688,7 +688,7 @@ func TestClient_ListAllObjects_20k(t *testing.T) { } // List all 20k objects with pagination - objects, err := client.ListAllObjects(ctx) + objects, err := client.ListObjectsV2(ctx) require.Nil(t, err) require.Len(t, objects, numObjects) @@ -708,7 +708,7 @@ func TestClient_ListAllObjects_20k(t *testing.T) { require.Nil(t, err) // List again: should have 19000 - objects, err = client.ListAllObjects(ctx) + objects, err = client.ListObjectsV2(ctx) require.Nil(t, err) require.Len(t, objects, numObjects-1000) } @@ -757,7 +757,7 @@ func TestClient_RealBucket(t *testing.T) { ctx := context.Background() // Clean up any leftover objects from previous runs - existing, err := client.ListAllObjects(ctx) + existing, err := client.ListObjectsV2(ctx) require.Nil(t, err) if len(existing) > 0 { keys := make([]string, len(existing)) @@ -823,7 +823,7 @@ func TestClient_RealBucket(t *testing.T) { } // List - objects, err := listClient.ListAllObjects(ctx) + objects, err := listClient.ListObjectsV2(ctx) require.Nil(t, err) require.Len(t, objects, 10) diff --git a/s3/types.go b/s3/types.go index 23ccb15b..a3694bd4 100644 --- a/s3/types.go +++ b/s3/types.go @@ -4,6 +4,8 @@ import ( "encoding/xml" "fmt" "net/http" + "net/url" + "strings" "time" ) @@ -19,7 +21,7 @@ type Config struct { HTTPClient *http.Client // if nil, http.DefaultClient is used } -// bucketURL returns the base URL for bucket-level operations. +// BucketURL returns the base URL for bucket-level operations. func (c *Config) BucketURL() string { if c.PathStyle { return fmt.Sprintf("https://%s/%s", c.Endpoint, c.Bucket) @@ -27,7 +29,7 @@ func (c *Config) BucketURL() string { return fmt.Sprintf("https://%s.%s", c.Bucket, c.Endpoint) } -// hostHeader returns the value for the Host header. +// HostHeader returns the value for the Host header. func (c *Config) HostHeader() string { if c.PathStyle { return c.Endpoint @@ -35,6 +37,38 @@ func (c *Config) HostHeader() string { return c.Bucket + "." + c.Endpoint } +// ListPrefix returns the prefix to use in ListObjectsV2 requests, +// with a trailing slash so that only objects under the prefix directory are returned. +func (c *Config) ListPrefix() string { + if c.Prefix != "" { + return c.Prefix + "/" + } + return "" +} + +// StripPrefix removes the configured prefix from a key returned by ListObjectsV2, +// so keys match what was passed to PutObject/GetObject/DeleteObjects. +func (c *Config) StripPrefix(key string) string { + if c.Prefix != "" { + return strings.TrimPrefix(key, c.Prefix+"/") + } + return key +} + +// ObjectKey prepends the configured prefix to the given key. +func (c *Config) ObjectKey(key string) string { + if c.Prefix != "" { + return c.Prefix + "/" + key + } + return key +} + +// ObjectURL returns the full URL for an object, automatically prepending the configured prefix. +func (c *Config) ObjectURL(key string) string { + u, _ := url.JoinPath(c.BucketURL(), c.ObjectKey(key)) + return u +} + // Object represents an S3 object returned by list operations. type Object struct { Key string @@ -42,13 +76,6 @@ type Object struct { LastModified time.Time } -// listResult holds the response from a single ListObjectsV2 page. -type listResult struct { - Objects []Object - IsTruncated bool - NextContinuationToken string -} - // ErrorResponse is returned when S3 responds with a non-2xx status code. type ErrorResponse struct { StatusCode int @@ -66,9 +93,16 @@ func (e *ErrorResponse) Error() string { // listObjectsV2Response is the XML response from S3 ListObjectsV2 type listObjectsV2Response struct { - Contents []listObject `xml:"Contents"` - IsTruncated bool `xml:"IsTruncated"` - NextContinuationToken string `xml:"NextContinuationToken"` + Contents []*listObject `xml:"Contents"` + IsTruncated bool `xml:"IsTruncated"` + NextContinuationToken string `xml:"NextContinuationToken"` +} + +// listObjectsV2Result holds the response from a single ListObjectsV2 page. +type listObjectsV2Result struct { + Objects []*Object + IsTruncated bool + NextContinuationToken string } type listObject struct { @@ -77,8 +111,8 @@ type listObject struct { LastModified string `xml:"LastModified"` } -// deleteRequest is the XML request body for S3 DeleteObjects -type deleteRequest struct { +// deleteObjectsRequest is the XML request body for S3 DeleteObjects +type deleteObjectsRequest struct { XMLName xml.Name `xml:"Delete"` Quiet bool `xml:"Quiet"` Objects []*deleteObject `xml:"Object"` @@ -88,8 +122,8 @@ type deleteObject struct { Key string `xml:"Key"` } -// deleteResult is the XML response from S3 DeleteObjects -type deleteResult struct { +// deleteObjectsResult is the XML response from S3 DeleteObjects +type deleteObjectsResult struct { Errors []deleteError `xml:"Error"` } @@ -99,8 +133,8 @@ type deleteError struct { Message string `xml:"Message"` } -// MultipartUpload represents an in-progress multipart upload returned by listMultipartUploads. -type MultipartUpload struct { +// multipartUpload represents an in-progress multipart upload returned by listMultipartUploads. +type multipartUpload struct { Key string UploadID string Initiated time.Time @@ -108,10 +142,10 @@ type MultipartUpload struct { // listMultipartUploadsResult is the XML response from S3 listMultipartUploads type listMultipartUploadsResult struct { - Uploads []listUpload `xml:"Upload"` - IsTruncated bool `xml:"IsTruncated"` - NextKeyMarker string `xml:"NextKeyMarker"` - NextUploadIDMarker string `xml:"NextUploadIdMarker"` + Uploads []*listUpload `xml:"Upload"` + IsTruncated bool `xml:"IsTruncated"` + NextKeyMarker string `xml:"NextKeyMarker"` + NextUploadIDMarker string `xml:"NextUploadIdMarker"` } type listUpload struct { diff --git a/s3/util.go b/s3/util.go index 546a940a..06f7e3d1 100644 --- a/s3/util.go +++ b/s3/util.go @@ -20,8 +20,8 @@ const ( // Sent as the payload hash for streaming uploads where the body is not buffered in memory unsignedPayload = "UNSIGNED-PAYLOAD" - // maxResponseBytes caps the size of S3 response bodies we read into memory (10 MB) - maxResponseBytes = 10 * 1024 * 1024 + // maxResponseBytes caps the size of S3 response bodies we read into memory + maxResponseBytes = 2 * 1024 * 1024 // partSize is the size of each part for multipart uploads (5 MB). This is also the threshold // above which PutObject switches from a simple PUT to multipart upload. S3 requires a minimum @@ -29,7 +29,7 @@ const ( partSize = 5 * 1024 * 1024 // maxPages is the max number of pages to iterate through when listing objects - maxPages = 10000 + maxPages = 500 ) // ParseURL parses an S3 URL of the form: @@ -88,7 +88,7 @@ func ParseURL(s3URL string) (*Config, error) { func parseError(resp *http.Response) error { body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { - return fmt.Errorf("s3: reading error response: %w", err) + return fmt.Errorf("error reading S3 error response: %w", err) } return parseErrorFromBytes(resp.StatusCode, body) } diff --git a/tools/s3cli/main.go b/tools/s3cli/main.go index 1dbac0cf..0e640823 100644 --- a/tools/s3cli/main.go +++ b/tools/s3cli/main.go @@ -105,7 +105,7 @@ func cmdRm(ctx context.Context, client *s3.Client) { } func cmdLs(ctx context.Context, client *s3.Client) { - objects, err := client.ListAllObjects(ctx) + objects, err := client.ListObjectsV2(ctx) if err != nil { fail("ls: %s", err) } From 393f730d11f78d7b2920f707d215b3e5a68be3fe Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Mar 2026 16:12:46 -0400 Subject: [PATCH 014/126] More manual review and refinement --- s3/client.go | 34 +++++++++------------- s3/client_multipart.go | 64 +++++++++++++++++++++--------------------- s3/client_test.go | 10 +++---- s3/types.go | 31 ++++++++------------ 4 files changed, 62 insertions(+), 77 deletions(-) diff --git a/s3/client.go b/s3/client.go index 754a1bfb..83d8195e 100644 --- a/s3/client.go +++ b/s3/client.go @@ -118,9 +118,16 @@ func (c *Client) ListObjectsV2(ctx context.Context) ([]*Object, error) { if err != nil { return nil, err } - for _, obj := range result.Objects { - obj.Key = c.config.StripPrefix(obj.Key) - all = append(all, obj) + for _, obj := range result.Contents { + var lastModified time.Time + if obj.LastModified != "" { + lastModified, _ = time.Parse(time.RFC3339, obj.LastModified) + } + all = append(all, &Object{ + Key: c.config.StripPrefix(obj.Key), + Size: obj.Size, + LastModified: lastModified, + }) } if !result.IsTruncated { return all, nil @@ -148,27 +155,11 @@ func (c *Client) listObjectsV2(ctx context.Context, continuationToken string, ma if err != nil { return nil, err } - var result listObjectsV2Response + var result listObjectsV2Result if err := xml.Unmarshal(respBody, &result); err != nil { return nil, fmt.Errorf("failed to unmarshal list object response: %w", err) } - objects := make([]*Object, len(result.Contents)) - for i, obj := range result.Contents { - var lastModified time.Time - if obj.LastModified != "" { - lastModified, _ = time.Parse(time.RFC3339, obj.LastModified) - } - objects[i] = &Object{ - Key: obj.Key, - Size: obj.Size, - LastModified: lastModified, - } - } - return &listObjectsV2Result{ - Objects: objects, - IsTruncated: result.IsTruncated, - NextContinuationToken: result.NextContinuationToken, - }, nil + return &result, nil } // DeleteObjects removes multiple objects in a single batch request. Keys are automatically @@ -222,6 +213,7 @@ func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { // If body is nil, the request is sent with an empty payload. If body is non-nil, it is sent // with a computed SHA-256 payload hash and Content-Type: application/xml. func (c *Client) do(ctx context.Context, op, method, reqURL string, body []byte, headers map[string]string) ([]byte, error) { + log.Tag(tagS3Client).Trace("Performing request %s %s %s (body: %d bytes)", op, method, reqURL, len(body)) var reader io.Reader var hash string if body != nil { diff --git a/s3/client_multipart.go b/s3/client_multipart.go index f6b68784..5e98db38 100644 --- a/s3/client_multipart.go +++ b/s3/client_multipart.go @@ -26,7 +26,6 @@ func (c *Client) AbortIncompleteUploads(ctx context.Context, cutoff time.Time) e } for _, u := range uploads { if !u.Initiated.IsZero() && u.Initiated.Before(cutoff) { - log.Tag(tagS3Client).Debug("DeleteIncomplete key=%s uploadId=%s initiated=%s", u.Key, u.UploadID, u.Initiated) c.abortMultipartUpload(ctx, u.Key, u.UploadID) } } @@ -47,13 +46,13 @@ func (c *Client) listMultipartUploads(ctx context.Context) ([]*multipartUpload, query.Set("key-marker", keyMarker) query.Set("upload-id-marker", uploadIDMarker) } - respBody, err := c.do(ctx, "listMultipartUploads", http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil) + respBody, err := c.do(ctx, "ListMultipartUploads", http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil) if err != nil { return nil, err } var result listMultipartUploadsResult if err := xml.Unmarshal(respBody, &result); err != nil { - return nil, fmt.Errorf("s3: listMultipartUploads XML: %w", err) + return nil, fmt.Errorf("error unmarshalling multipart upload result: %w", err) } for _, u := range result.Uploads { var initiated time.Time @@ -75,6 +74,22 @@ func (c *Client) listMultipartUploads(ctx context.Context) ([]*multipartUpload, return nil, fmt.Errorf("s3: listMultipartUploads exceeded %d pages", maxPages) } +// abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. +func (c *Client) abortMultipartUpload(ctx context.Context, key, uploadID string) { + log.Tag(tagS3Client).Info("Aborting multipart upload for object %s", key) + reqURL := fmt.Sprintf("%s?uploadId=%s", c.config.ObjectURL(key), url.QueryEscape(uploadID)) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) + if err != nil { + return + } + c.signV4(req, emptyPayloadHash) + resp, err := c.http.Do(req) + if err != nil { + return + } + resp.Body.Close() +} + // putObjectMultipart uploads body using S3 multipart upload. It reads the body in partSize // chunks, uploading each as a separate part. This allows uploading without knowing the total // body size in advance. @@ -88,9 +103,9 @@ func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Rea } // Step 2: Upload parts - var parts []completedPart - buf := make([]byte, partSize) partNumber := 1 + buf := make([]byte, partSize) + var parts []*completedPart for { n, err := io.ReadFull(body, buf) if n > 0 { @@ -99,7 +114,10 @@ func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Rea c.abortMultipartUpload(ctx, key, uploadID) return uploadErr } - parts = append(parts, completedPart{PartNumber: partNumber, ETag: etag}) + parts = append(parts, &completedPart{ + PartNumber: partNumber, + ETag: etag, + }) partNumber++ } if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) { @@ -123,38 +141,36 @@ func (c *Client) initiateMultipartUpload(ctx context.Context, key string) (strin } var result initiateMultipartUploadResult if err := xml.Unmarshal(respBody, &result); err != nil { - return "", fmt.Errorf("s3: InitiateMultipartUpload XML: %w", err) + return "", fmt.Errorf("error unmarshalling initiate multipart upload response: %w", err) } - log.Tag(tagS3Client).Debug("InitiateMultipartUpload key=%s uploadId=%s", key, result.UploadID) return result.UploadID, nil } // uploadPart uploads a single part of a multipart upload and returns the ETag. func (c *Client) uploadPart(ctx context.Context, key, uploadID string, partNumber int, data []byte) (string, error) { - log.Tag(tagS3Client).Debug("UploadPart key=%s part=%d size=%d", key, partNumber, len(data)) + log.Tag(tagS3Client).Debug("Uploading multipart part for object %s, part %d, size %d", key, partNumber, len(data)) reqURL := fmt.Sprintf("%s?partNumber=%d&uploadId=%s", c.config.ObjectURL(key), partNumber, url.QueryEscape(uploadID)) req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(data)) if err != nil { - return "", fmt.Errorf("s3: UploadPart request: %w", err) + return "", fmt.Errorf("error creating multipart upload part request for object %s: %w", key, err) } req.ContentLength = int64(len(data)) c.signV4(req, unsignedPayload) resp, err := c.http.Do(req) if err != nil { - return "", fmt.Errorf("s3: UploadPart: %w", err) + return "", fmt.Errorf("error uploading multipart part for object %s: %w", key, err) } defer resp.Body.Close() if !isHTTPSuccess(resp) { return "", parseError(resp) } - etag := resp.Header.Get("ETag") - return etag, nil + return resp.Header.Get("ETag"), nil } // completeMultipartUpload finalizes a multipart upload with the given parts. -func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID string, parts []completedPart) error { - log.Tag(tagS3Client).Debug("CompleteMultipartUpload key=%s uploadId=%s parts=%d", key, uploadID, len(parts)) - bodyBytes, err := xml.Marshal(completeMultipartUploadRequest{Parts: parts}) +func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID string, parts []*completedPart) error { + log.Tag(tagS3Client).Debug("Completing multipart upload for object %s, %d parts", key, len(parts)) + bodyBytes, err := xml.Marshal(&completeMultipartUploadRequest{Parts: parts}) if err != nil { return fmt.Errorf("s3: CompleteMultipartUpload marshal: %w", err) } @@ -170,19 +186,3 @@ func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID stri } return nil } - -// abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. -func (c *Client) abortMultipartUpload(ctx context.Context, key, uploadID string) { - log.Tag(tagS3Client).Debug("AbortMultipartUpload key=%s uploadId=%s", key, uploadID) - reqURL := fmt.Sprintf("%s?uploadId=%s", c.config.ObjectURL(key), url.QueryEscape(uploadID)) - req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil) - if err != nil { - return - } - c.signV4(req, emptyPayloadHash) - resp, err := c.http.Do(req) - if err != nil { - return - } - resp.Body.Close() -} diff --git a/s3/client_test.go b/s3/client_test.go index d488c832..d267a6a8 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -514,13 +514,13 @@ func TestClient_ListObjects(t *testing.T) { // List with prefix client: should only see 3 result, err := client.listObjectsV2(ctx, "", 0) require.Nil(t, err) - require.Len(t, result.Objects, 3) + require.Len(t, result.Contents, 3) require.False(t, result.IsTruncated) // List with no-prefix client: should see all 4 result, err = clientNoPrefix.listObjectsV2(ctx, "", 0) require.Nil(t, err) - require.Len(t, result.Objects, 4) + require.Len(t, result.Contents, 4) } func TestClient_ListObjects_Pagination(t *testing.T) { @@ -539,20 +539,20 @@ func TestClient_ListObjects_Pagination(t *testing.T) { // List with max-keys=2 result, err := client.listObjectsV2(ctx, "", 2) require.Nil(t, err) - require.Len(t, result.Objects, 2) + require.Len(t, result.Contents, 2) require.True(t, result.IsTruncated) require.NotEmpty(t, result.NextContinuationToken) // Get next page result2, err := client.listObjectsV2(ctx, result.NextContinuationToken, 2) require.Nil(t, err) - require.Len(t, result2.Objects, 2) + require.Len(t, result2.Contents, 2) require.True(t, result2.IsTruncated) // Get last page result3, err := client.listObjectsV2(ctx, result2.NextContinuationToken, 2) require.Nil(t, err) - require.Len(t, result3.Objects, 1) + require.Len(t, result3.Contents, 1) require.False(t, result3.IsTruncated) } diff --git a/s3/types.go b/s3/types.go index a3694bd4..1782b88d 100644 --- a/s3/types.go +++ b/s3/types.go @@ -91,20 +91,13 @@ func (e *ErrorResponse) Error() string { return fmt.Sprintf("s3: HTTP %d: %s", e.StatusCode, e.Body) } -// listObjectsV2Response is the XML response from S3 ListObjectsV2 -type listObjectsV2Response struct { +// listObjectsV2Result is the XML response from S3 ListObjectsV2 +type listObjectsV2Result struct { Contents []*listObject `xml:"Contents"` IsTruncated bool `xml:"IsTruncated"` NextContinuationToken string `xml:"NextContinuationToken"` } -// listObjectsV2Result holds the response from a single ListObjectsV2 page. -type listObjectsV2Result struct { - Objects []*Object - IsTruncated bool - NextContinuationToken string -} - type listObject struct { Key string `xml:"Key"` Size int64 `xml:"Size"` @@ -124,7 +117,7 @@ type deleteObject struct { // deleteObjectsResult is the XML response from S3 DeleteObjects type deleteObjectsResult struct { - Errors []deleteError `xml:"Error"` + Errors []*deleteError `xml:"Error"` } type deleteError struct { @@ -133,13 +126,6 @@ type deleteError struct { Message string `xml:"Message"` } -// multipartUpload represents an in-progress multipart upload returned by listMultipartUploads. -type multipartUpload struct { - Key string - UploadID string - Initiated time.Time -} - // listMultipartUploadsResult is the XML response from S3 listMultipartUploads type listMultipartUploadsResult struct { Uploads []*listUpload `xml:"Upload"` @@ -154,6 +140,13 @@ type listUpload struct { Initiated string `xml:"Initiated"` } +// multipartUpload represents an in-progress multipart upload returned by listMultipartUploads. +type multipartUpload struct { + Key string + UploadID string + Initiated time.Time +} + // initiateMultipartUploadResult is the XML response from S3 InitiateMultipartUpload type initiateMultipartUploadResult struct { UploadID string `xml:"UploadId"` @@ -161,8 +154,8 @@ type initiateMultipartUploadResult struct { // completeMultipartUploadRequest is the XML request body for S3 CompleteMultipartUpload type completeMultipartUploadRequest struct { - XMLName xml.Name `xml:"CompleteMultipartUpload"` - Parts []completedPart `xml:"Part"` + XMLName xml.Name `xml:"CompleteMultipartUpload"` + Parts []*completedPart `xml:"Part"` } // completedPart represents a successfully uploaded part for CompleteMultipartUpload From 1742302f83e1a0b3963924bc354d75ace228793e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Mar 2026 16:27:41 -0400 Subject: [PATCH 015/126] More tests and human review --- attachment/store.go | 2 + s3/client_multipart.go | 11 +++-- s3/client_test.go | 4 +- s3/types.go | 6 +-- s3/util.go | 4 +- s3/util_test.go | 4 +- util/limit_test.go | 100 ++++++++++++++++++++++++++++++++++++++++- 7 files changed, 115 insertions(+), 16 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index 0192b09a..10dcd17b 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -110,9 +110,11 @@ func (c *Store) Remove(ids ...string) error { return errInvalidFileID } } + // Remove from backend if err := c.backend.Delete(ids...); err != nil { return err } + // Update total cache size c.mu.Lock() for _, id := range ids { if size, ok := c.sizes[id]; ok { diff --git a/s3/client_multipart.go b/s3/client_multipart.go index 5e98db38..198175d4 100644 --- a/s3/client_multipart.go +++ b/s3/client_multipart.go @@ -71,7 +71,7 @@ func (c *Client) listMultipartUploads(ctx context.Context) ([]*multipartUpload, keyMarker = result.NextKeyMarker uploadIDMarker = result.NextUploadIDMarker } - return nil, fmt.Errorf("s3: listMultipartUploads exceeded %d pages", maxPages) + return nil, fmt.Errorf("error listing multipart uploads, exceeded %d pages", maxPages) } // abortMultipartUpload cancels an in-progress multipart upload. Called on error to clean up. @@ -122,10 +122,9 @@ func (c *Client) putObjectMultipart(ctx context.Context, key string, body io.Rea } if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) { break - } - if err != nil { + } else if err != nil { c.abortMultipartUpload(ctx, key, uploadID) - return fmt.Errorf("s3: PutObject read: %w", err) + return fmt.Errorf("error uploading object %s, reading from client failed: %w", key, err) } } @@ -172,7 +171,7 @@ func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID stri log.Tag(tagS3Client).Debug("Completing multipart upload for object %s, %d parts", key, len(parts)) bodyBytes, err := xml.Marshal(&completeMultipartUploadRequest{Parts: parts}) if err != nil { - return fmt.Errorf("s3: CompleteMultipartUpload marshal: %w", err) + return fmt.Errorf("error marshalling complete multipart upload request: %w", err) } reqURL := fmt.Sprintf("%s?uploadId=%s", c.config.ObjectURL(key), url.QueryEscape(uploadID)) respBody, err := c.do(ctx, "CompleteMultipartUpload", http.MethodPost, reqURL, bodyBytes, nil) @@ -180,7 +179,7 @@ func (c *Client) completeMultipartUpload(ctx context.Context, key, uploadID stri return err } // Check if the response contains an error (S3 can return 200 with an error body) - var errResp ErrorResponse + var errResp errorResponse if xml.Unmarshal(respBody, &errResp) == nil && errResp.Code != "" { return &errResp } diff --git a/s3/client_test.go b/s3/client_test.go index d267a6a8..84402831 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -456,7 +456,7 @@ func TestClient_GetObject_NotFound(t *testing.T) { _, _, err := client.GetObject(context.Background(), "nonexistent") require.Error(t, err) - var errResp *ErrorResponse + var errResp *errorResponse require.ErrorAs(t, err, &errResp) require.Equal(t, 404, errResp.StatusCode) require.Equal(t, "NoSuchKey", errResp.Code) @@ -799,7 +799,7 @@ func TestClient_RealBucket(t *testing.T) { // Get after delete should fail _, _, err = client.GetObject(ctx, key) require.Error(t, err) - var errResp *ErrorResponse + var errResp *errorResponse require.ErrorAs(t, err, &errResp) require.Equal(t, 404, errResp.StatusCode) }) diff --git a/s3/types.go b/s3/types.go index 1782b88d..96b62649 100644 --- a/s3/types.go +++ b/s3/types.go @@ -76,15 +76,15 @@ type Object struct { LastModified time.Time } -// ErrorResponse is returned when S3 responds with a non-2xx status code. -type ErrorResponse struct { +// errorResponse is returned when S3 responds with a non-2xx status code. +type errorResponse struct { StatusCode int Code string `xml:"Code"` Message string `xml:"Message"` Body string `xml:"-"` // raw response body } -func (e *ErrorResponse) Error() string { +func (e *errorResponse) Error() string { if e.Code != "" { return fmt.Sprintf("s3: %s (HTTP %d): %s", e.Code, e.StatusCode, e.Message) } diff --git a/s3/util.go b/s3/util.go index 06f7e3d1..0bcc96d2 100644 --- a/s3/util.go +++ b/s3/util.go @@ -84,7 +84,7 @@ func ParseURL(s3URL string) (*Config, error) { }, nil } -// parseError reads an S3 error response and returns an *ErrorResponse. +// parseError reads an S3 error response and returns an *errorResponse. func parseError(resp *http.Response) error { body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { @@ -94,7 +94,7 @@ func parseError(resp *http.Response) error { } func parseErrorFromBytes(statusCode int, body []byte) error { - errResp := &ErrorResponse{ + errResp := &errorResponse{ StatusCode: statusCode, Body: string(body), } diff --git a/s3/util_test.go b/s3/util_test.go index 3f08911d..93ddd707 100644 --- a/s3/util_test.go +++ b/s3/util_test.go @@ -163,7 +163,7 @@ func TestParseError_XMLResponse(t *testing.T) { xmlBody := []byte(`NoSuchKeyThe specified key does not exist.`) err := parseErrorFromBytes(404, xmlBody) - var errResp *ErrorResponse + var errResp *errorResponse require.ErrorAs(t, err, &errResp) require.Equal(t, 404, errResp.StatusCode) require.Equal(t, "NoSuchKey", errResp.Code) @@ -173,7 +173,7 @@ func TestParseError_XMLResponse(t *testing.T) { func TestParseError_NonXMLResponse(t *testing.T) { err := parseErrorFromBytes(500, []byte("internal server error")) - var errResp *ErrorResponse + var errResp *errorResponse require.ErrorAs(t, err, &errResp) require.Equal(t, 500, errResp.StatusCode) require.Equal(t, "", errResp.Code) // XML parsing failed, no code diff --git a/util/limit_test.go b/util/limit_test.go index 51595351..9ca9fe39 100644 --- a/util/limit_test.go +++ b/util/limit_test.go @@ -2,9 +2,12 @@ package util import ( "bytes" - "github.com/stretchr/testify/require" + "io" + "strings" "testing" "time" + + "github.com/stretchr/testify/require" ) func TestFixedLimiter_AllowValueReset(t *testing.T) { @@ -147,3 +150,98 @@ func TestLimitWriter_WriteTwoDifferentLimiters_Wait_FixedLimiterFail(t *testing. _, err = lw.Write(make([]byte, 8)) // <<< FixedLimiter fails require.Equal(t, ErrLimitReached, err) } + +func TestCountingReader_Total(t *testing.T) { + cr := NewCountingReader(strings.NewReader("hello world")) + buf := make([]byte, 5) + + n, err := cr.Read(buf) + require.Nil(t, err) + require.Equal(t, 5, n) + require.Equal(t, int64(5), cr.Total()) + + n, err = cr.Read(buf) + require.Nil(t, err) + require.Equal(t, 5, n) + require.Equal(t, int64(10), cr.Total()) + + n, err = cr.Read(buf) + require.Nil(t, err) + require.Equal(t, 1, n) + require.Equal(t, int64(11), cr.Total()) + + _, err = cr.Read(buf) + require.Equal(t, io.EOF, err) + require.Equal(t, int64(11), cr.Total()) +} + +func TestCountingReader_Empty(t *testing.T) { + cr := NewCountingReader(strings.NewReader("")) + require.Equal(t, int64(0), cr.Total()) + + _, err := cr.Read(make([]byte, 10)) + require.Equal(t, io.EOF, err) + require.Equal(t, int64(0), cr.Total()) +} + +func TestLimitReader_ReadNoLimiter(t *testing.T) { + lr := NewLimitReader(strings.NewReader("hello")) + data, err := io.ReadAll(lr) + require.Nil(t, err) + require.Equal(t, "hello", string(data)) +} + +func TestLimitReader_ReadOneLimiter(t *testing.T) { + l := NewFixedLimiter(10) + lr := NewLimitReader(strings.NewReader("hello world!"), l) + + buf := make([]byte, 5) + n, err := lr.Read(buf) + require.Nil(t, err) + require.Equal(t, 5, n) + require.Equal(t, int64(5), l.Value()) + + n, err = lr.Read(buf) + require.Nil(t, err) + require.Equal(t, 5, n) + require.Equal(t, int64(10), l.Value()) + + _, err = lr.Read(buf) + require.Equal(t, ErrLimitReached, err) +} + +func TestLimitReader_ReadTwoLimiters(t *testing.T) { + l1 := NewFixedLimiter(11) + l2 := NewFixedLimiter(8) + lr := NewLimitReader(strings.NewReader("hello world!"), l1, l2) + + buf := make([]byte, 5) + n, err := lr.Read(buf) + require.Nil(t, err) + require.Equal(t, 5, n) + + // Second read: l2 (limit 8) should reject 5 more bytes + _, err = lr.Read(buf) + require.Equal(t, ErrLimitReached, err) + // l1 should have been reverted + require.Equal(t, int64(5), l1.Value()) + require.Equal(t, int64(5), l2.Value()) +} + +func TestLimitReader_ReadAll(t *testing.T) { + l := NewFixedLimiter(100) + lr := NewLimitReader(strings.NewReader("hello"), l) + data, err := io.ReadAll(lr) + require.Nil(t, err) + require.Equal(t, "hello", string(data)) + require.Equal(t, int64(5), l.Value()) +} + +func TestLimitReader_ReadExactLimit(t *testing.T) { + l := NewFixedLimiter(5) + lr := NewLimitReader(bytes.NewReader(make([]byte, 5)), l) + data, err := io.ReadAll(lr) + require.Nil(t, err) + require.Equal(t, 5, len(data)) + require.Equal(t, int64(5), l.Value()) +} From 6a820b503046dd3e2838b874631ca7b7dbd8bec2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Mar 2026 16:29:58 -0400 Subject: [PATCH 016/126] Tags --- attachment/backend_file.go | 2 +- attachment/backend_s3.go | 2 +- attachment/store.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/attachment/backend_file.go b/attachment/backend_file.go index 8aaf20b9..260236d1 100644 --- a/attachment/backend_file.go +++ b/attachment/backend_file.go @@ -9,7 +9,7 @@ import ( "heckel.io/ntfy/v2/log" ) -const tagFileBackend = "file_backend" +const tagFileBackend = "attachment_file" type fileBackend struct { dir string diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go index eb911edc..61d1c7b1 100644 --- a/attachment/backend_s3.go +++ b/attachment/backend_s3.go @@ -10,7 +10,7 @@ import ( ) const ( - tagS3Backend = "s3_backend" + tagS3Backend = "attachment_s3" deleteBatchSize = 1000 ) diff --git a/attachment/store.go b/attachment/store.go index 10dcd17b..8059a0cf 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -15,7 +15,7 @@ import ( ) const ( - tagStore = "attachment_cache" + tagStore = "attachment_store" 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 ) From 78d3138565ba17f05f74a04af5f536700b85586d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Mar 2026 16:54:16 -0400 Subject: [PATCH 017/126] Fix flaky test --- server/server_webpush_test.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go index 047c8708..bba13db4 100644 --- a/server/server_webpush_test.go +++ b/server/server_webpush_test.go @@ -235,13 +235,12 @@ func TestServer_WebPush_Publish_RemoveOnError(t *testing.T) { request(t, s, "POST", "/test-topic", "web push test", nil) - waitFor(t, func() bool { - return received.Load() - }) - // Receiving the 410 should've caused the publisher to expire all subscriptions on the endpoint - - requireSubscriptionCount(t, s, "test-topic", 0) + waitFor(t, func() bool { + subs, err := s.webPush.SubscriptionsForTopic("test-topic") + require.Nil(t, err) + return len(subs) == 0 + }) requireSubscriptionCount(t, s, "test-topic-abc", 0) }) } From b3a8f18019e3744c4ddd09af392318dc1756b3ed Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Mar 2026 17:03:29 -0400 Subject: [PATCH 018/126] Docs --- attachment/store.go | 2 +- docs/config.md | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/attachment/store.go b/attachment/store.go index 8059a0cf..ba2e22cc 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -162,7 +162,7 @@ func (c *Store) sync() error { sizes[obj.ID] = obj.Size } } - log.Tag(tagStore).Debug("Attachment cache size updated to %s", util.FormatSizeHuman(size)) + log.Tag(tagStore).Debug("Attachment store updated: %d attachment(s), %s", len(localIDs), util.FormatSizeHuman(size)) c.mu.Lock() c.size = size c.sizes = sizes diff --git a/docs/config.md b/docs/config.md index edfa43ff..853d4891 100644 --- a/docs/config.md +++ b/docs/config.md @@ -547,6 +547,11 @@ When `endpoint` is specified, path-style addressing is enabled automatically (us attachment-cache-dir: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" ``` +**Cleanup behavior:** A background sync runs every 15 minutes to reconcile the S3 bucket (or configured prefix) with +the server's message database. Objects whose keys match attachment file IDs that are no longer referenced in the database +(and are older than 1 hour) are automatically deleted. This also cleans up incomplete S3 multipart uploads that were +abandoned due to interrupted or failed attachment uploads. + Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. From b81218953a9ac89b57ad7e86e6d97de94c67b46e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Mar 2026 21:14:49 -0400 Subject: [PATCH 019/126] Allow streaming to S3 --- attachment/backend.go | 2 +- attachment/backend_file.go | 12 ++++- attachment/backend_s3.go | 4 +- attachment/store.go | 14 +++--- attachment/store_file_test.go | 46 ++++++++++++++---- attachment/store_s3_test.go | 22 ++++----- s3/client.go | 29 ++++++++---- s3/client_test.go | 87 +++++++++++++++++++++++++++++------ s3/util.go | 4 ++ server/server.go | 19 ++++---- server/server_test.go | 4 +- tools/s3cli/main.go | 8 +++- 12 files changed, 181 insertions(+), 70 deletions(-) diff --git a/attachment/backend.go b/attachment/backend.go index e95fc91e..921ceb3e 100644 --- a/attachment/backend.go +++ b/attachment/backend.go @@ -15,7 +15,7 @@ type object struct { // backend is a minimal I/O interface for storing and retrieving attachment files. // It has no knowledge of size tracking, limiting, or ID validation. type backend interface { - Put(id string, in io.Reader) error + Put(id string, reader io.Reader, untrustedLength int64) error Get(id string) (io.ReadCloser, int64, error) List() ([]object, error) Delete(ids ...string) error diff --git a/attachment/backend_file.go b/attachment/backend_file.go index 260236d1..8726ddf4 100644 --- a/attachment/backend_file.go +++ b/attachment/backend_file.go @@ -1,6 +1,7 @@ package attachment import ( + "fmt" "io" "os" "path/filepath" @@ -24,16 +25,23 @@ func newFileBackend(dir string) (*fileBackend, error) { return &fileBackend{dir: dir}, nil } -func (b *fileBackend) Put(id string, in io.Reader) error { +func (b *fileBackend) Put(id string, reader io.Reader, untrustedLength int64) error { + if untrustedLength > 0 { + reader = io.LimitReader(reader, untrustedLength) + } file := filepath.Join(b.dir, id) f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { return err } defer f.Close() - if _, err := io.Copy(f, in); err != nil { + n, err := io.Copy(f, reader) + if err != nil { os.Remove(file) return err + } else if untrustedLength > 0 && n != untrustedLength { + os.Remove(file) + return fmt.Errorf("content length mismatch: claimed %d, got %d", untrustedLength, n) } if err := f.Close(); err != nil { os.Remove(file) diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go index 61d1c7b1..081d6002 100644 --- a/attachment/backend_s3.go +++ b/attachment/backend_s3.go @@ -24,8 +24,8 @@ func newS3Backend(client *s3.Client) *s3Backend { return &s3Backend{client: client} } -func (b *s3Backend) Put(id string, in io.Reader) error { - return b.client.PutObject(context.Background(), id, in) +func (b *s3Backend) Put(id string, reader io.Reader, untrustedLength int64) error { + return b.client.PutObject(context.Background(), id, reader, untrustedLength) } func (b *s3Backend) Get(id string) (io.ReadCloser, int64, error) { diff --git a/attachment/store.go b/attachment/store.go index ba2e22cc..6e7cfb99 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -72,20 +72,22 @@ func newStore(backend backend, totalSizeLimit int64, localIDs func() ([]string, } // Write stores an attachment file. The id is validated, and the write is subject to -// the total size limit and any additional limiters. -func (c *Store) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { +// the total size limit and any additional limiters. The untrustedLength is a hint +// from the client's Content-Length header; backends may use it to optimize uploads (e.g. +// streaming directly to S3 without buffering). +func (c *Store) Write(id string, reader io.Reader, untrustedLength int64, limiters ...util.Limiter) (int64, error) { if !fileIDRegex.MatchString(id) { return 0, errInvalidFileID } log.Tag(tagStore).Field("message_id", id).Debug("Writing attachment") limiters = append(limiters, util.NewFixedLimiter(c.Remaining())) - cr := util.NewCountingReader(in) - lr := util.NewLimitReader(cr, limiters...) - if err := c.backend.Put(id, lr); err != nil { + countingReader := util.NewCountingReader(reader) + limitReader := util.NewLimitReader(countingReader, limiters...) + if err := c.backend.Put(id, limitReader, untrustedLength); err != nil { c.backend.Delete(id) //nolint:errcheck return 0, err } - size := cr.Total() + size := countingReader.Total() c.mu.Lock() c.size += size c.sizes[id] = size diff --git a/attachment/store_file_test.go b/attachment/store_file_test.go index c65bad92..998a2bea 100644 --- a/attachment/store_file_test.go +++ b/attachment/store_file_test.go @@ -19,7 +19,7 @@ var ( func TestFileStore_Write_Success(t *testing.T) { dir, c := newTestFileStore(t) - size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), util.NewFixedLimiter(999)) + size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), 0, util.NewFixedLimiter(999)) require.Nil(t, err) require.Equal(t, int64(11), size) require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl")) @@ -29,7 +29,7 @@ func TestFileStore_Write_Success(t *testing.T) { func TestFileStore_Write_Read_Success(t *testing.T) { _, c := newTestFileStore(t) - size, err := c.Write("abcdefghijkl", strings.NewReader("hello world")) + size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 0) require.Nil(t, err) require.Equal(t, int64(11), size) @@ -45,7 +45,7 @@ func TestFileStore_Write_Read_Success(t *testing.T) { func TestFileStore_Write_Remove_Success(t *testing.T) { dir, c := newTestFileStore(t) // max = 10k (10240), each = 1k (1024) for i := 0; i < 10; i++ { // 10x999 = 9990 - size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999))) + size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999)), 0) require.Nil(t, err) require.Equal(t, int64(999), size) } @@ -64,22 +64,48 @@ func TestFileStore_Write_Remove_Success(t *testing.T) { func TestFileStore_Write_FailedTotalSizeLimit(t *testing.T) { dir, c := newTestFileStore(t) for i := 0; i < 10; i++ { - size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray)) + size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray), 0) require.Nil(t, err) require.Equal(t, int64(1024), size) } - _, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray)) + _, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray), 0) require.Equal(t, util.ErrLimitReached, err) require.NoFileExists(t, dir+"/abcdefghijkX") } func TestFileStore_Write_FailedAdditionalLimiter(t *testing.T) { dir, c := newTestFileStore(t) - _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) + _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), 0, util.NewFixedLimiter(1000)) require.Equal(t, util.ErrLimitReached, err) require.NoFileExists(t, dir+"/abcdefghijkl") } +func TestFileStore_Write_UntrustedContentLengthExact(t *testing.T) { + dir, c := newTestFileStore(t) + size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 11) + require.Nil(t, err) + require.Equal(t, int64(11), size) + require.Equal(t, "hello world", readFile(t, dir+"/abcdefghijkl")) +} + +func TestFileStore_Write_UntrustedContentLengthBodyLonger(t *testing.T) { + dir, c := newTestFileStore(t) + // Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored + size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 5) + require.Nil(t, err) + require.Equal(t, int64(5), size) + require.Equal(t, "hello", readFile(t, dir+"/abcdefghijkl")) +} + +func TestFileStore_Write_UntrustedContentLengthBodyShorter(t *testing.T) { + dir, c := newTestFileStore(t) + // Body has 5 bytes, but we claim 100 — should fail with content length mismatch + _, err := c.Write("abcdefghijkl", strings.NewReader("hello"), 100) + require.Error(t, err) + require.Contains(t, err.Error(), "content length mismatch") + require.NoFileExists(t, dir+"/abcdefghijkl") +} + func TestFileStore_Read_NotFound(t *testing.T) { _, c := newTestFileStore(t) _, _, err := c.Read("abcdefghijkl") @@ -90,11 +116,11 @@ func TestFileStore_Sync(t *testing.T) { dir, c := newTestFileStore(t) // Write some files - _, err := c.Write("abcdefghijk0", strings.NewReader("file0")) + _, err := c.Write("abcdefghijk0", strings.NewReader("file0"), 0) require.Nil(t, err) - _, err = c.Write("abcdefghijk1", strings.NewReader("file1")) + _, err = c.Write("abcdefghijk1", strings.NewReader("file1"), 0) require.Nil(t, err) - _, err = c.Write("abcdefghijk2", strings.NewReader("file2")) + _, err = c.Write("abcdefghijk2", strings.NewReader("file2"), 0) require.Nil(t, err) require.Equal(t, int64(15), c.Size()) @@ -124,7 +150,7 @@ func TestFileStore_Sync_SkipsRecentFiles(t *testing.T) { dir, c := newTestFileStore(t) // Write a file - _, err := c.Write("abcdefghijk0", strings.NewReader("file0")) + _, err := c.Write("abcdefghijk0", strings.NewReader("file0"), 0) require.Nil(t, err) // Set the ID provider to return empty (no valid IDs) diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index 3ad5a93c..37bd0ecb 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -26,7 +26,7 @@ func TestS3Store_WriteReadRemove(t *testing.T) { cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) // Write - size, err := cache.Write("abcdefghijkl", strings.NewReader("hello world")) + size, err := cache.Write("abcdefghijkl", strings.NewReader("hello world"), 0) require.Nil(t, err) require.Equal(t, int64(11), size) require.Equal(t, int64(11), cache.Size()) @@ -55,7 +55,7 @@ func TestS3Store_WriteNoPrefix(t *testing.T) { cache := newTestS3Store(t, server, "my-bucket", "", 10*1024) - size, err := cache.Write("abcdefghijkl", strings.NewReader("test")) + size, err := cache.Write("abcdefghijkl", strings.NewReader("test"), 0) require.Nil(t, err) require.Equal(t, int64(4), size) @@ -74,13 +74,13 @@ func TestS3Store_WriteTotalSizeLimit(t *testing.T) { cache := newTestS3Store(t, server, "my-bucket", "pfx", 100) // First write fits - _, err := cache.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80))) + _, err := cache.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80)), 0) require.Nil(t, err) require.Equal(t, int64(80), cache.Size()) require.Equal(t, int64(20), cache.Remaining()) // Second write exceeds total limit - _, err = cache.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50))) + _, err = cache.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50)), 0) require.ErrorIs(t, err, util.ErrLimitReached) } @@ -90,7 +90,7 @@ func TestS3Store_WriteFileSizeLimit(t *testing.T) { cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - _, err := cache.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), util.NewFixedLimiter(100)) + _, err := cache.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100)) require.ErrorIs(t, err, util.ErrLimitReached) } @@ -101,7 +101,7 @@ func TestS3Store_WriteRemoveMultiple(t *testing.T) { cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) for i := 0; i < 5; i++ { - _, err := cache.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100))) + _, err := cache.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100)), 0) require.Nil(t, err) } require.Equal(t, int64(500), cache.Size()) @@ -126,7 +126,7 @@ func TestS3Store_InvalidID(t *testing.T) { cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - _, err := cache.Write("bad", strings.NewReader("x")) + _, err := cache.Write("bad", strings.NewReader("x"), 0) require.Equal(t, errInvalidFileID, err) _, _, err = cache.Read("bad") @@ -143,11 +143,11 @@ func TestS3Store_Sync(t *testing.T) { cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) // Write some files - _, err := cache.Write("abcdefghijk0", strings.NewReader("file0")) + _, err := cache.Write("abcdefghijk0", strings.NewReader("file0"), 0) require.Nil(t, err) - _, err = cache.Write("abcdefghijk1", strings.NewReader("file1")) + _, err = cache.Write("abcdefghijk1", strings.NewReader("file1"), 0) require.Nil(t, err) - _, err = cache.Write("abcdefghijk2", strings.NewReader("file2")) + _, err = cache.Write("abcdefghijk2", strings.NewReader("file2"), 0) require.Nil(t, err) require.Equal(t, int64(15), cache.Size()) @@ -175,7 +175,7 @@ func TestS3Store_Sync_SkipsRecentFiles(t *testing.T) { cache := newTestS3Store(t, mockServer, "my-bucket", "pfx", 10*1024) - _, err := cache.Write("abcdefghijk0", strings.NewReader("file0")) + _, err := cache.Write("abcdefghijk0", strings.NewReader("file0"), 0) require.Nil(t, err) // Set the ID provider to return empty (no valid IDs) diff --git a/s3/client.go b/s3/client.go index 83d8195e..29cad3a4 100644 --- a/s3/client.go +++ b/s3/client.go @@ -45,25 +45,36 @@ func New(config *Config) *Client { } // PutObject uploads body to the given key. The key is automatically prefixed with the client's -// configured prefix. The body size does not need to be known in advance. +// configured prefix. // -// If the entire body fits in a single part (5 MB), it is uploaded with a simple PUT request -// (https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html). Otherwise, the body -// is uploaded using S3 multipart upload, reading one part at a time into memory -// (https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html). -func (c *Client) PutObject(ctx context.Context, key string, body io.Reader) error { +// If untrustedLength is between 1 and 5 GB, the body is streamed directly to S3 via a +// single PUT request without buffering. The read is limited to untrustedLength bytes; +// any extra data in the body is ignored. If the body is shorter than claimed, the upload fails. +// +// Otherwise (untrustedLength <= 0 or > 5 GB), the first 5 MB are buffered to decide +// between a simple PUT and multipart upload. +// +// See https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html +// and https://docs.aws.amazon.com/AmazonS3/latest/API/API_CreateMultipartUpload.html +func (c *Client) PutObject(ctx context.Context, key string, body io.Reader, untrustedLength int64) error { + if untrustedLength > 0 && untrustedLength <= maxSinglePutSize { + // Stream directly: Content-Length is known (but untrusted). LimitReader ensures we send at most + // untrustedLength bytes, and any extra data in body is ignored. + return c.putObject(ctx, key, io.LimitReader(body, untrustedLength), untrustedLength) + } + // Buffered path: read first 5 MB to decide simple vs multipart first := make([]byte, partSize) n, err := io.ReadFull(body, first) if errors.Is(err, io.ErrUnexpectedEOF) || err == io.EOF { - return c.putObjectSimple(ctx, key, bytes.NewReader(first[:n]), int64(n)) + return c.putObject(ctx, key, bytes.NewReader(first[:n]), int64(n)) } else if err != nil { return fmt.Errorf("error reading object %s from client: %w", key, err) } return c.putObjectMultipart(ctx, key, io.MultiReader(bytes.NewReader(first), body)) } -// putObjectSimple uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. -func (c *Client) putObjectSimple(ctx context.Context, key string, body io.Reader, size int64) error { +// putObject uploads a body with known size using a simple PUT with UNSIGNED-PAYLOAD. +func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size int64) error { log.Tag(tagS3Client).Debug("Uploading object %s (%d bytes)", key, size) req, err := http.NewRequestWithContext(ctx, http.MethodPut, c.config.ObjectURL(key), body) if err != nil { diff --git a/s3/client_test.go b/s3/client_test.go index 84402831..652db3e7 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -419,7 +419,7 @@ func TestClient_PutGetObject(t *testing.T) { ctx := context.Background() // Put - err := client.PutObject(ctx, "test-key", strings.NewReader("hello world")) + err := client.PutObject(ctx, "test-key", strings.NewReader("hello world"), 0) require.Nil(t, err) // Get @@ -439,7 +439,7 @@ func TestClient_PutGetObject_WithPrefix(t *testing.T) { ctx := context.Background() - err := client.PutObject(ctx, "test-key", strings.NewReader("hello")) + err := client.PutObject(ctx, "test-key", strings.NewReader("hello"), 0) require.Nil(t, err) reader, _, err := client.GetObject(ctx, "test-key") @@ -471,7 +471,7 @@ func TestClient_DeleteObjects(t *testing.T) { // Put several objects for i := 0; i < 5; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%d", i), bytes.NewReader([]byte("data"))) + err := client.PutObject(ctx, fmt.Sprintf("key-%d", i), bytes.NewReader([]byte("data")), 0) require.Nil(t, err) } require.Equal(t, 5, mock.objectCount()) @@ -502,13 +502,13 @@ func TestClient_ListObjects(t *testing.T) { // Client with prefix "pfx": list should only return objects under pfx/ client := newTestClient(server, "my-bucket", "pfx") for i := 0; i < 3; i++ { - err := client.PutObject(ctx, fmt.Sprintf("%d", i), bytes.NewReader([]byte("x"))) + err := client.PutObject(ctx, fmt.Sprintf("%d", i), bytes.NewReader([]byte("x")), 0) require.Nil(t, err) } // Also put an object outside the prefix using a no-prefix client clientNoPrefix := newTestClient(server, "my-bucket", "") - err := clientNoPrefix.PutObject(ctx, "other", bytes.NewReader([]byte("y"))) + err := clientNoPrefix.PutObject(ctx, "other", bytes.NewReader([]byte("y")), 0) require.Nil(t, err) // List with prefix client: should only see 3 @@ -532,7 +532,7 @@ func TestClient_ListObjects_Pagination(t *testing.T) { // Put 5 objects for i := 0; i < 5; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x"))) + err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x")), 0) require.Nil(t, err) } @@ -564,7 +564,7 @@ func TestClient_ListAllObjects(t *testing.T) { ctx := context.Background() for i := 0; i < 10; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x"))) + err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x")), 0) require.Nil(t, err) } @@ -585,7 +585,7 @@ func TestClient_PutObject_LargeBody(t *testing.T) { for i := range data { data[i] = byte(i % 256) } - err := client.PutObject(ctx, "large", bytes.NewReader(data)) + err := client.PutObject(ctx, "large", bytes.NewReader(data), 0) require.Nil(t, err) reader, size, err := client.GetObject(ctx, "large") @@ -609,7 +609,7 @@ func TestClient_PutObject_ChunkedUpload(t *testing.T) { for i := range data { data[i] = byte(i % 256) } - err := client.PutObject(ctx, "multipart", bytes.NewReader(data)) + err := client.PutObject(ctx, "multipart", bytes.NewReader(data), 0) require.Nil(t, err) reader, size, err := client.GetObject(ctx, "multipart") @@ -633,7 +633,7 @@ func TestClient_PutObject_ExactPartSize(t *testing.T) { for i := range data { data[i] = byte(i % 256) } - err := client.PutObject(ctx, "exact", bytes.NewReader(data)) + err := client.PutObject(ctx, "exact", bytes.NewReader(data), 0) require.Nil(t, err) reader, size, err := client.GetObject(ctx, "exact") @@ -645,6 +645,63 @@ func TestClient_PutObject_ExactPartSize(t *testing.T) { require.Equal(t, data, got) } +func TestClient_PutObject_StreamingExactLength(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "pfx") + + ctx := context.Background() + + // untrustedLength matches body exactly — streams directly via putObject + err := client.PutObject(ctx, "stream-exact", strings.NewReader("hello world"), 11) + require.Nil(t, err) + + reader, size, err := client.GetObject(ctx, "stream-exact") + require.Nil(t, err) + require.Equal(t, int64(11), size) + got, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello world", string(got)) +} + +func TestClient_PutObject_StreamingBodyLongerThanClaimed(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "pfx") + + ctx := context.Background() + + // Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored + err := client.PutObject(ctx, "stream-long", strings.NewReader("hello world"), 5) + require.Nil(t, err) + + reader, size, err := client.GetObject(ctx, "stream-long") + require.Nil(t, err) + require.Equal(t, int64(5), size) + got, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello", string(got)) +} + +func TestClient_PutObject_StreamingBodyShorterThanClaimed(t *testing.T) { + server, _ := newMockS3Server() + defer server.Close() + client := newTestClient(server, "my-bucket", "pfx") + + ctx := context.Background() + + // Body has 5 bytes, but we claim 100 — should fail + err := client.PutObject(ctx, "stream-short", strings.NewReader("hello"), 100) + require.Error(t, err) + require.Contains(t, err.Error(), "ContentLength") + + // Object should not exist + _, _, err = client.GetObject(ctx, "stream-short") + require.Error(t, err) +} + func TestClient_PutObject_NestedKey(t *testing.T) { server, _ := newMockS3Server() defer server.Close() @@ -652,7 +709,7 @@ func TestClient_PutObject_NestedKey(t *testing.T) { ctx := context.Background() - err := client.PutObject(ctx, "deep/nested/prefix/file.txt", strings.NewReader("nested")) + err := client.PutObject(ctx, "deep/nested/prefix/file.txt", strings.NewReader("nested"), 0) require.Nil(t, err) reader, _, err := client.GetObject(ctx, "deep/nested/prefix/file.txt") @@ -682,7 +739,7 @@ func TestClient_ListAllObjects_20k(t *testing.T) { for i := 0; i < batchSize; i++ { idx := batch*batchSize + i key := fmt.Sprintf("%08d", idx) - err := client.PutObject(ctx, key, bytes.NewReader([]byte("x"))) + err := client.PutObject(ctx, key, bytes.NewReader([]byte("x")), 0) require.Nil(t, err) } } @@ -780,7 +837,7 @@ func TestClient_RealBucket(t *testing.T) { content := "hello from ntfy s3 test" // Put - err := client.PutObject(ctx, key, strings.NewReader(content)) + err := client.PutObject(ctx, key, strings.NewReader(content), 0) require.Nil(t, err) // Get @@ -818,7 +875,7 @@ func TestClient_RealBucket(t *testing.T) { // Put 10 objects for i := 0; i < 10; i++ { - err := listClient.PutObject(ctx, fmt.Sprintf("%d", i), strings.NewReader("x")) + err := listClient.PutObject(ctx, fmt.Sprintf("%d", i), strings.NewReader("x"), 0) require.Nil(t, err) } @@ -843,7 +900,7 @@ func TestClient_RealBucket(t *testing.T) { data[i] = byte(i % 256) } - err := client.PutObject(ctx, key, bytes.NewReader(data)) + err := client.PutObject(ctx, key, bytes.NewReader(data), 0) require.Nil(t, err) reader, size, err := client.GetObject(ctx, key) diff --git a/s3/util.go b/s3/util.go index 0bcc96d2..1f4c2dd9 100644 --- a/s3/util.go +++ b/s3/util.go @@ -28,6 +28,10 @@ const ( // part size of 5 MB for all parts except the last. partSize = 5 * 1024 * 1024 + // maxSinglePutSize is the maximum size for a single PUT upload (5 GB). + // Objects larger than this must use multipart upload. + maxSinglePutSize = 5 * 1024 * 1024 * 1024 + // maxPages is the max number of pages to iterate through when listing objects maxPages = 500 ) diff --git a/server/server.go b/server/server.go index 99a61906..87eee5d6 100644 --- a/server/server.go +++ b/server/server.go @@ -1432,16 +1432,13 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me if m.Time > attachmentExpiry { return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m) } - contentLengthStr := r.Header.Get("Content-Length") - if contentLengthStr != "" { // Early "do-not-trust" check, hard limit see below - contentLength, err := strconv.ParseInt(contentLengthStr, 10, 64) - if err == nil && (contentLength > vinfo.Stats.AttachmentTotalSizeRemaining || contentLength > vinfo.Limits.AttachmentFileSizeLimit) { - return errHTTPEntityTooLargeAttachment.With(m).Fields(log.Context{ - "message_content_length": contentLength, - "attachment_total_size_remaining": vinfo.Stats.AttachmentTotalSizeRemaining, - "attachment_file_size_limit": vinfo.Limits.AttachmentFileSizeLimit, - }) - } + // Early "do-not-trust" check, hard limit see below + if r.ContentLength > 0 && (r.ContentLength > vinfo.Stats.AttachmentTotalSizeRemaining || r.ContentLength > vinfo.Limits.AttachmentFileSizeLimit) { + return errHTTPEntityTooLargeAttachment.With(m).Fields(log.Context{ + "message_content_length": r.ContentLength, + "attachment_total_size_remaining": vinfo.Stats.AttachmentTotalSizeRemaining, + "attachment_file_size_limit": vinfo.Limits.AttachmentFileSizeLimit, + }) } if m.Attachment == nil { m.Attachment = &model.Attachment{} @@ -1461,7 +1458,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit), util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining), } - m.Attachment.Size, err = s.fileCache.Write(m.ID, body, limiters...) + m.Attachment.Size, err = s.fileCache.Write(m.ID, body, r.ContentLength, limiters...) if errors.Is(err, util.ErrLimitReached) { return errHTTPEntityTooLargeAttachment.With(m) } else if err != nil { diff --git a/server/server_test.go b/server/server_test.go index cb20cbda..449b6006 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2218,8 +2218,8 @@ func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { content := util.RandomString(5000) // > 4096 s := newTestServer(t, newTestConfig(t, databaseURL)) - response := request(t, s, "PUT", "/mytopic", content, map[string]string{ - "Content-Length": "20000000", + response := request(t, s, "PUT", "/mytopic", content, nil, func(r *http.Request) { + r.ContentLength = 20000000 }) err := toHTTPError(t, response.Body.String()) require.Equal(t, 413, response.Code) diff --git a/tools/s3cli/main.go b/tools/s3cli/main.go index 0e640823..5de8a75c 100644 --- a/tools/s3cli/main.go +++ b/tools/s3cli/main.go @@ -58,6 +58,7 @@ func cmdPut(ctx context.Context, client *s3.Client) { path := os.Args[3] var r io.Reader + var size int64 if path == "-" { r = os.Stdin } else { @@ -66,10 +67,15 @@ func cmdPut(ctx context.Context, client *s3.Client) { fail("open %s: %s", path, err) } defer f.Close() + stat, err := f.Stat() + if err != nil { + fail("stat %s: %s", path, err) + } r = f + size = stat.Size() } - if err := client.PutObject(ctx, key, r); err != nil { + if err := client.PutObject(ctx, key, r, size); err != nil { fail("put: %s", err) } fmt.Fprintf(os.Stderr, "uploaded %s\n", key) From ad501feab1d46f9cfb8460d22c746c0a977c8806 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 21 Mar 2026 21:59:59 -0400 Subject: [PATCH 020/126] Rewrite tests --- attachment/store_file_test.go | 168 +---------------------- attachment/store_s3_test.go | 195 +++----------------------- attachment/store_test.go | 252 ++++++++++++++++++++++++++++++++++ 3 files changed, 275 insertions(+), 340 deletions(-) create mode 100644 attachment/store_test.go diff --git a/attachment/store_file_test.go b/attachment/store_file_test.go index 998a2bea..d0b6e135 100644 --- a/attachment/store_file_test.go +++ b/attachment/store_file_test.go @@ -1,180 +1,16 @@ package attachment import ( - "bytes" - "fmt" - "io" - "os" - "strings" "testing" - "time" "github.com/stretchr/testify/require" - "heckel.io/ntfy/v2/util" ) -var ( - oneKilobyteArray = make([]byte, 1024) -) - -func TestFileStore_Write_Success(t *testing.T) { - dir, c := newTestFileStore(t) - size, err := c.Write("abcdefghijkl", strings.NewReader("normal file"), 0, util.NewFixedLimiter(999)) - require.Nil(t, err) - require.Equal(t, int64(11), size) - require.Equal(t, "normal file", readFile(t, dir+"/abcdefghijkl")) - require.Equal(t, int64(11), c.Size()) - require.Equal(t, int64(10229), c.Remaining()) -} - -func TestFileStore_Write_Read_Success(t *testing.T) { - _, c := newTestFileStore(t) - size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 0) - require.Nil(t, err) - require.Equal(t, int64(11), size) - - reader, readSize, err := c.Read("abcdefghijkl") - require.Nil(t, err) - require.Equal(t, int64(11), readSize) - defer reader.Close() - data, err := io.ReadAll(reader) - require.Nil(t, err) - require.Equal(t, "hello world", string(data)) -} - -func TestFileStore_Write_Remove_Success(t *testing.T) { - dir, c := newTestFileStore(t) // max = 10k (10240), each = 1k (1024) - for i := 0; i < 10; i++ { // 10x999 = 9990 - size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 999)), 0) - require.Nil(t, err) - require.Equal(t, int64(999), size) - } - require.Equal(t, int64(9990), c.Size()) - require.Equal(t, int64(250), c.Remaining()) - require.FileExists(t, dir+"/abcdefghijk1") - require.FileExists(t, dir+"/abcdefghijk5") - - require.Nil(t, c.Remove("abcdefghijk1", "abcdefghijk5")) - require.NoFileExists(t, dir+"/abcdefghijk1") - require.NoFileExists(t, dir+"/abcdefghijk5") - require.Equal(t, int64(8*999), c.Size()) - require.Equal(t, int64(10240-8*999), c.Remaining()) -} - -func TestFileStore_Write_FailedTotalSizeLimit(t *testing.T) { - dir, c := newTestFileStore(t) - for i := 0; i < 10; i++ { - size, err := c.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(oneKilobyteArray), 0) - require.Nil(t, err) - require.Equal(t, int64(1024), size) - } - _, err := c.Write("abcdefghijkX", bytes.NewReader(oneKilobyteArray), 0) - require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abcdefghijkX") -} - -func TestFileStore_Write_FailedAdditionalLimiter(t *testing.T) { - dir, c := newTestFileStore(t) - _, err := c.Write("abcdefghijkl", bytes.NewReader(make([]byte, 1001)), 0, util.NewFixedLimiter(1000)) - require.Equal(t, util.ErrLimitReached, err) - require.NoFileExists(t, dir+"/abcdefghijkl") -} - -func TestFileStore_Write_UntrustedContentLengthExact(t *testing.T) { - dir, c := newTestFileStore(t) - size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 11) - require.Nil(t, err) - require.Equal(t, int64(11), size) - require.Equal(t, "hello world", readFile(t, dir+"/abcdefghijkl")) -} - -func TestFileStore_Write_UntrustedContentLengthBodyLonger(t *testing.T) { - dir, c := newTestFileStore(t) - // Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored - size, err := c.Write("abcdefghijkl", strings.NewReader("hello world"), 5) - require.Nil(t, err) - require.Equal(t, int64(5), size) - require.Equal(t, "hello", readFile(t, dir+"/abcdefghijkl")) -} - -func TestFileStore_Write_UntrustedContentLengthBodyShorter(t *testing.T) { - dir, c := newTestFileStore(t) - // Body has 5 bytes, but we claim 100 — should fail with content length mismatch - _, err := c.Write("abcdefghijkl", strings.NewReader("hello"), 100) - require.Error(t, err) - require.Contains(t, err.Error(), "content length mismatch") - require.NoFileExists(t, dir+"/abcdefghijkl") -} - -func TestFileStore_Read_NotFound(t *testing.T) { - _, c := newTestFileStore(t) - _, _, err := c.Read("abcdefghijkl") - require.Error(t, err) -} - -func TestFileStore_Sync(t *testing.T) { - dir, c := newTestFileStore(t) - - // Write some files - _, err := c.Write("abcdefghijk0", strings.NewReader("file0"), 0) - require.Nil(t, err) - _, err = c.Write("abcdefghijk1", strings.NewReader("file1"), 0) - require.Nil(t, err) - _, err = c.Write("abcdefghijk2", strings.NewReader("file2"), 0) - require.Nil(t, err) - - require.Equal(t, int64(15), c.Size()) - - // Set the ID provider to only know about file 0 and 2 - c.localIDs = func() ([]string, error) { - return []string{"abcdefghijk0", "abcdefghijk2"}, nil - } - - // Make file 1's mod time old enough to be cleaned up (> 1 hour) - oldTime := time.Unix(1, 0) - os.Chtimes(dir+"/abcdefghijk1", oldTime, oldTime) - - // Run sync - require.Nil(t, c.sync()) - - // File 1 should be deleted (orphan, old enough) - require.NoFileExists(t, dir+"/abcdefghijk1") - require.FileExists(t, dir+"/abcdefghijk0") - require.FileExists(t, dir+"/abcdefghijk2") - - // Size should be updated - require.Equal(t, int64(10), c.Size()) -} - -func TestFileStore_Sync_SkipsRecentFiles(t *testing.T) { - dir, c := newTestFileStore(t) - - // Write a file - _, err := c.Write("abcdefghijk0", strings.NewReader("file0"), 0) - require.Nil(t, err) - - // Set the ID provider to return empty (no valid IDs) - c.localIDs = func() ([]string, error) { - return []string{}, nil - } - - // File was just created, so it should NOT be deleted (< 1 hour old) - require.Nil(t, c.sync()) - require.FileExists(t, dir+"/abcdefghijk0") -} - -func newTestFileStore(t *testing.T) (dir string, cache *Store) { +func newTestFileStore(t *testing.T, totalSizeLimit int64) (dir string, cache *Store) { t.Helper() dir = t.TempDir() - cache, err := NewFileStore(dir, 10*1024, nil) + cache, err := NewFileStore(dir, totalSizeLimit, nil) require.Nil(t, err) t.Cleanup(func() { cache.Close() }) return dir, cache } - -func readFile(t *testing.T, f string) string { - t.Helper() - b, err := os.ReadFile(f) - require.Nil(t, err) - return string(b) -} diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index 37bd0ecb..2d4635ff 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -1,7 +1,6 @@ package attachment import ( - "bytes" "encoding/xml" "fmt" "io" @@ -14,43 +13,12 @@ import ( "github.com/stretchr/testify/require" "heckel.io/ntfy/v2/s3" - "heckel.io/ntfy/v2/util" ) -// --- Integration tests using a mock S3 server --- - -func TestS3Store_WriteReadRemove(t *testing.T) { - server := newMockS3Server() - defer server.Close() - - cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - - // Write - size, err := cache.Write("abcdefghijkl", strings.NewReader("hello world"), 0) - require.Nil(t, err) - require.Equal(t, int64(11), size) - require.Equal(t, int64(11), cache.Size()) - - // Read back - reader, readSize, err := cache.Read("abcdefghijkl") - require.Nil(t, err) - require.Equal(t, int64(11), readSize) - data, err := io.ReadAll(reader) - reader.Close() - require.Nil(t, err) - require.Equal(t, "hello world", string(data)) - - // Remove - require.Nil(t, cache.Remove("abcdefghijkl")) - require.Equal(t, int64(0), cache.Size()) - - // Read after remove should fail - _, _, err = cache.Read("abcdefghijkl") - require.Error(t, err) -} +// --- S3-specific tests --- func TestS3Store_WriteNoPrefix(t *testing.T) { - server := newMockS3Server() + server, _ := newMockS3Server() defer server.Close() cache := newTestS3Store(t, server, "my-bucket", "", 10*1024) @@ -67,131 +35,6 @@ func TestS3Store_WriteNoPrefix(t *testing.T) { require.Equal(t, "test", string(data)) } -func TestS3Store_WriteTotalSizeLimit(t *testing.T) { - server := newMockS3Server() - defer server.Close() - - cache := newTestS3Store(t, server, "my-bucket", "pfx", 100) - - // First write fits - _, err := cache.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80)), 0) - require.Nil(t, err) - require.Equal(t, int64(80), cache.Size()) - require.Equal(t, int64(20), cache.Remaining()) - - // Second write exceeds total limit - _, err = cache.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50)), 0) - require.ErrorIs(t, err, util.ErrLimitReached) -} - -func TestS3Store_WriteFileSizeLimit(t *testing.T) { - server := newMockS3Server() - defer server.Close() - - cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - - _, err := cache.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100)) - require.ErrorIs(t, err, util.ErrLimitReached) -} - -func TestS3Store_WriteRemoveMultiple(t *testing.T) { - server := newMockS3Server() - defer server.Close() - - cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - - for i := 0; i < 5; i++ { - _, err := cache.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100)), 0) - require.Nil(t, err) - } - require.Equal(t, int64(500), cache.Size()) - - require.Nil(t, cache.Remove("abcdefghijk1", "abcdefghijk3")) - require.Equal(t, int64(300), cache.Size()) -} - -func TestS3Store_ReadNotFound(t *testing.T) { - server := newMockS3Server() - defer server.Close() - - cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - - _, _, err := cache.Read("abcdefghijkl") - require.Error(t, err) -} - -func TestS3Store_InvalidID(t *testing.T) { - server := newMockS3Server() - defer server.Close() - - cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - - _, err := cache.Write("bad", strings.NewReader("x"), 0) - require.Equal(t, errInvalidFileID, err) - - _, _, err = cache.Read("bad") - require.Equal(t, errInvalidFileID, err) - - err = cache.Remove("bad") - require.Equal(t, errInvalidFileID, err) -} - -func TestS3Store_Sync(t *testing.T) { - server := newMockS3Server() - defer server.Close() - - cache := newTestS3Store(t, server, "my-bucket", "pfx", 10*1024) - - // Write some files - _, err := cache.Write("abcdefghijk0", strings.NewReader("file0"), 0) - require.Nil(t, err) - _, err = cache.Write("abcdefghijk1", strings.NewReader("file1"), 0) - require.Nil(t, err) - _, err = cache.Write("abcdefghijk2", strings.NewReader("file2"), 0) - require.Nil(t, err) - - require.Equal(t, int64(15), cache.Size()) - - // Set the ID provider to only know about file 0 and 2 - // All mock objects have LastModified set to 2 hours ago, so orphans are eligible for deletion - cache.localIDs = func() ([]string, error) { - return []string{"abcdefghijk0", "abcdefghijk2"}, nil - } - - // Run sync - require.Nil(t, cache.sync()) - - // File 1 should be deleted (orphan) - _, _, err = cache.Read("abcdefghijk1") - require.Error(t, err) - - // Size should be updated - require.Equal(t, int64(10), cache.Size()) -} - -func TestS3Store_Sync_SkipsRecentFiles(t *testing.T) { - mockServer := newMockS3ServerWithModTime(time.Now()) - defer mockServer.Close() - - cache := newTestS3Store(t, mockServer, "my-bucket", "pfx", 10*1024) - - _, err := cache.Write("abcdefghijk0", strings.NewReader("file0"), 0) - require.Nil(t, err) - - // Set the ID provider to return empty (no valid IDs) - cache.localIDs = func() ([]string, error) { - return []string{}, nil - } - - // File was "just created" (mock returns recent time), so it should NOT be deleted - require.Nil(t, cache.sync()) - - // File should still exist - reader, _, err := cache.Read("abcdefghijk0") - require.Nil(t, err) - reader.Close() -} - // --- Helpers --- func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string, totalSizeLimit int64) *Store { @@ -219,24 +62,26 @@ func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string // ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory. type mockS3Server struct { - objects map[string][]byte // full key (bucket/key) -> body - uploads map[string]map[int][]byte // uploadID -> partNumber -> data - nextID int // counter for generating upload IDs - lastModTime time.Time // time to return for LastModified in list responses - mu sync.RWMutex + objects map[string][]byte // full key (bucket/key) -> body + modTimes map[string]time.Time // full key (bucket/key) -> last modified time + uploads map[string]map[int][]byte // uploadID -> partNumber -> data + nextID int // counter for generating upload IDs + mu sync.RWMutex } -func newMockS3Server() *httptest.Server { - return newMockS3ServerWithModTime(time.Now().Add(-2 * time.Hour)) -} - -func newMockS3ServerWithModTime(modTime time.Time) *httptest.Server { +func newMockS3Server() (*httptest.Server, *mockS3Server) { m := &mockS3Server{ - objects: make(map[string][]byte), - uploads: make(map[string]map[int][]byte), - lastModTime: modTime, + objects: make(map[string][]byte), + modTimes: make(map[string]time.Time), + uploads: make(map[string]map[int][]byte), } - return httptest.NewTLSServer(m) + return httptest.NewTLSServer(m), m +} + +func (m *mockS3Server) setModTime(path string, t time.Time) { + m.mu.Lock() + m.modTimes[path] = t + m.mu.Unlock() } func (m *mockS3Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -274,6 +119,7 @@ func (m *mockS3Server) handlePut(w http.ResponseWriter, r *http.Request, path st } m.mu.Lock() m.objects[path] = body + m.modTimes[path] = time.Now() m.mu.Unlock() w.WriteHeader(http.StatusOK) } @@ -333,6 +179,7 @@ func (m *mockS3Server) handleCompleteMultipart(w http.ResponseWriter, r *http.Re assembled = append(assembled, parts[i]...) } m.objects[path] = assembled + m.modTimes[path] = time.Now() delete(m.uploads, uploadID) m.mu.Unlock() @@ -402,7 +249,7 @@ func (m *mockS3Server) handleList(w http.ResponseWriter, r *http.Request, bucket contents = append(contents, s3ListObject{ Key: objKey, Size: int64(len(body)), - LastModified: m.lastModTime.Format(time.RFC3339), + LastModified: m.modTimes[key].Format(time.RFC3339), }) } } diff --git a/attachment/store_test.go b/attachment/store_test.go new file mode 100644 index 00000000..7b5a6013 --- /dev/null +++ b/attachment/store_test.go @@ -0,0 +1,252 @@ +package attachment + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/util" +) + +const testSizeLimit = 10 * 1024 + +func TestStore_WriteReadRemove(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Write + size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 0) + require.Nil(t, err) + require.Equal(t, int64(11), size) + require.Equal(t, int64(11), s.Size()) + + // Read back + reader, readSize, err := s.Read("abcdefghijkl") + require.Nil(t, err) + require.Equal(t, int64(11), readSize) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello world", string(data)) + + // Remove + require.Nil(t, s.Remove("abcdefghijkl")) + require.Equal(t, int64(0), s.Size()) + + // Read after remove should fail + _, _, err = s.Read("abcdefghijkl") + require.Error(t, err) + }) +} + +func TestStore_WriteRemoveMultiple(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + for i := 0; i < 5; i++ { + _, err := s.Write(fmt.Sprintf("abcdefghijk%d", i), bytes.NewReader(make([]byte, 100)), 0) + require.Nil(t, err) + } + require.Equal(t, int64(500), s.Size()) + + require.Nil(t, s.Remove("abcdefghijk1", "abcdefghijk3")) + require.Equal(t, int64(300), s.Size()) + + // Removed files should not be readable + _, _, err := s.Read("abcdefghijk1") + require.Error(t, err) + _, _, err = s.Read("abcdefghijk3") + require.Error(t, err) + + // Remaining files should still be readable + for _, id := range []string{"abcdefghijk0", "abcdefghijk2", "abcdefghijk4"} { + reader, _, err := s.Read(id) + require.Nil(t, err) + reader.Close() + } + }) +} + +func TestStore_WriteTotalSizeLimit(t *testing.T) { + forEachBackend(t, 100, func(t *testing.T, s *Store, _ func(string)) { + // First write fits + _, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 80)), 0) + require.Nil(t, err) + require.Equal(t, int64(80), s.Size()) + require.Equal(t, int64(20), s.Remaining()) + + // Second write exceeds total limit + _, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 50)), 0) + require.ErrorIs(t, err, util.ErrLimitReached) + }) +} + +func TestStore_WriteAdditionalLimiter(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + _, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100)) + require.ErrorIs(t, err, util.ErrLimitReached) + + // File should not be readable (was cleaned up) + _, _, err = s.Read("abcdefghijkl") + require.Error(t, err) + }) +} + +func TestStore_WriteWithLimiter(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + size, err := s.Write("abcdefghijkl", strings.NewReader("normal file"), 0, util.NewFixedLimiter(999)) + require.Nil(t, err) + require.Equal(t, int64(11), size) + require.Equal(t, int64(11), s.Size()) + }) +} + +func TestStore_ReadNotFound(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + _, _, err := s.Read("abcdefghijkl") + require.Error(t, err) + }) +} + +func TestStore_InvalidID(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + _, err := s.Write("bad", strings.NewReader("x"), 0) + require.Equal(t, errInvalidFileID, err) + + _, _, err = s.Read("bad") + require.Equal(t, errInvalidFileID, err) + + err = s.Remove("bad") + require.Equal(t, errInvalidFileID, err) + }) +} + +func TestStore_WriteUntrustedLengthExact(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 11) + require.Nil(t, err) + require.Equal(t, int64(11), size) + + reader, _, err := s.Read("abcdefghijkl") + require.Nil(t, err) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello world", string(data)) + }) +} + +func TestStore_WriteUntrustedLengthBodyLonger(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored + size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 5) + require.Nil(t, err) + require.Equal(t, int64(5), size) + + reader, _, err := s.Read("abcdefghijkl") + require.Nil(t, err) + data, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, "hello", string(data)) + }) +} + +func TestStore_WriteUntrustedLengthBodyShorter(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Body has 5 bytes, but we claim 100 — should fail + _, err := s.Write("abcdefghijkl", strings.NewReader("hello"), 100) + require.Error(t, err) + + // File should not be readable (was cleaned up) + _, _, err = s.Read("abcdefghijkl") + require.Error(t, err) + }) +} + +func TestStore_Sync(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) { + // Write some files + _, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0) + require.Nil(t, err) + _, err = s.Write("abcdefghijk1", strings.NewReader("file1"), 0) + require.Nil(t, err) + _, err = s.Write("abcdefghijk2", strings.NewReader("file2"), 0) + require.Nil(t, err) + + require.Equal(t, int64(15), s.Size()) + + // Set the ID provider to only know about file 0 and 2 + s.localIDs = func() ([]string, error) { + return []string{"abcdefghijk0", "abcdefghijk2"}, nil + } + + // Make file 1 old enough to be cleaned up + makeOld("abcdefghijk1") + + // Run sync + require.Nil(t, s.sync()) + + // File 1 should be deleted (orphan, old enough) + _, _, err = s.Read("abcdefghijk1") + require.Error(t, err) + + // Files 0 and 2 should still be readable + r, _, err := s.Read("abcdefghijk0") + require.Nil(t, err) + r.Close() + r, _, err = s.Read("abcdefghijk2") + require.Nil(t, err) + r.Close() + + // Size should be updated + require.Equal(t, int64(10), s.Size()) + }) +} + +func TestStore_Sync_SkipsRecentFiles(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Write a file + _, err := s.Write("abcdefghijk0", strings.NewReader("file0"), 0) + require.Nil(t, err) + + // Set the ID provider to return empty (no valid IDs) + s.localIDs = func() ([]string, error) { + return []string{}, nil + } + + // File was just created, so it should NOT be deleted (< 1 hour old) + require.Nil(t, s.sync()) + + // File should still exist + reader, _, err := s.Read("abcdefghijk0") + require.Nil(t, err) + reader.Close() + }) +} + +// forEachBackend runs f against both the file and S3 backends. It also provides a makeOld +// callback that makes a specific object's timestamp old enough for orphan cleanup (> 1 hour). +// For the file backend, this uses os.Chtimes; for the S3 backend, it sets the object's +// LastModified time in the mock server. Objects start with recent timestamps by default. +func forEachBackend(t *testing.T, totalSizeLimit int64, f func(t *testing.T, s *Store, makeOld func(string))) { + t.Run("file", func(t *testing.T) { + dir, s := newTestFileStore(t, totalSizeLimit) + makeOld := func(id string) { + oldTime := time.Unix(1, 0) + os.Chtimes(filepath.Join(dir, id), oldTime, oldTime) + } + f(t, s, makeOld) + }) + t.Run("s3", func(t *testing.T) { + server, mock := newMockS3Server() + defer server.Close() + s := newTestS3Store(t, server, "my-bucket", "pfx", totalSizeLimit) + makeOld := func(id string) { + mock.setModTime("my-bucket/pfx/"+id, time.Unix(1, 0)) + } + f(t, s, makeOld) + }) +} From f2d4575831a3f48014c56576e81686ce19312d79 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 08:15:23 -0400 Subject: [PATCH 021/126] Use real S3 for tests --- attachment/store_s3_test.go | 299 ++++++++---------------------------- attachment/store_test.go | 11 +- s3/client.go | 2 +- 3 files changed, 71 insertions(+), 241 deletions(-) diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index 2d4635ff..a41c6f8b 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -1,11 +1,9 @@ package attachment import ( - "encoding/xml" - "fmt" + "context" "io" - "net/http" - "net/http/httptest" + "os" "strings" "sync" "testing" @@ -15,13 +13,23 @@ import ( "heckel.io/ntfy/v2/s3" ) -// --- S3-specific tests --- - -func TestS3Store_WriteNoPrefix(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - - cache := newTestS3Store(t, server, "my-bucket", "", 10*1024) +func TestS3Store_WriteWithPrefix(t *testing.T) { + s3URL := os.Getenv("NTFY_TEST_ATTACHMENT_S3_URL") + if s3URL == "" { + t.Skip("NTFY_TEST_ATTACHMENT_S3_URL not set") + } + cfg, err := s3.ParseURL(s3URL) + require.Nil(t, err) + cfg.Prefix = "test-prefix" + client := s3.New(cfg) + deleteAllObjects(client) + backend := newS3Backend(client) + cache, err := newStore(backend, 10*1024, nil) + require.Nil(t, err) + t.Cleanup(func() { + deleteAllObjects(client) + cache.Close() + }) size, err := cache.Write("abcdefghijkl", strings.NewReader("test"), 0) require.Nil(t, err) @@ -37,241 +45,64 @@ func TestS3Store_WriteNoPrefix(t *testing.T) { // --- Helpers --- -func newTestS3Store(t *testing.T, server *httptest.Server, bucket, prefix string, totalSizeLimit int64) *Store { +func newTestRealS3Store(t *testing.T, totalSizeLimit int64) (*Store, *modTimeOverrideBackend) { t.Helper() - host := strings.TrimPrefix(server.URL, "https://") - backend := newS3Backend(s3.New(&s3.Config{ - AccessKey: "AKID", - SecretKey: "SECRET", - Region: "us-east-1", - Endpoint: host, - Bucket: bucket, - Prefix: prefix, - PathStyle: true, - HTTPClient: server.Client(), - })) - cache, err := newStore(backend, totalSizeLimit, nil) + s3URL := os.Getenv("NTFY_TEST_ATTACHMENT_S3_URL") + if s3URL == "" { + t.Skip("NTFY_TEST_ATTACHMENT_S3_URL not set") + } + cfg, err := s3.ParseURL(s3URL) require.Nil(t, err) - t.Cleanup(func() { cache.Close() }) - return cache + client := s3.New(cfg) + inner := newS3Backend(client) + wrapper := &modTimeOverrideBackend{backend: inner, modTimes: make(map[string]time.Time)} + deleteAllObjects(client) + store, err := newStore(wrapper, totalSizeLimit, nil) + require.Nil(t, err) + t.Cleanup(func() { + deleteAllObjects(client) + store.Close() + }) + return store, wrapper } -// --- Mock S3 server --- -// -// A minimal S3-compatible HTTP server that supports PutObject, GetObject, DeleteObjects, and -// ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory. - -type mockS3Server struct { - objects map[string][]byte // full key (bucket/key) -> body - modTimes map[string]time.Time // full key (bucket/key) -> last modified time - uploads map[string]map[int][]byte // uploadID -> partNumber -> data - nextID int // counter for generating upload IDs - mu sync.RWMutex -} - -func newMockS3Server() (*httptest.Server, *mockS3Server) { - m := &mockS3Server{ - objects: make(map[string][]byte), - modTimes: make(map[string]time.Time), - uploads: make(map[string]map[int][]byte), +func deleteAllObjects(client *s3.Client) { + objects, _ := client.ListObjectsV2(context.Background()) + keys := make([]string, 0, len(objects)) + for _, obj := range objects { + keys = append(keys, obj.Key) } - return httptest.NewTLSServer(m), m -} - -func (m *mockS3Server) setModTime(path string, t time.Time) { - m.mu.Lock() - m.modTimes[path] = t - m.mu.Unlock() -} - -func (m *mockS3Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Path is /{bucket}[/{key...}] - path := strings.TrimPrefix(r.URL.Path, "/") - q := r.URL.Query() - - switch { - case r.Method == http.MethodPut && q.Has("partNumber"): - m.handleUploadPart(w, r, path) - case r.Method == http.MethodPut: - m.handlePut(w, r, path) - case r.Method == http.MethodPost && q.Has("uploads"): - m.handleInitiateMultipart(w, r, path) - case r.Method == http.MethodPost && q.Has("uploadId"): - m.handleCompleteMultipart(w, r, path) - case r.Method == http.MethodDelete && q.Has("uploadId"): - m.handleAbortMultipart(w, r, path) - case r.Method == http.MethodGet && q.Get("list-type") == "2": - m.handleList(w, r, path) - case r.Method == http.MethodGet: - m.handleGet(w, r, path) - case r.Method == http.MethodPost && q.Has("delete"): - m.handleDelete(w, r, path) - default: - http.Error(w, "not implemented", http.StatusNotImplemented) + if len(keys) > 0 { + client.DeleteObjects(context.Background(), keys) //nolint:errcheck } } -func (m *mockS3Server) handlePut(w http.ResponseWriter, r *http.Request, path string) { - body, err := io.ReadAll(r.Body) +// modTimeOverrideBackend wraps a backend and allows overriding LastModified times returned by List(). +// This is used in tests to simulate old objects on backends (like real S3) where +// LastModified cannot be set directly. +type modTimeOverrideBackend struct { + backend + mu sync.Mutex + modTimes map[string]time.Time // object ID -> override time +} + +func (b *modTimeOverrideBackend) List() ([]object, error) { + objects, err := b.backend.List() if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + return nil, err } - m.mu.Lock() - m.objects[path] = body - m.modTimes[path] = time.Now() - m.mu.Unlock() - w.WriteHeader(http.StatusOK) -} - -func (m *mockS3Server) handleInitiateMultipart(w http.ResponseWriter, r *http.Request, path string) { - m.mu.Lock() - m.nextID++ - uploadID := fmt.Sprintf("upload-%d", m.nextID) - m.uploads[uploadID] = make(map[int][]byte) - m.mu.Unlock() - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, `%s`, uploadID) -} - -func (m *mockS3Server) handleUploadPart(w http.ResponseWriter, r *http.Request, path string) { - uploadID := r.URL.Query().Get("uploadId") - var partNumber int - fmt.Sscanf(r.URL.Query().Get("partNumber"), "%d", &partNumber) - - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - m.mu.Lock() - parts, ok := m.uploads[uploadID] - if !ok { - m.mu.Unlock() - http.Error(w, "NoSuchUpload", http.StatusNotFound) - return - } - parts[partNumber] = body - m.mu.Unlock() - - etag := fmt.Sprintf(`"etag-part-%d"`, partNumber) - w.Header().Set("ETag", etag) - w.WriteHeader(http.StatusOK) -} - -func (m *mockS3Server) handleCompleteMultipart(w http.ResponseWriter, r *http.Request, path string) { - uploadID := r.URL.Query().Get("uploadId") - - m.mu.Lock() - parts, ok := m.uploads[uploadID] - if !ok { - m.mu.Unlock() - http.Error(w, "NoSuchUpload", http.StatusNotFound) - return - } - - // Assemble parts in order - var assembled []byte - for i := 1; i <= len(parts); i++ { - assembled = append(assembled, parts[i]...) - } - m.objects[path] = assembled - m.modTimes[path] = time.Now() - delete(m.uploads, uploadID) - m.mu.Unlock() - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, `%s`, path) -} - -func (m *mockS3Server) handleAbortMultipart(w http.ResponseWriter, r *http.Request, path string) { - uploadID := r.URL.Query().Get("uploadId") - m.mu.Lock() - delete(m.uploads, uploadID) - m.mu.Unlock() - w.WriteHeader(http.StatusNoContent) -} - -func (m *mockS3Server) handleGet(w http.ResponseWriter, r *http.Request, path string) { - m.mu.RLock() - body, ok := m.objects[path] - m.mu.RUnlock() - if !ok { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`NoSuchKeyThe specified key does not exist.`)) - return - } - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) - w.WriteHeader(http.StatusOK) - w.Write(body) -} - -func (m *mockS3Server) handleDelete(w http.ResponseWriter, r *http.Request, bucketPath string) { - // bucketPath is just the bucket name - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - var req struct { - Objects []struct { - Key string `xml:"Key"` - } `xml:"Object"` - } - if err := xml.Unmarshal(body, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - m.mu.Lock() - for _, obj := range req.Objects { - delete(m.objects, bucketPath+"/"+obj.Key) - } - m.mu.Unlock() - w.WriteHeader(http.StatusOK) - w.Write([]byte(``)) -} - -func (m *mockS3Server) handleList(w http.ResponseWriter, r *http.Request, bucketPath string) { - prefix := r.URL.Query().Get("prefix") - m.mu.RLock() - var contents []s3ListObject - for key, body := range m.objects { - // key is "bucket/objectkey", strip bucket prefix - objKey := strings.TrimPrefix(key, bucketPath+"/") - if objKey == key { - continue // different bucket - } - if prefix == "" || strings.HasPrefix(objKey, prefix) { - contents = append(contents, s3ListObject{ - Key: objKey, - Size: int64(len(body)), - LastModified: m.modTimes[key].Format(time.RFC3339), - }) + b.mu.Lock() + defer b.mu.Unlock() + for i, obj := range objects { + if t, ok := b.modTimes[obj.ID]; ok { + objects[i].LastModified = t } } - m.mu.RUnlock() - - resp := s3ListResponse{ - Contents: contents, - IsTruncated: false, - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - xml.NewEncoder(w).Encode(resp) + return objects, nil } -type s3ListResponse struct { - XMLName xml.Name `xml:"ListBucketResult"` - Contents []s3ListObject `xml:"Contents"` - IsTruncated bool `xml:"IsTruncated"` -} - -type s3ListObject struct { - Key string `xml:"Key"` - Size int64 `xml:"Size"` - LastModified string `xml:"LastModified"` +func (b *modTimeOverrideBackend) setModTime(id string, t time.Time) { + b.mu.Lock() + b.modTimes[id] = t + b.mu.Unlock() } diff --git a/attachment/store_test.go b/attachment/store_test.go index 7b5a6013..645a2159 100644 --- a/attachment/store_test.go +++ b/attachment/store_test.go @@ -229,8 +229,9 @@ func TestStore_Sync_SkipsRecentFiles(t *testing.T) { // forEachBackend runs f against both the file and S3 backends. It also provides a makeOld // callback that makes a specific object's timestamp old enough for orphan cleanup (> 1 hour). -// For the file backend, this uses os.Chtimes; for the S3 backend, it sets the object's -// LastModified time in the mock server. Objects start with recent timestamps by default. +// For the file backend, this uses os.Chtimes; for the S3 backend, it overrides the object's +// LastModified time via a modTimeOverrideBackend wrapper. Objects start with recent timestamps +// by default. The S3 subtest is skipped if NTFY_TEST_ATTACHMENT_S3_URL is not set. func forEachBackend(t *testing.T, totalSizeLimit int64, f func(t *testing.T, s *Store, makeOld func(string))) { t.Run("file", func(t *testing.T) { dir, s := newTestFileStore(t, totalSizeLimit) @@ -241,11 +242,9 @@ func forEachBackend(t *testing.T, totalSizeLimit int64, f func(t *testing.T, s * f(t, s, makeOld) }) t.Run("s3", func(t *testing.T) { - server, mock := newMockS3Server() - defer server.Close() - s := newTestS3Store(t, server, "my-bucket", "pfx", totalSizeLimit) + s, wrapper := newTestRealS3Store(t, totalSizeLimit) makeOld := func(id string) { - mock.setModTime("my-bucket/pfx/"+id, time.Unix(1, 0)) + wrapper.setModTime(id, time.Unix(1, 0)) } f(t, s, makeOld) }) diff --git a/s3/client.go b/s3/client.go index 29cad3a4..d9ec1ab8 100644 --- a/s3/client.go +++ b/s3/client.go @@ -86,7 +86,7 @@ func (c *Client) putObject(ctx context.Context, key string, body io.Reader, size if err != nil { return fmt.Errorf("uploading object %s failed: %w", key, err) } - resp.Body.Close() + defer resp.Body.Close() if !isHTTPSuccess(resp) { return parseError(resp) } From f76135c5ab21e6f739a12697326292f8bc07efce Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 08:23:46 -0400 Subject: [PATCH 022/126] Large objects test --- attachment/store_test.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/attachment/store_test.go b/attachment/store_test.go index 645a2159..ec658114 100644 --- a/attachment/store_test.go +++ b/attachment/store_test.go @@ -123,6 +123,37 @@ func TestStore_InvalidID(t *testing.T) { }) } +func TestStore_WriteLargeObjects(t *testing.T) { + sizes := map[string]int64{ + "100B": 100, + "6MB": 6 * 1024 * 1024, + "12MB": 12 * 1024 * 1024, + } + for name, sz := range sizes { + t.Run(name, func(t *testing.T) { + forEachBackend(t, sz+1024, func(t *testing.T, s *Store, _ func(string)) { + data := make([]byte, sz) + for i := range data { + data[i] = byte(i % 251) + } + + size, err := s.Write("abcdefghijkl", bytes.NewReader(data), 0) + require.Nil(t, err) + require.Equal(t, sz, size) + require.Equal(t, sz, s.Size()) + + reader, readSize, err := s.Read("abcdefghijkl") + require.Nil(t, err) + require.Equal(t, sz, readSize) + got, err := io.ReadAll(reader) + reader.Close() + require.Nil(t, err) + require.Equal(t, data, got) + }) + }) + } +} + func TestStore_WriteUntrustedLengthExact(t *testing.T) { forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { size, err := s.Write("abcdefghijkl", strings.NewReader("hello world"), 11) From fa33d63138aa5dc230b3ef84e21fe5d63407d15d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 08:26:18 -0400 Subject: [PATCH 023/126] More tests --- attachment/store_test.go | 70 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/attachment/store_test.go b/attachment/store_test.go index ec658114..7ac7cddb 100644 --- a/attachment/store_test.go +++ b/attachment/store_test.go @@ -103,6 +103,76 @@ func TestStore_WriteWithLimiter(t *testing.T) { }) } +func TestStore_WriteOverwriteSameID(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Write 100 bytes + _, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 100)), 0) + require.Nil(t, err) + require.Equal(t, int64(100), s.Size()) + + // Overwrite with 50 bytes + _, err = s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 50)), 0) + require.Nil(t, err) + require.Equal(t, int64(150), s.Size()) // Store tracks both writes + + // Read back should return the latest content + reader, readSize, err := s.Read("abcdefghijkl") + require.Nil(t, err) + require.Equal(t, int64(50), readSize) + reader.Close() + }) +} + +func TestStore_WriteAfterFailure(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { + // Failed write: limiter rejects it + _, err := s.Write("abcdefghijkl", bytes.NewReader(make([]byte, 200)), 0, util.NewFixedLimiter(100)) + require.ErrorIs(t, err, util.ErrLimitReached) + require.Equal(t, int64(0), s.Size()) + + // Subsequent write with a different ID should succeed + size, err := s.Write("abcdefghijk2", strings.NewReader("hello"), 0) + require.Nil(t, err) + require.Equal(t, int64(5), size) + require.Equal(t, int64(5), s.Size()) + + // The failed ID should not be readable + _, _, err = s.Read("abcdefghijkl") + require.Error(t, err) + + // The successful ID should be readable + reader, _, err := s.Read("abcdefghijk2") + require.Nil(t, err) + reader.Close() + }) +} + +func TestStore_SyncRecomputesSize(t *testing.T) { + forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, makeOld func(string)) { + // Write two files + _, err := s.Write("abcdefghijk0", bytes.NewReader(make([]byte, 100)), 0) + require.Nil(t, err) + _, err = s.Write("abcdefghijk1", bytes.NewReader(make([]byte, 200)), 0) + require.Nil(t, err) + require.Equal(t, int64(300), s.Size()) + + // Corrupt the in-memory size tracking + s.mu.Lock() + s.size = 999 + s.mu.Unlock() + require.Equal(t, int64(999), s.Size()) + + // Set localIDs to include both files so nothing gets deleted + s.localIDs = func() ([]string, error) { + return []string{"abcdefghijk0", "abcdefghijk1"}, nil + } + + // Sync should recompute size from the backend + require.Nil(t, s.sync()) + require.Equal(t, int64(300), s.Size()) + }) +} + func TestStore_ReadNotFound(t *testing.T) { forEachBackend(t, testSizeLimit, func(t *testing.T, s *Store, _ func(string)) { _, _, err := s.Read("abcdefghijkl") From 56b63c475ceffb9ba40364c78f0c9c165856bc7b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 08:28:18 -0400 Subject: [PATCH 024/126] Remove MinIO mention --- docs/config.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config.md b/docs/config.md index 853d4891..ae7547b3 100644 --- a/docs/config.md +++ b/docs/config.md @@ -525,7 +525,7 @@ Here's an example config using the local filesystem for attachment storage: ### S3 storage As an alternative to the local filesystem, you can store attachments in an S3-compatible object store (e.g. AWS S3, -MinIO, DigitalOcean Spaces). This is useful for HA/cloud deployments where you don't want to rely on local disk storage. +DigitalOcean Spaces). This is useful for HA/cloud deployments where you don't want to rely on local disk storage. To use S3, set `attachment-cache-dir` to an S3 URL with the following format: @@ -533,7 +533,7 @@ To use S3, set `attachment-cache-dir` to an S3 URL with the following format: s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT] ``` -When `endpoint` is specified, path-style addressing is enabled automatically (useful for MinIO and other S3-compatible stores). +When `endpoint` is specified, path-style addressing is enabled automatically (useful for S3-compatible stores like DigitalOcean Spaces). === "/etc/ntfy/server.yml (AWS S3)" ``` yaml @@ -541,7 +541,7 @@ When `endpoint` is specified, path-style addressing is enabled automatically (us attachment-cache-dir: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1" ``` -=== "/etc/ntfy/server.yml (MinIO/custom endpoint)" +=== "/etc/ntfy/server.yml (custom endpoint)" ``` yaml base-url: "https://ntfy.sh" attachment-cache-dir: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" From 536c6f58072504a5c38ed04339785dd0ba8be609 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 08:38:41 -0400 Subject: [PATCH 025/126] More consistent logging --- attachment/backend_file.go | 8 +- attachment/backend_s3.go | 12 +- attachment/store.go | 3 + go.mod | 2 +- go.sum | 4 +- server/server.go | 28 ++--- server/server_manager.go | 12 +- web/package-lock.json | 224 ++++++++++++++++++------------------- 8 files changed, 142 insertions(+), 151 deletions(-) diff --git a/attachment/backend_file.go b/attachment/backend_file.go index 8726ddf4..e86ff1ec 100644 --- a/attachment/backend_file.go +++ b/attachment/backend_file.go @@ -6,12 +6,8 @@ import ( "os" "path/filepath" "time" - - "heckel.io/ntfy/v2/log" ) -const tagFileBackend = "attachment_file" - type fileBackend struct { dir string } @@ -86,8 +82,8 @@ func (b *fileBackend) Get(id string) (io.ReadCloser, int64, error) { func (b *fileBackend) Delete(ids ...string) error { for _, id := range ids { file := filepath.Join(b.dir, id) - if err := os.Remove(file); err != nil { - log.Tag(tagFileBackend).Field("message_id", id).Err(err).Debug("Error deleting attachment") + if err := os.Remove(file); err != nil && !os.IsNotExist(err) { + return err } } return nil diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go index 081d6002..44f946f6 100644 --- a/attachment/backend_s3.go +++ b/attachment/backend_s3.go @@ -5,14 +5,10 @@ import ( "io" "time" - "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/s3" ) -const ( - tagS3Backend = "attachment_s3" - deleteBatchSize = 1000 -) +const deleteBatchSize = 1000 type s3Backend struct { client *s3.Client @@ -55,11 +51,7 @@ func (b *s3Backend) Delete(ids ...string) error { if end > len(ids) { end = len(ids) } - batch := ids[i:end] - for _, id := range batch { - log.Tag(tagS3Backend).Field("message_id", id).Debug("Deleting attachment from S3") - } - if err := b.client.DeleteObjects(context.Background(), batch); err != nil { + if err := b.client.DeleteObjects(context.Background(), ids[i:end]); err != nil { return err } } diff --git a/attachment/store.go b/attachment/store.go index 6e7cfb99..7d6b0620 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -113,6 +113,9 @@ func (c *Store) Remove(ids ...string) error { } } // Remove from backend + for _, id := range ids { + log.Tag(tagStore).Field("message_id", id).Debug("Removing attachment") + } if err := c.backend.Delete(ids...); err != nil { return err } diff --git a/go.mod b/go.mod index 4f0451b6..7edd3710 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,7 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.19.0 github.com/SherClockHolmes/webpush-go v1.4.0 - github.com/jackc/pgx/v5 v5.8.0 + github.com/jackc/pgx/v5 v5.9.0 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 diff --git a/go.sum b/go.sum index 0851929d..97738e0f 100644 --- a/go.sum +++ b/go.sum @@ -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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.0 h1:T/dI+2TvmI2H8s/KH1/lXIbz1CUFk3gn5oTjr0/mBsE= +github.com/jackc/pgx/v5 v5.9.0/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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= diff --git a/server/server.go b/server/server.go index 87eee5d6..77e7b0c0 100644 --- a/server/server.go +++ b/server/server.go @@ -65,7 +65,7 @@ type Server struct { userManager *user.Manager // Might be nil! messageCache *message.Cache // Database that stores the messages webPush *webpush.Store // Database that stores web push subscriptions - fileCache *attachment.Store // Attachment store (file system or S3) + attachment *attachment.Store // Attachment store (file system or S3) stripe stripeAPI // Stripe API, can be replaced with a mock priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set @@ -229,7 +229,7 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } - fileCache, err := createAttachmentStore(conf, messageCache) + attachmentStore, err := createAttachmentStore(conf, messageCache) if err != nil { return nil, err } @@ -275,7 +275,7 @@ func New(conf *Config) (*Server, error) { db: pool, messageCache: messageCache, webPush: wp, - fileCache: fileCache, + attachment: attachmentStore, firebaseClient: firebaseClient, smtpSender: mailer, topics: topics, @@ -432,8 +432,8 @@ func (s *Server) Stop() { if s.smtpServer != nil { s.smtpServer.Close() } - if s.fileCache != nil { - s.fileCache.Close() + if s.attachment != nil { + s.attachment.Close() } s.closeDatabases() close(s.closeChan) @@ -609,7 +609,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureWebEnabled(s.handleStatic)(w, r, v) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.ensureWebEnabled(s.handleDocs)(w, r, v) - } else if (r.Method == http.MethodGet || r.Method == http.MethodHead) && fileRegex.MatchString(r.URL.Path) && s.fileCache != nil { + } else if (r.Method == http.MethodGet || r.Method == http.MethodHead) && fileRegex.MatchString(r.URL.Path) && s.attachment != nil { return s.limitRequests(s.handleFile)(w, r, v) } else if r.Method == http.MethodOptions { return s.limitRequests(s.handleOptions)(w, r, v) // Should work even if the web app is not enabled, see #598 @@ -766,7 +766,7 @@ func (s *Server) handleStats(w http.ResponseWriter, _ *http.Request, _ *visitor) // Before streaming the file to a client, it locates uploader (m.Sender or m.User) in the message cache, so it // can associate the download bandwidth with the uploader. func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) error { - if s.fileCache == nil { + if s.attachment == nil { return errHTTPInternalError } matches := fileRegex.FindStringSubmatch(r.URL.Path) @@ -774,7 +774,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) return errHTTPInternalErrorInvalidPath } messageID := matches[1] - reader, size, err := s.fileCache.Read(messageID) + reader, size, err := s.attachment.Read(messageID) if err != nil { return errHTTPNotFound.Fields(log.Context{ "message_id": messageID, @@ -935,8 +935,8 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess return nil, err } // Delete attachment files for deleted scheduled messages - if s.fileCache != nil && len(deletedIDs) > 0 { - if err := s.fileCache.Remove(deletedIDs...); err != nil { + if s.attachment != nil && len(deletedIDs) > 0 { + if err := s.attachment.Remove(deletedIDs...); err != nil { logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages") } } @@ -1042,8 +1042,8 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * return err } // Delete attachment files for deleted scheduled messages - if s.fileCache != nil && len(deletedIDs) > 0 { - if err := s.fileCache.Remove(deletedIDs...); err != nil { + if s.attachment != nil && len(deletedIDs) > 0 { + if err := s.attachment.Remove(deletedIDs...); err != nil { logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages") } } @@ -1421,7 +1421,7 @@ func (s *Server) renderTemplate(name, tpl, source string) (string, error) { } func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Message, body *util.PeekedReadCloser) error { - if s.fileCache == nil || s.config.BaseURL == "" { + if s.attachment == nil || s.config.BaseURL == "" { return errHTTPBadRequestAttachmentsDisallowed.With(m) } vinfo, err := v.Info() @@ -1458,7 +1458,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me util.NewFixedLimiter(vinfo.Limits.AttachmentFileSizeLimit), util.NewFixedLimiter(vinfo.Stats.AttachmentTotalSizeRemaining), } - m.Attachment.Size, err = s.fileCache.Write(m.ID, body, r.ContentLength, limiters...) + m.Attachment.Size, err = s.attachment.Write(m.ID, body, r.ContentLength, limiters...) if errors.Is(err, util.ErrLimitReached) { return errHTTPEntityTooLargeAttachment.With(m) } else if err != nil { diff --git a/server/server_manager.go b/server/server_manager.go index 5bf42924..89ff38c2 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -99,8 +99,8 @@ func (s *Server) execManager() { mset(metricUsers, usersCount) mset(metricSubscribers, subscribers) mset(metricTopics, topicsCount) - if s.fileCache != nil { - mset(metricAttachmentsTotalSize, s.fileCache.Size()) + if s.attachment != nil { + mset(metricAttachmentsTotalSize, s.attachment.Size()) } } @@ -140,7 +140,7 @@ func (s *Server) pruneTokens() { } func (s *Server) pruneAttachments() { - if s.fileCache == nil { + if s.attachment == nil { return } log. @@ -153,7 +153,7 @@ func (s *Server) pruneAttachments() { if log.Tag(tagManager).IsDebug() { log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", ")) } - if err := s.fileCache.Remove(ids...); err != nil { + 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 { @@ -174,8 +174,8 @@ func (s *Server) pruneMessages() { if err != nil { log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages") } else if len(expiredMessageIDs) > 0 { - if s.fileCache != nil { - if err := s.fileCache.Remove(expiredMessageIDs...); err != nil { + if s.attachment != nil { + if err := s.attachment.Remove(expiredMessageIDs...); err != nil { log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages") } } diff --git a/web/package-lock.json b/web/package-lock.json index e2ec6f9f..175ef11b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2738,9 +2738,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", "cpu": [ "arm" ], @@ -2752,9 +2752,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", "cpu": [ "arm64" ], @@ -2766,9 +2766,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", "cpu": [ "arm64" ], @@ -2780,9 +2780,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", "cpu": [ "x64" ], @@ -2794,9 +2794,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", "cpu": [ "arm64" ], @@ -2808,9 +2808,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", "cpu": [ "x64" ], @@ -2822,9 +2822,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", "cpu": [ "arm" ], @@ -2836,9 +2836,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", "cpu": [ "arm" ], @@ -2850,9 +2850,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", "cpu": [ "arm64" ], @@ -2864,9 +2864,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", "cpu": [ "arm64" ], @@ -2878,9 +2878,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", "cpu": [ "loong64" ], @@ -2892,9 +2892,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", "cpu": [ "loong64" ], @@ -2906,9 +2906,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", "cpu": [ "ppc64" ], @@ -2920,9 +2920,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", "cpu": [ "ppc64" ], @@ -2934,9 +2934,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", "cpu": [ "riscv64" ], @@ -2948,9 +2948,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", "cpu": [ "riscv64" ], @@ -2962,9 +2962,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", "cpu": [ "s390x" ], @@ -2976,9 +2976,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", "cpu": [ "x64" ], @@ -2990,9 +2990,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", "cpu": [ "x64" ], @@ -3004,9 +3004,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", "cpu": [ "x64" ], @@ -3018,9 +3018,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", "cpu": [ "arm64" ], @@ -3032,9 +3032,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", "cpu": [ "arm64" ], @@ -3046,9 +3046,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", "cpu": [ "ia32" ], @@ -3060,9 +3060,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", "cpu": [ "x64" ], @@ -3074,9 +3074,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", "cpu": [ "x64" ], @@ -3642,9 +3642,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.9", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", - "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3940,9 +3940,9 @@ } }, "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", "engines": { "node": ">= 6" @@ -7580,9 +7580,9 @@ } }, "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7596,31 +7596,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" } }, @@ -9515,9 +9515,9 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "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, From 59ec76e8b2ac6f593049ab18dd6f9d0f0da0ec58 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 15:10:28 -0400 Subject: [PATCH 026/126] Fix brittle tests, move delete batching into client package, run s3 tests against real bucket --- attachment/backend_s3.go | 14 +- attachment/store_s3_test.go | 44 ++- attachment/store_test.go | 2 +- s3/client.go | 23 +- s3/client_test.go | 679 +++++------------------------------- s3/util.go | 3 + 6 files changed, 137 insertions(+), 628 deletions(-) diff --git a/attachment/backend_s3.go b/attachment/backend_s3.go index 44f946f6..9a2d4bef 100644 --- a/attachment/backend_s3.go +++ b/attachment/backend_s3.go @@ -8,8 +8,6 @@ import ( "heckel.io/ntfy/v2/s3" ) -const deleteBatchSize = 1000 - type s3Backend struct { client *s3.Client } @@ -45,17 +43,7 @@ func (b *s3Backend) List() ([]object, error) { } func (b *s3Backend) Delete(ids ...string) error { - // S3 DeleteObjects supports up to 1000 keys per call - for i := 0; i < len(ids); i += deleteBatchSize { - end := i + deleteBatchSize - if end > len(ids) { - end = len(ids) - } - if err := b.client.DeleteObjects(context.Background(), ids[i:end]); err != nil { - return err - } - } - return nil + return b.client.DeleteObjects(context.Background(), ids) } func (b *s3Backend) DeleteIncomplete(cutoff time.Time) error { diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index a41c6f8b..6615f4e9 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -14,20 +14,20 @@ import ( ) func TestS3Store_WriteWithPrefix(t *testing.T) { - s3URL := os.Getenv("NTFY_TEST_ATTACHMENT_S3_URL") + s3URL := os.Getenv("NTFY_TEST_S3_URL") if s3URL == "" { - t.Skip("NTFY_TEST_ATTACHMENT_S3_URL not set") + t.Skip("NTFY_TEST_S3_URL not set") } cfg, err := s3.ParseURL(s3URL) require.Nil(t, err) cfg.Prefix = "test-prefix" client := s3.New(cfg) - deleteAllObjects(client) + deleteAllObjects(t, client) backend := newS3Backend(client) cache, err := newStore(backend, 10*1024, nil) require.Nil(t, err) t.Cleanup(func() { - deleteAllObjects(client) + deleteAllObjects(t, client) cache.Close() }) @@ -47,34 +47,46 @@ func TestS3Store_WriteWithPrefix(t *testing.T) { func newTestRealS3Store(t *testing.T, totalSizeLimit int64) (*Store, *modTimeOverrideBackend) { t.Helper() - s3URL := os.Getenv("NTFY_TEST_ATTACHMENT_S3_URL") + s3URL := os.Getenv("NTFY_TEST_S3_URL") if s3URL == "" { - t.Skip("NTFY_TEST_ATTACHMENT_S3_URL not set") + t.Skip("NTFY_TEST_S3_URL not set") } cfg, err := s3.ParseURL(s3URL) require.Nil(t, err) + if cfg.Prefix != "" { + cfg.Prefix = cfg.Prefix + "/testpkg-attachment" + } else { + cfg.Prefix = "testpkg-attachment" + } client := s3.New(cfg) inner := newS3Backend(client) wrapper := &modTimeOverrideBackend{backend: inner, modTimes: make(map[string]time.Time)} - deleteAllObjects(client) + deleteAllObjects(t, client) store, err := newStore(wrapper, totalSizeLimit, nil) require.Nil(t, err) t.Cleanup(func() { - deleteAllObjects(client) + deleteAllObjects(t, client) store.Close() }) return store, wrapper } -func deleteAllObjects(client *s3.Client) { - objects, _ := client.ListObjectsV2(context.Background()) - keys := make([]string, 0, len(objects)) - for _, obj := range objects { - keys = append(keys, obj.Key) - } - if len(keys) > 0 { - client.DeleteObjects(context.Background(), keys) //nolint:errcheck +func deleteAllObjects(t *testing.T, client *s3.Client) { + t.Helper() + for i := 0; i < 20; i++ { + objects, err := client.ListObjectsV2(context.Background()) + require.Nil(t, err) + if len(objects) == 0 { + return + } + keys := make([]string, len(objects)) + for j, obj := range objects { + keys[j] = obj.Key + } + require.Nil(t, client.DeleteObjects(context.Background(), keys)) + time.Sleep(200 * time.Millisecond) } + t.Fatal("timed out waiting for bucket to be empty") } // modTimeOverrideBackend wraps a backend and allows overriding LastModified times returned by List(). diff --git a/attachment/store_test.go b/attachment/store_test.go index 7ac7cddb..11d0b244 100644 --- a/attachment/store_test.go +++ b/attachment/store_test.go @@ -332,7 +332,7 @@ func TestStore_Sync_SkipsRecentFiles(t *testing.T) { // callback that makes a specific object's timestamp old enough for orphan cleanup (> 1 hour). // For the file backend, this uses os.Chtimes; for the S3 backend, it overrides the object's // LastModified time via a modTimeOverrideBackend wrapper. Objects start with recent timestamps -// by default. The S3 subtest is skipped if NTFY_TEST_ATTACHMENT_S3_URL is not set. +// by default. The S3 subtest is skipped if NTFY_TEST_S3_URL is not set. func forEachBackend(t *testing.T, totalSizeLimit int64, f func(t *testing.T, s *Store, makeOld func(string))) { t.Run("file", func(t *testing.T) { dir, s := newTestFileStore(t, totalSizeLimit) diff --git a/s3/client.go b/s3/client.go index d9ec1ab8..e06ff5c9 100644 --- a/s3/client.go +++ b/s3/client.go @@ -11,7 +11,6 @@ import ( "io" "net/http" "net/url" - "strconv" "strings" "time" @@ -125,7 +124,7 @@ func (c *Client) ListObjectsV2(ctx context.Context) ([]*Object, error) { var all []*Object var token string for page := 0; page < maxPages; page++ { - result, err := c.listObjectsV2(ctx, token, 0) + result, err := c.listObjectsV2(ctx, token) if err != nil { return nil, err } @@ -149,8 +148,7 @@ func (c *Client) ListObjectsV2(ctx context.Context) ([]*Object, error) { } // listObjectsV2 performs a single ListObjectsV2 request using the client's configured prefix. -// Use continuationToken for pagination. Set maxKeys to 0 for the server default (typically 1000). -func (c *Client) listObjectsV2(ctx context.Context, continuationToken string, maxKeys int) (*listObjectsV2Result, error) { +func (c *Client) listObjectsV2(ctx context.Context, continuationToken string) (*listObjectsV2Result, error) { log.Tag(tagS3Client).Debug("Listing remote objects with continuation token '%s'", continuationToken) query := url.Values{"list-type": {"2"}} if prefix := c.config.ListPrefix(); prefix != "" { @@ -159,9 +157,6 @@ func (c *Client) listObjectsV2(ctx context.Context, continuationToken string, ma if continuationToken != "" { query.Set("continuation-token", continuationToken) } - if maxKeys > 0 { - query.Set("max-keys", strconv.Itoa(maxKeys)) - } respBody, err := c.do(ctx, "ListObjects", http.MethodGet, c.config.BucketURL()+"?"+query.Encode(), nil, nil) if err != nil { return nil, err @@ -182,6 +177,20 @@ func (c *Client) listObjectsV2(ctx context.Context, continuationToken string, ma // // See https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html func (c *Client) DeleteObjects(ctx context.Context, keys []string) error { + // S3 DeleteObjects supports up to 1000 keys per call + for i := 0; i < len(keys); i += maxDeleteBatchSize { + end := i + maxDeleteBatchSize + if end > len(keys) { + end = len(keys) + } + if err := c.deleteObjects(ctx, keys[i:end]); err != nil { + return err + } + } + return nil +} + +func (c *Client) deleteObjects(ctx context.Context, keys []string) error { log.Tag(tagS3Client).Debug("Deleting %d object(s)", len(keys)) req := &deleteObjectsRequest{ Quiet: true, diff --git a/s3/client_test.go b/s3/client_test.go index 652db3e7..f4b85089 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -3,13 +3,9 @@ package s3 import ( "bytes" "context" - "encoding/xml" "fmt" "io" - "net/http" - "net/http/httptest" "os" - "sort" "strings" "sync" "testing" @@ -18,271 +14,6 @@ import ( "github.com/stretchr/testify/require" ) -// --- Mock S3 server --- -// -// A minimal S3-compatible HTTP server that supports PutObject, GetObject, DeleteObjects, and -// ListObjectsV2. Uses path-style addressing: /{bucket}/{key}. Objects are stored in memory. - -type mockS3Server struct { - objects map[string][]byte // full key (bucket/key) -> body - uploads map[string]map[int][]byte // uploadID -> partNumber -> data - nextID int // counter for generating upload IDs - mu sync.RWMutex -} - -func newMockS3Server() (*httptest.Server, *mockS3Server) { - m := &mockS3Server{ - objects: make(map[string][]byte), - uploads: make(map[string]map[int][]byte), - } - return httptest.NewTLSServer(m), m -} - -func (m *mockS3Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Path is /{bucket}[/{key...}] - path := strings.TrimPrefix(r.URL.Path, "/") - q := r.URL.Query() - - switch { - case r.Method == http.MethodPut && q.Has("partNumber"): - m.handleUploadPart(w, r, path) - case r.Method == http.MethodPut: - m.handlePut(w, r, path) - case r.Method == http.MethodPost && q.Has("uploads"): - m.handleInitiateMultipart(w, r, path) - case r.Method == http.MethodPost && q.Has("uploadId"): - m.handleCompleteMultipart(w, r, path) - case r.Method == http.MethodDelete && q.Has("uploadId"): - m.handleAbortMultipart(w, r, path) - case r.Method == http.MethodGet && q.Get("list-type") == "2": - m.handleList(w, r, path) - case r.Method == http.MethodGet: - m.handleGet(w, r, path) - case r.Method == http.MethodPost && q.Has("delete"): - m.handleDelete(w, r, path) - default: - http.Error(w, "not implemented", http.StatusNotImplemented) - } -} - -func (m *mockS3Server) handlePut(w http.ResponseWriter, r *http.Request, path string) { - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - m.mu.Lock() - m.objects[path] = body - m.mu.Unlock() - w.WriteHeader(http.StatusOK) -} - -func (m *mockS3Server) handleInitiateMultipart(w http.ResponseWriter, r *http.Request, path string) { - m.mu.Lock() - m.nextID++ - uploadID := fmt.Sprintf("upload-%d", m.nextID) - m.uploads[uploadID] = make(map[int][]byte) - m.mu.Unlock() - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, `%s`, uploadID) -} - -func (m *mockS3Server) handleUploadPart(w http.ResponseWriter, r *http.Request, path string) { - uploadID := r.URL.Query().Get("uploadId") - var partNumber int - fmt.Sscanf(r.URL.Query().Get("partNumber"), "%d", &partNumber) - - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - m.mu.Lock() - parts, ok := m.uploads[uploadID] - if !ok { - m.mu.Unlock() - http.Error(w, "NoSuchUpload", http.StatusNotFound) - return - } - parts[partNumber] = body - m.mu.Unlock() - - etag := fmt.Sprintf(`"etag-part-%d"`, partNumber) - w.Header().Set("ETag", etag) - w.WriteHeader(http.StatusOK) -} - -func (m *mockS3Server) handleCompleteMultipart(w http.ResponseWriter, r *http.Request, path string) { - uploadID := r.URL.Query().Get("uploadId") - - m.mu.Lock() - parts, ok := m.uploads[uploadID] - if !ok { - m.mu.Unlock() - http.Error(w, "NoSuchUpload", http.StatusNotFound) - return - } - - // Assemble parts in order - var assembled []byte - for i := 1; i <= len(parts); i++ { - assembled = append(assembled, parts[i]...) - } - m.objects[path] = assembled - delete(m.uploads, uploadID) - m.mu.Unlock() - - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - fmt.Fprintf(w, `%s`, path) -} - -func (m *mockS3Server) handleAbortMultipart(w http.ResponseWriter, r *http.Request, path string) { - uploadID := r.URL.Query().Get("uploadId") - m.mu.Lock() - delete(m.uploads, uploadID) - m.mu.Unlock() - w.WriteHeader(http.StatusNoContent) -} - -func (m *mockS3Server) handleGet(w http.ResponseWriter, r *http.Request, path string) { - m.mu.RLock() - body, ok := m.objects[path] - m.mu.RUnlock() - if !ok { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`NoSuchKeyThe specified key does not exist.`)) - return - } - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(body))) - w.WriteHeader(http.StatusOK) - w.Write(body) -} - -type listObjectsResponse struct { - XMLName xml.Name `xml:"ListBucketResult"` - Contents []listObject `xml:"Contents"` - // Pagination support - IsTruncated bool `xml:"IsTruncated"` - NextContinuationToken string `xml:"NextContinuationToken"` -} - -func (m *mockS3Server) handleDelete(w http.ResponseWriter, r *http.Request, bucketPath string) { - // bucketPath is just the bucket name - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - var req struct { - Objects []struct { - Key string `xml:"Key"` - } `xml:"Object"` - } - if err := xml.Unmarshal(body, &req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - m.mu.Lock() - for _, obj := range req.Objects { - delete(m.objects, bucketPath+"/"+obj.Key) - } - m.mu.Unlock() - w.WriteHeader(http.StatusOK) - w.Write([]byte(``)) -} - -func (m *mockS3Server) handleList(w http.ResponseWriter, r *http.Request, bucketPath string) { - prefix := r.URL.Query().Get("prefix") - contToken := r.URL.Query().Get("continuation-token") - - m.mu.RLock() - var allKeys []string - for key := range m.objects { - objKey := strings.TrimPrefix(key, bucketPath+"/") - if objKey == key { - continue // different bucket - } - if prefix == "" || strings.HasPrefix(objKey, prefix) { - allKeys = append(allKeys, objKey) - } - } - m.mu.RUnlock() - sort.Strings(allKeys) - - // Simple continuation token: it's the key to start after - startIdx := 0 - if contToken != "" { - for i, k := range allKeys { - if k == contToken { - startIdx = i + 1 - break - } - } - } - - maxKeys := 1000 - if mk := r.URL.Query().Get("max-keys"); mk != "" { - fmt.Sscanf(mk, "%d", &maxKeys) - } - - endIdx := startIdx + maxKeys - truncated := false - nextToken := "" - if endIdx < len(allKeys) { - truncated = true - nextToken = allKeys[endIdx-1] - allKeys = allKeys[startIdx:endIdx] - } else { - allKeys = allKeys[startIdx:] - } - - m.mu.RLock() - var contents []listObject - for _, objKey := range allKeys { - body := m.objects[bucketPath+"/"+objKey] - contents = append(contents, listObject{Key: objKey, Size: int64(len(body)), LastModified: time.Now().Format(time.RFC3339)}) - } - m.mu.RUnlock() - - resp := listObjectsResponse{ - Contents: contents, - IsTruncated: truncated, - NextContinuationToken: nextToken, - } - w.Header().Set("Content-Type", "application/xml") - w.WriteHeader(http.StatusOK) - xml.NewEncoder(w).Encode(resp) -} - -func (m *mockS3Server) objectCount() int { - m.mu.RLock() - defer m.mu.RUnlock() - return len(m.objects) -} - -// --- Helper to create a test client pointing at mock server --- - -func newTestClient(server *httptest.Server, bucket, prefix string) *Client { - // httptest.NewTLSServer URL is like "https://127.0.0.1:PORT" - host := strings.TrimPrefix(server.URL, "https://") - return New(&Config{ - AccessKey: "AKID", - SecretKey: "SECRET", - Region: "us-east-1", - Endpoint: host, - Bucket: bucket, - Prefix: prefix, - PathStyle: true, - HTTPClient: server.Client(), - }) -} - -// --- URL parsing tests --- - func TestParseURL_Success(t *testing.T) { cfg, err := ParseURL("s3://AKID:SECRET@my-bucket/attachments?region=us-east-1") require.Nil(t, err) @@ -409,13 +140,10 @@ func TestConfig_ListPrefix(t *testing.T) { require.Equal(t, "", c2.ListPrefix()) } -// --- Integration tests using mock S3 server --- +// --- Integration tests using real S3 --- func TestClient_PutGetObject(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "") - + client := newTestClient(t) ctx := context.Background() // Put @@ -432,152 +160,85 @@ func TestClient_PutGetObject(t *testing.T) { require.Equal(t, "hello world", string(data)) } -func TestClient_PutGetObject_WithPrefix(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "pfx") - - ctx := context.Background() - - err := client.PutObject(ctx, "test-key", strings.NewReader("hello"), 0) - require.Nil(t, err) - - reader, _, err := client.GetObject(ctx, "test-key") - require.Nil(t, err) - data, _ := io.ReadAll(reader) - reader.Close() - require.Equal(t, "hello", string(data)) -} - func TestClient_GetObject_NotFound(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "") + client := newTestClient(t) _, _, err := client.GetObject(context.Background(), "nonexistent") require.Error(t, err) - var errResp *errorResponse - require.ErrorAs(t, err, &errResp) - require.Equal(t, 404, errResp.StatusCode) - require.Equal(t, "NoSuchKey", errResp.Code) } func TestClient_DeleteObjects(t *testing.T) { - server, mock := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "") - + client := newTestClient(t) ctx := context.Background() // Put several objects for i := 0; i < 5; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%d", i), bytes.NewReader([]byte("data")), 0) + err := client.PutObject(ctx, fmt.Sprintf("del-%d", i), bytes.NewReader([]byte("data")), 0) require.Nil(t, err) } - require.Equal(t, 5, mock.objectCount()) + waitForCount(t, client, 5) // Delete some - err := client.DeleteObjects(ctx, []string{"key-1", "key-3"}) + err := client.DeleteObjects(ctx, []string{"del-1", "del-3"}) require.Nil(t, err) - require.Equal(t, 3, mock.objectCount()) + waitForCount(t, client, 3) // Verify deleted ones are gone - _, _, err = client.GetObject(ctx, "key-1") + _, _, err = client.GetObject(ctx, "del-1") require.Error(t, err) - _, _, err = client.GetObject(ctx, "key-3") + _, _, err = client.GetObject(ctx, "del-3") require.Error(t, err) // Verify remaining ones are still there - reader, _, err := client.GetObject(ctx, "key-0") - require.Nil(t, err) - reader.Close() + for _, key := range []string{"del-0", "del-2", "del-4"} { + reader, _, err := client.GetObject(ctx, key) + require.Nil(t, err) + reader.Close() + } } func TestClient_ListObjects(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - + client := newTestClient(t) ctx := context.Background() - // Client with prefix "pfx": list should only return objects under pfx/ - client := newTestClient(server, "my-bucket", "pfx") for i := 0; i < 3; i++ { - err := client.PutObject(ctx, fmt.Sprintf("%d", i), bytes.NewReader([]byte("x")), 0) + err := client.PutObject(ctx, fmt.Sprintf("list-%d", i), bytes.NewReader([]byte("x")), 0) require.Nil(t, err) } - - // Also put an object outside the prefix using a no-prefix client - clientNoPrefix := newTestClient(server, "my-bucket", "") - err := clientNoPrefix.PutObject(ctx, "other", bytes.NewReader([]byte("y")), 0) - require.Nil(t, err) - - // List with prefix client: should only see 3 - result, err := client.listObjectsV2(ctx, "", 0) - require.Nil(t, err) - require.Len(t, result.Contents, 3) - require.False(t, result.IsTruncated) - - // List with no-prefix client: should see all 4 - result, err = clientNoPrefix.listObjectsV2(ctx, "", 0) - require.Nil(t, err) - require.Len(t, result.Contents, 4) + waitForCount(t, client, 3) } func TestClient_ListObjects_Pagination(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "") - + client := newTestClient(t) ctx := context.Background() - // Put 5 objects - for i := 0; i < 5; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x")), 0) + // Create 1010 objects in parallel (5 goroutines) + const total = 1010 + const workers = 5 + var wg sync.WaitGroup + errs := make(chan error, total) + for w := 0; w < workers; w++ { + wg.Add(1) + go func(start int) { + defer wg.Done() + for i := start; i < total; i += workers { + if err := client.PutObject(ctx, fmt.Sprintf("pg-%04d", i), bytes.NewReader([]byte("x")), 0); err != nil { + errs <- err + return + } + } + }(w) + } + wg.Wait() + close(errs) + for err := range errs { require.Nil(t, err) } - - // List with max-keys=2 - result, err := client.listObjectsV2(ctx, "", 2) - require.Nil(t, err) - require.Len(t, result.Contents, 2) - require.True(t, result.IsTruncated) - require.NotEmpty(t, result.NextContinuationToken) - - // Get next page - result2, err := client.listObjectsV2(ctx, result.NextContinuationToken, 2) - require.Nil(t, err) - require.Len(t, result2.Contents, 2) - require.True(t, result2.IsTruncated) - - // Get last page - result3, err := client.listObjectsV2(ctx, result2.NextContinuationToken, 2) - require.Nil(t, err) - require.Len(t, result3.Contents, 1) - require.False(t, result3.IsTruncated) -} - -func TestClient_ListAllObjects(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "pfx") - - ctx := context.Background() - - for i := 0; i < 10; i++ { - err := client.PutObject(ctx, fmt.Sprintf("key-%02d", i), bytes.NewReader([]byte("x")), 0) - require.Nil(t, err) - } - - objects, err := client.ListObjectsV2(ctx) - require.Nil(t, err) - require.Len(t, objects, 10) + waitForCount(t, client, total) } func TestClient_PutObject_LargeBody(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "") - + client := newTestClient(t) ctx := context.Background() // 1 MB object @@ -598,10 +259,7 @@ func TestClient_PutObject_LargeBody(t *testing.T) { } func TestClient_PutObject_ChunkedUpload(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "") - + client := newTestClient(t) ctx := context.Background() // 12 MB object, exceeds 5 MB partSize, triggers multipart upload path @@ -622,10 +280,7 @@ func TestClient_PutObject_ChunkedUpload(t *testing.T) { } func TestClient_PutObject_ExactPartSize(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "") - + client := newTestClient(t) ctx := context.Background() // Exactly 5 MB (partSize), should use the simple put path (ReadFull succeeds fully) @@ -646,10 +301,7 @@ func TestClient_PutObject_ExactPartSize(t *testing.T) { } func TestClient_PutObject_StreamingExactLength(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "pfx") - + client := newTestClient(t) ctx := context.Background() // untrustedLength matches body exactly — streams directly via putObject @@ -666,10 +318,7 @@ func TestClient_PutObject_StreamingExactLength(t *testing.T) { } func TestClient_PutObject_StreamingBodyLongerThanClaimed(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "pfx") - + client := newTestClient(t) ctx := context.Background() // Body has 11 bytes, but we claim 5 — only first 5 bytes should be stored @@ -686,16 +335,12 @@ func TestClient_PutObject_StreamingBodyLongerThanClaimed(t *testing.T) { } func TestClient_PutObject_StreamingBodyShorterThanClaimed(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "pfx") - + client := newTestClient(t) ctx := context.Background() // Body has 5 bytes, but we claim 100 — should fail err := client.PutObject(ctx, "stream-short", strings.NewReader("hello"), 100) require.Error(t, err) - require.Contains(t, err.Error(), "ContentLength") // Object should not exist _, _, err = client.GetObject(ctx, "stream-short") @@ -703,10 +348,7 @@ func TestClient_PutObject_StreamingBodyShorterThanClaimed(t *testing.T) { } func TestClient_PutObject_NestedKey(t *testing.T) { - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "") - + client := newTestClient(t) ctx := context.Background() err := client.PutObject(ctx, "deep/nested/prefix/file.txt", strings.NewReader("nested"), 0) @@ -719,199 +361,54 @@ func TestClient_PutObject_NestedKey(t *testing.T) { require.Equal(t, "nested", string(data)) } -// --- Scale test: 20k objects (ntfy-adjacent) --- - -func TestClient_ListAllObjects_20k(t *testing.T) { - if testing.Short() { - t.Skip("skipping 20k object test in short mode") +func newTestClient(t *testing.T) *Client { + t.Helper() + s3URL := os.Getenv("NTFY_TEST_S3_URL") + if s3URL == "" { + t.Skip("NTFY_TEST_S3_URL not set") } - - server, _ := newMockS3Server() - defer server.Close() - client := newTestClient(server, "my-bucket", "attachments") - - ctx := context.Background() - const numObjects = 20000 - const batchSize = 500 - - // Insert 20k objects in batches to keep it fast - for batch := 0; batch < numObjects/batchSize; batch++ { - for i := 0; i < batchSize; i++ { - idx := batch*batchSize + i - key := fmt.Sprintf("%08d", idx) - err := client.PutObject(ctx, key, bytes.NewReader([]byte("x")), 0) - require.Nil(t, err) - } - } - - // List all 20k objects with pagination - objects, err := client.ListObjectsV2(ctx) + cfg, err := ParseURL(s3URL) require.Nil(t, err) - require.Len(t, objects, numObjects) - - // Verify total size - var totalSize int64 - for _, obj := range objects { - totalSize += obj.Size + // Use per-test prefix to isolate objects between tests + if cfg.Prefix != "" { + cfg.Prefix = cfg.Prefix + "/testpkg-s3/" + t.Name() + } else { + cfg.Prefix = "testpkg-s3/" + t.Name() } - require.Equal(t, int64(numObjects), totalSize) - - // Delete 1000 objects (simulating attachment expiry cleanup) - keys := make([]string, 1000) - for i := range keys { - keys[i] = fmt.Sprintf("%08d", i) - } - err = client.DeleteObjects(ctx, keys) - require.Nil(t, err) - - // List again: should have 19000 - objects, err = client.ListObjectsV2(ctx) - require.Nil(t, err) - require.Len(t, objects, numObjects-1000) + client := New(cfg) + deleteAllObjects(t, client) + t.Cleanup(func() { deleteAllObjects(t, client) }) + return client } -// --- Real S3 integration test --- -// -// Set the following environment variables to run this test against a real S3 bucket: -// -// S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_BUCKET -// -// Optional: -// -// S3_ENDPOINT: host[:port] for S3-compatible providers (e.g. "nyc3.digitaloceanspaces.com") -// S3_PATH_STYLE: set to "true" for path-style addressing -// S3_PREFIX: key prefix to use (default: "ntfy-s3-test") -func TestClient_RealBucket(t *testing.T) { - accessKey := os.Getenv("S3_ACCESS_KEY") - secretKey := os.Getenv("S3_SECRET_KEY") - region := os.Getenv("S3_REGION") - bucket := os.Getenv("S3_BUCKET") - - if accessKey == "" || secretKey == "" || region == "" || bucket == "" { - t.Skip("skipping real S3 test: set S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION, S3_BUCKET") +func deleteAllObjects(t *testing.T, client *Client) { + t.Helper() + for i := 0; i < 20; i++ { + objects, err := client.ListObjectsV2(context.Background()) + require.Nil(t, err) + if len(objects) == 0 { + return + } + keys := make([]string, len(objects)) + for j, obj := range objects { + keys[j] = obj.Key + } + require.Nil(t, client.DeleteObjects(context.Background(), keys)) + time.Sleep(200 * time.Millisecond) } + t.Fatal("timed out waiting for bucket to be empty") +} - endpoint := os.Getenv("S3_ENDPOINT") - if endpoint == "" { - endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region) - } - pathStyle := os.Getenv("S3_PATH_STYLE") == "true" - prefix := os.Getenv("S3_PREFIX") - if prefix == "" { - prefix = "ntfy-s3-test" - } - - client := New(&Config{ - AccessKey: accessKey, - SecretKey: secretKey, - Region: region, - Endpoint: endpoint, - Bucket: bucket, - Prefix: prefix, - PathStyle: pathStyle, - }) - - ctx := context.Background() - - // Clean up any leftover objects from previous runs - existing, err := client.ListObjectsV2(ctx) - require.Nil(t, err) - if len(existing) > 0 { - keys := make([]string, len(existing)) - for i, obj := range existing { - keys[i] = obj.Key - } - // Batch delete in groups of 1000 - for i := 0; i < len(keys); i += 1000 { - end := i + 1000 - if end > len(keys) { - end = len(keys) - } - err := client.DeleteObjects(ctx, keys[i:end]) - require.Nil(t, err) +func waitForCount(t *testing.T, client *Client, expected int) { + t.Helper() + for i := 0; i < 20; i++ { + objects, err := client.ListObjectsV2(context.Background()) + require.Nil(t, err) + if len(objects) == expected { + return } + time.Sleep(200 * time.Millisecond) } - - t.Run("PutGetDelete", func(t *testing.T) { - key := "test-object" - content := "hello from ntfy s3 test" - - // Put - err := client.PutObject(ctx, key, strings.NewReader(content), 0) - require.Nil(t, err) - - // Get - reader, size, err := client.GetObject(ctx, key) - require.Nil(t, err) - require.Equal(t, int64(len(content)), size) - data, err := io.ReadAll(reader) - reader.Close() - require.Nil(t, err) - require.Equal(t, content, string(data)) - - // Delete - err = client.DeleteObjects(ctx, []string{key}) - require.Nil(t, err) - - // Get after delete should fail - _, _, err = client.GetObject(ctx, key) - require.Error(t, err) - var errResp *errorResponse - require.ErrorAs(t, err, &errResp) - require.Equal(t, 404, errResp.StatusCode) - }) - - t.Run("ListObjects", func(t *testing.T) { - // Use a sub-prefix client for isolation - listClient := New(&Config{ - AccessKey: accessKey, - SecretKey: secretKey, - Region: region, - Endpoint: endpoint, - Bucket: bucket, - Prefix: prefix + "/list-test", - PathStyle: pathStyle, - }) - - // Put 10 objects - for i := 0; i < 10; i++ { - err := listClient.PutObject(ctx, fmt.Sprintf("%d", i), strings.NewReader("x"), 0) - require.Nil(t, err) - } - - // List - objects, err := listClient.ListObjectsV2(ctx) - require.Nil(t, err) - require.Len(t, objects, 10) - - // Clean up - keys := make([]string, 10) - for i := range keys { - keys[i] = fmt.Sprintf("%d", i) - } - err = listClient.DeleteObjects(ctx, keys) - require.Nil(t, err) - }) - - t.Run("LargeObject", func(t *testing.T) { - key := "large-object" - data := make([]byte, 5*1024*1024) // 5 MB - for i := range data { - data[i] = byte(i % 256) - } - - err := client.PutObject(ctx, key, bytes.NewReader(data), 0) - require.Nil(t, err) - - reader, size, err := client.GetObject(ctx, key) - require.Nil(t, err) - require.Equal(t, int64(len(data)), size) - got, err := io.ReadAll(reader) - reader.Close() - require.Nil(t, err) - require.Equal(t, data, got) - - err = client.DeleteObjects(ctx, []string{key}) - require.Nil(t, err) - }) + objects, _ := client.ListObjectsV2(context.Background()) + t.Fatalf("timed out waiting for %d objects, got %d", expected, len(objects)) } diff --git a/s3/util.go b/s3/util.go index 1f4c2dd9..ae692735 100644 --- a/s3/util.go +++ b/s3/util.go @@ -34,6 +34,9 @@ const ( // maxPages is the max number of pages to iterate through when listing objects maxPages = 500 + + // maxDeleteBatchSize is the maximum number of keys per S3 DeleteObjects call + maxDeleteBatchSize = 1000 ) // ParseURL parses an S3 URL of the form: From f8397838e625768516ce7ac5967c581ef53deb55 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 16:05:48 -0400 Subject: [PATCH 027/126] Update docs --- cmd/serve.go | 2 +- docs/config.md | 180 +++++++++++++++++++++++++---------------------- docs/releases.md | 2 +- 3 files changed, 97 insertions(+), 87 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 52794a07..2af1f389 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -52,7 +52,7 @@ var flagsServe = append( altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}), altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-expiry-duration", Aliases: []string{"attachment_expiry_duration", "X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: util.FormatDuration(server.DefaultAttachmentExpiryDuration), Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), diff --git a/docs/config.md b/docs/config.md index ae7547b3..de534241 100644 --- a/docs/config.md +++ b/docs/config.md @@ -490,12 +490,14 @@ Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscri ## Attachments If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments). To enable this feature, you have to configure an attachment storage backend and a base URL (`base-url`). Attachments can be stored -either on the local filesystem or in an S3-compatible object store, both using the `attachment-cache-dir` option. +either on the [local filesystem](#filesystem-storage) or in an [S3-compatible object store](#s3-storage), both using the `attachment-cache-dir` option. Once configured, you can upload attachments via PUT. By default, attachments are stored **for only 3 hours**. The main reason for this is to avoid legal issues and such when hosting user controlled content. Typically, this is more than enough time for the user (or the auto download -feature) to download the file. The following config options are relevant to attachments: +feature) to download the file. You can increase this time by [purchasing ntfy Pro](https://ntfy.sh/app) via the web app. + +The following config options are relevant to attachments: * `base-url` is the root URL for the ntfy server; this is needed for the generated attachment URLs * `attachment-cache-dir` is the cache directory for attached files, or an S3 URL for object storage @@ -503,6 +505,13 @@ feature) to download the file. The following config options are relevant to atta * `attachment-file-size-limit` is the per-file attachment size limit (e.g. 300k, 2M, 100M, default: 15M) * `attachment-expiry-duration` is the duration after which uploaded attachments will be deleted (e.g. 3h, 20h, default: 3h) +!!! warning + ntfy takes full control over the attachment directory or S3 bucket. Files that match the message ID format without + entries in the message table will be deleted. **Do not use a directory or S3 bucket that is also used for something else.** + +Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` +and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. + ### Filesystem storage Here's an example config using the local filesystem for attachment storage: @@ -538,22 +547,23 @@ When `endpoint` is specified, path-style addressing is enabled automatically (us === "/etc/ntfy/server.yml (AWS S3)" ``` yaml base-url: "https://ntfy.sh" - attachment-cache-dir: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1" + attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=us-east-1" + ``` + +=== "/etc/ntfy/server.yml (DigitalOcean Spaces)" + ``` yaml + base-url: "https://ntfy.sh" + attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=nyc3&endpoint=https://nyc3.digitaloceanspaces.com" ``` === "/etc/ntfy/server.yml (custom endpoint)" ``` yaml base-url: "https://ntfy.sh" - attachment-cache-dir: "s3://AKID:SECRET@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" + attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" ``` -**Cleanup behavior:** A background sync runs every 15 minutes to reconcile the S3 bucket (or configured prefix) with -the server's message database. Objects whose keys match attachment file IDs that are no longer referenced in the database -(and are older than 1 hour) are automatically deleted. This also cleans up incomplete S3 multipart uploads that were -abandoned due to interrupted or failed attachment uploads. - -Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit` -and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse. +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...`. ## Access control By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how @@ -2125,80 +2135,80 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). `cache_duration` and `cache-duration` are both supported. This is to support stricter YAML parsers that do not support dashes. -| Config option | Env variable | Format | Default | Description | -|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | -| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | -| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | -| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on | -| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 | -| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | -| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | -| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). | +| Config option | Env variable | Format | Default | Description | +|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) | +| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server | +| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. | +| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on | +| `listen-unix-mode` | `NTFY_LISTEN_UNIX_MODE` | *file mode* | *system default* | File mode of the Unix socket, e.g. 0700 or 0777 | +| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. | +| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. | +| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM)](#firebase-fcm). | | `database-url` | `NTFY_DATABASE_URL` | *string (connection URL)* | - | PostgreSQL connection string (e.g. `postgres://user:pass@host:5432/ntfy`). If set, uses PostgreSQL for all database-backed stores (message cache, user manager, web push) instead of SQLite. See [database options](#database-options). | -| `database-replica-urls` | `NTFY_DATABASE_REPLICA_URLS` | *list of strings (connection URLs)* | - | PostgreSQL read replica connection strings. Non-critical read-only queries are distributed across replicas (round-robin) with automatic fallback to primary. Requires `database-url`. See [read replicas](#read-replicas). | -| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). | -| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | -| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | -| `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) | -| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) | -| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). | -| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | -| `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-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`). | -| `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-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`. | -| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending | -| `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-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | -| `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-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | -| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 | -| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 | -| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | -| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 | -| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | -| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | -| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. | -| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header | -| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | -| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers | -| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth | -| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | -| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | -| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | -| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | -| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. | -| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has | -| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | -| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | -| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | -| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | -| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | -| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | -| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | -| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | -| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | -| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | -| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login | -| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | -| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | -| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | -| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy webpush keys` to generate | -| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy webpush keys` to generate | -| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions | -| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address | -| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup | -| `web-push-expiry-duration` | `NTFY_WEB_PUSH_EXPIRY_DURATION` | *duration* | 60d | Web Push: Duration after which a subscription is considered stale and will be deleted. This is to prevent stale subscriptions. | -| `web-push-expiry-warning-duration` | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION` | *duration* | 55d | Web Push: Duration after which a warning is sent to subscribers that their subscription will expire soon. This is to prevent stale subscriptions. | -| `log-format` | `NTFY_LOG_FORMAT` | *string* | `text` | Defines the output format, can be text or json | -| `log-file` | `NTFY_LOG_FILE` | *string* | - | Defines the filename to write logs to. If this is not set, ntfy logs to stderr | -| `log-level` | `NTFY_LOG_LEVEL` | *string* | `info` | Defines the default log level, can be one of trace, debug, info, warn or error | +| `database-replica-urls` | `NTFY_DATABASE_REPLICA_URLS` | *list of strings (connection URLs)* | - | PostgreSQL read replica connection strings. Non-critical read-only queries are distributed across replicas (round-robin) with automatic fallback to primary. Requires `database-url`. | +| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). | +| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. | +| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#message-cache) | +| `cache-batch-size` | `NTFY_CACHE_BATCH_SIZE` | *int* | 0 | Max size of messages to batch together when writing to message cache (if zero, writes are synchronous) | +| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) | +| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). | +| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. | +| `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-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-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-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`. | +| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending | +| `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-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled | +| `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-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | +| `twilio-account` | `NTFY_TWILIO_ACCOUNT` | *string* | - | Twilio account SID, e.g. AC12345beefbeef67890beefbeef122586 | +| `twilio-auth-token` | `NTFY_TWILIO_AUTH_TOKEN` | *string* | - | Twilio auth token, e.g. affebeef258625862586258625862586 | +| `twilio-phone-number` | `NTFY_TWILIO_PHONE_NUMBER` | *string* | - | Twilio outgoing phone number, e.g. +18775132586 | +| `twilio-verify-service` | `NTFY_TWILIO_VERIFY_SERVICE` | *string* | - | Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586 | +| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. | +| `manager-interval` | `NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. | +| `message-size-limit` | `NTFY_MESSAGE_SIZE_LIMIT` | *size* | 4K | The size limit for the message body. Please note that this is largely untested, and that FCM/APNS have limits around 4KB. If you increase this size limit, FCM and APNS will NOT work for large messages. | +| `message-delay-limit` | `NTFY_MESSAGE_DELAY_LIMIT` | *duration* | 3d | Amount of time a message can be [scheduled](publish.md#scheduled-delivery) into the future when using the `Delay` header | +| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | +| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers | +| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth | +| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | +| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | +| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | +| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | +| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. | +| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has | +| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | +| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | +| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | +| `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | +| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | +| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | +| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | +| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | +| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | +| `enable-reservations` | `NTFY_ENABLE_RESERVATIONS` | *boolean* (`true` or `false`) | `false` | Allows users to reserve topics (if their tier allows it) | +| `require-login` | `NTFY_REQUIRE_LOGIN` | *boolean* (`true` or `false`) | `false` | All actions via the web app require a login | +| `stripe-secret-key` | `NTFY_STRIPE_SECRET_KEY` | *string* | - | Payments: Key used for the Stripe API communication, this enables payments | +| `stripe-webhook-key` | `NTFY_STRIPE_WEBHOOK_KEY` | *string* | - | Payments: Key required to validate the authenticity of incoming webhooks from Stripe | +| `billing-contact` | `NTFY_BILLING_CONTACT` | *email address* or *website* | - | Payments: Email or website displayed in Upgrade dialog as a billing contact | +| `web-push-public-key` | `NTFY_WEB_PUSH_PUBLIC_KEY` | *string* | - | Web Push: Public Key. Run `ntfy webpush keys` to generate | +| `web-push-private-key` | `NTFY_WEB_PUSH_PRIVATE_KEY` | *string* | - | Web Push: Private Key. Run `ntfy webpush keys` to generate | +| `web-push-file` | `NTFY_WEB_PUSH_FILE` | *string* | - | Web Push: Database file that stores subscriptions | +| `web-push-email-address` | `NTFY_WEB_PUSH_EMAIL_ADDRESS` | *string* | - | Web Push: Sender email address | +| `web-push-startup-queries` | `NTFY_WEB_PUSH_STARTUP_QUERIES` | *string* | - | Web Push: SQL queries to run against subscription database at startup | +| `web-push-expiry-duration` | `NTFY_WEB_PUSH_EXPIRY_DURATION` | *duration* | 60d | Web Push: Duration after which a subscription is considered stale and will be deleted. This is to prevent stale subscriptions. | +| `web-push-expiry-warning-duration` | `NTFY_WEB_PUSH_EXPIRY_WARNING_DURATION` | *duration* | 55d | Web Push: Duration after which a warning is sent to subscribers that their subscription will expire soon. This is to prevent stale subscriptions. | +| `log-format` | `NTFY_LOG_FORMAT` | *string* | `text` | Defines the output format, can be text or json | +| `log-file` | `NTFY_LOG_FILE` | *string* | - | Defines the filename to write logs to. If this is not set, ntfy logs to stderr | +| `log-level` | `NTFY_LOG_LEVEL` | *string* | `info` | Defines the default log level, can be one of trace, debug, info, warn or error | The format for a *duration* is: `(smhd)`, e.g. 30s, 20m, 1h or 3d. The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. @@ -2249,7 +2259,7 @@ OPTIONS: --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-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) [$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]) [$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-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] diff --git a/docs/releases.md b/docs/releases.md index b16608c6..afdb8065 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1802,7 +1802,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Add S3-compatible object storage as an alternative attachment backend via `attachment-cache-dir` config option +* Add S3-compatible object storage as an alternative [attachment](config.md#attachments) backend via `attachment-cache-dir` config option **Bug fixes + maintenance:** From ef051afc09a3dc0ee965bd22c1a1d4ad0105881f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 16:06:57 -0400 Subject: [PATCH 028/126] Update base-url in examples --- docs/config.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/config.md b/docs/config.md index de534241..b6ae3009 100644 --- a/docs/config.md +++ b/docs/config.md @@ -517,13 +517,13 @@ Here's an example config using the local filesystem for attachment storage: === "/etc/ntfy/server.yml (minimal)" ``` yaml - base-url: "https://ntfy.sh" + base-url: "https://ntfy.example.com" attachment-cache-dir: "/var/cache/ntfy/attachments" ``` === "/etc/ntfy/server.yml (all options)" ``` yaml - base-url: "https://ntfy.sh" + base-url: "https://ntfy.example.com" attachment-cache-dir: "/var/cache/ntfy/attachments" attachment-total-size-limit: "5G" attachment-file-size-limit: "15M" @@ -546,19 +546,19 @@ When `endpoint` is specified, path-style addressing is enabled automatically (us === "/etc/ntfy/server.yml (AWS S3)" ``` yaml - base-url: "https://ntfy.sh" + base-url: "https://ntfy.example.com" attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=us-east-1" ``` === "/etc/ntfy/server.yml (DigitalOcean Spaces)" ``` yaml - base-url: "https://ntfy.sh" + base-url: "https://ntfy.example.com" attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=nyc3&endpoint=https://nyc3.digitaloceanspaces.com" ``` === "/etc/ntfy/server.yml (custom endpoint)" ``` yaml - base-url: "https://ntfy.sh" + base-url: "https://ntfy.example.com" attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=us-east-1&endpoint=https://s3.example.com" ``` From a04128520d1e9fbd9d4cd35afde329d7eab3b1ed Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 16:17:17 -0400 Subject: [PATCH 029/126] Run S3 tests in CI --- .github/workflows/release.yaml | 1 + .github/workflows/test.yaml | 1 + server/server_test.go | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 4ebb9d56..3c959bb6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,6 +22,7 @@ jobs: --health-retries 5 env: NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable" + NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 94f08fd9..803ca01f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -19,6 +19,7 @@ jobs: --health-retries 5 env: NTFY_TEST_DATABASE_URL: "postgres://ntfy:ntfy@localhost:5432/ntfy_test?sslmode=disable" + NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/server/server_test.go b/server/server_test.go index 449b6006..44b9ac94 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2145,7 +2145,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { require.Equal(t, "myfile.txt", msg.Attachment.Name) require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) require.Equal(t, int64(21), msg.Attachment.Size) - require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) + require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()-1) require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") require.Equal(t, netip.Addr{}, msg.Sender) // Should never be returned require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) From 4d07897d2dbaa159754398a040e0ee3d6d571610 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 16:20:45 -0400 Subject: [PATCH 030/126] RWMutex --- attachment/store.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index 3666bdd7..f250d106 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -34,7 +34,7 @@ type Store struct { 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() closeChan chan struct{} - mu sync.Mutex // Protects size and sizes + mu sync.RWMutex // Protects size and sizes } // NewFileStore creates a new file-system backed attachment cache @@ -191,15 +191,15 @@ func (c *Store) sync() error { // Size returns the current total size of all attachments func (c *Store) Size() int64 { - c.mu.Lock() - defer c.mu.Unlock() + c.mu.RLock() + defer c.mu.RUnlock() return c.size } // Remaining returns the remaining capacity for attachments func (c *Store) Remaining() int64 { - c.mu.Lock() - defer c.mu.Unlock() + c.mu.RLock() + defer c.mu.RUnlock() remaining := c.limit - c.size if remaining < 0 { return 0 From 69cc80ec1e86917095f19cca811470782739c6ea Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 20:52:25 -0400 Subject: [PATCH 031/126] Add comments about AWS S3 --- attachment/store.go | 5 +++-- docs/config.md | 28 ++++++++++++++++++++++++++++ docs/releases.md | 2 +- s3/client.go | 32 +++++++++++++++++++++++++++++++- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index f250d106..d70ea2ab 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -157,7 +157,7 @@ func (c *Store) sync() error { // than the grace period to account for races, and skipping objects with invalid IDs. cutoff := time.Now().Add(-orphanGracePeriod) var orphanIDs []string - var size int64 + var count, size int64 sizes := make(map[string]int64, len(remoteObjects)) for _, obj := range remoteObjects { if !fileIDRegex.MatchString(obj.ID) { @@ -166,11 +166,12 @@ func (c *Store) sync() error { if _, ok := localIDMap[obj.ID]; !ok && obj.LastModified.Before(cutoff) { orphanIDs = append(orphanIDs, obj.ID) } else { + count++ size += obj.Size sizes[obj.ID] = obj.Size } } - log.Tag(tagStore).Debug("Attachment store updated: %d attachment(s), %s", len(localIDs), util.FormatSizeHuman(size)) + log.Tag(tagStore).Debug("Attachment store updated: %d attachment(s), %s", count, util.FormatSizeHuman(size)) c.mu.Lock() c.size = size c.sizes = sizes diff --git a/docs/config.md b/docs/config.md index b6ae3009..3456b661 100644 --- a/docs/config.md +++ b/docs/config.md @@ -565,6 +565,34 @@ When `endpoint` is specified, path-style addressing is enabled automatically (us 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...`. +For **AWS S3**, the IAM user needs the following permissions on the bucket: + +``` json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:ListBucket", + "s3:ListBucketMultipartUploads" + ], + "Resource": "arn:aws:s3:::BUCKET_NAME" + }, + { + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:AbortMultipartUpload" + ], + "Resource": "arn:aws:s3:::BUCKET_NAME/*" + } + ] +} +``` + ## Access control By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization. diff --git a/docs/releases.md b/docs/releases.md index afdb8065..975c7536 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1802,7 +1802,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Features:** -* Add S3-compatible object storage as an alternative [attachment](config.md#attachments) backend via `attachment-cache-dir` config option +* Add S3-compatible object storage as an alternative [attachment store](config.md#attachments) via `attachment-cache-dir` config option **Bug fixes + maintenance:** diff --git a/s3/client.go b/s3/client.go index e06ff5c9..8e84bbc5 100644 --- a/s3/client.go +++ b/s3/client.go @@ -25,6 +25,32 @@ const ( // and ListObjectsV2 operations using AWS Signature V4 signing. The bucket and optional key prefix // are fixed at construction time. All operations target the same bucket and prefix. // +// The following IAM policy is required for AWS S3: +// +// { +// "Version": "2012-10-17", +// "Statement": [ +// { +// "Effect": "Allow", +// "Action": [ +// "s3:ListBucket", +// "s3:ListBucketMultipartUploads" +// ], +// "Resource": "arn:aws:s3:::BUCKET_NAME" +// }, +// { +// "Effect": "Allow", +// "Action": [ +// "s3:GetObject", +// "s3:PutObject", +// "s3:DeleteObject", +// "s3:AbortMultipartUpload" +// ], +// "Resource": "arn:aws:s3:::BUCKET_NAME/*" +// } +// ] +// } +// // Fields must not be modified after the Client is passed to any method or goroutine. type Client struct { config *Config @@ -149,7 +175,11 @@ func (c *Client) ListObjectsV2(ctx context.Context) ([]*Object, error) { // listObjectsV2 performs a single ListObjectsV2 request using the client's configured prefix. func (c *Client) listObjectsV2(ctx context.Context, continuationToken string) (*listObjectsV2Result, error) { - log.Tag(tagS3Client).Debug("Listing remote objects with continuation token '%s'", continuationToken) + if continuationToken == "" { + log.Tag(tagS3Client).Debug("Listing remote objects") + } else { + log.Tag(tagS3Client).Debug("Listing remote objects, continuing with token '%s'", continuationToken) + } query := url.Values{"list-type": {"2"}} if prefix := c.config.ListPrefix(); prefix != "" { query.Set("prefix", prefix) From 233ec0973d2569ab7680b2eca69c8c43e5097e4e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 21:01:33 -0400 Subject: [PATCH 032/126] bump wait time --- s3/client_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/s3/client_test.go b/s3/client_test.go index f4b85089..23cde72c 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -383,7 +383,7 @@ func newTestClient(t *testing.T) *Client { func deleteAllObjects(t *testing.T, client *Client) { t.Helper() - for i := 0; i < 20; i++ { + for i := 0; i < 60; i++ { objects, err := client.ListObjectsV2(context.Background()) require.Nil(t, err) if len(objects) == 0 { @@ -394,20 +394,20 @@ func deleteAllObjects(t *testing.T, client *Client) { keys[j] = obj.Key } require.Nil(t, client.DeleteObjects(context.Background(), keys)) - time.Sleep(200 * time.Millisecond) + time.Sleep(500 * time.Millisecond) } t.Fatal("timed out waiting for bucket to be empty") } func waitForCount(t *testing.T, client *Client, expected int) { t.Helper() - for i := 0; i < 20; i++ { + for i := 0; i < 60; i++ { objects, err := client.ListObjectsV2(context.Background()) require.Nil(t, err) if len(objects) == expected { return } - time.Sleep(200 * time.Millisecond) + time.Sleep(500 * time.Millisecond) } objects, _ := client.ListObjectsV2(context.Background()) t.Fatalf("timed out waiting for %d objects, got %d", expected, len(objects)) From e87a3e62feab6ae4fbe1399128406d5873bc51fc Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 21:11:21 -0400 Subject: [PATCH 033/126] Fix workflows to not double run --- .github/workflows/build.yaml | 5 ++++- .github/workflows/test.yaml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1baa82c2..ca44e4b6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,5 +1,8 @@ name: build -on: [ push, pull_request ] +on: + push: + branches: [ main ] + pull_request: jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 803ca01f..4d6bbbdb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -1,5 +1,8 @@ name: test -on: [ push, pull_request ] +on: + push: + branches: [ main ] + pull_request: jobs: test: runs-on: ubuntu-latest From e6192c94bd58924e1d071fd00b3cf28ea14ce43f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 22 Mar 2026 21:24:43 -0400 Subject: [PATCH 034/126] Docs updates --- docs/config.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/config.md b/docs/config.md index 3456b661..c9e6687d 100644 --- a/docs/config.md +++ b/docs/config.md @@ -533,22 +533,15 @@ Here's an example config using the local filesystem for attachment storage: ``` ### S3 storage -As an alternative to the local filesystem, you can store attachments in an S3-compatible object store (e.g. AWS S3, -DigitalOcean Spaces). This is useful for HA/cloud deployments where you don't want to rely on local disk storage. - -To use S3, set `attachment-cache-dir` to an S3 URL with the following format: +As an alternative to the local filesystem, you can store attachments in an S3-compatible object store (e.g. [AWS S3](https://aws.amazon.com/s3/), +[DigitalOcean Spaces](https://www.digitalocean.com/products/spaces)). This is useful for HA/cloud deployments where you don't want to rely on local disk storage. +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] ``` -When `endpoint` is specified, path-style addressing is enabled automatically (useful for S3-compatible stores like DigitalOcean Spaces). - -=== "/etc/ntfy/server.yml (AWS S3)" - ``` yaml - base-url: "https://ntfy.example.com" - attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=us-east-1" - ``` +Here are a few examples: === "/etc/ntfy/server.yml (DigitalOcean Spaces)" ``` yaml @@ -556,6 +549,12 @@ When `endpoint` is specified, path-style addressing is enabled automatically (us attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=nyc3&endpoint=https://nyc3.digitaloceanspaces.com" ``` +=== "/etc/ntfy/server.yml (AWS S3)" + ``` yaml + base-url: "https://ntfy.example.com" + attachment-cache-dir: "s3://ACCESS_KEY:SECRET_KEY@my-bucket/attachments?region=us-east-1" + ``` + === "/etc/ntfy/server.yml (custom endpoint)" ``` yaml base-url: "https://ntfy.example.com" @@ -565,7 +564,12 @@ When `endpoint` is specified, path-style addressing is enabled automatically (us 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...`. -For **AWS S3**, the IAM user needs the following permissions on the bucket: +!!! info + 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. + Also, if you **use [this referral link](https://m.do.co/c/442b929528db), you can get $200 credit**. + +For AWS S3, the IAM user needs the following permissions on the bucket: ``` json { From 075f2ffa1526592f9108cfb8639fb2f2fd7f6db6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Mar 2026 12:44:40 -0400 Subject: [PATCH 035/126] Refine sync() to properly update sizes --- attachment/store.go | 70 +++++++++++++++++++++------------------ attachment/store_test.go | 14 ++++---- message/cache.go | 36 ++++++++++++++------ message/cache_postgres.go | 5 ++- message/cache_sqlite.go | 5 ++- server/server.go | 7 ++-- 6 files changed, 76 insertions(+), 61 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index d70ea2ab..a9eaaeae 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -28,44 +28,54 @@ var ( // Store manages attachment storage with shared logic for size tracking, limiting, // ID validation, and background sync to reconcile storage with the database. type Store struct { - backend backend - limit int64 // Defined limit 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 - localIDs func() ([]string, error) // Returns file IDs that should exist locally, used for sync() - closeChan chan struct{} - mu sync.RWMutex // Protects size and sizes + backend backend + limit int64 // Defined limit 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 + attachmentsWithSizes func() (map[string]int64, error) // Returns file ID -> size for active attachments + closeChan chan struct{} + mu sync.RWMutex // Protects size and sizes } // 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, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { b, err := newFileBackend(dir) if err != nil { return nil, err } - return newStore(b, totalSizeLimit, localIDsFn) + return newStore(b, totalSizeLimit, attachmentsWithSizes) } // 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] -func NewS3Store(s3URL string, totalSizeLimit int64, localIDs func() ([]string, error)) (*Store, error) { +func NewS3Store(s3URL string, totalSizeLimit int64, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { config, err := s3.ParseURL(s3URL) if err != nil { return nil, err } - return newStore(newS3Backend(s3.New(config)), totalSizeLimit, localIDs) + return newStore(newS3Backend(s3.New(config)), totalSizeLimit, attachmentsWithSizes) } -func newStore(backend backend, totalSizeLimit int64, localIDs func() ([]string, error)) (*Store, error) { +func newStore(backend backend, totalSizeLimit int64, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { c := &Store{ - backend: backend, - limit: totalSizeLimit, - sizes: make(map[string]int64), - localIDs: localIDs, - closeChan: make(chan struct{}), + backend: backend, + limit: totalSizeLimit, + sizes: make(map[string]int64), + attachmentsWithSizes: attachmentsWithSizes, + closeChan: 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() } return c, nil @@ -136,18 +146,14 @@ func (c *Store) Remove(ids ...string) error { // 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 -// the total size from the remaining objects. +// the total size from the existing attachments in the database. func (c *Store) sync() error { - if c.localIDs == nil { + if c.attachmentsWithSizes == nil { return nil } - localIDs, err := c.localIDs() + attachmentsWithSizes, err := c.attachmentsWithSizes() if err != nil { - return fmt.Errorf("attachment sync: failed to get valid IDs: %w", err) - } - localIDMap := make(map[string]struct{}, len(localIDs)) - for _, id := range localIDs { - localIDMap[id] = struct{}{} + return fmt.Errorf("attachment sync: failed to get existing attachments: %w", err) } remoteObjects, err := c.backend.List() if err != nil { @@ -157,23 +163,23 @@ func (c *Store) sync() error { // than the grace period to account for races, and skipping objects with invalid IDs. cutoff := time.Now().Add(-orphanGracePeriod) var orphanIDs []string - var count, size int64 + var count, totalSize int64 sizes := make(map[string]int64, len(remoteObjects)) for _, obj := range remoteObjects { if !fileIDRegex.MatchString(obj.ID) { 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) } else { count++ - size += obj.Size - sizes[obj.ID] = obj.Size + totalSize += attachmentsWithSizes[obj.ID] + 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.size = size + c.size = totalSize c.sizes = sizes c.mu.Unlock() // Delete orphaned attachments diff --git a/attachment/store_test.go b/attachment/store_test.go index 11d0b244..0cb32a3c 100644 --- a/attachment/store_test.go +++ b/attachment/store_test.go @@ -162,9 +162,9 @@ func TestStore_SyncRecomputesSize(t *testing.T) { s.mu.Unlock() require.Equal(t, int64(999), s.Size()) - // Set localIDs to include both files so nothing gets deleted - s.localIDs = func() ([]string, error) { - return []string{"abcdefghijk0", "abcdefghijk1"}, nil + // Set attachmentsWithSizes to include both files so nothing gets deleted + s.attachmentsWithSizes = func() (map[string]int64, error) { + return map[string]int64{"abcdefghijk0": 100, "abcdefghijk1": 200}, nil } // Sync should recompute size from the backend @@ -280,8 +280,8 @@ func TestStore_Sync(t *testing.T) { require.Equal(t, int64(15), s.Size()) // Set the ID provider to only know about file 0 and 2 - s.localIDs = func() ([]string, error) { - return []string{"abcdefghijk0", "abcdefghijk2"}, nil + s.attachmentsWithSizes = func() (map[string]int64, error) { + return map[string]int64{"abcdefghijk0": 5, "abcdefghijk2": 5}, nil } // Make file 1 old enough to be cleaned up @@ -314,8 +314,8 @@ func TestStore_Sync_SkipsRecentFiles(t *testing.T) { require.Nil(t, err) // Set the ID provider to return empty (no valid IDs) - s.localIDs = func() ([]string, error) { - return []string{}, nil + s.attachmentsWithSizes = func() (map[string]int64, error) { + return map[string]int64{}, nil } // File was just created, so it should NOT be deleted (< 1 hour old) diff --git a/message/cache.go b/message/cache.go index dd4ef0a4..76ba7926 100644 --- a/message/cache.go +++ b/message/cache.go @@ -43,10 +43,10 @@ type queries struct { selectAttachmentsExpired string selectAttachmentsSizeBySender string selectAttachmentsSizeByUserID string + selectAttachmentsWithSizes string selectStats string updateStats string updateMessageTime string - selectAttachmentIDs string } // Cache stores published messages @@ -363,16 +363,6 @@ func (c *Cache) ExpireMessages(topics ...string) error { }) } -// AttachmentIDs returns message IDs with active (non-expired, non-deleted) attachments -func (c *Cache) AttachmentIDs() ([]string, error) { - rows, err := c.db.ReadOnly().Query(c.queries.selectAttachmentIDs, time.Now().Unix()) - 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()) @@ -415,6 +405,30 @@ func (c *Cache) AttachmentBytesUsedByUser(userID string) (int64, error) { 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) { defer rows.Close() var size int64 diff --git a/message/cache_postgres.go b/message/cache_postgres.go index d59b2590..f0a32036 100644 --- a/message/cache_postgres.go +++ b/message/cache_postgres.go @@ -70,12 +70,11 @@ const ( postgresSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE` 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` + postgresSelectAttachmentsWithSizesQuery = `SELECT mid, attachment_size FROM message WHERE attachment_expires > $1 AND attachment_deleted = FALSE` postgresSelectStatsQuery = `SELECT value FROM message_stats WHERE key = 'messages'` postgresUpdateStatsQuery = `UPDATE message_stats SET value = $1 WHERE key = 'messages'` 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{ @@ -99,10 +98,10 @@ var postgresQueries = queries{ selectAttachmentsExpired: postgresSelectAttachmentsExpiredQuery, selectAttachmentsSizeBySender: postgresSelectAttachmentsSizeBySenderQuery, selectAttachmentsSizeByUserID: postgresSelectAttachmentsSizeByUserIDQuery, + selectAttachmentsWithSizes: postgresSelectAttachmentsWithSizesQuery, selectStats: postgresSelectStatsQuery, updateStats: postgresUpdateStatsQuery, updateMessageTime: postgresUpdateMessageTimeQuery, - selectAttachmentIDs: postgresSelectAttachmentIDsQuery, } // NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. diff --git a/message/cache_sqlite.go b/message/cache_sqlite.go index 6126f1e1..b39095e0 100644 --- a/message/cache_sqlite.go +++ b/message/cache_sqlite.go @@ -73,12 +73,11 @@ const ( sqliteSelectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` 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 >= ?` + sqliteSelectAttachmentsWithSizesQuery = `SELECT mid, attachment_size FROM messages WHERE attachment_expires > ? AND attachment_deleted = 0` sqliteSelectStatsQuery = `SELECT value FROM stats WHERE key = 'messages'` sqliteUpdateStatsQuery = `UPDATE stats SET value = ? WHERE key = 'messages'` sqliteUpdateMessageTimeQuery = `UPDATE messages SET time = ? WHERE mid = ?` - - sqliteSelectAttachmentIDsQuery = `SELECT mid FROM messages WHERE attachment_expires > ? AND attachment_deleted = 0` ) var sqliteQueries = queries{ @@ -102,10 +101,10 @@ var sqliteQueries = queries{ selectAttachmentsExpired: sqliteSelectAttachmentsExpiredQuery, selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery, selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery, + selectAttachmentsWithSizes: sqliteSelectAttachmentsWithSizesQuery, selectStats: sqliteSelectStatsQuery, updateStats: sqliteUpdateStatsQuery, updateMessageTime: sqliteUpdateMessageTimeQuery, - selectAttachmentIDs: sqliteSelectAttachmentIDsQuery, } // NewSQLiteStore creates a SQLite file-backed cache diff --git a/server/server.go b/server/server.go index 77e7b0c0..dc56d57f 100644 --- a/server/server.go +++ b/server/server.go @@ -301,13 +301,10 @@ func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) { } func createAttachmentStore(conf *Config, messageCache *message.Cache) (*attachment.Store, error) { - attachmentIDs := func() ([]string, error) { - return messageCache.AttachmentIDs() - } if strings.HasPrefix(conf.AttachmentCacheDir, "s3://") { - return attachment.NewS3Store(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, attachmentIDs) + return attachment.NewS3Store(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, messageCache.AttachmentsWithSizes) } else if conf.AttachmentCacheDir != "" { - return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, attachmentIDs) + return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, messageCache.AttachmentsWithSizes) } return nil, nil } From b95efe8dd3e663959b58d9139d32ca6c35884569 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Mar 2026 12:54:13 -0400 Subject: [PATCH 036/126] Tighten message IDs in attachments --- attachment/store.go | 14 +++++--------- model/model.go | 21 +++++++++++++-------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index a9eaaeae..70fb55c0 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "io" - "regexp" "sync" "time" @@ -20,10 +19,7 @@ const ( orphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads ) -var ( - fileIDRegex = regexp.MustCompile(fmt.Sprintf(`^[-_A-Za-z0-9]{%d}$`, model.MessageIDLength)) - errInvalidFileID = errors.New("invalid file ID") -) +var errInvalidFileID = errors.New("invalid file ID") // Store manages attachment storage with shared logic for size tracking, limiting, // ID validation, and background sync to reconcile storage with the database. @@ -86,7 +82,7 @@ func newStore(backend backend, totalSizeLimit int64, attachmentsWithSizes func() // from the client's Content-Length header; backends may use it to optimize uploads (e.g. // streaming directly to S3 without buffering). 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 } log.Tag(tagStore).Field("message_id", id).Debug("Writing attachment") @@ -107,7 +103,7 @@ func (c *Store) Write(id string, reader io.Reader, untrustedLength int64, limite // Read retrieves an attachment file by ID func (c *Store) Read(id string) (io.ReadCloser, int64, error) { - if !fileIDRegex.MatchString(id) { + if !model.ValidMessageID(id) { return nil, 0, errInvalidFileID } return c.backend.Get(id) @@ -118,7 +114,7 @@ func (c *Store) Read(id string) (io.ReadCloser, int64, error) { // started and before the first sync) are corrected by the next sync() call. func (c *Store) Remove(ids ...string) error { for _, id := range ids { - if !fileIDRegex.MatchString(id) { + if !model.ValidMessageID(id) { return errInvalidFileID } } @@ -166,7 +162,7 @@ func (c *Store) sync() error { var count, totalSize int64 sizes := make(map[string]int64, len(remoteObjects)) for _, obj := range remoteObjects { - if !fileIDRegex.MatchString(obj.ID) { + if !model.ValidMessageID(obj.ID) { continue } if _, ok := attachmentsWithSizes[obj.ID]; !ok && obj.LastModified.Before(cutoff) { diff --git a/model/model.go b/model/model.go index 97fecf2d..b3dae915 100644 --- a/model/model.go +++ b/model/model.go @@ -19,8 +19,8 @@ const ( PollRequestEvent = "poll_request" ) -// MessageIDLength is the length of a randomly generated message ID -const MessageIDLength = 12 +// messageIDLength is the length of a randomly generated message ID +const messageIDLength = 12 // Errors for message operations 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 func NewMessage(event, topic, msg string) *Message { return &Message{ - ID: util.RandomString(MessageIDLength), + ID: GenerateMessageID(), Time: time.Now().Unix(), Event: event, Topic: topic, @@ -173,11 +183,6 @@ func NewPollRequestMessage(topic, pollID string) *Message { 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 type SinceMarker struct { time time.Time From d00277107aa3ac0772c24b92896d373b2c9a2aa5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 23 Mar 2026 13:04:47 -0400 Subject: [PATCH 037/126] Release notes --- docs/releases.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 975c7536..101ff949 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -12,7 +12,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release Please check out the release notes for [upcoming releases](#not-released-yet) below. -### ntfy server v2.19.2 +## ntfy server v2.19.2 Released March 16, 2026 This is another small bugfix release for PostgreSQL, avoiding races between primary and read replica, as well as to @@ -1800,6 +1800,19 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ### ntfy server v2.20.x (UNRELEASED) +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 From e55d1cee6b8dd1bcfb9973b24d04a375ffbe48f7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 24 Mar 2026 17:37:50 -0400 Subject: [PATCH 038/126] Attachment fixes to address inconsistencies between DB and backend store --- message/cache.go | 4 +-- message/cache_postgres_schema.go | 46 +++++++++++++++++++++++++++++--- message/cache_sqlite_schema.go | 15 ++++++++++- server/server.go | 3 +++ server/server_manager.go | 19 ++++++------- web/package-lock.json | 18 ------------- 6 files changed, 69 insertions(+), 36 deletions(-) diff --git a/message/cache.go b/message/cache.go index 76ba7926..870eb599 100644 --- a/message/cache.go +++ b/message/cache.go @@ -246,7 +246,7 @@ func (c *Cache) MessagesDue() ([]*model.Message, error) { return readMessages(rows) } -// MessagesExpired returns a list of IDs for messages that have expired (should be deleted) +// MessagesExpired returns a list of message IDs that have expired and should be deleted func (c *Cache) MessagesExpired() ([]string, error) { rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix()) if err != nil { @@ -262,10 +262,10 @@ func (c *Cache) Message(id string) (*model.Message, error) { if err != nil { return nil, err } + defer rows.Close() if !rows.Next() { return nil, model.ErrMessageNotFound } - defer rows.Close() return readMessage(rows) } diff --git a/message/cache_postgres_schema.go b/message/cache_postgres_schema.go index 4a539d0e..99e18c2c 100644 --- a/message/cache_postgres_schema.go +++ b/message/cache_postgres_schema.go @@ -5,6 +5,7 @@ import ( "fmt" "heckel.io/ntfy/v2/db" + "heckel.io/ntfy/v2/log" ) // 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_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_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_user_id_attachment_expires ON message (user_id, attachment_expires); CREATE TABLE IF NOT EXISTS message_stats ( @@ -57,21 +59,57 @@ const ( // PostgreSQL schema management queries const ( - postgresCurrentSchemaVersion = 14 + postgresCurrentSchemaVersion = 15 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'` ) -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(db *sql.DB) error{ + 14: postgresMigrateFrom14, +} + +func setupPostgres(sqlDB *sql.DB) error { var schemaVersion int - if err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { - return setupNewPostgresDB(db) + if err := sqlDB.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { + return setupNewPostgresDB(sqlDB) + } else 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) } + 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(sqlDB); err != nil { + return err + } + } return nil } +func postgresMigrateFrom14(sqlDB *sql.DB) error { + log.Tag(tagMessageCache).Info("Migrating message cache database schema: from 14 to 15") + return db.ExecTx(sqlDB, 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 { return db.ExecTx(sqlDB, func(tx *sql.Tx) error { if _, err := tx.Exec(postgresCreateTablesQuery); err != nil { diff --git a/message/cache_sqlite_schema.go b/message/cache_sqlite_schema.go index 8c68bad8..b19bfca1 100644 --- a/message/cache_sqlite_schema.go +++ b/message/cache_sqlite_schema.go @@ -57,7 +57,7 @@ const ( // Schema version management for SQLite const ( - sqliteCurrentSchemaVersion = 14 + sqliteCurrentSchemaVersion = 15 sqliteCreateSchemaVersionTableQuery = ` CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, @@ -208,6 +208,7 @@ var ( 11: sqliteMigrateFrom11, 12: sqliteMigrateFrom12, 13: sqliteMigrateFrom13, + 14: sqliteMigrateFrom14, } ) @@ -451,3 +452,15 @@ func sqliteMigrateFrom13(sqlDB *sql.DB, _ time.Duration) error { 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 + }) +} diff --git a/server/server.go b/server/server.go index dc56d57f..508d9a70 100644 --- a/server/server.go +++ b/server/server.go @@ -1426,6 +1426,9 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *model.Me return err } 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 { return errHTTPBadRequestAttachmentsExpiryBeforeDelivery.With(m) } diff --git a/server/server_manager.go b/server/server_manager.go index 89ff38c2..11fa4dfe 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -3,7 +3,6 @@ package server import ( "heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/util" - "strings" ) func (s *Server) execManager() { @@ -151,11 +150,11 @@ func (s *Server) pruneAttachments() { log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments") } else if len(ids) > 0 { if log.Tag(tagManager).IsDebug() { - log.Tag(tagManager).Debug("Deleting attachments %s", strings.Join(ids, ", ")) - } - if err := s.attachment.Remove(ids...); err != nil { - log.Tag(tagManager).Err(err).Warn("Error deleting attachments") + log.Tag(tagManager).Debug("Marking %d expired attachment(s) as deleted", len(ids)) } + // 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. if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil { log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") } @@ -174,13 +173,11 @@ func (s *Server) pruneMessages() { if err != nil { log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages") } else if len(expiredMessageIDs) > 0 { - if s.attachment != nil { - if err := s.attachment.Remove(expiredMessageIDs...); err != nil { - log.Tag(tagManager).Err(err).Warn("Error deleting attachments for expired messages") - } - } + // 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. if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil { - log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") + log.Tag(tagManager).Err(err).Warn("Error deleting expired messages") } } else { log.Tag(tagManager).Debug("No expired messages to delete") diff --git a/web/package-lock.json b/web/package-lock.json index 175ef11b..b04b2b41 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9514,24 +9514,6 @@ "dev": true, "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": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From e0362dce36b60b99ec8e9f8299f89f6b7bd61efc Mon Sep 17 00:00:00 2001 From: ageru Date: Tue, 24 Mar 2026 23:36:19 +0100 Subject: [PATCH 039/126] install.md - Install ntfy server service manually Rework of steps to install ntfy server service on systemd and OpenRC --- docs/install.md | 84 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/docs/install.md b/docs/install.md index eb16baa0..48d98d2f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -29,6 +29,7 @@ Please check out the [releases page](https://github.com/binwiederhier/ntfy/relea deb/rpm packages. ### Just download and run +Following those steps 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" ```bash @@ -66,51 +67,96 @@ deb/rpm packages. sudo ntfy serve ``` -### Install permanently and start at boot -The above allows you to quickly run ntfy. If you want to install it permanently and your OS/distribution of choice doesn't offer a package, there are a few more steps to follow. +### Install as a service starting at boot time +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 -```useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for the simple HTTP-based pub-sub notification service" ntfy``` +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 -```bash -sudo mv package/server/ntfy.service /etc/systemd/system/ -sudo chmod 644 /etc/systemd/system/ntfy.service -``` +Install the ntfy server unit file (which contains parameters to start the service at boot time): + +=== "x86_64/amd64" + ```bash + sudo mv ntfy_2.19.2_linux_amd64/server/ntfy.service /etc/systemd/system/ + sudo chmod 644 /etc/systemd/system/ntfy.service + ``` + +=== "armv6" + ```bash + sudo mv ntfy_2.19.2_linux_armv6/server/ntfy.service /etc/systemd/system/ + sudo chmod 644 /etc/systemd/system/ntfy.service + ``` + +=== "armv7/armhf" + ```bash + sudo mv ntfy_2.19.2_linux_armv7/server/ntfy.service /etc/systemd/system/ + sudo chmod 644 /etc/systemd/system/ntfy.service + ``` + +=== "arm64" + ```bash + sudo mv ntfy_2.19.2_linux_arm64/server/ntfy.service /etc/systemd/system/ + sudo chmod 644 /etc/systemd/system/ntfy.service + ``` + +Notify systemd we have added a new service: -Notify systemd the new service exist ```bash sudo systemctl daemon-reload ``` -Launch the service on systemd +Start the service: + ```bash sudo systemctl ntfy start ``` #### On OpenRC systems -Install the ntfy server service script -```bash -sudo mv origin/path/ntfy.openrc /etc/init.d/ntfy -sudo chmod 755 /etc/init.d/ntfy -``` +Install the ntfy server service script: + +=== "x86_64/amd64" + ```bash + sudo mv ntfy_2.19.2_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy + sudo chmod 755 /etc/init.d/ntfy + ``` + +=== "armv6" + ```bash + sudo mv ntfy_2.19.2_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy + sudo chmod 755 /etc/init.d/ntfy + ``` + +=== "armv7/armhf" + ```bash + sudo mv ntfy_2.19.2_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy + sudo chmod 755 /etc/init.d/ntfy + ``` + +=== "arm64" + ```bash + sudo mv ntfy_2.19.2_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy + sudo chmod 755 /etc/init.d/ntfy + ``` + +Start the ntfy server service: -Launch the ntfy server service ```bash sudo rc-service ntfy start ``` -Add ntfy server service to the default runlevel (so that it runs at startup) +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 !!! info From e22a77d4bbe592eb7d9af5bfc60648e3436141ce Mon Sep 17 00:00:00 2001 From: ageru Date: Tue, 24 Mar 2026 23:38:16 +0100 Subject: [PATCH 040/126] Rename ntfy-server.openrc to ntfy.openrc --- server/{ntfy-server.openrc => ntfy.openrc} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/{ntfy-server.openrc => ntfy.openrc} (100%) diff --git a/server/ntfy-server.openrc b/server/ntfy.openrc similarity index 100% rename from server/ntfy-server.openrc rename to server/ntfy.openrc From 071543efdadfe7239fb38b13ea7bd9f8478d7c1c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 15:28:23 -0400 Subject: [PATCH 041/126] Fixes --- attachment/store.go | 28 +++++++++------ attachment/store_file_test.go | 3 +- attachment/store_s3_test.go | 4 +-- message/cache.go | 62 ++++++++++---------------------- message/cache_postgres.go | 12 +++---- message/cache_postgres_schema.go | 14 ++++---- message/cache_sqlite.go | 12 +++---- message/cache_sqlite_test.go | 4 +-- message/cache_test.go | 40 ++++++--------------- server/config.go | 17 ++++++--- server/server.go | 4 +-- server/server_account_test.go | 2 ++ server/server_manager.go | 35 ++++++++---------- server/server_payments_test.go | 2 ++ server/server_test.go | 4 ++- 15 files changed, 102 insertions(+), 141 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index 70fb55c0..14b39f81 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -14,9 +14,8 @@ import ( ) const ( - tagStore = "attachment_store" - 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 + tagStore = "attachment_store" + syncInterval = 15 * time.Minute // How often to run the background sync loop ) var errInvalidFileID = errors.New("invalid file ID") @@ -29,36 +28,38 @@ type Store struct { size int64 // Current size of the store in bytes sizes map[string]int64 // File ID -> size, for subtracting on Remove attachmentsWithSizes func() (map[string]int64, error) // Returns file ID -> size for active attachments + orphanGracePeriod time.Duration // Don't delete orphaned objects younger than this closeChan chan struct{} mu sync.RWMutex // Protects size and sizes } // NewFileStore creates a new file-system backed attachment cache -func NewFileStore(dir string, totalSizeLimit int64, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { +func NewFileStore(dir string, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { b, err := newFileBackend(dir) if err != nil { return nil, err } - return newStore(b, totalSizeLimit, attachmentsWithSizes) + return newStore(b, totalSizeLimit, orphanGracePeriod, attachmentsWithSizes) } // 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] -func NewS3Store(s3URL string, totalSizeLimit int64, attachmentsWithSizes func() (map[string]int64, 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) if err != nil { return nil, err } - return newStore(newS3Backend(s3.New(config)), totalSizeLimit, attachmentsWithSizes) + return newStore(newS3Backend(s3.New(config)), totalSizeLimit, orphanGracePeriod, attachmentsWithSizes) } -func newStore(backend backend, totalSizeLimit int64, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { +func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { c := &Store{ backend: backend, limit: totalSizeLimit, sizes: make(map[string]int64), attachmentsWithSizes: attachmentsWithSizes, + orphanGracePeriod: orphanGracePeriod, closeChan: make(chan struct{}), } // Hydrate sizes from the database immediately so that Size()/Remaining()/Remove() @@ -140,9 +141,14 @@ func (c *Store) Remove(ids ...string) error { 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, -// deletes orphans (not in the valid ID set and older than 1 hour), and recomputes -// the total size from the existing attachments in the database. +// deletes orphans (not in the valid ID set and older than the grace period), and +// recomputes the total size from the existing attachments in the database. func (c *Store) sync() error { if c.attachmentsWithSizes == nil { return nil @@ -157,7 +163,7 @@ func (c *Store) sync() error { } // 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. - cutoff := time.Now().Add(-orphanGracePeriod) + cutoff := time.Now().Add(-c.orphanGracePeriod) var orphanIDs []string var count, totalSize int64 sizes := make(map[string]int64, len(remoteObjects)) diff --git a/attachment/store_file_test.go b/attachment/store_file_test.go index d0b6e135..0f7495b4 100644 --- a/attachment/store_file_test.go +++ b/attachment/store_file_test.go @@ -2,6 +2,7 @@ package attachment import ( "testing" + "time" "github.com/stretchr/testify/require" ) @@ -9,7 +10,7 @@ import ( func newTestFileStore(t *testing.T, totalSizeLimit int64) (dir string, cache *Store) { t.Helper() dir = t.TempDir() - cache, err := NewFileStore(dir, totalSizeLimit, nil) + cache, err := NewFileStore(dir, totalSizeLimit, time.Hour, nil) require.Nil(t, err) t.Cleanup(func() { cache.Close() }) return dir, cache diff --git a/attachment/store_s3_test.go b/attachment/store_s3_test.go index 6615f4e9..22c1d6bf 100644 --- a/attachment/store_s3_test.go +++ b/attachment/store_s3_test.go @@ -24,7 +24,7 @@ func TestS3Store_WriteWithPrefix(t *testing.T) { client := s3.New(cfg) deleteAllObjects(t, client) backend := newS3Backend(client) - cache, err := newStore(backend, 10*1024, nil) + cache, err := newStore(backend, 10*1024, time.Hour, nil) require.Nil(t, err) t.Cleanup(func() { deleteAllObjects(t, client) @@ -62,7 +62,7 @@ func newTestRealS3Store(t *testing.T, totalSizeLimit int64) (*Store, *modTimeOve inner := newS3Backend(client) wrapper := &modTimeOverrideBackend{backend: inner, modTimes: make(map[string]time.Time)} deleteAllObjects(t, client) - store, err := newStore(wrapper, totalSizeLimit, nil) + store, err := newStore(wrapper, totalSizeLimit, time.Hour, nil) require.Nil(t, err) t.Cleanup(func() { deleteAllObjects(t, client) diff --git a/message/cache.go b/message/cache.go index 870eb599..90fbf51d 100644 --- a/message/cache.go +++ b/message/cache.go @@ -24,7 +24,6 @@ var errNoRows = errors.New("no rows found") // queries holds the database-specific SQL queries type queries struct { insertMessage string - deleteMessage string selectScheduledMessageIDsBySeqID string deleteScheduledBySequenceID string updateMessagesForTopicExpiry string @@ -35,12 +34,11 @@ type queries struct { selectMessagesSinceIDScheduled string selectMessagesLatest string selectMessagesDue string - selectMessagesExpired string + deleteExpiredMessages string updateMessagePublished string selectMessagesCount string selectTopics string - updateAttachmentDeleted string - selectAttachmentsExpired string + markExpiredAttachmentsDeleted string selectAttachmentsSizeBySender string selectAttachmentsSizeByUserID string selectAttachmentsWithSizes string @@ -246,14 +244,16 @@ func (c *Cache) MessagesDue() ([]*model.Message, error) { return readMessages(rows) } -// MessagesExpired returns a list of message IDs that have expired and should be deleted -func (c *Cache) MessagesExpired() ([]string, error) { - rows, err := c.db.Query(c.queries.selectMessagesExpired, time.Now().Unix()) +// DeleteExpiredMessages deletes up to `limit` expired messages in a single query +// and returns the number of deleted rows. +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 { - return nil, err + return 0, err } - defer rows.Close() - return readStrings(rows) + return result.RowsAffected() } // Message returns the message with the given ID, or ErrMessageNotFound if not found @@ -312,20 +312,6 @@ func (c *Cache) Topics() ([]string, error) { 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. // 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) { @@ -363,28 +349,16 @@ func (c *Cache) ExpireMessages(topics ...string) error { }) } -// 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 { +// MarkExpiredAttachmentsDeleted marks up to `limit` expired attachments as deleted in a single +// query and returns the number of updated rows. +func (c *Cache) MarkExpiredAttachmentsDeleted(limit int) (int64, 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.updateAttachmentDeleted, id); err != nil { - return err - } - } - return nil - }) + result, err := c.db.Exec(c.queries.markExpiredAttachmentsDeleted, time.Now().Unix(), limit) + if err != nil { + return 0, err + } + return result.RowsAffected() } // AttachmentBytesUsedBySender returns the total size of active attachments sent by the given sender diff --git a/message/cache_postgres.go b/message/cache_postgres.go index f0a32036..4d7c3f93 100644 --- a/message/cache_postgres.go +++ b/message/cache_postgres.go @@ -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) 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` postgresDeleteScheduledBySequenceIDQuery = `DELETE FROM message WHERE topic = $1 AND sequence_id = $2 AND published = FALSE` postgresUpdateMessagesForTopicExpiryQuery = `UPDATE message SET expires = $1 WHERE topic = $2` @@ -61,13 +60,12 @@ const ( WHERE time <= $1 AND published = FALSE ORDER BY time, id ` - postgresSelectMessagesExpiredQuery = `SELECT mid FROM message WHERE expires <= $1 AND published = TRUE` postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1` postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message` postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic` - postgresUpdateAttachmentDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid = $1` - postgresSelectAttachmentsExpiredQuery = `SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE` + postgresDeleteExpiredMessagesQuery = `DELETE FROM message WHERE mid IN (SELECT mid FROM message WHERE expires <= $1 AND published = TRUE LIMIT $2)` + 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` 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` @@ -79,7 +77,6 @@ const ( var postgresQueries = queries{ insertMessage: postgresInsertMessageQuery, - deleteMessage: postgresDeleteMessageQuery, selectScheduledMessageIDsBySeqID: postgresSelectScheduledMessageIDsBySeqIDQuery, deleteScheduledBySequenceID: postgresDeleteScheduledBySequenceIDQuery, updateMessagesForTopicExpiry: postgresUpdateMessagesForTopicExpiryQuery, @@ -90,12 +87,11 @@ var postgresQueries = queries{ selectMessagesSinceIDScheduled: postgresSelectMessagesSinceIDIncludeScheduledQuery, selectMessagesLatest: postgresSelectMessagesLatestQuery, selectMessagesDue: postgresSelectMessagesDueQuery, - selectMessagesExpired: postgresSelectMessagesExpiredQuery, + deleteExpiredMessages: postgresDeleteExpiredMessagesQuery, updateMessagePublished: postgresUpdateMessagePublishedQuery, selectMessagesCount: postgresSelectMessagesCountQuery, selectTopics: postgresSelectTopicsQuery, - updateAttachmentDeleted: postgresUpdateAttachmentDeletedQuery, - selectAttachmentsExpired: postgresSelectAttachmentsExpiredQuery, + markExpiredAttachmentsDeleted: postgresMarkExpiredAttachmentsDeletedQuery, selectAttachmentsSizeBySender: postgresSelectAttachmentsSizeBySenderQuery, selectAttachmentsSizeByUserID: postgresSelectAttachmentsSizeByUserIDQuery, selectAttachmentsWithSizes: postgresSelectAttachmentsWithSizesQuery, diff --git a/message/cache_postgres_schema.go b/message/cache_postgres_schema.go index 99e18c2c..994df9b0 100644 --- a/message/cache_postgres_schema.go +++ b/message/cache_postgres_schema.go @@ -73,14 +73,14 @@ const ( ` ) -var postgresMigrations = map[int]func(db *sql.DB) error{ +var postgresMigrations = map[int]func(d *sql.DB) error{ 14: postgresMigrateFrom14, } -func setupPostgres(sqlDB *sql.DB) error { +func setupPostgres(d *sql.DB) error { var schemaVersion int - if err := sqlDB.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { - return setupNewPostgresDB(sqlDB) + if err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { + return setupNewPostgresDB(d) } else if schemaVersion == postgresCurrentSchemaVersion { return nil } else if schemaVersion > postgresCurrentSchemaVersion { @@ -90,16 +90,16 @@ func setupPostgres(sqlDB *sql.DB) error { 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(sqlDB); err != nil { + } else if err := fn(d); err != nil { return err } } return nil } -func postgresMigrateFrom14(sqlDB *sql.DB) error { +func postgresMigrateFrom14(d *sql.DB) error { log.Tag(tagMessageCache).Info("Migrating message cache database schema: from 14 to 15") - return db.ExecTx(sqlDB, func(tx *sql.Tx) error { + return db.ExecTx(d, func(tx *sql.Tx) error { if _, err := tx.Exec(postgresMigrate14To15CreateIndexQuery); err != nil { return err } diff --git a/message/cache_sqlite.go b/message/cache_sqlite.go index b39095e0..b9d7394f 100644 --- a/message/cache_sqlite.go +++ b/message/cache_sqlite.go @@ -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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` - sqliteDeleteMessageQuery = `DELETE FROM messages WHERE mid = ?` 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` sqliteUpdateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?` @@ -64,13 +63,12 @@ const ( WHERE time <= ? AND published = 0 ORDER BY time, id ` - sqliteSelectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1` sqliteUpdateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?` sqliteSelectMessagesCountQuery = `SELECT COUNT(*) FROM messages` sqliteSelectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` - sqliteUpdateAttachmentDeletedQuery = `UPDATE messages SET attachment_deleted = 1 WHERE mid = ?` - sqliteSelectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires <= ? AND attachment_deleted = 0` + sqliteDeleteExpiredMessagesQuery = `DELETE FROM messages WHERE mid IN (SELECT mid FROM messages WHERE expires <= ? AND published = 1 LIMIT ?)` + 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 >= ?` 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` @@ -82,7 +80,6 @@ const ( var sqliteQueries = queries{ insertMessage: sqliteInsertMessageQuery, - deleteMessage: sqliteDeleteMessageQuery, selectScheduledMessageIDsBySeqID: sqliteSelectScheduledMessageIDsBySeqIDQuery, deleteScheduledBySequenceID: sqliteDeleteScheduledBySequenceIDQuery, updateMessagesForTopicExpiry: sqliteUpdateMessagesForTopicExpiryQuery, @@ -93,12 +90,11 @@ var sqliteQueries = queries{ selectMessagesSinceIDScheduled: sqliteSelectMessagesSinceIDIncludeScheduledQuery, selectMessagesLatest: sqliteSelectMessagesLatestQuery, selectMessagesDue: sqliteSelectMessagesDueQuery, - selectMessagesExpired: sqliteSelectMessagesExpiredQuery, + deleteExpiredMessages: sqliteDeleteExpiredMessagesQuery, updateMessagePublished: sqliteUpdateMessagePublishedQuery, selectMessagesCount: sqliteSelectMessagesCountQuery, selectTopics: sqliteSelectTopicsQuery, - updateAttachmentDeleted: sqliteUpdateAttachmentDeletedQuery, - selectAttachmentsExpired: sqliteSelectAttachmentsExpiredQuery, + markExpiredAttachmentsDeleted: sqliteMarkExpiredAttachmentsDeletedQuery, selectAttachmentsSizeBySender: sqliteSelectAttachmentsSizeBySenderQuery, selectAttachmentsSizeByUserID: sqliteSelectAttachmentsSizeByUserIDQuery, selectAttachmentsWithSizes: sqliteSelectAttachmentsWithSizesQuery, diff --git a/message/cache_sqlite_test.go b/message/cache_sqlite_test.go index e69488e6..95ff7e48 100644 --- a/message/cache_sqlite_test.go +++ b/message/cache_sqlite_test.go @@ -209,7 +209,7 @@ func TestSqliteStore_Migration_From9(t *testing.T) { require.True(t, rows.Next()) var version int require.Nil(t, rows.Scan(&version)) - require.Equal(t, 14, version) + require.Equal(t, 15, version) require.Nil(t, rows.Close()) messages, err := s.Messages("mytopic", model.SinceAllMessages, false) @@ -287,6 +287,6 @@ func checkSqliteSchemaVersion(t *testing.T, filename string) { require.True(t, rows.Next()) var schemaVersion int require.Nil(t, rows.Scan(&schemaVersion)) - require.Equal(t, 14, schemaVersion) + require.Equal(t, 15, schemaVersion) require.Nil(t, rows.Close()) } diff --git a/message/cache_test.go b/message/cache_test.go index 0fddc88b..059a1f62 100644 --- a/message/cache_test.go +++ b/message/cache_test.go @@ -3,7 +3,6 @@ package message_test import ( "net/netip" "path/filepath" - "sort" "sync" "testing" "time" @@ -274,9 +273,9 @@ func TestStore_Prune(t *testing.T) { require.Nil(t, err) require.Equal(t, 3, count) - expiredMessageIDs, err := s.MessagesExpired() + deleted, err := s.DeleteExpiredMessages(10) require.Nil(t, err) - require.Nil(t, s.DeleteMessages(expiredMessageIDs...)) + require.Equal(t, int64(2), deleted) count, err = s.MessagesCount() require.Nil(t, err) @@ -414,10 +413,9 @@ func TestStore_AttachmentsExpired(t *testing.T) { } require.Nil(t, s.AddMessage(m)) - ids, err := s.AttachmentsExpired() + count, err := s.MarkExpiredAttachmentsDeleted(10) require.Nil(t, err) - require.Equal(t, 1, len(ids)) - require.Equal(t, "m4", ids[0]) + require.Equal(t, int64(1), count) }) } @@ -583,13 +581,9 @@ func TestStore_ExpireMessages(t *testing.T) { require.Nil(t, s.ExpireMessages("topic1")) // topic1 messages should now be expired (expires set to past) - expiredIDs, err := s.MessagesExpired() + deleted, err := s.DeleteExpiredMessages(100) require.Nil(t, err) - require.Equal(t, 2, len(expiredIDs)) - sort.Strings(expiredIDs) - expectedIDs := []string{m1.ID, m2.ID} - sort.Strings(expectedIDs) - require.Equal(t, expectedIDs, expiredIDs) + require.Equal(t, int64(2), deleted) // topic2 should be unaffected messages, err = s.Messages("topic2", model.SinceAllMessages, false) @@ -629,27 +623,15 @@ func TestStore_MarkAttachmentsDeleted(t *testing.T) { } require.Nil(t, s.AddMessage(m2)) - // Both should show as expired attachments needing cleanup - ids, err := s.AttachmentsExpired() + // Both should be marked as deleted in one batch + count, err := s.MarkExpiredAttachmentsDeleted(10) require.Nil(t, err) - require.Equal(t, 2, len(ids)) - - // 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")) + require.Equal(t, int64(2), count) // No more expired attachments to clean up - ids, err = s.AttachmentsExpired() + count, err = s.MarkExpiredAttachmentsDeleted(10) require.Nil(t, err) - require.Equal(t, 0, len(ids)) + require.Equal(t, int64(0), count) // Messages themselves still exist messages, err := s.Messages("mytopic", model.SinceAllMessages, false) diff --git a/server/config.go b/server/config.go index 8ead312c..8497b18e 100644 --- a/server/config.go +++ b/server/config.go @@ -20,6 +20,7 @@ const ( DefaultCacheBatchTimeout = time.Duration(0) DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!) DefaultManagerInterval = time.Minute + DefaultManagerBatchSize = 30000 DefaultDelayedSenderInterval = 10 * time.Second DefaultMessageDelayMin = 10 * time.Second DefaultMessageDelayMax = 3 * 24 * time.Hour @@ -46,11 +47,13 @@ const ( // - total topic limit: max number of topics overall // - various attachment limits const ( - DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message - DefaultTotalTopicLimit = 15000 - DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB - DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB - DefaultAttachmentExpiryDuration = 3 * time.Hour + DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message + DefaultTotalTopicLimit = 15000 + DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB + DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB + DefaultAttachmentExpiryDuration = 3 * time.Hour + DefaultAttachmentOrphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads + ) // Defines all per-visitor limits @@ -115,9 +118,11 @@ type Config struct { AttachmentTotalSizeLimit int64 AttachmentFileSizeLimit int64 AttachmentExpiryDuration time.Duration + AttachmentOrphanGracePeriod time.Duration TemplateDir string // Directory to load named templates from KeepaliveInterval time.Duration ManagerInterval time.Duration + ManagerBatchSize int DisallowedTopics []string WebRoot string // empty to disable DelayedSenderInterval time.Duration @@ -217,9 +222,11 @@ func NewConfig() *Config { AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, + AttachmentOrphanGracePeriod: DefaultAttachmentOrphanGracePeriod, TemplateDir: DefaultTemplateDir, KeepaliveInterval: DefaultKeepaliveInterval, ManagerInterval: DefaultManagerInterval, + ManagerBatchSize: DefaultManagerBatchSize, DisallowedTopics: DefaultDisallowedTopics, WebRoot: "/", DelayedSenderInterval: DefaultDelayedSenderInterval, diff --git a/server/server.go b/server/server.go index 508d9a70..71d08b01 100644 --- a/server/server.go +++ b/server/server.go @@ -302,9 +302,9 @@ func createMessageCache(conf *Config, pool *db.DB) (*message.Cache, error) { func createAttachmentStore(conf *Config, messageCache *message.Cache) (*attachment.Store, error) { if strings.HasPrefix(conf.AttachmentCacheDir, "s3://") { - return attachment.NewS3Store(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, messageCache.AttachmentsWithSizes) + return attachment.NewS3Store(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, conf.AttachmentOrphanGracePeriod, messageCache.AttachmentsWithSizes) } else if conf.AttachmentCacheDir != "" { - return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, messageCache.AttachmentsWithSizes) + return attachment.NewFileStore(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, conf.AttachmentOrphanGracePeriod, messageCache.AttachmentsWithSizes) } return nil, nil } diff --git a/server/server_account_test.go b/server/server_account_test.go index 0360fcd4..58f4d5d4 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -673,6 +673,7 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { t.Parallel() conf := newTestConfigWithAuthFile(t, databaseURL) conf.AuthDefault = user.PermissionReadWrite + conf.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately s := newTestServer(t, conf) // Create user with tier @@ -742,6 +743,7 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { // Verify that messages and attachments were deleted // This does not explicitly call the manager! 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) require.Nil(t, err) return len(ms) == 0 && !util.FileExists(filepath.Join(s.config.AttachmentCacheDir, m1.ID)) diff --git a/server/server_manager.go b/server/server_manager.go index 11fa4dfe..d0eefe72 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -142,22 +142,17 @@ func (s *Server) pruneAttachments() { if s.attachment == nil { return } + // Only mark as deleted in DB. The actual storage files are cleaned up + // by the attachment store's sync() loop, which periodically reconciles + // storage with the database and removes orphaned files. log. Tag(tagManager). Timing(func() { - ids, err := s.messageCache.AttachmentsExpired() + count, err := s.messageCache.MarkExpiredAttachmentsDeleted(s.config.ManagerBatchSize) if err != nil { - log.Tag(tagManager).Err(err).Warn("Error retrieving expired attachments") - } else if len(ids) > 0 { - if log.Tag(tagManager).IsDebug() { - log.Tag(tagManager).Debug("Marking %d expired attachment(s) as deleted", len(ids)) - } - // 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. - if err := s.messageCache.MarkAttachmentsDeleted(ids...); err != nil { - log.Tag(tagManager).Err(err).Warn("Error marking attachments deleted") - } + log.Tag(tagManager).Err(err).Warn("Error marking expired attachments as deleted") + } else if count > 0 { + log.Tag(tagManager).Debug("Marked %d expired attachment(s) as deleted", count) } else { log.Tag(tagManager).Debug("No expired attachments to delete") } @@ -166,19 +161,17 @@ func (s *Server) pruneAttachments() { } func (s *Server) pruneMessages() { + // Only delete DB rows. Attachment storage files are cleaned up by the + // attachment store's sync() loop, which periodically reconciles storage + // with the database and removes orphaned files. log. Tag(tagManager). Timing(func() { - expiredMessageIDs, err := s.messageCache.MessagesExpired() + count, err := s.messageCache.DeleteExpiredMessages(s.config.ManagerBatchSize) if err != nil { - log.Tag(tagManager).Err(err).Warn("Error retrieving expired messages") - } else if len(expiredMessageIDs) > 0 { - // 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. - if err := s.messageCache.DeleteMessages(expiredMessageIDs...); err != nil { - log.Tag(tagManager).Err(err).Warn("Error deleting expired messages") - } + log.Tag(tagManager).Err(err).Warn("Error deleting expired messages") + } else if count > 0 { + log.Tag(tagManager).Debug("Deleted %d expired message(s)", count) } else { log.Tag(tagManager).Debug("No expired messages to delete") } diff --git a/server/server_payments_test.go b/server/server_payments_test.go index 9873d6d8..30b1a22e 100644 --- a/server/server_payments_test.go +++ b/server/server_payments_test.go @@ -443,6 +443,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" + c.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately s := newTestServer(t, c) s.stripe = stripeMock @@ -546,6 +547,7 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( // Verify that messages and attachments were deleted time.Sleep(time.Second) s.execManager() + s.attachment.Sync() // File cleanup is done by sync, not by the manager ms, err := s.messageCache.Messages("atopic", model.SinceAllMessages, false) require.Nil(t, err) diff --git a/server/server_test.go b/server/server_test.go index 44b9ac94..3f5f5b06 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2285,6 +2285,7 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) { c := newTestConfig(t, databaseURL) c.AttachmentExpiryDuration = time.Millisecond // Hack + c.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately s := newTestServer(t, c) // Publish and make sure we can retrieve it @@ -2301,7 +2302,8 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) { // Prune and makes sure it's gone 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) }) response = request(t, s, "GET", path, "", nil) From ca59cfc1e1c05298dc7940c2668f6fc3726724bb Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 17:01:29 -0400 Subject: [PATCH 042/126] Words --- server/server_manager.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/server_manager.go b/server/server_manager.go index d0eefe72..387ad2b8 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -119,7 +119,7 @@ func (s *Server) pruneVisitors() { } }). Field("stale_visitors", staleVisitors). - Debug("Deleted %d stale visitor(s)", staleVisitors) + Debug("Finished deleting stale visitors") } func (s *Server) pruneTokens() { @@ -134,7 +134,7 @@ func (s *Server) pruneTokens() { log.Tag(tagManager).Err(err).Warn("Error deleting soft-deleted users") } }). - Debug("Removed expired tokens and users") + Debug("Finished deleting expired tokens and users") } } @@ -157,7 +157,7 @@ func (s *Server) pruneAttachments() { log.Tag(tagManager).Debug("No expired attachments to delete") } }). - Debug("Deleted expired attachments") + Debug("Finished marking expired attachments as deleted") } func (s *Server) pruneMessages() { @@ -176,5 +176,5 @@ func (s *Server) pruneMessages() { log.Tag(tagManager).Debug("No expired messages to delete") } }). - Debug("Pruned messages") + Debug("Finished deleting expired messages") } From 3ff8bacc45a63abe5b640879ab05189050c63f4d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 17:09:44 -0400 Subject: [PATCH 043/126] Update changelog --- docs/releases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 101ff949..70783a9f 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1815,7 +1815,7 @@ for details. **Features:** -* Add S3-compatible object storage as an alternative [attachment store](config.md#attachments) via `attachment-cache-dir` config option +* 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:** From f256a4101bed94ad6e1f205dfee535ba4a861e00 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 17:20:02 -0400 Subject: [PATCH 044/126] Fix test --- server/server_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server_test.go b/server/server_test.go index 3f5f5b06..75d61772 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2700,7 +2700,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { response := request(t, s, "PUT", "/mytopic", "some body", nil) m := toMessage(t, response.Body.String()) require.Equal(t, "some body", m.Message) - require.True(t, time.Since(start) < 100*time.Millisecond) + require.True(t, time.Since(start) < 500*time.Millisecond) log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) // Wait for all Goroutines From f790143b0b89a654ffd81bf25182a367e3d7f229 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 17:35:24 -0400 Subject: [PATCH 045/126] Refined wording, review --- docs/install.md | 21 ++++++++------------- docs/releases.md | 1 + 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/docs/install.md b/docs/install.md index 48d98d2f..0bb241ea 100644 --- a/docs/install.md +++ b/docs/install.md @@ -28,8 +28,9 @@ 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 deb/rpm packages. -### Just download and run -Following those steps 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. +### 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" ```bash @@ -67,8 +68,9 @@ Following those steps allow you to download ntfy server and run it in a pinch. B sudo ntfy serve ``` -### Install as a service starting at boot time -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. +### 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 @@ -78,7 +80,6 @@ useradd --system --home-dir /var/lib/ntfy --shell /bin/false --comment "User for 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" @@ -105,20 +106,14 @@ Install the ntfy server unit file (which contains parameters to start the servic sudo chmod 644 /etc/systemd/system/ntfy.service ``` -Notify systemd we have added a new service: +Then notify systemd we have added a new service and start the service: ```bash sudo systemctl daemon-reload -``` - -Start the service: - -```bash -sudo systemctl ntfy start +sudo systemctl start ntfy ``` #### On OpenRC systems - Install the ntfy server service script: === "x86_64/amd64" diff --git a/docs/releases.md b/docs/releases.md index 70783a9f..feb2d00b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1820,3 +1820,4 @@ for details. **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) From 0de9dc11ad96b2a4ab9be0b591074f43097c0cf5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 20:29:12 -0400 Subject: [PATCH 046/126] Bump --- go.mod | 8 ++-- go.sum | 16 ++++---- web/package-lock.json | 87 +++++++++++++++++++++++++++++++------------ 3 files changed, 75 insertions(+), 36 deletions(-) diff --git a/go.mod b/go.mod index 7edd3710..28e4911f 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/term v0.41.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 ) @@ -30,7 +30,7 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.19.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/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 @@ -41,7 +41,7 @@ require ( require ( cel.dev/expr v0.25.1 // 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/compute/metadata v0.9.0 // indirect cloud.google.com/go/iam v1.5.3 // indirect @@ -70,7 +70,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // 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/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect diff --git a/go.sum b/go.sum index 97738e0f..7ddbb3d4 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= 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/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568 h1:PJt3KrySfZkKdcEV2wlyNkfAPbMZGjtnv5oLrT4tWPg= -cloud.google.com/go/auth v0.18.3-0.20260310051336-87cdcc9f7568/go.mod h1:/Tt0rLCp4FHXEBtdyYqvIZPcJzbpJ/fmqtgIaXseDK4= +cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ= +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/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= @@ -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/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/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= -github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/googleapis/gax-go/v2 v2.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs= +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/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 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.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 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.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= +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/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= diff --git a/web/package-lock.json b/web/package-lock.json index b04b2b41..3e9d4648 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2829,6 +2829,9 @@ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2843,6 +2846,9 @@ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2857,6 +2863,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2871,6 +2880,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2885,6 +2897,9 @@ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2899,6 +2914,9 @@ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2913,6 +2931,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2927,6 +2948,9 @@ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2941,6 +2965,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2955,6 +2982,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2969,6 +2999,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2983,6 +3016,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2997,6 +3033,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3766,9 +3805,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001780", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", - "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", "dev": true, "funding": [ { @@ -3939,15 +3978,6 @@ "node": ">=10" } }, - "node_modules/cosmiconfig/node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/cross-fetch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", @@ -4203,9 +4233,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.321", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", - "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "version": "1.5.325", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", + "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", "dev": true, "license": "ISC" }, @@ -7068,9 +7098,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -9202,9 +9232,9 @@ } }, "node_modules/workbox-build/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -9270,9 +9300,9 @@ } }, "node_modules/workbox-build/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -9514,6 +9544,15 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From d159580ecf8c8b4ac82def5ed9dec10b0655f2c6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 20:34:48 -0400 Subject: [PATCH 047/126] Bump --- docs/install.md | 76 ++++++++++++++++++++++++------------------------ docs/releases.md | 50 ++++++++++++++++--------------- 2 files changed, 64 insertions(+), 62 deletions(-) diff --git a/docs/install.md b/docs/install.md index 0bb241ea..9e289f50 100644 --- a/docs/install.md +++ b/docs/install.md @@ -34,37 +34,37 @@ as a service starting at boot time. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.tar.gz - tar zxvf ntfy_2.19.2_linux_amd64.tar.gz - sudo cp -a ntfy_2.19.2_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_amd64.tar.gz + tar zxvf ntfy_2.20.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.20.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.tar.gz - tar zxvf ntfy_2.19.2_linux_armv6.tar.gz - sudo cp -a ntfy_2.19.2_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv6.tar.gz + tar zxvf ntfy_2.20.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.20.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.tar.gz - tar zxvf ntfy_2.19.2_linux_armv7.tar.gz - sudo cp -a ntfy_2.19.2_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv7.tar.gz + tar zxvf ntfy_2.20.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.20.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.tar.gz - tar zxvf ntfy_2.19.2_linux_arm64.tar.gz - sudo cp -a ntfy_2.19.2_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_arm64.tar.gz + tar zxvf ntfy_2.20.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.20.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic === "x86_64/amd64" ```bash - sudo mv ntfy_2.19.2_linux_amd64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.20.0_linux_amd64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "armv6" ```bash - sudo mv ntfy_2.19.2_linux_armv6/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.20.0_linux_armv6/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "armv7/armhf" ```bash - sudo mv ntfy_2.19.2_linux_armv7/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.20.0_linux_armv7/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "arm64" ```bash - sudo mv ntfy_2.19.2_linux_arm64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.20.0_linux_arm64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` @@ -118,25 +118,25 @@ Install the ntfy server service script: === "x86_64/amd64" ```bash - sudo mv ntfy_2.19.2_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.20.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "armv6" ```bash - sudo mv ntfy_2.19.2_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.20.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "armv7/armhf" ```bash - sudo mv ntfy_2.19.2_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.20.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "arm64" ```bash - sudo mv ntfy_2.19.2_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.20.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` @@ -204,7 +204,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```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.20.0/ntfy_2.20.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -212,7 +212,7 @@ Manually installing the .deb file: === "armv6" ```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.20.0/ntfy_2.20.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -220,7 +220,7 @@ Manually installing the .deb file: === "armv7/armhf" ```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.20.0/ntfy_2.20.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -228,7 +228,7 @@ Manually installing the .deb file: === "arm64" ```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.20.0/ntfy_2.20.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -238,28 +238,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```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.20.0/ntfy_2.20.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```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.20.0/ntfy_2.20.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```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.20.0/ntfy_2.20.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```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.20.0/ntfy_2.20.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -301,18 +301,18 @@ pkg install go-ntfy ## macOS 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.20.0/ntfy_2.20.0_darwin_all.tar.gz), 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 `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```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 -tar zxvf ntfy_2.19.2_darwin_all.tar.gz -sudo cp -a ntfy_2.19.2_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_darwin_all.tar.gz > ntfy_2.20.0_darwin_all.tar.gz +tar zxvf ntfy_2.20.0_darwin_all.tar.gz +sudo cp -a ntfy_2.20.0_darwin_all/ntfy /usr/local/bin/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.20.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -333,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. 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.20.0/ntfy_2.20.0_windows_amd64.zip), 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` diff --git a/docs/releases.md b/docs/releases.md index feb2d00b..e67579a1 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,36 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.19.2 | Mar 16, 2026 | +| ntfy server | v2.20.0 | Mar 25, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy server v2.20.0 + +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 @@ -1798,26 +1822,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.20.x (UNRELEASED) - -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) +_Nothing_ \ No newline at end of file From a2206dba9f753ce1c4e784259961a6dbac08b5db Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 20:47:20 -0400 Subject: [PATCH 048/126] Fix races in tests --- server/server_account_test.go | 8 ++++++-- server/server_payments_test.go | 8 ++++++-- server/server_test.go | 5 +++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/server/server_account_test.go b/server/server_account_test.go index 58f4d5d4..f4b4d7c5 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -9,6 +9,7 @@ import ( "heckel.io/ntfy/v2/util" "io" "net/netip" + "os" "path/filepath" "strings" "testing" @@ -673,7 +674,6 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { t.Parallel() conf := newTestConfigWithAuthFile(t, databaseURL) conf.AuthDefault = user.PermissionReadWrite - conf.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately s := newTestServer(t, conf) // Create user with tier @@ -741,7 +741,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) { require.Equal(t, 200, rr.Code) // 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 { s.attachment.Sync() // File cleanup is done by sync, not by the manager ms, err := s.messageCache.Messages("mytopic1", model.SinceAllMessages, false) diff --git a/server/server_payments_test.go b/server/server_payments_test.go index 30b1a22e..86e72c6d 100644 --- a/server/server_payments_test.go +++ b/server/server_payments_test.go @@ -14,6 +14,7 @@ import ( "heckel.io/ntfy/v2/util" "io" "net/netip" + "os" "path/filepath" "strings" "sync" @@ -443,7 +444,6 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( c := newTestConfigWithAuthFile(t, databaseURL) c.StripeSecretKey = "secret key" c.StripeWebhookKey = "webhook key" - c.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately s := newTestServer(t, c) s.stripe = stripeMock @@ -544,7 +544,11 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active( require.Equal(t, 1, len(r)) // "ztopic" reservation was deleted 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) s.execManager() s.attachment.Sync() // File cleanup is done by sync, not by the manager diff --git a/server/server_test.go b/server/server_test.go index 75d61772..644c0877 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2285,7 +2285,6 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) { c := newTestConfig(t, databaseURL) c.AttachmentExpiryDuration = time.Millisecond // Hack - c.AttachmentOrphanGracePeriod = 0 // For testing: delete orphans immediately s := newTestServer(t, c) // Publish and make sure we can retrieve it @@ -2300,7 +2299,9 @@ func TestServer_PublishAttachmentAndExpire(t *testing.T) { require.Equal(t, 200, response.Code) 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 { s.execManager() s.attachment.Sync() // File cleanup is done by sync, not by the manager From 15d963cb5305419c4ea2ca83ec497578d713bd4e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 21:00:58 -0400 Subject: [PATCH 049/126] Fix flaky test due to new attachment expiry cap logic --- server/server_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/server_test.go b/server/server_test.go index 644c0877..78a04b8f 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2414,6 +2414,7 @@ func TestServer_PublishAttachmentWithTierBasedLimits(t *testing.T) { require.Nil(t, s.userManager.AddTier(&user.Tier{ Code: "test", MessageLimit: 100, + MessageExpiryDuration: time.Hour, AttachmentFileSizeLimit: 50_000, AttachmentTotalSizeLimit: 200_000, AttachmentExpiryDuration: 30 * time.Second, From db6f813386d0a46b4898b22123b187381cb84842 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 21:03:49 -0400 Subject: [PATCH 050/126] Release notes derp --- docs/releases.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index e67579a1..66e2dd2b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -19,9 +19,9 @@ attachments in an S3-compatible object store as an alterative to the directory. 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.** + 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 From 2770f65027b145202a32becfda96556350a34b66 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 21:12:27 -0400 Subject: [PATCH 051/126] Arrrg --- server/server.go | 8 ++++++-- server/server_test.go | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/server/server.go b/server/server.go index 71d08b01..ff631359 100644 --- a/server/server.go +++ b/server/server.go @@ -433,14 +433,18 @@ func (s *Server) Stop() { s.attachment.Close() } s.closeDatabases() - close(s.closeChan) + if s.closeChan != nil { + close(s.closeChan) + } } func (s *Server) closeDatabases() { if s.userManager != nil { s.userManager.Close() } - s.messageCache.Close() + if s.messageCache != nil { + s.messageCache.Close() + } if s.webPush != nil { s.webPush.Close() } diff --git a/server/server_test.go b/server/server_test.go index 78a04b8f..fa4bf915 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -4195,7 +4195,7 @@ func newTestConfigWithAuthFile(t *testing.T, databaseURL string) *Config { func newTestServer(t *testing.T, config *Config) *Server { server, err := New(config) require.Nil(t, err) - t.Cleanup(server.closeDatabases) + t.Cleanup(server.Stop) return server } From 136b50f9268d1a19d5f022018cbf1b97a9129021 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 25 Mar 2026 21:35:51 -0400 Subject: [PATCH 052/126] I'm tired and GitHub Actions hates me. I'll release this tomorrow ... --- docs/releases.md | 50 +++++++++++++++++++++++------------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 66e2dd2b..feb2d00b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,36 +6,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.20.0 | Mar 25, 2026 | +| ntfy server | v2.19.2 | Mar 16, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. -## ntfy server v2.20.0 - -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 @@ -1822,4 +1798,26 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -_Nothing_ \ No newline at end of file +### ntfy server v2.20.x (UNRELEASED) + +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) From 3759ff26b4b94e36116c96d5f148bae4646a2871 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 09:28:05 -0400 Subject: [PATCH 053/126] "npm ci" --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 28b94f3f..ec445e20 100644 --- a/Makefile +++ b/Makefile @@ -146,7 +146,7 @@ web-build: ../server/site/config.js web-deps: - cd web && $(NPM) install + cd web && $(NPM) ci # If this fails for .svg files, optimize them with svgo web-deps-update: From 11a14d8fe7d33c7812e0551af0354f371f99fd83 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 09:36:34 -0400 Subject: [PATCH 054/126] Fix CI --- Makefile | 1 + web/package-lock.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Makefile b/Makefile index ec445e20..1371ac3c 100644 --- a/Makefile +++ b/Makefile @@ -151,6 +151,7 @@ web-deps: web-deps-update: cd web && $(NPM) update + cd web && $(NPM) install web-fmt: cd web && $(NPM) run format diff --git a/web/package-lock.json b/web/package-lock.json index 3e9d4648..e5ef21d2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3978,6 +3978,15 @@ "node": ">=10" } }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-fetch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", @@ -9544,15 +9553,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", - "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", From e19ba059b5e4d714f0d1b79503010792baef0ab7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 09:57:06 -0400 Subject: [PATCH 055/126] Make attachment.Close() synchronous --- attachment/store.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/attachment/store.go b/attachment/store.go index 14b39f81..de6eb293 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -30,6 +30,7 @@ type Store struct { attachmentsWithSizes func() (map[string]int64, error) // Returns file ID -> size for active attachments orphanGracePeriod time.Duration // Don't delete orphaned objects younger than this closeChan chan struct{} + doneChan chan struct{} mu sync.RWMutex // Protects size and sizes } @@ -61,6 +62,7 @@ func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Dura attachmentsWithSizes: attachmentsWithSizes, orphanGracePeriod: orphanGracePeriod, closeChan: make(chan struct{}), + doneChan: make(chan struct{}), } // Hydrate sizes from the database immediately so that Size()/Remaining()/Remove() // are accurate from the start, without waiting for the first sync() call. @@ -74,6 +76,8 @@ func newStore(backend backend, totalSizeLimit int64, orphanGracePeriod time.Dura c.size += size } go c.syncLoop() + } else { + close(c.doneChan) } return c, nil } @@ -216,12 +220,14 @@ func (c *Store) Remaining() int64 { return remaining } -// Close stops the background sync goroutine +// Close stops the background sync goroutine and waits for it to finish func (c *Store) Close() { close(c.closeChan) + <-c.doneChan } func (c *Store) syncLoop() { + defer close(c.doneChan) if err := c.sync(); err != nil { log.Tag(tagStore).Err(err).Warn("Attachment sync failed") } From be4134fc3bfd241f5d0cc34cb9ad2e501b79e59e Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 10:49:39 -0400 Subject: [PATCH 056/126] Try to fix flaky again --- server/server_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/server_test.go b/server/server_test.go index fa4bf915..384be7dc 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2702,7 +2702,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { response := request(t, s, "PUT", "/mytopic", "some body", nil) m := toMessage(t, response.Body.String()) require.Equal(t, "some body", m.Message) - require.True(t, time.Since(start) < 500*time.Millisecond) + require.True(t, time.Since(start) < 2*time.Second) log.Info("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond)) // Wait for all Goroutines From 27bbb10a31f3cab3a48b9d97aeee92fc2586f32a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 11:13:43 -0400 Subject: [PATCH 057/126] Bump --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 1371ac3c..a46f4dba 100644 --- a/Makefile +++ b/Makefile @@ -147,6 +147,7 @@ web-build: web-deps: 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 web-deps-update: From 2235d4472687146ce5fdd08bdf41ba54e61954aa Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 13:39:04 -0400 Subject: [PATCH 058/126] Update changelog --- docs/releases.md | 50 +++++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index feb2d00b..23f92f59 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,36 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.19.2 | Mar 16, 2026 | +| ntfy server | v2.20.0 | Mar 26, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +### ntfy server v2.20.0 + +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 @@ -1798,26 +1822,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -### ntfy server v2.20.x (UNRELEASED) - -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) +_Nothing._ \ No newline at end of file From 874bdcf9f108df065ad26fb1c5c7d5d1647608b5 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 20:47:06 -0400 Subject: [PATCH 059/126] No HTTP/2 --- s3/client.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/s3/client.go b/s3/client.go index 8e84bbc5..e11e5d47 100644 --- a/s3/client.go +++ b/s3/client.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/md5" //nolint:gosec // MD5 is required by the S3 protocol for Content-MD5 headers + "crypto/tls" "encoding/base64" "encoding/xml" "errors" @@ -61,7 +62,18 @@ type Client struct { func New(config *Config) *Client { httpClient := config.HTTPClient if httpClient == nil { - httpClient = http.DefaultClient + // Force HTTP/1.1 to avoid HTTP/2 stream errors with S3-compatible providers + // (e.g. DigitalOcean Spaces). HTTP/2 can cause non-retryable failures on + // streaming uploads when the server resets the stream mid-transfer. + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + ForceAttemptHTTP2: false, + TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), + }, + } } return &Client{ config: config, From ae1ecfa1e90d1ff6fa680406bbaf834a5813e766 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 22:17:54 -0400 Subject: [PATCH 060/126] BUmp --- go.mod | 2 +- go.sum | 4 ++-- web/package-lock.json | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 28e4911f..2f23f0cd 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,7 @@ require ( cloud.google.com/go/auth v0.19.0 // 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/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/monitoring v1.24.3 // indirect github.com/AlekSi/pointer v1.2.0 // indirect diff --git a/go.sum b/go.sum index 7ddbb3d4..2f67ff78 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,8 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB 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/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= -cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= -cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/iam v1.6.0 h1:JiSIcEi38dWBKhB3BtfKCW+dMvCZJEhBA2BsaGJgoxs= +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/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak= cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= diff --git a/web/package-lock.json b/web/package-lock.json index e5ef21d2..cdf02942 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3681,9 +3681,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", - "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "version": "2.10.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", + "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4242,9 +4242,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.325", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz", - "integrity": "sha512-PwfIw7WQSt3xX7yOf5OE/unLzsK9CaN2f/FvV3WjPR1Knoc1T9vePRVV4W1EM301JzzysK51K7FNKcusCr0zYA==", + "version": "1.5.326", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.326.tgz", + "integrity": "sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==", "dev": true, "license": "ISC" }, From e1a344339fb3a3f573b491c610c2725e77beecbd Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 22:20:07 -0400 Subject: [PATCH 061/126] Dummy From b5fee121d779c07d51c0254136a3640e754e0487 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Thu, 26 Mar 2026 22:58:24 -0400 Subject: [PATCH 062/126] Empty commit From 67fc7fe96abc157915133d43069c3830a7a6caba Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 27 Mar 2026 13:59:07 -0400 Subject: [PATCH 063/126] Disable HTTP2 for S3 backend with ?disable_http2=true option --- attachment/store.go | 2 +- docs/config.md | 11 +++++++---- s3/client.go | 32 +++++++++++++++++++++----------- s3/client_test.go | 12 ++++++++++++ s3/types.go | 17 +++++++++-------- s3/util.go | 21 +++++++++++++-------- 6 files changed, 63 insertions(+), 32 deletions(-) diff --git a/attachment/store.go b/attachment/store.go index de6eb293..0b12877d 100644 --- a/attachment/store.go +++ b/attachment/store.go @@ -45,7 +45,7 @@ func NewFileStore(dir string, totalSizeLimit int64, orphanGracePeriod time.Durat // 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, orphanGracePeriod time.Duration, attachmentsWithSizes func() (map[string]int64, error)) (*Store, error) { config, err := s3.ParseURL(s3URL) if err != nil { diff --git a/docs/config.md b/docs/config.md index c9e6687d..e7a98774 100644 --- a/docs/config.md +++ b/docs/config.md @@ -538,7 +538,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: ``` -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: @@ -546,7 +546,7 @@ Here are a few examples: === "/etc/ntfy/server.yml (DigitalOcean Spaces)" ``` yaml 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)" @@ -564,6 +564,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 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 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. @@ -2189,7 +2192,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) | | `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 | -| `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-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`. | @@ -2291,7 +2294,7 @@ OPTIONS: --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-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-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] diff --git a/s3/client.go b/s3/client.go index e11e5d47..412e00d3 100644 --- a/s3/client.go +++ b/s3/client.go @@ -62,17 +62,10 @@ type Client struct { func New(config *Config) *Client { httpClient := config.HTTPClient if httpClient == nil { - // Force HTTP/1.1 to avoid HTTP/2 stream errors with S3-compatible providers - // (e.g. DigitalOcean Spaces). HTTP/2 can cause non-retryable failures on - // streaming uploads when the server resets the stream mid-transfer. - httpClient = &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - }, - ForceAttemptHTTP2: false, - TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper), - }, + if config.DisableHTTP2 { + httpClient = newHTTP1Client() + } else { + httpClient = http.DefaultClient } } return &Client{ @@ -312,3 +305,20 @@ func (c *Client) do(ctx context.Context, op, method, reqURL string, body []byte, } 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), + }, + } +} diff --git a/s3/client_test.go b/s3/client_test.go index 23cde72c..d15e75ab 100644 --- a/s3/client_test.go +++ b/s3/client_test.go @@ -92,6 +92,18 @@ func TestParseURL_EmptyBucket(t *testing.T) { 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 --- func TestConfig_BucketURL_PathStyle(t *testing.T) { diff --git a/s3/types.go b/s3/types.go index 96b62649..094a96d3 100644 --- a/s3/types.go +++ b/s3/types.go @@ -11,14 +11,15 @@ import ( // Config holds the parsed fields from an S3 URL. Use ParseURL to create one from a URL string. type Config struct { - Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com" - PathStyle bool - Bucket string - Prefix string - Region string - AccessKey string - SecretKey string - HTTPClient *http.Client // if nil, http.DefaultClient is used + Endpoint string // host[:port] only, e.g. "s3.us-east-1.amazonaws.com" + PathStyle bool + Bucket string + Prefix string + Region string + AccessKey string + SecretKey string + 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. diff --git a/s3/util.go b/s3/util.go index ae692735..64f0f7c7 100644 --- a/s3/util.go +++ b/s3/util.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "sort" + "strconv" "strings" ) @@ -41,9 +42,11 @@ const ( // 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 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) { u, err := url.Parse(s3URL) if err != nil { @@ -80,14 +83,16 @@ func ParseURL(s3URL string) (*Config, error) { endpoint = fmt.Sprintf("s3.%s.amazonaws.com", region) pathStyle = false } + disableHTTP2, _ := strconv.ParseBool(u.Query().Get("disable_http2")) return &Config{ - Endpoint: endpoint, - PathStyle: pathStyle, - Bucket: bucket, - Prefix: prefix, - Region: region, - AccessKey: accessKey, - SecretKey: secretKey, + Endpoint: endpoint, + PathStyle: pathStyle, + Bucket: bucket, + Prefix: prefix, + Region: region, + AccessKey: accessKey, + SecretKey: secretKey, + DisableHTTP2: disableHTTP2, }, nil } From 835d1faac410f827627d6c0484bacea360d1fae6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 27 Mar 2026 14:14:31 -0400 Subject: [PATCH 064/126] Bump --- docs/install.md | 76 ++++++++++++++++++++++++------------------------ docs/releases.md | 12 +++++++- 2 files changed, 49 insertions(+), 39 deletions(-) diff --git a/docs/install.md b/docs/install.md index 9e289f50..4deed09b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -34,37 +34,37 @@ as a service starting at boot time. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_amd64.tar.gz - tar zxvf ntfy_2.20.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.20.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.tar.gz + tar zxvf ntfy_2.20.1_linux_amd64.tar.gz + sudo cp -a ntfy_2.20.1_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv6.tar.gz - tar zxvf ntfy_2.20.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.20.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.tar.gz + tar zxvf ntfy_2.20.1_linux_armv6.tar.gz + sudo cp -a ntfy_2.20.1_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv7.tar.gz - tar zxvf ntfy_2.20.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.20.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.tar.gz + tar zxvf ntfy_2.20.1_linux_armv7.tar.gz + sudo cp -a ntfy_2.20.1_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_arm64.tar.gz - tar zxvf ntfy_2.20.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.20.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.tar.gz + tar zxvf ntfy_2.20.1_linux_arm64.tar.gz + sudo cp -a ntfy_2.20.1_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic === "x86_64/amd64" ```bash - sudo mv ntfy_2.20.0_linux_amd64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.20.1_linux_amd64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "armv6" ```bash - sudo mv ntfy_2.20.0_linux_armv6/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.20.1_linux_armv6/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "armv7/armhf" ```bash - sudo mv ntfy_2.20.0_linux_armv7/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.20.1_linux_armv7/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "arm64" ```bash - sudo mv ntfy_2.20.0_linux_arm64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.20.1_linux_arm64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` @@ -118,25 +118,25 @@ Install the ntfy server service script: === "x86_64/amd64" ```bash - sudo mv ntfy_2.20.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.20.1_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "armv6" ```bash - sudo mv ntfy_2.20.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.20.1_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "armv7/armhf" ```bash - sudo mv ntfy_2.20.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.20.1_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "arm64" ```bash - sudo mv ntfy_2.20.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.20.1_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` @@ -204,7 +204,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -212,7 +212,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -220,7 +220,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -228,7 +228,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -238,28 +238,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -301,18 +301,18 @@ pkg install go-ntfy ## macOS 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.20.0/ntfy_2.20.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_darwin_all.tar.gz), 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 `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_darwin_all.tar.gz > ntfy_2.20.0_darwin_all.tar.gz -tar zxvf ntfy_2.20.0_darwin_all.tar.gz -sudo cp -a ntfy_2.20.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_darwin_all.tar.gz > ntfy_2.20.1_darwin_all.tar.gz +tar zxvf ntfy_2.20.1_darwin_all.tar.gz +sudo cp -a ntfy_2.20.1_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.20.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.20.1_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -333,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. To install, you can either -* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.20.0/ntfy_2.20.0_windows_amd64.zip), +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_windows_amd64.zip), 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` diff --git a/docs/releases.md b/docs/releases.md index 23f92f59..a8024923 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,13 +6,23 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.20.0 | Mar 26, 2026 | +| ntfy server | v2.20.1 | Mar 27, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +### 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) From 61dd788dace82b2e32ebbe0fac39b81edb086298 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 29 Mar 2026 22:47:38 -0400 Subject: [PATCH 065/126] WIP: Email verification --- cmd/serve.go | 5 + docs/config.md | 24 +++- docs/releases.md | 7 ++ docs/static/js/config-generator.js | 1 + mail/mail.go | 126 +++++++++++++++++++ server/config.go | 2 + server/errors.go | 4 + server/server.go | 32 ++++- server/server.yml | 7 ++ server/server_account.go | 102 +++++++++++++++ server/server_manager.go | 3 + server/server_middleware.go | 9 ++ server/types.go | 10 ++ user/manager.go | 50 ++++++++ user/manager_postgres.go | 8 ++ user/manager_postgres_schema.go | 45 ++++++- user/manager_sqlite.go | 8 ++ user/manager_sqlite_schema.go | 32 ++++- user/types.go | 7 ++ web/public/static/langs/en.json | 12 ++ web/src/app/AccountApi.js | 39 ++++++ web/src/app/utils.js | 2 + web/src/components/Account.jsx | 193 +++++++++++++++++++++++++++++ 23 files changed, 720 insertions(+), 8 deletions(-) create mode 100644 mail/mail.go diff --git a/cmd/serve.go b/cmd/serve.go index 2af1f389..d20242e2 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -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-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.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-email-verify", Aliases: []string{"smtp_sender_email_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_EMAIL_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-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-')"}), @@ -184,6 +185,7 @@ func execServe(c *cli.Context) error { smtpSenderUser := c.String("smtp-sender-user") smtpSenderPass := c.String("smtp-sender-pass") smtpSenderFrom := c.String("smtp-sender-from") + smtpSenderEmailVerify := c.Bool("smtp-sender-email-verify") smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") 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") } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") + } else if smtpSenderEmailVerify && smtpSenderAddr == "" { + return errors.New("if smtp-sender-email-verify is set, smtp-sender-addr must also be set") } else if smtpServerListen != "" && smtpServerDomain == "" { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { @@ -471,6 +475,7 @@ func execServe(c *cli.Context) error { conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass conf.SMTPSenderFrom = smtpSenderFrom + conf.SMTPSenderEmailVerify = smtpSenderEmailVerify conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix diff --git a/docs/config.md b/docs/config.md index e7a98774..eb34382a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -353,6 +353,13 @@ This generator helps you configure your self-hosted ntfy instance. It's not full +
+ + +
@@ -1031,7 +1038,21 @@ configured for `ntfy.sh`): smtp-sender-from: "ntfy@ntfy.sh" ``` -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-email-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. + +=== "/etc/ntfy/server.yml (with email verification)" + ``` yaml + smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" + smtp-sender-user: "AKIDEADBEEFAFFE12345" + smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." + smtp-sender-from: "ntfy@ntfy.sh" + smtp-sender-email-verify: true + ``` + +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. ## E-mail publishing @@ -2200,6 +2221,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-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-email-verify` | `NTFY_SMTP_SENDER_EMAIL_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-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-` | diff --git a/docs/releases.md b/docs/releases.md index a8024923..8271f36b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -110,6 +110,13 @@ if things are working (or not working). There is a [one-off migration tool](http * Preserve `
` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) +## ntfy Android v1.25.x (UNRELEASED) + +**Features:** + +* 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) + ## ntfy Android v1.24.0 Released March 5, 2026 diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index 7c093ead..7e4c806f 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -125,6 +125,7 @@ { 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-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, + { key: "smtp-sender-email-verify", env: "NTFY_SMTP_SENDER_EMAIL_VERIFY", section: "smtp-out" }, { 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-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, diff --git a/mail/mail.go b/mail/mail.go new file mode 100644 index 00000000..dc26ced4 --- /dev/null +++ b/mail/mail.go @@ -0,0 +1,126 @@ +package mail + +import ( + "crypto/rand" + "fmt" + "math/big" + "mime" + "net" + "net/smtp" + "strings" + "sync" + "time" + + "heckel.io/ntfy/v2/log" +) + +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 + verifyCodes map[string]verifyCode // keyed by email + mu sync.Mutex +} + +type verifyCode struct { + code string + expires time.Time +} + +// NewSender creates a new mail Sender with the given SMTP config +func NewSender(config *Config) *Sender { + return &Sender{ + config: config, + verifyCodes: make(map[string]verifyCode), + } +} + +// Send sends a plain text email via SMTP +func (s *Sender) Send(to, subject, body string) 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) + } + 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 smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message)) +} + +// SendVerification generates a 6-digit code, stores it in-memory, and sends a verification email +func (s *Sender) SendVerification(to string) error { + code, err := generateCode() + if err != nil { + return err + } + s.mu.Lock() + s.verifyCodes[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.verifyCodes[email] + if !ok || time.Now().After(vc.expires) || vc.code != code { + return false + } + delete(s.verifyCodes, email) + return true +} + +// ExpireVerificationCodes removes expired entries from the in-memory map +func (s *Sender) ExpireVerificationCodes() { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now() + for email, vc := range s.verifyCodes { + if now.After(vc.expires) { + delete(s.verifyCodes, email) + } + } +} + +func generateCode() (string, error) { + max := big.NewInt(1000000) // 0-999999 + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", n.Int64()), nil +} diff --git a/server/config.go b/server/config.go index 8497b18e..0bd6bd32 100644 --- a/server/config.go +++ b/server/config.go @@ -135,6 +135,7 @@ type Config struct { SMTPSenderUser string SMTPSenderPass string SMTPSenderFrom string + SMTPSenderEmailVerify bool SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string @@ -239,6 +240,7 @@ func NewConfig() *Config { SMTPSenderUser: "", SMTPSenderPass: "", SMTPSenderFrom: "", + SMTPSenderEmailVerify: false, SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", diff --git a/server/errors.go b/server/errors.go index 77caf239..16acc9cd 100644 --- a/server/errors.go +++ b/server/errors.go @@ -143,6 +143,9 @@ var ( errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil} errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil} errHTTPBadRequestEmailAddressInvalid = &errHTTP{40050, http.StatusBadRequest, "invalid request: invalid e-mail address", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} + 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, or no matching verified email addresses found", "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} 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} @@ -152,6 +155,7 @@ var ( errHTTPConflictPhoneNumberExists = &errHTTP{40904, http.StatusConflict, "conflict: phone number already exists", "", nil} errHTTPConflictProvisionedUserChange = &errHTTP{40905, http.StatusConflict, "conflict: cannot change or delete provisioned user", "", nil} errHTTPConflictProvisionedTokenChange = &errHTTP{40906, http.StatusConflict, "conflict: cannot change or delete provisioned token", "", nil} + errHTTPConflictEmailExists = &errHTTP{40907, http.StatusConflict, "conflict: email address already exists", "", nil} errHTTPGonePhoneVerificationExpired = &errHTTP{41001, http.StatusGone, "phone number verification expired or does not exist", "", nil} errHTTPEntityTooLargeAttachment = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPEntityTooLargeMatrixRequest = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", "", nil} diff --git a/server/server.go b/server/server.go index ff631359..f265986b 100644 --- a/server/server.go +++ b/server/server.go @@ -36,6 +36,7 @@ import ( "heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/mail" "heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/payments" @@ -57,6 +58,7 @@ type Server struct { smtpServer *smtp.Server smtpServerBackend *smtpBackend smtpSender mailer + mailSender *mail.Sender topics map[string]*topic visitors map[string]*visitor // ip: or user: firebaseClient *firebaseClient @@ -112,6 +114,8 @@ var ( apiAccountReservationPath = "/v1/account/reservation" apiAccountPhonePath = "/v1/account/phone" apiAccountPhoneVerifyPath = "/v1/account/phone/verify" + apiAccountEmailPath = "/v1/account/email" + apiAccountEmailVerifyPath = "/v1/account/email/verify" apiAccountBillingPortalPath = "/v1/account/billing/portal" apiAccountBillingWebhookPath = "/v1/account/billing/webhook" apiAccountBillingSubscriptionPath = "/v1/account/billing/subscription" @@ -173,8 +177,15 @@ const ( // subscriber (if configured). func New(conf *Config) (*Server, error) { var mailer mailer + var mailSender *mail.Sender if conf.SMTPSenderAddr != "" { mailer = &smtpSender{config: conf} + mailSender = mail.NewSender(&mail.Config{ + SMTPAddr: conf.SMTPSenderAddr, + SMTPUser: conf.SMTPSenderUser, + SMTPPass: conf.SMTPSenderPass, + From: conf.SMTPSenderFrom, + }) } var stripe stripeAPI if payments.Available && conf.StripeSecretKey != "" { @@ -278,6 +289,7 @@ func New(conf *Config) (*Server, error) { attachment: attachmentStore, firebaseClient: firebaseClient, smtpSender: mailer, + mailSender: mailSender, topics: topics, userManager: userManager, messages: messages, @@ -594,6 +606,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) } else if r.Method == http.MethodDelete && r.URL.Path == apiAccountPhonePath { 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 { return s.ensureWebPushEnabled(s.limitRequests(s.handleWebPushUpdate))(w, r, v) } else if r.Method == http.MethodDelete && apiWebPushPath == r.URL.Path { @@ -865,9 +883,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess return nil, errHTTPInsufficientStorageUnifiedPush.With(t) } else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return nil, errHTTPTooManyRequestsLimitMessages.With(t) - } else if email != "" && !vrate.EmailAllowed() { - return nil, errHTTPTooManyRequestsLimitEmails.With(t) - } else if call != "" { + } else if email != "" { + if !vrate.EmailAllowed() { + return nil, errHTTPTooManyRequestsLimitEmails.With(t) + } + var httpErr *errHTTP + email, httpErr = s.convertEmailAddress(v.User(), email) + if httpErr != nil { + return nil, httpErr.With(t) + } + } + if call != "" { var httpErr *errHTTP call, httpErr = s.convertPhoneNumber(v.User(), call) if httpErr != nil { diff --git a/server/server.yml b/server/server.yml index 9dc92968..471d5b88 100644 --- a/server/server.yml +++ b/server/server.yml @@ -199,6 +199,13 @@ # smtp-sender-user: # smtp-sender-pass: +# If set to true, only verified email recipients will receive email notifications. +# Anonymous users will not be able to send emails, and authenticated users must verify +# their email addresses first. Users can use "yes"/"true"/"1" as the email value to +# send to their first verified address. +# +# smtp-sender-email-verify: false + # If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send # emails to a topic e-mail address to publish messages to a topic. # diff --git a/server/server_account.go b/server/server_account.go index 7b719533..93859490 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -160,6 +160,15 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis 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 { response.Username = user.Everyone response.Role = string(user.RoleAnonymous) @@ -606,6 +615,99 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R 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 || (u.IsUser() && u.Tier == nil) { + return errHTTPUnauthorized + } else if u.IsUser() && u.Tier.EmailLimit == 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 + } + // Send verification email + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("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 + } + if !emailAddressRegex.MatchString(req.Email) { + return errHTTPBadRequestEmailAddressInvalid + } + if !s.mailSender.CheckVerification(req.Email, req.Code) { + return errHTTPBadRequestEmailVerificationCodeInvalid + } + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("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-email-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.SMTPSenderEmailVerify { + return email, nil + } + if u == nil { + return "", errHTTPBadRequestAnonymousEmailNotAllowed + } + if s.userManager == nil { + return email, nil + } + emails, err := s.userManager.Emails(u.ID) + if err != nil { + return "", errHTTPInternalError + } + 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 func (s *Server) publishSyncEventAsync(v *visitor) { go func() { diff --git a/server/server_manager.go b/server/server_manager.go index 387ad2b8..5251e1aa 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -15,6 +15,9 @@ func (s *Server) execManager() { s.pruneAttachments() s.pruneMessages() s.pruneAndNotifyWebPushSubscriptions() + if s.mailSender != nil { + s.mailSender.ExpireVerificationCodes() + } // Message count messagesCached, err := s.messageCache.MessagesCount() diff --git a/server/server_middleware.go b/server/server_middleware.go index 17ae0963..07457f2f 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -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 { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.StripeSecretKey == "" || s.stripe == nil { diff --git a/server/types.go b/server/types.go index 77a3c33e..c9d4688d 100644 --- a/server/types.go +++ b/server/types.go @@ -226,6 +226,15 @@ type apiAccountPhoneNumberAddRequest struct { 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 { Code string `json:"code"` Name string `json:"name"` @@ -282,6 +291,7 @@ type apiAccountResponse struct { Reservations []*apiAccountReservation `json:"reservations,omitempty"` Tokens []*apiAccountTokenResponse `json:"tokens,omitempty"` PhoneNumbers []string `json:"phone_numbers,omitempty"` + Emails []string `json:"emails,omitempty"` Tier *apiAccountTier `json:"tier,omitempty"` Limits *apiAccountLimits `json:"limits,omitempty"` Stats *apiAccountStats `json:"stats,omitempty"` diff --git a/user/manager.go b/user/manager.go index 99bd705e..303c7a49 100644 --- a/user/manager.go +++ b/user/manager.go @@ -1294,6 +1294,56 @@ func (a *Manager) readPhoneNumber(rows *sql.Rows) (string, error) { 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 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 { diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 77c35ece..efa9998e 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -208,6 +208,11 @@ const ( 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` + // Email queries + postgresSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = $1` + 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 postgresUpdateBillingQuery = ` UPDATE "user" @@ -274,6 +279,9 @@ var postgresQueries = queries{ selectPhoneNumbers: postgresSelectPhoneNumbersQuery, insertPhoneNumber: postgresInsertPhoneNumberQuery, deletePhoneNumber: postgresDeletePhoneNumberQuery, + selectEmails: postgresSelectEmailsQuery, + insertEmail: postgresInsertEmailQuery, + deleteEmail: postgresDeleteEmailQuery, updateBilling: postgresUpdateBillingQuery, } diff --git a/user/manager_postgres_schema.go b/user/manager_postgres_schema.go index 3684c279..ba8502f2 100644 --- a/user/manager_postgres_schema.go +++ b/user/manager_postgres_schema.go @@ -72,6 +72,11 @@ const ( phone_number TEXT NOT NULL, 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 ( store TEXT PRIMARY KEY, version INT NOT NULL @@ -84,21 +89,55 @@ const ( // Schema table management queries for Postgres const ( - postgresCurrentSchemaVersion = 6 + postgresCurrentSchemaVersion = 7 postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'` 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 { var schemaVersion int err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) if err != nil { 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) } - // 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 } diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index e92c6349..8db75cad 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -207,6 +207,11 @@ const ( sqliteInsertPhoneNumberQuery = `INSERT INTO user_phone (user_id, phone_number) VALUES (?, ?)` sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` + // Email queries + sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ?` + sqliteInsertEmailQuery = `INSERT INTO user_email (user_id, email) VALUES (?, ?)` + sqliteDeleteEmailQuery = `DELETE FROM user_email WHERE user_id = ? AND email = ?` + // Billing queries sqliteUpdateBillingQuery = ` UPDATE user @@ -272,6 +277,9 @@ var sqliteQueries = queries{ selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, insertPhoneNumber: sqliteInsertPhoneNumberQuery, deletePhoneNumber: sqliteDeletePhoneNumberQuery, + selectEmails: sqliteSelectEmailsQuery, + insertEmail: sqliteInsertEmailQuery, + deleteEmail: sqliteDeleteEmailQuery, updateBilling: sqliteUpdateBillingQuery, } diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go index 01942163..6ee24f8c 100644 --- a/user/manager_sqlite_schema.go +++ b/user/manager_sqlite_schema.go @@ -85,6 +85,12 @@ const ( PRIMARY KEY (user_id, phone_number), 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 ( id INT PRIMARY KEY, version INT NOT NULL @@ -101,7 +107,7 @@ const ( // Schema version table management for SQLite const ( - sqliteCurrentSchemaVersion = 6 + sqliteCurrentSchemaVersion = 7 sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1` sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1` @@ -220,6 +226,16 @@ const ( 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 sqliteMigrate5To6UpdateQueries = ` PRAGMA foreign_keys=off; @@ -322,6 +338,7 @@ var ( 3: sqliteMigrateFrom3, 4: sqliteMigrateFrom4, 5: sqliteMigrateFrom5, + 6: sqliteMigrateFrom6, } ) @@ -463,3 +480,16 @@ func sqliteMigrateFrom5(sqlDB *sql.DB) error { 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 + }) +} diff --git a/user/types.go b/user/types.go index 08c65220..d0d40e33 100644 --- a/user/types.go +++ b/user/types.go @@ -271,6 +271,8 @@ var ( ErrPhoneNumberNotFound = errors.New("phone number not found") ErrTooManyReservations = errors.New("new tier has lower reservation limit") 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") ErrProvisionedTokenChange = errors.New("cannot change or delete provisioned token") ) @@ -343,6 +345,11 @@ type queries struct { insertPhoneNumber string deletePhoneNumber string + // Email queries + selectEmails string + insertEmail string + deleteEmail string + // Billing queries updateBilling string } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 19fe2195..077d021c 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -215,6 +215,18 @@ "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", "account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_basics_phone_numbers_dialog_channel_call": "Call", + "account_basics_emails_title": "Verified email recipients", + "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_check_verification_button": "Confirm", "account_basics_cannot_edit_or_delete_provisioned_user": "A provisioned user cannot be edited or deleted", "account_usage_title": "Usage", "account_usage_of_limit": "of {{limit}}", diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index d9380438..5b44391d 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -2,6 +2,8 @@ import i18n from "i18next"; import { accountBillingPortalUrl, accountBillingSubscriptionUrl, + accountEmailUrl, + accountEmailVerifyUrl, accountPasswordUrl, accountPhoneUrl, 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() { try { if (!session.token()) { diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 8e27365b..d6467eb7 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -34,6 +34,8 @@ export const accountBillingSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account export const accountBillingPortalUrl = (baseUrl) => `${baseUrl}/v1/account/billing/portal`; export const accountPhoneUrl = (baseUrl) => `${baseUrl}/v1/account/phone`; 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?:\/\/.+/); diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 508d6de2..5b732719 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -84,6 +84,7 @@ const Basics = () => { + @@ -354,6 +355,198 @@ const AccountType = () => { ); }; +const VerifiedEmails = () => { + 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_emails) { + return null; + } + + if (account?.limits.emails === 0) { + return ( + + {t("account_basics_emails_title")} + {config.enable_payments && } + + } + description={t("account_basics_emails_description")} + > + {t("account_usage_emails_none")} + + ); + } + + return ( + +
+ {account?.emails?.map((email) => ( + + {email} + + } + variant="outlined" + onClick={() => handleCopy(email)} + onDelete={() => handleDelete(email)} + /> + ))} + {!account?.emails && {t("account_basics_emails_no_emails_yet")}} + + + +
+ + + setSnackOpen(false)} + message={t("account_basics_emails_copied_to_clipboard")} + /> + +
+ ); +}; + +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 { + 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 ( + + {t("account_basics_emails_dialog_title")} + + {t("account_basics_emails_dialog_description")} + {!verificationCodeSent && ( + setEmail(ev.target.value)} + fullWidth + variant="standard" + /> + )} + {verificationCodeSent && ( + setCode(ev.target.value)} + fullWidth + inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} + variant="standard" + /> + )} + + + + + + + ); +}; + const PhoneNumbers = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); From 6aebc5c67749ccdc97a8ca4c8cf7f12b14dd2dd8 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 08:26:56 -0400 Subject: [PATCH 066/126] Refine --- cmd/serve.go | 10 ++--- docs/config.md | 8 ++-- docs/static/js/config-generator.js | 2 +- mail/{mail.go => sender.go} | 68 +++++++++++++++++------------- server/config.go | 4 +- server/server.go | 11 +++-- server/server.yml | 10 ++--- server/server_account.go | 17 ++++---- server/server_manager.go | 3 -- web/public/static/langs/en.json | 1 + web/src/app/errors.js | 10 +++++ web/src/components/Account.jsx | 4 +- 12 files changed, 83 insertions(+), 65 deletions(-) rename mail/{mail.go => sender.go} (72%) diff --git a/cmd/serve.go b/cmd/serve.go index d20242e2..0c0b1139 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -71,7 +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-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.NewBoolFlag(&cli.BoolFlag{Name: "smtp-sender-email-verify", Aliases: []string{"smtp_sender_email_verify"}, EnvVars: []string{"NTFY_SMTP_SENDER_EMAIL_VERIFY"}, Value: false, Usage: "require verified email addresses for sending email notifications"}), + 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-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-')"}), @@ -185,7 +185,7 @@ func execServe(c *cli.Context) error { smtpSenderUser := c.String("smtp-sender-user") smtpSenderPass := c.String("smtp-sender-pass") smtpSenderFrom := c.String("smtp-sender-from") - smtpSenderEmailVerify := c.Bool("smtp-sender-email-verify") + smtpSenderVerify := c.Bool("smtp-sender-verify") smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") @@ -312,8 +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") } else if smtpSenderAddr != "" && (baseURL == "" || smtpSenderFrom == "") { return errors.New("if smtp-sender-addr is set, base-url, and smtp-sender-from must also be set") - } else if smtpSenderEmailVerify && smtpSenderAddr == "" { - return errors.New("if smtp-sender-email-verify is set, smtp-sender-addr 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 == "" { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } else if attachmentCacheDir != "" && baseURL == "" { @@ -475,7 +475,7 @@ func execServe(c *cli.Context) error { conf.SMTPSenderUser = smtpSenderUser conf.SMTPSenderPass = smtpSenderPass conf.SMTPSenderFrom = smtpSenderFrom - conf.SMTPSenderEmailVerify = smtpSenderEmailVerify + conf.SMTPSenderVerify = smtpSenderVerify conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix diff --git a/docs/config.md b/docs/config.md index eb34382a..44943165 100644 --- a/docs/config.md +++ b/docs/config.md @@ -355,7 +355,7 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
- @@ -1039,7 +1039,7 @@ configured for `ntfy.sh`): ``` By default, any user (including anonymous users) can send email notifications to any address. To require email -address verification, set `smtp-sender-email-verify` to `true`. When enabled, anonymous users cannot send emails, +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. @@ -1049,7 +1049,7 @@ also use `yes`/`true`/`1` as the `X-Email` value to send to their first verified smtp-sender-user: "AKIDEADBEEFAFFE12345" smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." smtp-sender-from: "ntfy@ntfy.sh" - smtp-sender-email-verify: true + smtp-sender-verify: true ``` Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-email-limit-burst` @@ -2221,7 +2221,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-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-email-verify` | `NTFY_SMTP_SENDER_EMAIL_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled | +| `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-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-` | diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index 7e4c806f..dc8ea4ed 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -125,7 +125,7 @@ { 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-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, - { key: "smtp-sender-email-verify", env: "NTFY_SMTP_SENDER_EMAIL_VERIFY", section: "smtp-out" }, + { key: "smtp-sender-verify", env: "NTFY_SMTP_SENDER_VERIFY", section: "smtp-out" }, { 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-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, diff --git a/mail/mail.go b/mail/sender.go similarity index 72% rename from mail/mail.go rename to mail/sender.go index dc26ced4..9efb2f6c 100644 --- a/mail/mail.go +++ b/mail/sender.go @@ -1,9 +1,7 @@ package mail import ( - "crypto/rand" "fmt" - "math/big" "mime" "net" "net/smtp" @@ -12,6 +10,7 @@ import ( "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/util" ) const ( @@ -30,9 +29,10 @@ type Config struct { // Sender sends emails and manages email verification codes type Sender struct { - config *Config - verifyCodes map[string]verifyCode // keyed by email - mu sync.Mutex + config *Config + codes map[string]verifyCode // Verification codes, keyed by email + mu sync.Mutex + closeChan chan struct{} } type verifyCode struct { @@ -42,10 +42,18 @@ type verifyCode struct { // NewSender creates a new mail Sender with the given SMTP config func NewSender(config *Config) *Sender { - return &Sender{ - config: config, - verifyCodes: make(map[string]verifyCode), + 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) } // Send sends a plain text email via SMTP @@ -76,14 +84,11 @@ Content-Type: text/plain; charset="utf-8" return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message)) } -// SendVerification generates a 6-digit code, stores it in-memory, and sends a verification email +// SendVerification generates a random code, stores it in-memory, and sends a verification email func (s *Sender) SendVerification(to string) error { - code, err := generateCode() - if err != nil { - return err - } + code := util.RandomString(verifyCodeLength) s.mu.Lock() - s.verifyCodes[to] = verifyCode{ + s.codes[to] = verifyCode{ code: code, expires: time.Now().Add(verifyCodeExpiry), } @@ -96,31 +101,34 @@ func (s *Sender) SendVerification(to string) error { func (s *Sender) CheckVerification(email, code string) bool { s.mu.Lock() defer s.mu.Unlock() - vc, ok := s.verifyCodes[email] + vc, ok := s.codes[email] if !ok || time.Now().After(vc.expires) || vc.code != code { return false } - delete(s.verifyCodes, email) + delete(s.codes, email) return true } -// ExpireVerificationCodes removes expired entries from the in-memory map -func (s *Sender) ExpireVerificationCodes() { - s.mu.Lock() - defer s.mu.Unlock() - now := time.Now() - for email, vc := range s.verifyCodes { - if now.After(vc.expires) { - delete(s.verifyCodes, email) +func (s *Sender) expireLoop() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.expireVerificationCodes() + case <-s.closeChan: + return } } } -func generateCode() (string, error) { - max := big.NewInt(1000000) // 0-999999 - n, err := rand.Int(rand.Reader, max) - if err != nil { - return "", err +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) + } } - return fmt.Sprintf("%06d", n.Int64()), nil } diff --git a/server/config.go b/server/config.go index 0bd6bd32..f472930a 100644 --- a/server/config.go +++ b/server/config.go @@ -135,7 +135,7 @@ type Config struct { SMTPSenderUser string SMTPSenderPass string SMTPSenderFrom string - SMTPSenderEmailVerify bool + SMTPSenderVerify bool SMTPServerListen string SMTPServerDomain string SMTPServerAddrPrefix string @@ -240,7 +240,7 @@ func NewConfig() *Config { SMTPSenderUser: "", SMTPSenderPass: "", SMTPSenderFrom: "", - SMTPSenderEmailVerify: false, + SMTPSenderVerify: false, SMTPServerListen: "", SMTPServerDomain: "", SMTPServerAddrPrefix: "", diff --git a/server/server.go b/server/server.go index f265986b..62213879 100644 --- a/server/server.go +++ b/server/server.go @@ -441,6 +441,9 @@ func (s *Server) Stop() { if s.smtpServer != nil { s.smtpServer.Close() } + if s.mailSender != nil { + s.mailSender.Close() + } if s.attachment != nil { s.attachment.Close() } @@ -883,14 +886,14 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess return nil, errHTTPInsufficientStorageUnifiedPush.With(t) } else if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() { return nil, errHTTPTooManyRequestsLimitMessages.With(t) - } else if email != "" { - if !vrate.EmailAllowed() { - return nil, errHTTPTooManyRequestsLimitEmails.With(t) - } + } + if email != "" { 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 != "" { diff --git a/server/server.yml b/server/server.yml index 471d5b88..833f1bea 100644 --- a/server/server.yml +++ b/server/server.yml @@ -193,18 +193,14 @@ # - smtp-sender-addr is the hostname:port of the SMTP server # - smtp-sender-from is the e-mail address of the sender # - smtp-sender-user/smtp-sender-pass are the username and password of the SMTP user (leave blank for no auth) +# - smtp-sender-verify is a flag that forces email recipient verification when enabled. If set to true, +# only verified email recipients can be used in the X-Email header. # # smtp-sender-addr: # smtp-sender-from: # smtp-sender-user: # smtp-sender-pass: - -# If set to true, only verified email recipients will receive email notifications. -# Anonymous users will not be able to send emails, and authenticated users must verify -# their email addresses first. Users can use "yes"/"true"/"1" as the email value to -# send to their first verified address. -# -# smtp-sender-email-verify: false +# smtp-sender-verify: false # If enabled, ntfy will launch a lightweight SMTP server for incoming messages. Once configured, users can send # emails to a topic e-mail address to publish messages to a topic. diff --git a/server/server_account.go b/server/server_account.go index 93859490..39554348 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -636,6 +636,10 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request } 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).Debug("Sending email verification") if err := s.mailSender.SendVerification(req.Email); err != nil { @@ -680,24 +684,21 @@ func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request } // convertEmailAddress checks the email address against the user's verified email list. -// If smtp-sender-email-verify is false (default), the email is passed through as-is for +// 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.SMTPSenderEmailVerify { + if !s.config.SMTPSenderVerify { return email, nil - } - if u == nil { + } else if u == nil { return "", errHTTPBadRequestAnonymousEmailNotAllowed - } - if s.userManager == nil { + } else if s.userManager == nil { return email, nil } emails, err := s.userManager.Emails(u.ID) if err != nil { return "", errHTTPInternalError - } - if len(emails) == 0 { + } else if len(emails) == 0 { return "", errHTTPBadRequestEmailAddressNotVerified } if toBool(email) { diff --git a/server/server_manager.go b/server/server_manager.go index 5251e1aa..387ad2b8 100644 --- a/server/server_manager.go +++ b/server/server_manager.go @@ -15,9 +15,6 @@ func (s *Server) execManager() { s.pruneAttachments() s.pruneMessages() s.pruneAndNotifyWebPushSubscriptions() - if s.mailSender != nil { - s.mailSender.ExpireVerificationCodes() - } // Message count messagesCached, err := s.messageCache.MessagesCount() diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 077d021c..984db05c 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -226,6 +226,7 @@ "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, please try again", "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_usage_title": "Usage", diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 28f49af1..4214ad84 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -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) => { if (response.status === 401 || response.status === 403) { console.log(`[Error] HTTP ${response.status}`, response); @@ -63,6 +71,8 @@ export const throwAppError = async (response) => { throw new AccountCreateLimitReachedError(); } else if (error.code === IncorrectPasswordError.CODE) { throw new IncorrectPasswordError(); + } else if (error.code === EmailVerificationCodeInvalidError.CODE) { + throw new EmailVerificationCodeInvalidError(); } else if (error?.error) { throw new Error(`Error ${error.code}: ${error.error}`); } diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 5b732719..0bca6120 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -53,7 +53,7 @@ import UpgradeDialog from "./UpgradeDialog"; import { AccountContext } from "./App"; import DialogFooter from "./DialogFooter"; import { Paragraph } from "./styles"; -import { IncorrectPasswordError, UnauthorizedError } from "../app/errors"; +import { EmailVerificationCodeInvalidError, IncorrectPasswordError, UnauthorizedError } from "../app/errors"; import { ProChip } from "./SubscriptionPopup"; import session from "../app/Session"; @@ -478,6 +478,8 @@ const AddEmailDialog = (props) => { 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); } From 3e634e0a5a5e5bfc7b6de8d0735661cd8aca1b8a Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 08:51:18 -0400 Subject: [PATCH 067/126] Refine --- mail/sender.go | 26 +++++++++++++++++++++++--- server/errors.go | 2 +- server/server.go | 5 +++-- server/server_account.go | 3 +++ server/smtp_sender.go | 22 +++++++--------------- server/types.go | 1 + web/public/static/langs/en.json | 2 +- web/src/components/Account.jsx | 6 +++--- 8 files changed, 42 insertions(+), 25 deletions(-) diff --git a/mail/sender.go b/mail/sender.go index 9efb2f6c..5511cb04 100644 --- a/mail/sender.go +++ b/mail/sender.go @@ -56,8 +56,23 @@ func (s *Sender) Close() { close(s.closeChan) } -// Send sends a plain text email via SMTP -func (s *Sender) Send(to, subject, body string) error { +// 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 @@ -66,6 +81,11 @@ func (s *Sender) Send(to, subject, body string) error { 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}> @@ -81,7 +101,7 @@ Content-Type: text/plain; charset="utf-8" message = strings.ReplaceAll(message, "{subject}", encodedSubject) message = strings.ReplaceAll(message, "{body}", body) log.Tag("mail").Field("email_to", to).Debug("Sending email") - return smtp.SendMail(s.config.SMTPAddr, auth, s.config.From, []string{to}, []byte(message)) + return s.SendRaw(to, []byte(message)) } // SendVerification generates a random code, stores it in-memory, and sends a verification email diff --git a/server/errors.go b/server/errors.go index 16acc9cd..aab51df4 100644 --- a/server/errors.go +++ b/server/errors.go @@ -144,7 +144,7 @@ var ( 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} 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, or no matching verified email addresses found", "https://ntfy.sh/docs/publish/#e-mail-notifications", 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} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} diff --git a/server/server.go b/server/server.go index 62213879..89abe518 100644 --- a/server/server.go +++ b/server/server.go @@ -179,13 +179,13 @@ func New(conf *Config) (*Server, error) { var mailer mailer var mailSender *mail.Sender 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 if payments.Available && conf.StripeSecretKey != "" { @@ -721,6 +721,7 @@ func (s *Server) configResponse() *apiConfigResponse { EnablePayments: s.config.StripeSecretKey != "", EnableCalls: s.config.TwilioAccount != "", EnableEmails: s.config.SMTPSenderFrom != "", + EnableEmailVerify: s.config.SMTPSenderVerify, EnableReservations: s.config.EnableReservations, EnableWebPush: s.config.WebPushPublicKey != "", BillingContact: s.config.BillingContact, @@ -1202,7 +1203,7 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo m.Icon = icon } 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 } if s.smtpSender == nil && email != "" { diff --git a/server/server_account.go b/server/server_account.go index 39554348..9acdf450 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -689,6 +689,9 @@ func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request // 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 diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 4e5988ba..30966267 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -5,13 +5,12 @@ import ( "encoding/json" "fmt" "mime" - "net" - "net/smtp" "strings" "sync" "time" "heckel.io/ntfy/v2/log" + "heckel.io/ntfy/v2/mail" "heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/util" ) @@ -23,6 +22,7 @@ type mailer interface { type smtpSender struct { config *Config + sender *mail.Sender success int64 failure int64 mu sync.Mutex @@ -30,31 +30,23 @@ type smtpSender struct { func (s *smtpSender) Send(v *visitor, m *model.Message, to string) 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 { return err } - message, err := formatMail(s.config.BaseURL, v.ip.String(), s.config.SMTPSenderFrom, to, m) - if err != nil { - return err - } - var auth smtp.Auth - if s.config.SMTPSenderUser != "" { - auth = smtp.PlainAuth("", s.config.SMTPSenderUser, s.config.SMTPSenderPass, host) - } ev := logvm(v, m). Tag(tagEmail). Fields(log.Context{ - "email_via": s.config.SMTPSenderAddr, - "email_user": s.config.SMTPSenderUser, + "email_via": s.sender.Addr(), + "email_user": s.sender.User(), "email_to": to, }) if ev.IsTrace() { ev.Field("email_body", message).Trace("Sending email") } else if ev.IsDebug() { - ev.Debug("Sending email") + ev.Info("Sending email") } - return smtp.SendMail(s.config.SMTPSenderAddr, auth, s.config.SMTPSenderFrom, []string{to}, []byte(message)) + return s.sender.SendRaw(to, []byte(message)) }) } diff --git a/server/types.go b/server/types.go index c9d4688d..1f69d3de 100644 --- a/server/types.go +++ b/server/types.go @@ -312,6 +312,7 @@ type apiConfigResponse struct { EnablePayments bool `json:"enable_payments"` EnableCalls bool `json:"enable_calls"` EnableEmails bool `json:"enable_emails"` + EnableEmailVerify bool `json:"enable_email_verify"` EnableReservations bool `json:"enable_reservations"` EnableWebPush bool `json:"enable_web_push"` BillingContact string `json:"billing_contact"` diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 984db05c..617dce5b 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -215,7 +215,7 @@ "account_basics_phone_numbers_dialog_check_verification_button": "Confirm code", "account_basics_phone_numbers_dialog_channel_sms": "SMS", "account_basics_phone_numbers_dialog_channel_call": "Call", - "account_basics_emails_title": "Verified email recipients", + "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", diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 0bca6120..de76eac3 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -84,7 +84,7 @@ const Basics = () => { - + @@ -355,7 +355,7 @@ const AccountType = () => { ); }; -const VerifiedEmails = () => { +const Emails = () => { const { t } = useTranslation(); const { account } = useContext(AccountContext); const [dialogKey, setDialogKey] = useState(0); @@ -388,7 +388,7 @@ const VerifiedEmails = () => { } }; - if (!config.enable_emails) { + if (!config.enable_email_verify) { return null; } From bdea8c314f83f528de0ab41b587a9ea0961405ee Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 09:00:47 -0400 Subject: [PATCH 068/126] Refine docs and bump deps --- docs/publish.md | 14 ++- docs/releases.md | 27 +++-- go.mod | 2 +- go.sum | 4 +- web/package-lock.json | 252 ++++++++++++++++++++---------------------- 5 files changed, 152 insertions(+), 147 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 00c43b5e..6c887b52 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3213,12 +3213,16 @@ 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. 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, +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 -default configuration, you get **16 e-mails per visitor** (IP address) and then after that one per hour. On top of +Since ntfy does not provide auth (yet), 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 that, your IP address appears in the e-mail body. This is to prevent abuse. +On ntfy.sh, to verify your email address in the web app before you're allowed to send emails. The daily limit for +free users is **5 emails per visitor per day**. + === "Command line (curl)" ``` curl \ @@ -3658,7 +3662,7 @@ all the supported fields: | `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) | | `filename` | - | *string* | `file.jpg` | File name of the attachment | | `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) | | `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) | @@ -4871,7 +4875,7 @@ table in their canonical form. | `X-Markdown` | `Markdown`, `md` | Enable [Markdown formatting](#markdown-formatting) in the notification body | | `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-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-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | diff --git a/docs/releases.md b/docs/releases.md index 8271f36b..865b10bd 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,23 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.20.1 | Mar 27, 2026 | +| ntfy server | v2.21.0 | Mar 30, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +### ntfy server v2.21.0 +Released March 30, 2026 + +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 + ### ntfy server v2.20.1 Released March 27, 2026 @@ -110,13 +121,6 @@ if things are working (or not working). There is a [one-off migration tool](http * Preserve `
` line breaks in HTML-only emails received via SMTP ([#690](https://github.com/binwiederhier/ntfy/issues/690), [#1620](https://github.com/binwiederhier/ntfy/pull/1620), thanks to [@uzkikh](https://github.com/uzkikh) for the fix and to [@teastrainer](https://github.com/teastrainer) for reporting) -## ntfy Android v1.25.x (UNRELEASED) - -**Features:** - -* 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) - ## ntfy Android v1.24.0 Released March 5, 2026 @@ -1839,4 +1843,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -_Nothing._ \ No newline at end of file +## ntfy Android v1.25.x (UNRELEASED) + +**Features:** + +* 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) diff --git a/go.mod b/go.mod index 2f23f0cd..c5879636 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/emersion/go-smtp v0.18.0 github.com/gabriel-vasile/mimetype v1.4.13 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/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 diff --git a/go.sum b/go.sum index 2f67ff78..90d22ee6 100644 --- a/go.sum +++ b/go.sum @@ -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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= +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/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= diff --git a/web/package-lock.json b/web/package-lock.json index cdf02942..247b84a2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2738,9 +2738,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", - "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -2752,9 +2752,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", - "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -2766,9 +2766,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", - "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -2780,9 +2780,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", - "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -2794,9 +2794,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", - "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -2808,9 +2808,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", - "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -2822,9 +2822,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", - "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], @@ -2839,9 +2839,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", - "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], @@ -2856,9 +2856,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", - "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], @@ -2873,9 +2873,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", - "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], @@ -2890,9 +2890,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", - "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], @@ -2907,9 +2907,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", - "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], @@ -2924,9 +2924,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", - "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], @@ -2941,9 +2941,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", - "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], @@ -2958,9 +2958,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", - "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], @@ -2975,9 +2975,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", - "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], @@ -2992,9 +2992,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", - "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], @@ -3009,9 +3009,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", - "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], @@ -3026,9 +3026,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", - "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], @@ -3043,9 +3043,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", - "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -3057,9 +3057,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", - "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -3071,9 +3071,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", - "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -3085,9 +3085,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", - "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -3099,9 +3099,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", - "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -3113,9 +3113,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", - "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -3681,9 +3681,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.11.tgz", - "integrity": "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg==", + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3694,9 +3694,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3805,9 +3805,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001781", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", - "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", "dev": true, "funding": [ { @@ -4242,9 +4242,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.326", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.326.tgz", - "integrity": "sha512-uRBlUfKKdsXMkiiOurgaybNC10tjrD+skXLEg7NHbm6h0uAoqj3xMb9uue5BfcSCXJ4mcyJMOucI6q55D7p6KQ==", + "version": "1.5.328", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", "dev": true, "license": "ISC" }, @@ -5044,9 +5044,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "dev": true, "license": "MIT", "dependencies": { @@ -6299,13 +6299,6 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7619,9 +7612,9 @@ } }, "node_modules/rollup": { - "version": "4.60.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", - "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { @@ -7635,31 +7628,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.0", - "@rollup/rollup-android-arm64": "4.60.0", - "@rollup/rollup-darwin-arm64": "4.60.0", - "@rollup/rollup-darwin-x64": "4.60.0", - "@rollup/rollup-freebsd-arm64": "4.60.0", - "@rollup/rollup-freebsd-x64": "4.60.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", - "@rollup/rollup-linux-arm-musleabihf": "4.60.0", - "@rollup/rollup-linux-arm64-gnu": "4.60.0", - "@rollup/rollup-linux-arm64-musl": "4.60.0", - "@rollup/rollup-linux-loong64-gnu": "4.60.0", - "@rollup/rollup-linux-loong64-musl": "4.60.0", - "@rollup/rollup-linux-ppc64-gnu": "4.60.0", - "@rollup/rollup-linux-ppc64-musl": "4.60.0", - "@rollup/rollup-linux-riscv64-gnu": "4.60.0", - "@rollup/rollup-linux-riscv64-musl": "4.60.0", - "@rollup/rollup-linux-s390x-gnu": "4.60.0", - "@rollup/rollup-linux-x64-gnu": "4.60.0", - "@rollup/rollup-linux-x64-musl": "4.60.0", - "@rollup/rollup-openbsd-x64": "4.60.0", - "@rollup/rollup-openharmony-arm64": "4.60.0", - "@rollup/rollup-win32-arm64-msvc": "4.60.0", - "@rollup/rollup-win32-ia32-msvc": "4.60.0", - "@rollup/rollup-win32-x64-gnu": "4.60.0", - "@rollup/rollup-win32-x64-msvc": "4.60.0", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -9133,14 +9126,13 @@ } }, "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", - "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", "dev": true, "license": "MIT", "dependencies": { - "json-schema": "^0.4.0", - "jsonpointer": "^5.0.0", + "jsonpointer": "^5.0.1", "leven": "^3.1.0" }, "engines": { From ffa22fc24b340e371b24fd5a998796306d31ae6d Mon Sep 17 00:00:00 2001 From: jejo86 <28619134+jejo86@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:55:05 +0200 Subject: [PATCH 069/126] Restore VirtualHost Example for Apache HTTP Server 2.4.46 and Earlier In commit 76667ffc the VirtualHost example configuration was adapted to use the latest ProxyPass parameter 'upgrade'. Restore the previous example for Apache HTTP Server 2.4.46 and earlier, which do not have this function yet without replacing the more current solution, but having both side-by-side. https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#protoupgrade --- docs/config.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index e7a98774..802af13a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1385,7 +1385,7 @@ or the root domain: } ``` -=== "Apache2" +=== "Apache >= 2.4.47" ``` # /etc/apache2/sites-*/ntfy.conf @@ -1393,6 +1393,7 @@ or the root domain: ServerName ntfy.sh # Proxy connections to ntfy (requires "a2enmod proxy proxy_http") + # Use mod_proxy_http for websocket upgrade ('upgrade=websocket'), which requires Apache (httpd) >= 2.4.47. ProxyPass / http://127.0.0.1:2586/ upgrade=websocket ProxyPassReverse / http://127.0.0.1:2586/ @@ -1419,6 +1420,7 @@ or the root domain: Include /etc/letsencrypt/options-ssl-apache.conf # Proxy connections to ntfy (requires "a2enmod proxy proxy_http") + # Use mod_proxy_http for websocket upgrade ('upgrade=websocket'), which requires Apache (httpd) >= 2.4.47. ProxyPass / http://127.0.0.1:2586/ upgrade=websocket ProxyPassReverse / http://127.0.0.1:2586/ @@ -1431,6 +1433,68 @@ or the root domain: ``` +=== "Apache < 2.4.47" + ``` + # /etc/apache2/sites-*/ntfy.conf + + + ServerName ntfy.sh + + # Proxy connections to ntfy (requires "a2enmod proxy") + ProxyPass / http://127.0.0.1:2586/ + ProxyPassReverse / http://127.0.0.1:2586/ + + # Enable mod_rewrite (requires "a2enmod rewrite") + RewriteEngine on + # WebSockets support (requires "a2enmod proxy_wstunnel") + # mod_proxy_wstunnel is deprecated as of Apache (httpd) 2.4.47. It also uses more resources since it relies on mod_rewrite. + RewriteCond %{HTTP:Upgrade} websocket [NC] + RewriteCond %{HTTP:Connection} upgrade [NC] + RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L] + + SetEnv proxy-nokeepalive 1 + SetEnv proxy-sendchunked 1 + + # Higher than the max message size of 4096 bytes + LimitRequestBody 102400 + + # Redirect HTTP to HTTPS, but only for GET topic addresses, since we want + # it to work with curl without the annoying https:// prefix (requires "a2enmod alias") + + RedirectMatch permanent "^/([-_A-Za-z0-9]{0,64})$" "https://%{SERVER_NAME}/$1" + + + + + + ServerName ntfy.sh + + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/ntfy.sh/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/ntfy.sh/privkey.pem + Include /etc/letsencrypt/options-ssl-apache.conf + + # Proxy connections to ntfy (requires "a2enmod proxy") + ProxyPass / http://127.0.0.1:2586/ + ProxyPassReverse / http://127.0.0.1:2586/ + + # Enable mod_rewrite (requires "a2enmod rewrite") + RewriteEngine on + # WebSockets support (requires "a2enmod proxy_wstunnel") + # mod_proxy_wstunnel is deprecated as of Apache (httpd) 2.4.47. It also uses more resources since it relies on mod_rewrite. + RewriteCond %{HTTP:Upgrade} websocket [NC] + RewriteCond %{HTTP:Connection} upgrade [NC] + RewriteRule ^/?(.*) "ws://127.0.0.1:2586/$1" [P,L] + + SetEnv proxy-nokeepalive 1 + SetEnv proxy-sendchunked 1 + + # Higher than the max message size of 4096 bytes + LimitRequestBody 102400 + + + ``` + === "caddy" ``` # Note that this config is most certainly incomplete. Please help out and let me know what's missing From 63ec73a31944c0c16454b9f99d09a954e8fea351 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 12:21:19 -0400 Subject: [PATCH 070/126] Tests --- server/server_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++ user/manager_test.go | 75 +++++++++++++++++++++++++ 2 files changed, 200 insertions(+) diff --git a/server/server_test.go b/server/server_test.go index 384be7dc..dfad1fed 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1567,6 +1567,131 @@ 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_PublishAndExpungeTopicAfter16Hours(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() diff --git a/user/manager_test.go b/user/manager_test.go index c8e619cf..3bdb15b2 100644 --- a/user/manager_test.go +++ b/user/manager_test.go @@ -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) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { 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) { forEachStoreBackend(t, func(t *testing.T, manager *Manager) { require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false)) From 07b381254905eed83a5a073d59ecb446d8d30996 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 12:37:07 -0400 Subject: [PATCH 071/126] Docs, fix generator --- docs/config.md | 25 ++++++++++++------------- docs/static/js/config-generator.js | 7 ++++++- web/public/static/langs/en.json | 2 +- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/config.md b/docs/config.md index 44943165..61e7eb92 100644 --- a/docs/config.md +++ b/docs/config.md @@ -355,12 +355,13 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
- +
+ +
+ +
@@ -1018,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. `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 following settings: @@ -1025,6 +1030,8 @@ following settings: * `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-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 configured for `ntfy.sh`): @@ -1036,6 +1043,7 @@ configured for `ntfy.sh`): smtp-sender-user: "AKIDEADBEEFAFFE12345" smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." smtp-sender-from: "ntfy@ntfy.sh" + smtp-sender-verify: true ``` By default, any user (including anonymous users) can send email notifications to any address. To require email @@ -1043,15 +1051,6 @@ address verification, set `smtp-sender-verify` to `true`. When enabled, anonymou 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. -=== "/etc/ntfy/server.yml (with email verification)" - ``` yaml - smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587" - smtp-sender-user: "AKIDEADBEEFAFFE12345" - smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG." - 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` and `visitor-email-limit-burst`. Setting these conservatively is necessary to avoid abuse. diff --git a/docs/static/js/config-generator.js b/docs/static/js/config-generator.js index dc8ea4ed..ffada277 100644 --- a/docs/static/js/config-generator.js +++ b/docs/static/js/config-generator.js @@ -125,7 +125,7 @@ { 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-pass", env: "NTFY_SMTP_SENDER_PASS", section: "smtp-out" }, - { key: "smtp-sender-verify", env: "NTFY_SMTP_SENDER_VERIFY", 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-domain", env: "NTFY_SMTP_SERVER_DOMAIN", section: "smtp-in" }, { key: "smtp-server-addr-prefix", env: "NTFY_SMTP_SERVER_ADDR_PREFIX", section: "smtp-in" }, @@ -172,6 +172,7 @@ requireLoginHidden: modal.querySelector("#cg-require-login-hidden"), signupHidden: modal.querySelector("#cg-enable-signup-hidden"), proxyCheckbox: modal.querySelector("#cg-behind-proxy"), + smtpSenderVerifyHidden: modal.querySelector("#cg-smtp-sender-verify-hidden"), dbStep: modal.querySelector("#cg-wizard-db"), navDb: modal.querySelector("#cg-nav-database"), navEmail: modal.querySelector("#cg-nav-email"), @@ -744,6 +745,10 @@ const signupYes = modal.querySelector("input[name=\"cg-enable-signup\"][value=\"yes\"]"); 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; } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 617dce5b..b809a06f 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -226,7 +226,7 @@ "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, please try again", + "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_usage_title": "Usage", From 4c6225e311c97f91c16e59c97a0164a039eca530 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 12:38:47 -0400 Subject: [PATCH 072/126] Docs --- docs/publish.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 6c887b52..56995128 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3220,8 +3220,10 @@ Since ntfy does not provide auth (yet), the rate limiting is pretty strict (see 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. -On ntfy.sh, to verify your email address in the web app before you're allowed to send emails. The daily limit for -free users is **5 emails per visitor per day**. +!!! 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)" ``` From e57ef84f13951b32074588c7784283fd70bb65b7 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 16:06:53 -0400 Subject: [PATCH 073/126] Fix limits for anon users --- server/server_account.go | 16 +++++------ server/server_test.go | 46 +++++++++++++++++++++++++++++++ server/visitor.go | 6 +++- web/public/static/langs/en.json | 1 + web/src/components/Account.jsx | 4 ++- web/src/components/Navigation.jsx | 3 +- 6 files changed, 65 insertions(+), 11 deletions(-) diff --git a/server/server_account.go b/server/server_account.go index 9acdf450..7def814d 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -624,9 +624,11 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request return errHTTPBadRequestEmailAddressInvalid } // Check user is allowed to add emails - if u == nil || (u.IsUser() && u.Tier == nil) { + if u == nil { return errHTTPUnauthorized - } else if u.IsUser() && u.Tier.EmailLimit == 0 { + } 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 @@ -641,7 +643,7 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request return errHTTPTooManyRequestsLimitEmails } // Send verification email - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Sending email verification") + logvr(v, r).Tag(tagAccount).Field("email", req.Email).Info("Sending email verification") if err := s.mailSender.SendVerification(req.Email); err != nil { return err } @@ -653,14 +655,12 @@ func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v req, err := readJSONWithLimit[apiAccountEmailAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err - } - if !emailAddressRegex.MatchString(req.Email) { + } else if !emailAddressRegex.MatchString(req.Email) { return errHTTPBadRequestEmailAddressInvalid - } - if !s.mailSender.CheckVerification(req.Email, req.Code) { + } else if !s.mailSender.CheckVerification(req.Email, req.Code) { return errHTTPBadRequestEmailVerificationCodeInvalid } - logvr(v, r).Tag(tagAccount).Field("email", req.Email).Debug("Adding email as verified") + 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 } diff --git a/server/server_test.go b/server/server_test.go index dfad1fed..133517d9 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -1692,6 +1692,52 @@ func TestServer_PublishEmailVerify_Disabled_Backwards_Compatible(t *testing.T) { }) } +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) { forEachBackend(t, func(t *testing.T, databaseURL string) { t.Parallel() diff --git a/server/visitor.go b/server/visitor.go index 6d8fe6d1..d5e774d7 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -440,13 +440,17 @@ func configBasedVisitorLimits(conf *Config) *visitorLimits { if conf.VisitorMessageDailyLimit > 0 { messagesLimit = int64(conf.VisitorMessageDailyLimit) } + var emailLimit int64 + if conf.VisitorEmailLimitBurst > 0 { + emailLimit = replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish) // Approximation! + } return &visitorLimits{ Basis: visitorLimitBasisIP, RequestLimitBurst: conf.VisitorRequestLimitBurst, RequestLimitReplenish: rate.Every(conf.VisitorRequestLimitReplenish), MessageLimit: messagesLimit, MessageExpiryDuration: conf.CacheDuration, - EmailLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), // Approximation! + EmailLimit: emailLimit, EmailLimitBurst: conf.VisitorEmailLimitBurst, EmailLimitReplenish: rate.Every(conf.VisitorEmailLimitReplenish), CallLimit: visitorDefaultCallsLimit, diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index b809a06f..2e06cc64 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -251,6 +251,7 @@ "account_usage_messages_title": "Published messages", "account_usage_emails_title": "Emails sent", "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_reservations_title": "Reserved topics", "account_usage_reservations_none": "No reserved topics for this account", diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index de76eac3..29f4872c 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -945,7 +945,9 @@ const Stats = () => { )} {account.role === Role.USER && account.limits.basis === LimitBasis.IP && ( - {t("account_usage_basis_ip_description")} + + {t("account_usage_basis_ip_description")} + )} ); diff --git a/web/src/components/Navigation.jsx b/web/src/components/Navigation.jsx index 89381cb3..dad8abe8 100644 --- a/web/src/components/Navigation.jsx +++ b/web/src/components/Navigation.jsx @@ -117,7 +117,8 @@ const NavList = (props) => { const isAdmin = account?.role === Role.ADMIN; 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 showNotificationPermissionRequired = useNotificationPermissionListener(() => notifier.notRequested()); const showNotificationPermissionDenied = useNotificationPermissionListener(() => notifier.denied()); From 51da5e0f77b2210319ce83239ec8d7312c89dc66 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 16:33:19 -0400 Subject: [PATCH 074/126] Review --- docs/publish.md | 4 ++-- docs/releases.md | 7 ++++--- server/server.go | 2 +- server/smtp_sender.go | 3 +-- user/manager_postgres.go | 2 +- user/manager_sqlite.go | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/publish.md b/docs/publish.md index 56995128..5cef10cf 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -3213,10 +3213,10 @@ 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. 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. If the server has [`smtp-sender-verify`](config.md#e-mail-notifications) enabled, +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 that, your IP address appears in the e-mail body. This is to prevent abuse. diff --git a/docs/releases.md b/docs/releases.md index 865b10bd..93361d0a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,13 +15,14 @@ Please check out the release notes for [upcoming releases](#not-released-yet) be ### ntfy server v2.21.0 Released March 30, 2026 -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. +This release add 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 + address verification before sending email notifications ([#1681](https://github.com/binwiederhier/ntfy/pull/1681)) ### ntfy server v2.20.1 Released March 27, 2026 diff --git a/server/server.go b/server/server.go index 89abe518..78a668bd 100644 --- a/server/server.go +++ b/server/server.go @@ -1105,7 +1105,7 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) { } 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 { logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) minc(metricEmailsPublishedFailure) diff --git a/server/smtp_sender.go b/server/smtp_sender.go index 30966267..885f806b 100644 --- a/server/smtp_sender.go +++ b/server/smtp_sender.go @@ -43,9 +43,8 @@ func (s *smtpSender) Send(v *visitor, m *model.Message, to string) error { }) if ev.IsTrace() { ev.Field("email_body", message).Trace("Sending email") - } else if ev.IsDebug() { - ev.Info("Sending email") } + ev.Info("Sending email") return s.sender.SendRaw(to, []byte(message)) }) } diff --git a/user/manager_postgres.go b/user/manager_postgres.go index efa9998e..02cffd84 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -209,7 +209,7 @@ const ( 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` + 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` diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index 8db75cad..0f1a9227 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -208,7 +208,7 @@ const ( sqliteDeletePhoneNumberQuery = `DELETE FROM user_phone WHERE user_id = ? AND phone_number = ?` // Email queries - sqliteSelectEmailsQuery = `SELECT email FROM user_email WHERE user_id = ?` + 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 = ?` From 6219784aae11ca2ea81ed5b4db809ab159126ee1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 16:38:01 -0400 Subject: [PATCH 075/126] Bump --- docs/install.md | 76 ++++++++++++++++++++++++------------------------ docs/releases.md | 2 +- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/docs/install.md b/docs/install.md index 4deed09b..b57d522d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -34,37 +34,37 @@ as a service starting at boot time. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_amd64.tar.gz - tar zxvf ntfy_2.20.1_linux_amd64.tar.gz - sudo cp -a ntfy_2.20.1_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.tar.gz + tar zxvf ntfy_2.21.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.21.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv6.tar.gz - tar zxvf ntfy_2.20.1_linux_armv6.tar.gz - sudo cp -a ntfy_2.20.1_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.tar.gz + tar zxvf ntfy_2.21.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.21.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_armv7.tar.gz - tar zxvf ntfy_2.20.1_linux_armv7.tar.gz - sudo cp -a ntfy_2.20.1_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.tar.gz + tar zxvf ntfy_2.21.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.21.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_linux_arm64.tar.gz - tar zxvf ntfy_2.20.1_linux_arm64.tar.gz - sudo cp -a ntfy_2.20.1_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.20.1_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.tar.gz + tar zxvf ntfy_2.21.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.21.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic === "x86_64/amd64" ```bash - sudo mv ntfy_2.20.1_linux_amd64/server/ntfy.service /etc/systemd/system/ + 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.20.1_linux_armv6/server/ntfy.service /etc/systemd/system/ + 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.20.1_linux_armv7/server/ntfy.service /etc/systemd/system/ + 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.20.1_linux_arm64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` @@ -118,25 +118,25 @@ Install the ntfy server service script: === "x86_64/amd64" ```bash - sudo mv ntfy_2.20.1_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy + 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.20.1_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy + 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.20.1_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy + 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.20.1_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.21.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` @@ -204,7 +204,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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 systemctl enable ntfy sudo systemctl start ntfy @@ -212,7 +212,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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 systemctl enable ntfy sudo systemctl start ntfy @@ -220,7 +220,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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 systemctl enable ntfy sudo systemctl start ntfy @@ -228,7 +228,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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 systemctl enable ntfy sudo systemctl start ntfy @@ -238,28 +238,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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 start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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 start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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 start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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 start ntfy ``` @@ -301,18 +301,18 @@ pkg install go-ntfy ## macOS 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.20.1/ntfy_2.20.1_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`). 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). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_darwin_all.tar.gz > ntfy_2.20.1_darwin_all.tar.gz -tar zxvf ntfy_2.20.1_darwin_all.tar.gz -sudo cp -a ntfy_2.20.1_darwin_all/ntfy /usr/local/bin/ntfy +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.21.0_darwin_all.tar.gz +sudo cp -a ntfy_2.21.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.20.1_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 ``` @@ -333,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. To install, you can either -* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.20.1/ntfy_2.20.1_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%`. * Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy` diff --git a/docs/releases.md b/docs/releases.md index 93361d0a..72bfefed 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -15,7 +15,7 @@ Please check out the release notes for [upcoming releases](#not-released-yet) be ### ntfy server v2.21.0 Released March 30, 2026 -This release add the ability to verify email addresses using the `smtp-sender-verify` flag. This is a change that is +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. From 17ec63df77ffbde3b2d52f746f90e7d8cc5445f6 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 30 Mar 2026 20:24:14 -0400 Subject: [PATCH 076/126] Update privacy policy to reflect email change --- docs/privacy.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/privacy.md b/docs/privacy.md index 5572b3f3..055e3a35 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,6 +1,6 @@ # Privacy policy -**Last updated:** January 2, 2026 +**Last updated:** March 31, 2026 This privacy policy describes how ntfy ("we", "us", or "our") collects, uses, and handles your information when you use the ntfy.sh service, web app, and mobile applications (Android and iOS). @@ -19,7 +19,8 @@ If you create an account on ntfy.sh, we collect: - **Username** - A unique identifier you choose - **Password** - Stored as a secure bcrypt hash (we never store your plaintext password) -- **Email address** - Only if you subscribe to a paid plan (for billing purposes) +- **Email address** - If you subscribe to a paid plan (for billing purposes via Stripe), or if you add a verified + email address for use with the email notification feature - **Phone number** - Only if you enable the phone call notification feature (verified via SMS/call) You can use ntfy without creating an account. Anonymous usage is fully supported. @@ -143,6 +144,7 @@ No cookies are used for tracking. The web app does not have a backend beyond the | Attachments | 3 hours (configurable by server operators) | | User accounts | Until you delete your account | | Access tokens | Until you revoke them or delete your account | +| Email addresses | Until you remove them or delete your account | | Phone numbers | Until you remove them or delete your account | | Web push subscriptions | 60 days of inactivity, then automatically removed | | Server logs | Varies; debugging logs are typically temporary | From 4417b951cced71057167a1e0499e3c46f3cf0757 Mon Sep 17 00:00:00 2001 From: Daniel Maganha Date: Tue, 31 Mar 2026 01:53:56 +0200 Subject: [PATCH 077/126] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/ --- web/public/static/langs/pt_BR.json | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json index d0e704c1..9016c93d 100644 --- a/web/public/static/langs/pt_BR.json +++ b/web/public/static/langs/pt_BR.json @@ -59,11 +59,11 @@ "publish_dialog_topic_label": "Nome do tópico", "publish_dialog_topic_placeholder": "Nome do tópico, por exemplo, phil_alerts", "publish_dialog_title_label": "Título", - "publish_dialog_title_placeholder": "Título da notificação, por exemplo Alerta de espaço em disco", + "publish_dialog_title_placeholder": "Título da notificação, ex. Alerta de espaço em disco", "publish_dialog_message_label": "Mensagem", "publish_dialog_message_placeholder": "Digite uma mensagem aqui", "publish_dialog_tags_label": "Etiquetas", - "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: srv1-backup", + "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, ex.: srv1-backup", "publish_dialog_priority_label": "Prioridade", "publish_dialog_click_label": "Clique em URL", "publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada", @@ -112,7 +112,7 @@ "common_add": "Adicionar", "common_save": "Salvar", "prefs_appearance_title": "Aparência", - "prefs_appearance_language_title": "LInguagem", + "prefs_appearance_language_title": "Idioma", "priority_min": "minima", "priority_low": "baixa", "priority_default": "padrão", @@ -120,7 +120,7 @@ "priority_max": "máxima", "error_boundary_title": "Ah não, ntfy parou de funcionar", "error_boundary_gathering_info": "Coletar mais informações …", - "error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isto.
Se tiver um minuto, por favor relate isto no GitHub, ou informe-nos através de Discord ou Matrix.", + "error_boundary_description": "Isto obviamente não deveria ter acontecido. Lamentamos muito por isso.
Se tiver um minuto, por favor relate isto no GitHub, ou informe-nos através de Discord ou Matrix.", "error_boundary_button_copy_stack_trace": "Copiar rastreamento de pilha", "error_boundary_stack_trace": "Rastreamento de pilha", "publish_dialog_attachment_limits_file_and_quota_reached": "excede {{fileSizeLimit}} limite de arquivo e cota, {{remainingBytes}} restante", @@ -404,5 +404,7 @@ "web_push_subscription_expiring_title": "As notificações serão pausadas", "web_push_subscription_expiring_body": "Abra o ntfy para continuar recebendo notificações", "web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor", - "web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web" + "web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web", + "account_basics_cannot_edit_or_delete_provisioned_user": "Um usuário provisionado não pode ser editado ou apagado", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado" } From eb624f4bc53cc3a0645c63c9ea72f956258f8f17 Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Tue, 31 Mar 2026 07:59:40 +0200 Subject: [PATCH 078/126] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/ --- web/public/static/langs/pt_BR.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json index 9016c93d..43ce3ec3 100644 --- a/web/public/static/langs/pt_BR.json +++ b/web/public/static/langs/pt_BR.json @@ -63,7 +63,7 @@ "publish_dialog_message_label": "Mensagem", "publish_dialog_message_placeholder": "Digite uma mensagem aqui", "publish_dialog_tags_label": "Etiquetas", - "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, ex.: srv1-backup", + "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, ex.: aviso, srv1-backup", "publish_dialog_priority_label": "Prioridade", "publish_dialog_click_label": "Clique em URL", "publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada", From 36b76eb318b2056348db41532b1606be871a32eb Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Tue, 31 Mar 2026 07:45:59 +0200 Subject: [PATCH 079/126] Translated using Weblate (Spanish) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/ --- web/public/static/langs/es.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index a56f9e13..f3622eb1 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -52,7 +52,7 @@ "publish_dialog_topic_placeholder": "Nombre del tópico, ej. phil_alerts", "publish_dialog_title_label": "Título", "publish_dialog_message_label": "Mensaje", - "publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, por ejemplo: warning, srv1-backup", + "publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, por ejemplo: aviso, srv1-backup", "publish_dialog_click_label": "Click URL", "publish_dialog_click_placeholder": "URL que se abre cuando se hace click en la notificación", "publish_dialog_email_label": "Email", @@ -120,7 +120,7 @@ "publish_dialog_priority_low": "Prioridad baja", "publish_dialog_priority_high": "Prioridad alta", "publish_dialog_delay_label": "Retraso", - "publish_dialog_title_placeholder": "Título de la notificación, ej. Alerta de espacio en disco", + "publish_dialog_title_placeholder": "Título de la notificación, ej. \"Alerta de espacio en disco\"", "publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la documentación.", "publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk", "publish_dialog_filename_placeholder": "Nombre del archivo adjunto", @@ -153,7 +153,7 @@ "priority_low": "baja", "notifications_actions_not_supported": "Acción no soportada en la aplicación web", "notifications_actions_http_request_title": "Enviar HTTP {{method}} a {{url}}", - "error_boundary_unsupported_indexeddb_description": "La aplicación web ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada.

Si bien esto es desafortunado, tampoco tiene mucho sentido usar la aplicación web ntfy en modo de navegación privada de todos modos, porque todo está almacenado en el almacenamiento del navegador. Puede leer más sobre esto en este issue de GitHub, o hablar con nosotros en Discord o Matrix.", + "error_boundary_unsupported_indexeddb_description": "La aplicación web de ntfy necesita IndexedDB para funcionar y su navegador no soporta IndexedDB en modo de navegación privada.

Mismo que no sea ideal, tampoco tiene mucho sentido usar la aplicación web de ntfy en modo de navegación privada de todos modos, porque todo está guardado en el almacenamiento del navegador. Puede leer más sobre esto en este issue de GitHub, o hablar con nosotros en Discord o Matrix.", "action_bar_show_menu": "Mostrar menú", "action_bar_logo_alt": "logo de ntfy", "action_bar_toggle_action_menu": "Abrir/cerrar el menú de acción", From 05e0e4ed0582ee0574a076dc5c669002d34f9455 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 3 Apr 2026 21:28:53 -0400 Subject: [PATCH 080/126] Bump --- docs/releases.md | 5 +++ go.mod | 32 +++++++-------- go.sum | 68 +++++++++++++++---------------- web/package-lock.json | 94 +++++++++++++++++++++++++++---------------- 4 files changed, 114 insertions(+), 85 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 72bfefed..4aba4d2e 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1850,3 +1850,8 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * 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:** + +* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution) +* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting) diff --git a/go.mod b/go.mod index c5879636..714c2ec7 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/emersion/go-smtp v0.18.0 github.com/gabriel-vasile/mimetype v1.4.13 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.38 + github.com/mattn/go-sqlite3 v1.14.40 github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 @@ -19,7 +19,7 @@ require ( golang.org/x/sync v0.20.0 golang.org/x/term v0.41.0 golang.org/x/time v0.15.0 - google.golang.org/api v0.273.0 + google.golang.org/api v0.274.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -44,9 +44,9 @@ require ( cloud.google.com/go/auth v0.19.0 // 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/iam v1.6.0 // indirect - cloud.google.com/go/longrunning v0.8.0 // indirect - cloud.google.com/go/monitoring v1.24.3 // indirect + cloud.google.com/go/iam v1.7.0 // indirect + cloud.google.com/go/longrunning v0.9.0 // indirect + cloud.google.com/go/monitoring v1.25.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect @@ -61,7 +61,7 @@ require ( github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect @@ -70,7 +70,7 @@ require ( github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.20.0 // indirect + github.com/googleapis/gax-go/v2 v2.21.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -89,18 +89,18 @@ require ( go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/sdk v1.42.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/net v0.52.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect - google.golang.org/grpc v1.79.3 // indirect + google.golang.org/genproto v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 90d22ee6..d5dbf009 100644 --- a/go.sum +++ b/go.sum @@ -10,14 +10,14 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB 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/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= -cloud.google.com/go/iam v1.6.0 h1:JiSIcEi38dWBKhB3BtfKCW+dMvCZJEhBA2BsaGJgoxs= -cloud.google.com/go/iam v1.6.0/go.mod h1:ZS6zEy7QHmcNO18mjO2viYv/n+wOUkhJqGNkPPGueGU= +cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= +cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= 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/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= -cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= -cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE= -cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= +cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= +cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4= +cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= @@ -70,8 +70,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -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/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/gax-go/v2 v2.20.0 h1:NIKVuLhDlIV74muWlsMM4CcQZqN6JJ20Qcxd9YMuYcs= -github.com/googleapis/gax-go/v2 v2.20.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= +github.com/googleapis/gax-go/v2 v2.21.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/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= -github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94= +github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -171,18 +171,18 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= @@ -270,20 +270,20 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.273.0 h1:r/Bcv36Xa/te1ugaN1kdJ5LoA5Wj/cL+a4gj6FiPBjQ= -google.golang.org/api v0.273.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= +google.golang.org/api v0.274.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/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= -google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= -google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/genproto v0.0.0-20260401024825-9d38bb4040a9 h1:w8JYjr7zHemS95YA5FFwk+fUv5tdQU4I8twN9bFdxVU= +google.golang.org/genproto v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:YCEC8W7HTtK7iBv+pI7g7hGAi7qdGB6bQXw3BIYAusM= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/web/package-lock.json b/web/package-lock.json index 247b84a2..68922626 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -3587,9 +3587,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.1", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", - "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", + "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3681,9 +3681,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.12", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", - "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "version": "2.10.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", + "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3705,9 +3705,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3725,11 +3725,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3805,9 +3805,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001782", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", - "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", "dev": true, "funding": [ { @@ -4242,9 +4242,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.328", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", - "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", "dev": true, "license": "ISC" }, @@ -4628,15 +4628,15 @@ } }, "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, "node_modules/eslint-import-resolver-node/node_modules/debug": { @@ -4649,6 +4649,30 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-node/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/eslint-module-utils": { "version": "2.12.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", @@ -6442,9 +6466,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -6760,9 +6784,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.36", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", - "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "dev": true, "license": "MIT" }, @@ -9285,13 +9309,13 @@ "license": "MIT" }, "node_modules/workbox-build/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" From cc61d793130b1679d2ce63e35cb66827c1aa27aa Mon Sep 17 00:00:00 2001 From: Gringo Date: Sun, 5 Apr 2026 03:36:35 +0200 Subject: [PATCH 081/126] Translated using Weblate (Italian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/it/ --- web/public/static/langs/it.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json index d02bc913..b0786419 100644 --- a/web/public/static/langs/it.json +++ b/web/public/static/langs/it.json @@ -403,5 +403,7 @@ "web_push_subscription_expiring_body": "Apri ntfy per continuare a ricevere notifiche", "web_push_unknown_notification_title": "Notifica sconosciuta ricevuta dal server", "account_tokens_dialog_expires_x_hours": "Il token scade tra {{hours}} ore", - "prefs_reservations_table": "Tabella argomenti riservati" + "prefs_reservations_table": "Tabella argomenti riservati", + "account_basics_cannot_edit_or_delete_provisioned_user": "Un utente autorizzato non può essere modificato o eliminato", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Impossibile modificare o eliminare il token fornito" } From 4c3968eaba1ec55b41bbdc8b5c83bfda04342731 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 04:29:08 +0000 Subject: [PATCH 082/126] Bump vite from 6.4.1 to 6.4.2 in /web Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 6.4.1 to 6.4.2. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v6.4.2/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v6.4.2/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 6.4.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- web/package-lock.json | 53 ++++++------------------------------------- web/package.json | 2 +- 2 files changed, 8 insertions(+), 47 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 68922626..20f4de95 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,7 +12,7 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", - "@mui/material": "latest", + "@mui/material": "*", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", "humanize-duration": "^3.27.3", @@ -20,8 +20,8 @@ "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^1.4.0", "js-base64": "^3.7.2", - "react": "latest", - "react-dom": "latest", + "react": "*", + "react-dom": "*", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-remark": "^2.1.0", @@ -41,7 +41,7 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^2.8.8", - "vite": "^6.3.5", + "vite": "^6.4.2", "vite-plugin-pwa": "^1.0.0" } }, @@ -2829,9 +2829,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2846,9 +2843,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2863,9 +2857,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2880,9 +2871,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2897,9 +2885,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2914,9 +2899,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2931,9 +2913,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2948,9 +2927,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2965,9 +2941,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2982,9 +2955,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2999,9 +2969,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3016,9 +2983,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3033,9 +2997,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8824,9 +8785,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package.json b/web/package.json index 0de56abd..eb9d2cdc 100644 --- a/web/package.json +++ b/web/package.json @@ -44,7 +44,7 @@ "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "prettier": "^2.8.8", - "vite": "^6.3.5", + "vite": "^6.4.2", "vite-plugin-pwa": "^1.0.0" }, "browserslist": { From ad7dc1bf5edf0d0c17874278ac6a92430989154f Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Tue, 7 Apr 2026 16:58:00 +0200 Subject: [PATCH 083/126] Translated using Weblate (Portuguese (Brazil)) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/ --- web/public/static/langs/pt_BR.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/public/static/langs/pt_BR.json b/web/public/static/langs/pt_BR.json index 43ce3ec3..e5498948 100644 --- a/web/public/static/langs/pt_BR.json +++ b/web/public/static/langs/pt_BR.json @@ -59,11 +59,11 @@ "publish_dialog_topic_label": "Nome do tópico", "publish_dialog_topic_placeholder": "Nome do tópico, por exemplo, phil_alerts", "publish_dialog_title_label": "Título", - "publish_dialog_title_placeholder": "Título da notificação, ex. Alerta de espaço em disco", + "publish_dialog_title_placeholder": "Título da notificação, p.ex.: \"Alerta de espaço em disco\"", "publish_dialog_message_label": "Mensagem", "publish_dialog_message_placeholder": "Digite uma mensagem aqui", "publish_dialog_tags_label": "Etiquetas", - "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, ex.: aviso, srv1-backup", + "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, p.ex.: aviso, srv1-backup", "publish_dialog_priority_label": "Prioridade", "publish_dialog_click_label": "Clique em URL", "publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada", From 1b948e9dfa3c0d8fbe36559c584284496ca5fc64 Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Tue, 7 Apr 2026 17:00:28 +0200 Subject: [PATCH 084/126] Translated using Weblate (Portuguese) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/ --- web/public/static/langs/pt.json | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json index 5e237269..89b80a93 100644 --- a/web/public/static/langs/pt.json +++ b/web/public/static/langs/pt.json @@ -70,11 +70,11 @@ "publish_dialog_topic_label": "Nome do tópico", "publish_dialog_topic_placeholder": "Nome do tópico, por exemplo: \"avisos_do_filipe\"", "publish_dialog_topic_reset": "Limpar tópico", - "publish_dialog_title_placeholder": "Título da notificação, por exemplo: \"Alerta de espaço em disco\"", + "publish_dialog_title_placeholder": "Título da notificação, p.ex: \"Alerta de espaço em disco\"", "publish_dialog_message_label": "Mensagem", "publish_dialog_message_placeholder": "Escreva uma mensagem aqui", "publish_dialog_tags_label": "Etiquetas", - "publish_dialog_tags_placeholder": "Lista de etiquetas, separadas por vírgula, por exemplo: aviso, srv1-backup", + "publish_dialog_tags_placeholder": "Lista de etiquetas separadas por vírgula, p.ex.: aviso, srv1-backup", "publish_dialog_priority_label": "Prioridade", "publish_dialog_click_label": "URL de clique", "publish_dialog_click_placeholder": "URL que é aberto quando a notificação é clicada", @@ -404,5 +404,7 @@ "web_push_subscription_expiring_title": "As notificações serão pausadas", "web_push_subscription_expiring_body": "Abra o ntfy para continuar recebendo notificações", "web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor", - "web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web" + "web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo a aplicação da Web", + "account_basics_cannot_edit_or_delete_provisioned_user": "Não se pode editar ou eliminar um usuário predefinido", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não se pode editar ou eliminar um token predefinido" } From ec494aead367cdbcc8fd3df17324ce2e309f1435 Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Wed, 8 Apr 2026 16:09:05 +0200 Subject: [PATCH 085/126] Translated using Weblate (Catalan) Currently translated at 5.4% (22 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/ --- web/public/static/langs/ca.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/ca.json b/web/public/static/langs/ca.json index 4490f1d5..2ff3b3fa 100644 --- a/web/public/static/langs/ca.json +++ b/web/public/static/langs/ca.json @@ -12,5 +12,13 @@ "signup_form_username": "Nom d'usuari", "signup_form_password": "Contrasenya", "signup_form_confirm_password": "Confirma la contrasenya", - "signup_form_button_submit": "Dona't d'alta" + "signup_form_button_submit": "Dona't d'alta", + "signup_form_toggle_password_visibility": "Canvia la visibilitat de la contrasenya", + "signup_already_have_account": "Ja tens un compte? Inicia sessió!", + "signup_disabled": "Les inscripcions estan deshabilitades", + "signup_error_username_taken": "El nom d'usuari {{username}} ja està en ús", + "signup_error_creation_limit_reached": "Límit de creació de comptes assolit", + "login_title": "Inicia sessió al teu compte ntfy", + "login_form_button_submit": "Iniciar sessió", + "login_link_signup": "Crear compte" } From 85cc652449ffd1720b2df4fcbde6550654a6064a Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Tue, 7 Apr 2026 15:58:03 +0200 Subject: [PATCH 086/126] Translated using Weblate (Spanish) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/ --- web/public/static/langs/es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index f3622eb1..ba9481c4 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -52,7 +52,7 @@ "publish_dialog_topic_placeholder": "Nombre del tópico, ej. phil_alerts", "publish_dialog_title_label": "Título", "publish_dialog_message_label": "Mensaje", - "publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, por ejemplo: aviso, srv1-backup", + "publish_dialog_tags_placeholder": "Lista de etiquetas separadas por comas, ej.: aviso, srv1-backup", "publish_dialog_click_label": "Click URL", "publish_dialog_click_placeholder": "URL que se abre cuando se hace click en la notificación", "publish_dialog_email_label": "Email", From c11991a91d78a66496e51d63bb50b9669eb4e028 Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Wed, 8 Apr 2026 16:20:58 +0200 Subject: [PATCH 087/126] Translated using Weblate (Portuguese) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt/ --- web/public/static/langs/pt.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/pt.json b/web/public/static/langs/pt.json index 89b80a93..2f78db27 100644 --- a/web/public/static/langs/pt.json +++ b/web/public/static/langs/pt.json @@ -2,7 +2,7 @@ "action_bar_clear_notifications": "Limpar todas as notificações", "action_bar_send_test_notification": "Enviar notificação de teste", "action_bar_unsubscribe": "Anular subscrição", - "action_bar_toggle_mute": "Ativa/Desativa notificações", + "action_bar_toggle_mute": "Ativar/Desativar notificações", "action_bar_toggle_action_menu": "Abrir/fechar menu de ação", "message_bar_type_message": "Escreva uma mensagem aqui", "message_bar_error_publishing": "Erro ao publicar notificação", From 7e16203065b2b3a523d125cfdb765dd177fce783 Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Wed, 8 Apr 2026 16:24:07 +0200 Subject: [PATCH 088/126] Translated using Weblate (Catalan) Currently translated at 10.3% (42 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ca/ --- web/public/static/langs/ca.json | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/ca.json b/web/public/static/langs/ca.json index 2ff3b3fa..966adef1 100644 --- a/web/public/static/langs/ca.json +++ b/web/public/static/langs/ca.json @@ -20,5 +20,25 @@ "signup_error_creation_limit_reached": "Límit de creació de comptes assolit", "login_title": "Inicia sessió al teu compte ntfy", "login_form_button_submit": "Iniciar sessió", - "login_link_signup": "Crear compte" + "login_link_signup": "Crear compte", + "login_disabled": "L'accès està desactivat", + "action_bar_show_menu": "Mostrar el menú", + "action_bar_logo_alt": "logotip de ntfy", + "action_bar_change_display_name": "Canviar nom de pantalla", + "action_bar_reservation_add": "Reservar tema", + "action_bar_reservation_edit": "Canviar la reserva", + "action_bar_reservation_delete": "Eliminar la reserva", + "action_bar_reservation_limit_reached": "Límit assolit", + "action_bar_send_test_notification": "Enviar notificació de prova", + "action_bar_clear_notifications": "Esborrar totes les notificacions", + "action_bar_mute_notifications": "Silenciar notificacions", + "action_bar_unmute_notifications": "Reactivar notificacions", + "action_bar_unsubscribe": "Cancel·lar la subscripció", + "action_bar_toggle_mute": "Silenciar/reactivar notificacions", + "action_bar_toggle_action_menu": "Obrir/tancar el menú d'accions", + "action_bar_profile_settings": "Configuracions", + "action_bar_profile_logout": "Tancar sessió", + "action_bar_sign_in": "Iniciar sessió", + "action_bar_sign_up": "Crear compte", + "message_bar_type_message": "Escriu un missatge aquí" } From 795ef9da1cd9819abd83bc26bbf49c704a445e76 Mon Sep 17 00:00:00 2001 From: Cairo Braga Date: Wed, 8 Apr 2026 16:17:28 +0200 Subject: [PATCH 089/126] Translated using Weblate (Spanish) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/ --- web/public/static/langs/es.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/es.json b/web/public/static/langs/es.json index ba9481c4..26d787c7 100644 --- a/web/public/static/langs/es.json +++ b/web/public/static/langs/es.json @@ -207,7 +207,7 @@ "action_bar_account": "Cuenta", "action_bar_change_display_name": "Cambiar nombre de usuario", "action_bar_reservation_add": "Reservar tema", - "action_bar_reservation_edit": "Modificar reserva", + "action_bar_reservation_edit": "Alterar la reserva", "action_bar_reservation_delete": "Quitar reserva", "action_bar_reservation_limit_reached": "Límite alcanzado", "action_bar_profile_logout": "Cerrar sesión", From 4b0a4eee3b8dba4ae033d102631a6a0c87ecc5d5 Mon Sep 17 00:00:00 2001 From: ShipItAndPray Date: Thu, 9 Apr 2026 11:57:57 -0500 Subject: [PATCH 090/126] web: allow setting token expiration to never when editing --- server/server_account_test.go | 9 +++++++++ web/src/app/AccountApi.js | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/server_account_test.go b/server/server_account_test.go index f4b4d7c5..9aea7d4d 100644 --- a/server/server_account_test.go +++ b/server/server_account_test.go @@ -361,6 +361,15 @@ func TestAccount_ExtendToken(t *testing.T) { require.Nil(t, err) require.Equal(t, "some label", token.Label) require.Equal(t, expires.Unix(), token.Expires) + + body = fmt.Sprintf(`{"token":"%s", "expires": 0}`, token.Token) + rr = request(t, s, "PATCH", "/v1/account/token", body, map[string]string{ + "Authorization": util.BearerAuth(token.Token), + }) + require.Equal(t, 200, rr.Code) + token, err = util.UnmarshalJSON[apiAccountTokenResponse](io.NopCloser(rr.Body)) + require.Nil(t, err) + require.Equal(t, int64(0), token.Expires) }) } diff --git a/web/src/app/AccountApi.js b/web/src/app/AccountApi.js index 5b44391d..d340c2c0 100644 --- a/web/src/app/AccountApi.js +++ b/web/src/app/AccountApi.js @@ -137,8 +137,8 @@ class AccountApi { token, label, }; - if (expires > 0) { - body.expires = Math.floor(Date.now() / 1000) + expires; + if (expires >= 0) { + body.expires = expires > 0 ? Math.floor(Date.now() / 1000) + expires : 0; } console.log(`[AccountApi] Creating user access token ${url}`); await fetchOrThrow(url, { From 5d93cb400aeff48f953d334cfaaee5bade6582cd Mon Sep 17 00:00:00 2001 From: ShipItAndPray Date: Thu, 9 Apr 2026 14:22:38 -0500 Subject: [PATCH 091/126] Avoid formatting missing account token access times --- web/src/components/Account.jsx | 163 +++++++++++++++++---------------- 1 file changed, 85 insertions(+), 78 deletions(-) diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 29f4872c..29fc2b68 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -1056,87 +1056,94 @@ const TokensTable = (props) => { - {tokens.map((token) => ( - - - - {token.token.slice(0, 12)} - ... - - handleCopy(token.token)}> - - - - - - - {token.token === session.token() && {t("account_tokens_table_current_session")}} - {token.token !== session.token() && (token.label || "-")} - - - {token.expires ? formatShortDateTime(token.expires, i18n.language) : {t("account_tokens_table_never_expires")}} - - -
- {formatShortDateTime(token.last_access, i18n.language)} - - openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}> - - - -
-
- - {token.token !== session.token() && !token.provisioned && ( - <> - - handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> - + {tokens.map((token) => { + const hasLastAccess = Number.isFinite(token.last_access) && token.last_access > 0; + const hasLastOrigin = !!token.last_origin; + + return ( + + + + {token.token.slice(0, 12)} + ... + + handleCopy(token.token)}> + - - handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> - - + + + + {token.token === session.token() && {t("account_tokens_table_current_session")}} + {token.token !== session.token() && (token.label || "-")} + + + {token.expires ? formatShortDateTime(token.expires, i18n.language) : {t("account_tokens_table_never_expires")}} + + +
+ {hasLastAccess ? {formatShortDateTime(token.last_access, i18n.language)} : -} + {hasLastOrigin && ( + + openUrl(`https://whatismyipaddress.com/ip/${token.last_origin}`)}> + + + + )} +
+
+ + {token.token !== session.token() && !token.provisioned && ( + <> + + handleEditClick(token)} aria-label={t("account_tokens_dialog_title_edit")}> + + + + + handleDeleteClick(token)} aria-label={t("account_tokens_dialog_title_delete")}> + + + + + )} + {token.token === session.token() && ( + + + + + + + + + - - )} - {token.token === session.token() && ( - - - - - - - - - - - )} - {token.provisioned && ( - - - - - - - - - - - )} - -
- ))} + )} + {token.provisioned && ( + + + + + + + + + + + )} +
+
+ ); + })}
Date: Thu, 9 Apr 2026 21:26:11 +0200 Subject: [PATCH 092/126] Added translation using Weblate (Lithuanian) --- web/public/static/langs/lt.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/lt.json diff --git a/web/public/static/langs/lt.json b/web/public/static/langs/lt.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/lt.json @@ -0,0 +1 @@ +{} From 11ff36a19edb55de0e25ad416e359c6afb14deb1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 10 Apr 2026 21:53:48 -0400 Subject: [PATCH 093/126] Release notes --- docs/releases.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index 4aba4d2e..1f40ff5d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1850,6 +1850,9 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * 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) +* Restart the foreground service immediately when network returns, even if the app process was killed while offline +* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation) +* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution) **Bug fixes + maintenance:** From 55b27260cf5731367024ad15afb30d9687ebc063 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Fri, 10 Apr 2026 22:18:00 -0400 Subject: [PATCH 094/126] Update release notes --- docs/releases.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index 1f40ff5d..d5a3b2fe 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1846,6 +1846,18 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy Android v1.25.x (UNRELEASED) +This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out +or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings. + +The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there +is no network, ntfy will now stop the foreground service entirely. + +Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions +from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs. + +We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life, +especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server. + **Features:** * 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) @@ -1858,3 +1870,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release * Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution) * Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting) + +## ntfy iOS app v1.4.1 (UNRELEASED) + +This is the first iOS release in 3 years, focusing on stability fixes as per the [iOS improvement plan](https://github.com/binwiederhier/ntfy/issues/1680). + +**Bug fixes + maintenance:** + +* Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution) From e1dde9f3853e7b30a658364009e603c06adfe9e2 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 11 Apr 2026 15:14:52 -0400 Subject: [PATCH 095/126] Release notes --- docs/releases.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index d5a3b2fe..5834779b 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1879,3 +1879,9 @@ This is the first iOS release in 3 years, focusing on stability fixes as per the * Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution) * Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution) + +## ntfy server v2.21.x (UNRELEASED) + +**Bug fixes + maintenance:** + +* Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing) From 6cfadf96816119a1e88322cb9aa332006ff32478 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sat, 11 Apr 2026 15:24:28 -0400 Subject: [PATCH 096/126] Bump release notes --- docs/releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/releases.md b/docs/releases.md index 5834779b..de6f9a78 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1885,3 +1885,4 @@ This is the first iOS release in 3 years, focusing on stability fixes as per the **Bug fixes + maintenance:** * Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing) +* Fix web app crashing on account page for tokens without a last access time ([#1651](https://github.com/binwiederhier/ntfy/issues/1651), [#1684](https://github.com/binwiederhier/ntfy/issues/1684), thanks to [@Pulsar7](https://github.com/Pulsar7) and [@rzhli](https://github.com/rzhli) for reporting) From 6e90b16d0da6bcfc02d2cbb944f6f1a75d73a12f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=84=82=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=9D?= =?UTF-8?q?=20=28=F0=9D=95=98=F0=9D=95=9A=F0=9D=95=A5=F0=9D=95=99?= =?UTF-8?q?=F0=9D=95=A6=F0=9D=95=93=2E=F0=9D=95=94=F0=9D=95=A0=F0=9D=95=9E?= =?UTF-8?q?/=E2=84=82=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=9D=29?= Date: Sun, 12 Apr 2026 10:43:38 +0200 Subject: [PATCH 097/126] Added translation using Weblate (Latvian) --- web/public/static/langs/lv.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/lv.json diff --git a/web/public/static/langs/lv.json b/web/public/static/langs/lv.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/lv.json @@ -0,0 +1 @@ +{} From ac09f9802b4c70b802a990195c8a9f40378390da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=84=82=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=9D?= =?UTF-8?q?=20=28=F0=9D=95=98=F0=9D=95=9A=F0=9D=95=A5=F0=9D=95=99?= =?UTF-8?q?=F0=9D=95=A6=F0=9D=95=93=2E=F0=9D=95=94=F0=9D=95=A0=F0=9D=95=9E?= =?UTF-8?q?/=E2=84=82=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=A0=F0=9D=95=9D=29?= Date: Sun, 12 Apr 2026 13:50:57 +0200 Subject: [PATCH 098/126] Translated using Weblate (Latvian) Currently translated at 29.7% (121 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/lv/ --- web/public/static/langs/lv.json | 126 +++++++++++++++++++++++++++++++- 1 file changed, 125 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/lv.json b/web/public/static/langs/lv.json index 0967ef42..ec92b392 100644 --- a/web/public/static/langs/lv.json +++ b/web/public/static/langs/lv.json @@ -1 +1,125 @@ -{} +{ + "common_cancel": "Atcelt", + "common_save": "Saglabāt", + "common_add": "Pievienot", + "common_back": "Atpakaļ", + "signup_form_username": "Lietotājvārds", + "signup_form_password": "Parole", + "action_bar_settings": "Iestatījumi", + "action_bar_account": "Konts", + "action_bar_profile_title": "Profils", + "action_bar_profile_settings": "Iestatījumi", + "action_bar_profile_logout": "Iziet", + "nav_button_account": "Konts", + "nav_button_settings": "Iestatījumi", + "nav_button_documentation": "Dokumentācija", + "nav_button_connecting": "savienojas", + "notifications_list_item": "Paziņojums", + "notifications_delete": "Dzēst", + "notifications_tags": "Birkas", + "notifications_example": "Piemērs", + "publish_dialog_title_label": "Virsraksts", + "publish_dialog_message_label": "Ziņojums", + "publish_dialog_tags_label": "Birkas", + "publish_dialog_priority_label": "Prioritāte", + "publish_dialog_email_label": "E-pasta adrese", + "publish_dialog_filename_label": "Datnes nosaukums", + "publish_dialog_delay_label": "Aizkave", + "publish_dialog_button_cancel": "Atcelt", + "publish_dialog_button_send": "Sūtīt", + "subscribe_dialog_subscribe_button_cancel": "Atcelt", + "subscribe_dialog_subscribe_button_subscribe": "Abonēt", + "subscribe_dialog_login_password_label": "Parole", + "subscribe_dialog_error_user_anonymous": "anonīms lietotājs", + "account_basics_title": "Konts", + "account_basics_username_title": "Lietotājvārds", + "account_basics_password_title": "Parole", + "account_basics_phone_numbers_dialog_channel_sms": "Nosūtīt īsziņu", + "account_basics_phone_numbers_dialog_channel_call": "Zvanīt", + "account_usage_title": "Lietojums", + "account_usage_unlimited": "Neierobežots", + "account_basics_tier_admin": "Administrators", + "account_basics_tier_basic": "Pamata", + "account_basics_tier_free": "Bezmaksas", + "account_basics_tier_interval_monthly": "ikmēnesi", + "account_basics_tier_interval_yearly": "katru gadu", + "account_basics_tier_change_button": "Mainīt", + "account_delete_dialog_label": "Parole", + "account_delete_dialog_button_cancel": "Atcelt", + "account_upgrade_dialog_interval_monthly": "Ikmēnesi", + "account_upgrade_dialog_interval_yearly": "Katru gadu", + "account_upgrade_dialog_tier_price_per_month": "mēnesī", + "account_upgrade_dialog_tier_selected_label": "Atlasīts", + "account_upgrade_dialog_tier_current_label": "Pašreizējais", + "account_upgrade_dialog_button_cancel": "Atcelt", + "account_tokens_table_token_header": "Pilnvara", + "account_tokens_table_expires_header": "Derīgs līdz", + "account_tokens_dialog_button_cancel": "Atcelt", + "prefs_notifications_title": "Paziņojumi", + "prefs_notifications_delete_after_never": "Nekad", + "prefs_notifications_web_push_disabled": "Atspējots", + "prefs_users_table_user_header": "Lietotājs", + "prefs_users_dialog_password_label": "Parole", + "prefs_appearance_title": "Izskats", + "prefs_appearance_language_title": "Valoda", + "prefs_appearance_theme_title": "Motīvs", + "prefs_reservations_table_topic_header": "Tēma", + "prefs_reservations_table_access_header": "Piekļuve", + "prefs_reservations_dialog_topic_label": "Tēma", + "prefs_reservations_dialog_access_label": "Piekļuve", + "priority_min": "minimālā", + "priority_low": "zema", + "priority_default": "noklusējuma", + "priority_high": "augsta", + "priority_max": "maksimālā", + "signup_form_confirm_password": "Atkārtot paroli", + "signup_form_button_submit": "Izveidot kontu", + "login_link_signup": "Izveidot kontu", + "action_bar_show_menu": "Rādīt izvēlni", + "action_bar_logo_alt": "ntfy logotips", + "action_bar_reservation_add": "Rezervēt tēmu", + "action_bar_reservation_edit": "Mainīt rezervāciju", + "action_bar_reservation_delete": "Noņemt rezervāciju", + "action_bar_reservation_limit_reached": "Sasniegts limits", + "action_bar_mute_notifications": "Apklusināt paziņojumus", + "action_bar_sign_up": "Izveidot kontu", + "message_bar_publish": "Publicēt ziņojumu", + "nav_topics_title": "Abonētās tēmas", + "nav_button_all_notifications": "Visi paziņojumi", + "nav_button_publish_message": "Publicēt paziņojumu", + "nav_button_muted": "Paziņojumi apklusināti", + "alert_notification_permission_required_button": "Dot tagad", + "notifications_list": "Paziņojumu saraksts", + "notifications_priority_x": "{{priority}} prioritāte", + "notifications_new_indicator": "Jauns paziņojums", + "notifications_attachment_image": "Pielikuma attēls", + "notifications_attachment_copy_url_button": "Kopēt URL adresi", + "notifications_attachment_open_button": "Atvērt pielikumu", + "notifications_attachment_file_image": "attēla datne", + "notifications_attachment_file_video": "video datne", + "notifications_attachment_file_audio": "audio datne", + "notifications_attachment_file_document": "cits datnes tips", + "notifications_click_copy_url_button": "Kopēt saiti", + "notifications_click_open_button": "Atvērt saiti", + "notifications_actions_failed_notification": "Neveiksmīga darbība", + "publish_dialog_title_no_topic": "Publicēt paziņojumu", + "publish_dialog_progress_uploading": "Augšupielādē …", + "publish_dialog_message_published": "Paziņojums publicēts", + "publish_dialog_emoji_picker_show": "Atlasīt emocijzīmi", + "publish_dialog_priority_min": "Minimāla prioritāte", + "publish_dialog_priority_low": "Zema prioritāte", + "publish_dialog_priority_default": "Noklusējuma prioritāte", + "publish_dialog_priority_high": "Augsta prioritāte", + "publish_dialog_priority_max": "Maksimāla prioritāte", + "publish_dialog_base_url_label": "Pakalpojuma URL adrese", + "publish_dialog_topic_label": "Tēmas nosaukums", + "publish_dialog_topic_reset": "Atiestatīt tēmu", + "publish_dialog_click_label": "Klikšķināma URL adrese", + "publish_dialog_call_label": "Tālruņa zvans", + "publish_dialog_attach_label": "Pielikuma URL adrese", + "publish_dialog_filename_placeholder": "Pielikuma datnes nosaukums", + "publish_dialog_other_features": "Citas funkcijas:", + "publish_dialog_chip_call_label": "Tālruņa zvans", + "publish_dialog_chip_delay_label": "Aizkavēt piegādi", + "publish_dialog_chip_topic_label": "Mainīt tēmu" +} From e10ece9715f21df04b0400e04fd9b094d750f07c Mon Sep 17 00:00:00 2001 From: usefulish Date: Tue, 14 Apr 2026 18:31:09 +0200 Subject: [PATCH 099/126] Added translation using Weblate (English (United Kingdom)) --- web/public/static/langs/en_GB.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/en_GB.json diff --git a/web/public/static/langs/en_GB.json b/web/public/static/langs/en_GB.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/en_GB.json @@ -0,0 +1 @@ +{} From eefd3d1a5495f1a62991f1a3c58b18eb73b21051 Mon Sep 17 00:00:00 2001 From: usefulish Date: Tue, 14 Apr 2026 18:39:46 +0200 Subject: [PATCH 100/126] Translated using Weblate (English (United Kingdom)) Currently translated at 0.9% (4 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/en_GB/ --- web/public/static/langs/en_GB.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/en_GB.json b/web/public/static/langs/en_GB.json index 0967ef42..8c08c550 100644 --- a/web/public/static/langs/en_GB.json +++ b/web/public/static/langs/en_GB.json @@ -1 +1,6 @@ -{} +{ + "common_cancel": "Cancel", + "common_save": "Save", + "common_add": "Add", + "common_back": "Back" +} From fa83d68754e87f079201b9b31bc36621308e5229 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 21 Apr 2026 10:40:52 -0400 Subject: [PATCH 101/126] Tighten web push endpoint allow list --- docs/releases.md | 1 + server/server_webpush.go | 40 ++++++++++--------- server/server_webpush_test.go | 72 +++++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 19 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index de6f9a78..cb9a6958 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1884,5 +1884,6 @@ This is the first iOS release in 3 years, focusing on stability fixes as per the **Bug fixes + maintenance:** +* Tighten web push endpoint allow-list regex to prevent SSRF via unanchored pattern matching ([GHSA-w9hq-5jg7-q4j7](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-w9hq-5jg7-q4j7), thanks to [@MightyNawaf](https://github.com/MightyNawaf) for reporting) * Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing) * Fix web app crashing on account page for tokens without a last access time ([#1651](https://github.com/binwiederhier/ntfy/issues/1651), [#1684](https://github.com/binwiederhier/ntfy/issues/1684), thanks to [@Pulsar7](https://github.com/Pulsar7) and [@rzhli](https://github.com/rzhli) for reporting) diff --git a/server/server_webpush.go b/server/server_webpush.go index f98b8e91..70f34669 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "regexp" - "strings" "github.com/SherClockHolmes/webpush-go" "heckel.io/ntfy/v2/log" @@ -24,32 +23,35 @@ const ( webPushTopicSubscribeLimit = 50 ) -var ( - webPushAllowedEndpointsPatterns = []string{ - "https://*.google.com/", - "https://*.googleapis.com/", - "https://*.mozilla.com/", - "https://*.mozaws.net/", - "https://*.windows.com/", - "https://*.microsoft.com/", - "https://*.apple.com/", - } - webPushAllowedEndpointsRegex *regexp.Regexp -) +// webPushAllowedEndpointsRegexes is the host-level allow-list of web push services ntfy +// will deliver to. Each regex anchors the scheme and matches the stable service host, +// followed by the authority/path boundary "/". Instance-specific labels (e.g. the +// "wns2-" prefix on Windows Notification Service hosts) are wildcarded with +// a single-label pattern ([^/]+) that cannot span into the path. +// See GHSA-w9hq-5jg7-q4j7 for why wildcarding the entire host is insufficient. +var webPushAllowedEndpointsRegexes = []*regexp.Regexp{ + regexp.MustCompile(`^https://fcm\.googleapis\.com/`), + regexp.MustCompile(`^https://jmt17\.google\.com/`), + regexp.MustCompile(`^https://updates\.push\.services\.mozilla\.com/`), + regexp.MustCompile(`^https://[^/]+\.mozaws\.net/`), + regexp.MustCompile(`^https://web\.push\.apple\.com/`), + regexp.MustCompile(`^https://[^/]+\.notify\.windows\.com/`), +} -func init() { - for i, pattern := range webPushAllowedEndpointsPatterns { - webPushAllowedEndpointsPatterns[i] = strings.ReplaceAll(strings.ReplaceAll(pattern, ".", "\\."), "*", ".+") +func webPushEndpointAllowed(endpoint string) bool { + for _, re := range webPushAllowedEndpointsRegexes { + if re.MatchString(endpoint) { + return true + } } - allPatterns := fmt.Sprintf("^(%s)", strings.Join(webPushAllowedEndpointsPatterns, "|")) - webPushAllowedEndpointsRegex = regexp.MustCompile(allPatterns) + return false } func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { req, err := readJSONWithLimit[apiWebPushUpdateSubscriptionRequest](r.Body, jsonBodyBytesLimit, false) if err != nil || req.Endpoint == "" || req.P256dh == "" || req.Auth == "" { return errHTTPBadRequestWebPushSubscriptionInvalid - } else if !webPushAllowedEndpointsRegex.MatchString(req.Endpoint) { + } else if !webPushEndpointAllowed(req.Endpoint) { return errHTTPBadRequestWebPushEndpointUnknown } else if len(req.Topics) > webPushTopicSubscribeLimit { return errHTTPBadRequestWebPushTopicCountTooHigh diff --git a/server/server_webpush_test.go b/server/server_webpush_test.go index bba13db4..e69da16d 100644 --- a/server/server_webpush_test.go +++ b/server/server_webpush_test.go @@ -87,6 +87,78 @@ func TestServer_WebPush_TopicAdd_InvalidEndpoint(t *testing.T) { }) } +func TestServer_WebPush_EndpointRegex(t *testing.T) { + // Synthetic endpoint samples representing each supported push service host shape. + allowed := []string{ + // Google FCM (legacy send, webpush, preprod webpush) + "https://fcm.googleapis.com/fcm/send/FAKETOKEN:APA91b-placeholder-not-a-real-token", + "https://fcm.googleapis.com/wp/FAKETOKEN:APA91b-placeholder-not-a-real-token", + "https://fcm.googleapis.com/preprod/wp/FAKETOKEN:APA91b-placeholder-not-a-real-token", + "https://jmt17.google.com/fcm/send/FAKETOKEN:APA91b-placeholder-not-a-real-token", + // Mozilla autopush (v1 legacy, v2 current, plus AWS-hosted infra) + "https://updates.push.services.mozilla.com/wpush/v1/placeholder-not-a-real-token", + "https://updates.push.services.mozilla.com/wpush/v2/placeholder-not-a-real-token", + "https://autopush.mozaws.net/wpush/v1/placeholder-not-a-real-token", + // Apple Web Push + "https://web.push.apple.com/placeholder-not-a-real-token", + // Microsoft WNS: instance-specific "wns2-" prefix is wildcarded + "https://wns2-bn3p.notify.windows.com/w/?token=placeholder", + "https://wns2-ch1p.notify.windows.com/w/?token=placeholder", + "https://wns2-par02p.notify.windows.com/w/?token=placeholder", + "https://wns2-pn1p.notify.windows.com/w/?token=placeholder", + "https://wns2-am3p.notify.windows.com/w/?token=placeholder", + } + denied := []string{ + // HTTP (not HTTPS) + "http://fcm.googleapis.com/fcm/send/abc", + // Unrelated host + "https://attacker.example.com/webpush", + // GHSA-w9hq-5jg7-q4j7 bypass: allowed host embedded in path + "https://attacker.com/x.google.com/push", + "https://attacker.example.com/fcm.googleapis.com/fcm/send/abc", + "https://evil.test/web.push.apple.com/3/device/abc", + "https://ntfytest.requestcatcher.com/path.google.com/push", + "https://ntfytest.requestcatcher.com/a.google.com/toto", + "https://ntfytest.requestcatcher.com/bypass.google.com/test", + "https://webhook.site/86e94e2e-2af4-4a31-a80b-e2f335cc6495/path.google.com/push", + "https://webhook.site/86e94e2e-2af4-4a31-a80b-e2f335cc6495/bypass.google.com/", + // Allowed host as a prefix of a different host (no separating slash) + "https://fcm.googleapis.com.attacker.com/fcm/send/abc", + "https://web.push.apple.com.evil.test/tok", + // Allowed host as a suffix of a different host (no separating dot) + "https://evilgoogle.com/", + "https://notapple.com/", + // Credentials/userinfo in the URL pointing at a different host + "https://fcm.googleapis.com@attacker.com/fcm/send/abc", + // Previously allowed by the wildcard allowlist but not actually used by Web Push + "https://api.push.apple.com/3/device/abc", + "https://android.googleapis.com/send/xyz", + "https://login.microsoft.com/anything", + // Bare notify.windows.com with no subdomain label + "https://notify.windows.com/w/?token=abc", + } + for _, endpoint := range allowed { + require.Truef(t, webPushEndpointAllowed(endpoint), "expected endpoint to be allowed: %s", endpoint) + } + for _, endpoint := range denied { + require.Falsef(t, webPushEndpointAllowed(endpoint), "expected endpoint to be denied: %s", endpoint) + } +} + +func TestServer_WebPush_TopicAdd_BypassAttempt(t *testing.T) { + // Regression test for GHSA-w9hq-5jg7-q4j7: the allow-list regex previously had no + // end anchor, so a URL like https://attacker.example.com/x.google.com/... passed + // validation and caused the server to deliver push payloads to attacker-controlled + // endpoints (SSRF + message exfiltration via attacker-supplied p256dh key). + forEachBackend(t, func(t *testing.T, databaseURL string) { + s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) + + response := request(t, s, "POST", "/v1/webpush", payloadForTopics(t, []string{"test-topic"}, "https://attacker.example.com/x.google.com/push"), nil) + require.Equal(t, 400, response.Code) + require.Equal(t, `{"code":40039,"http":400,"error":"invalid request: web push endpoint unknown"}`+"\n", response.Body.String()) + }) +} + func TestServer_WebPush_TopicAdd_TooManyTopics(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { s := newTestServer(t, newTestConfigWithWebPush(t, databaseURL)) From 2401e183d2fd4763902a2c437cd896be939a4e66 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 21 Apr 2026 10:49:24 -0400 Subject: [PATCH 102/126] Bump --- docs/install.md | 76 ++++---- go.mod | 50 +++--- go.sum | 116 ++++++------ web/package-lock.json | 410 +++++++++++++++++++++++------------------- 4 files changed, 345 insertions(+), 307 deletions(-) diff --git a/docs/install.md b/docs/install.md index b57d522d..ae50065e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -34,37 +34,37 @@ as a service starting at boot time. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.tar.gz - tar zxvf ntfy_2.21.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.21.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_amd64.tar.gz + tar zxvf ntfy_2.22.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.22.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.22.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.tar.gz - tar zxvf ntfy_2.21.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.21.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv6.tar.gz + tar zxvf ntfy_2.22.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.22.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.22.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.tar.gz - tar zxvf ntfy_2.21.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.21.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv7.tar.gz + tar zxvf ntfy_2.22.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.22.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.22.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.tar.gz - tar zxvf ntfy_2.21.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.21.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.21.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_arm64.tar.gz + tar zxvf ntfy_2.22.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.22.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.22.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic === "x86_64/amd64" ```bash - sudo mv ntfy_2.21.0_linux_amd64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.22.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 mv ntfy_2.22.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 mv ntfy_2.22.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 mv ntfy_2.22.0_linux_arm64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` @@ -118,25 +118,25 @@ 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 mv ntfy_2.22.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 mv ntfy_2.22.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 mv ntfy_2.22.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 mv ntfy_2.22.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` @@ -204,7 +204,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -212,7 +212,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -220,7 +220,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -228,7 +228,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -238,28 +238,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -301,18 +301,18 @@ pkg install go-ntfy ## macOS 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.21.0/ntfy_2.21.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_darwin_all.tar.gz), 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 `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -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.21.0_darwin_all.tar.gz -sudo cp -a ntfy_2.21.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_darwin_all.tar.gz > ntfy_2.22.0_darwin_all.tar.gz +tar zxvf ntfy_2.22.0_darwin_all.tar.gz +sudo cp -a ntfy_2.22.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.21.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.22.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -333,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. To install, you can either -* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.21.0/ntfy_2.21.0_windows_amd64.zip), +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_windows_amd64.zip), 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` diff --git a/go.mod b/go.mod index 714c2ec7..495d0267 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,23 @@ module heckel.io/ntfy/v2 go 1.25.0 require ( - cloud.google.com/go/firestore v1.21.0 // indirect - cloud.google.com/go/storage v1.61.3 // indirect + cloud.google.com/go/firestore v1.22.0 // indirect + cloud.google.com/go/storage v1.62.1 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/emersion/go-smtp v0.18.0 github.com/gabriel-vasile/mimetype v1.4.13 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.40 + github.com/mattn/go-sqlite3 v1.14.42 github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.50.0 golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 golang.org/x/time v0.15.0 - google.golang.org/api v0.274.0 + google.golang.org/api v0.276.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -30,27 +30,27 @@ require github.com/pkg/errors v0.9.1 // indirect require ( firebase.google.com/go/v4 v4.19.0 github.com/SherClockHolmes/webpush-go v1.4.0 - github.com/jackc/pgx/v5 v5.9.1 + github.com/jackc/pgx/v5 v5.9.2 github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/sys v0.42.0 - golang.org/x/text v0.35.0 + golang.org/x/sys v0.43.0 + golang.org/x/text v0.36.0 ) require ( cel.dev/expr v0.25.1 // indirect cloud.google.com/go v0.123.0 // indirect - cloud.google.com/go/auth v0.19.0 // indirect + cloud.google.com/go/auth v0.20.0 // 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/iam v1.7.0 // indirect - cloud.google.com/go/longrunning v0.9.0 // indirect - cloud.google.com/go/monitoring v1.25.0 // indirect + cloud.google.com/go/iam v1.9.0 // indirect + cloud.google.com/go/longrunning v0.11.0 // indirect + cloud.google.com/go/monitoring v1.27.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -69,8 +69,8 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect - github.com/googleapis/gax-go/v2 v2.21.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -86,20 +86,20 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/net v0.53.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d5dbf009..3d0ad875 100644 --- a/go.sum +++ b/go.sum @@ -2,40 +2,40 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= 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/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.19.0 h1:DGYwtbcsGsT1ywuxsIoWi1u/vlks0moIblQHgSDgQkQ= -cloud.google.com/go/auth v0.19.0/go.mod h1:2Aph7BT2KnaSFOM0JDPyiYgNh6PL9vGMiP8CUIXZ+IY= +cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= 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/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= 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/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4= -cloud.google.com/go/iam v1.7.0 h1:JD3zh0C6LHl16aCn5Akff0+GELdp1+4hmh6ndoFLl8U= -cloud.google.com/go/iam v1.7.0/go.mod h1:tetWZW1PD/m6vcuY2Zj/aU0eCHNPuxedbnbRTyKXvdY= -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/longrunning v0.9.0 h1:0EzbDEGsAvOZNbqXopgniY0w0a1phvu5IdUFq8grmqY= -cloud.google.com/go/longrunning v0.9.0/go.mod h1:pkTz846W7bF4o2SzdWJ40Hu0Re+UoNT6Q5t+igIcb8E= -cloud.google.com/go/monitoring v1.25.0 h1:HnsTIOxTN6BCSkt1P/Im23r1m7MHTTpmSYCzPkW7NK4= -cloud.google.com/go/monitoring v1.25.0/go.mod h1:wlj6rX+JGyusw/8+2duW4cJ6kmDHGmde3zMTJuG3Jpc= -cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg= -cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk= -cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U= -cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= +cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= +cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= +cloud.google.com/go/iam v1.9.0 h1:89wyjxT6DL4b5rk/Nk8eBC9DHqf+JiMstrn5IEYxFw4= +cloud.google.com/go/iam v1.9.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/logging v1.16.0 h1:MMNgYRvZ/pEwiNSkcoJTKWfAbAJDqCqAMJiarZx+/CI= +cloud.google.com/go/logging v1.16.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= +cloud.google.com/go/longrunning v0.11.0 h1:fE4XVLJQj+gRnw1HrbDyQXXgC0aiqY3wxP7DDU4cWk0= +cloud.google.com/go/longrunning v0.11.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/monitoring v1.27.0 h1:BhYwMqao+e5Nn7JtWMM9m6zRtKtVUK6kJWMizXChkLU= +cloud.google.com/go/monitoring v1.27.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= +cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= +cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= +cloud.google.com/go/trace v1.14.0 h1:jUtnmOrNcu5XJNk4Gz0fv+v5sM0weaOa3z5MPQUjRXs= +cloud.google.com/go/trace v1.14.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 h1:O2sXMyJh8b7devAGdE+163xtRurt0RVpB6DIzX5vGfg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0/go.mod h1:hEpiGU18xf70qb3jbTcIggWAiEfX/cOIVc2OTe4OegA= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0 h1:ZIT85vKP7LBS84XJ0WdJ3dPOX3iz4j3c0+lpajGQMyo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.56.0/go.mod h1:rqP9UEhOXv9WhQ7Gjz+G5y/pf8+BJZW5/Ts0AhE0PwE= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0 h1:0YP0+/ixwu+Uqeu/FGiBZNQ19huiUxxiPXIc9WsLKuQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.56.0/go.mod h1:6ZZMQhZKDvUvkJw2rc+oDP90tMMzuU/J+5HG1ZmPOmE= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= @@ -96,10 +96,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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/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/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI= -github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4= +github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= 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/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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-sqlite3 v1.14.40 h1:f7+saIsbq4EF86mUqe0uiecQOJYMOdfi5uATADmUG94= -github.com/mattn/go-sqlite3 v1.14.40/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -165,16 +165,16 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= -go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU= +go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= -go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= @@ -193,8 +193,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -209,8 +209,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -234,8 +234,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -245,8 +245,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -258,8 +258,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -272,16 +272,16 @@ 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= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA= -google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew= +google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= +google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= 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/genproto v0.0.0-20260401024825-9d38bb4040a9 h1:w8JYjr7zHemS95YA5FFwk+fUv5tdQU4I8twN9bFdxVU= -google.golang.org/genproto v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:YCEC8W7HTtK7iBv+pI7g7hGAi7qdGB6bQXw3BIYAusM= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= -google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto v0.0.0-20260420184626-e10c466a9529 h1:QoMBg0moLIlB/eucPzc+ID5SgPZWuirtjAn3l8nW2Dg= +google.golang.org/genproto v0.0.0-20260420184626-e10c466a9529/go.mod h1:EjLmDZ8liSLBrCTK5vP+bGIxRQHE3ovGvOI0CzGk1PI= +google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 h1:zUWMZsvo/IJcD1t6MNCPO/azZTwz0TvwCBqr5aifoVY= +google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529/go.mod h1:a5OGAgyRr4lqco7AG9hQM9Fwh0N2ZV4grR0eXFEsXQg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= diff --git a/web/package-lock.json b/web/package-lock.json index 20f4de95..19a7c649 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,7 +12,7 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", - "@mui/material": "*", + "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", "humanize-duration": "^3.27.3", @@ -20,8 +20,8 @@ "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^1.4.0", "js-base64": "^3.7.2", - "react": "*", - "react-dom": "*", + "react": "latest", + "react-dom": "latest", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-remark": "^2.1.0", @@ -2738,9 +2738,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", "cpu": [ "arm" ], @@ -2752,9 +2752,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", "cpu": [ "arm64" ], @@ -2766,9 +2766,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", "cpu": [ "arm64" ], @@ -2780,9 +2780,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", "cpu": [ "x64" ], @@ -2794,9 +2794,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", "cpu": [ "arm64" ], @@ -2808,9 +2808,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", "cpu": [ "x64" ], @@ -2822,13 +2822,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2836,13 +2839,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2850,13 +2856,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2864,13 +2873,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2878,13 +2890,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2892,13 +2907,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2906,13 +2924,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2920,13 +2941,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2934,13 +2958,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2948,13 +2975,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2962,13 +2992,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2976,13 +3009,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2990,13 +3026,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3004,9 +3043,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", "cpu": [ "x64" ], @@ -3018,9 +3057,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", "cpu": [ "arm64" ], @@ -3032,9 +3071,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", "cpu": [ "arm64" ], @@ -3046,9 +3085,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", "cpu": [ "ia32" ], @@ -3060,9 +3099,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", "cpu": [ "x64" ], @@ -3074,9 +3113,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", "cpu": [ "x64" ], @@ -3548,9 +3587,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.2", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.2.tgz", - "integrity": "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==", + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", + "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3642,9 +3681,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.14.tgz", - "integrity": "sha512-fOVLPAsFTsQfuCkvahZkzq6nf8KvGWanlYoTh0SVA0A/PIUxQGU2AOZAoD95n2gFLVDW/jP6sbGLny95nmEuHA==", + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3655,9 +3694,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -3707,15 +3746,15 @@ "license": "MIT" }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -3766,9 +3805,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", "dev": true, "funding": [ { @@ -4203,9 +4242,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "version": "1.5.341", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.341.tgz", + "integrity": "sha512-1sZTssferjgDgaqRTc0ieP+ozzpOy7LQTPTtEW3yQFn4+ORdIAZWV5BthXPyHF7YqLvFJCUPhNhdAJQYlYUgiw==", "dev": true, "license": "ISC" }, @@ -4235,9 +4274,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -4317,23 +4356,22 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, "node_modules/es-iterator-helpers": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", - "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", @@ -4345,8 +4383,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0", - "safe-array-concat": "^1.1.3" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -5029,9 +5066,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", - "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -5468,9 +5505,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6312,9 +6349,9 @@ } }, "node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", "dependencies": { @@ -7060,9 +7097,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7108,9 +7145,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", "dev": true, "funding": [ { @@ -7247,24 +7284,24 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.5" } }, "node_modules/react-i18next": { @@ -7302,9 +7339,9 @@ } }, "node_modules/react-is": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", - "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", "license": "MIT" }, "node_modules/react-refresh": { @@ -7477,9 +7514,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", - "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -7540,11 +7577,12 @@ } }, "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "license": "MIT", "dependencies": { + "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" @@ -7597,9 +7635,9 @@ } }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7613,31 +7651,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", "fsevents": "~2.3.2" } }, @@ -7666,15 +7704,15 @@ } }, "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" }, @@ -7860,14 +7898,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -8239,9 +8277,9 @@ } }, "node_modules/stylis": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", - "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, "node_modules/stylis-plugin-rtl": { @@ -8359,14 +8397,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" From 5ad2431dc38d2eb2512bf3d90a4b81ff89e57dc1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 21 Apr 2026 11:01:17 -0400 Subject: [PATCH 103/126] Bump --- docs/releases.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index cb9a6958..088b2a3a 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,21 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.21.0 | Mar 30, 2026 | +| ntfy server | v2.22.0 | Apr 21, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy iOS app | v1.3 | Nov 26, 2023 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy server v2.22.0 +Released April 21, 2026 + +**Bug fixes + maintenance:** + +* Tighten web push endpoint allow-list regex to prevent SSRF via unanchored pattern matching ([GHSA-w9hq-5jg7-q4j7](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-w9hq-5jg7-q4j7), thanks to [@MightyNawaf](https://github.com/MightyNawaf) for reporting) +* Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing) +* Fix web app crashing on account page for tokens without a last access time ([#1651](https://github.com/binwiederhier/ntfy/issues/1651), [#1684](https://github.com/binwiederhier/ntfy/issues/1684), thanks to [@Pulsar7](https://github.com/Pulsar7) and [@rzhli](https://github.com/rzhli) for reporting) + ### ntfy server v2.21.0 Released March 30, 2026 @@ -1879,11 +1888,3 @@ This is the first iOS release in 3 years, focusing on stability fixes as per the * Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution) * Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution) - -## ntfy server v2.21.x (UNRELEASED) - -**Bug fixes + maintenance:** - -* Tighten web push endpoint allow-list regex to prevent SSRF via unanchored pattern matching ([GHSA-w9hq-5jg7-q4j7](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-w9hq-5jg7-q4j7), thanks to [@MightyNawaf](https://github.com/MightyNawaf) for reporting) -* Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing) -* Fix web app crashing on account page for tokens without a last access time ([#1651](https://github.com/binwiederhier/ntfy/issues/1651), [#1684](https://github.com/binwiederhier/ntfy/issues/1684), thanks to [@Pulsar7](https://github.com/Pulsar7) and [@rzhli](https://github.com/rzhli) for reporting) From fe8c9b8f2cd88165489e6e614266316c103ed85b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:43:47 +0000 Subject: [PATCH 104/126] Bump i18next-http-backend from 1.4.5 to 3.0.5 in /web Bumps [i18next-http-backend](https://github.com/i18next/i18next-http-backend) from 1.4.5 to 3.0.5. - [Changelog](https://github.com/i18next/i18next-http-backend/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next-http-backend/compare/v1.4.5...v3.0.5) --- updated-dependencies: - dependency-name: i18next-http-backend dependency-version: 3.0.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- web/package-lock.json | 69 ++++++++++--------------------------------- web/package.json | 2 +- 2 files changed, 16 insertions(+), 55 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 19a7c649..50804f89 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,16 +12,16 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", - "@mui/material": "latest", + "@mui/material": "*", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", "humanize-duration": "^3.27.3", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", - "i18next-http-backend": "^1.4.0", + "i18next-http-backend": "^3.0.5", "js-base64": "^3.7.2", - "react": "latest", - "react-dom": "latest", + "react": "*", + "react-dom": "*", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-remark": "^2.1.0", @@ -2829,9 +2829,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2846,9 +2843,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2863,9 +2857,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2880,9 +2871,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2897,9 +2885,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2914,9 +2899,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2931,9 +2913,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2948,9 +2927,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2965,9 +2941,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2982,9 +2955,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2999,9 +2969,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3016,9 +2983,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3033,9 +2997,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3988,12 +3949,12 @@ } }, "node_modules/cross-fetch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", - "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", "license": "MIT", "dependencies": { - "node-fetch": "2.6.7" + "node-fetch": "^2.7.0" } }, "node_modules/cross-spawn": { @@ -5601,12 +5562,12 @@ } }, "node_modules/i18next-http-backend": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.4.5.tgz", - "integrity": "sha512-tLuHWuLWl6CmS07o+UB6EcQCaUjrZ1yhdseIN7sfq0u7phsMePJ8pqlGhIAdRDPF/q7ooyo5MID5DRFBCH+x5w==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz", + "integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==", "license": "MIT", "dependencies": { - "cross-fetch": "3.1.5" + "cross-fetch": "4.1.0" } }, "node_modules/idb": { @@ -6762,9 +6723,9 @@ } }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" diff --git a/web/package.json b/web/package.json index eb9d2cdc..f40bba69 100644 --- a/web/package.json +++ b/web/package.json @@ -21,7 +21,7 @@ "humanize-duration": "^3.27.3", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", - "i18next-http-backend": "^1.4.0", + "i18next-http-backend": "^3.0.5", "js-base64": "^3.7.2", "react": "latest", "react-dom": "latest", From 4fbb8441ee75042e2ccb50099e7e9048833a135b Mon Sep 17 00:00:00 2001 From: Gringo Date: Thu, 23 Apr 2026 22:40:18 +0200 Subject: [PATCH 105/126] Translated using Weblate (Italian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/it/ --- web/public/static/langs/it.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/public/static/langs/it.json b/web/public/static/langs/it.json index b0786419..cf2ad718 100644 --- a/web/public/static/langs/it.json +++ b/web/public/static/langs/it.json @@ -307,7 +307,7 @@ "account_delete_dialog_label": "Password", "account_upgrade_dialog_tier_features_no_reservations": "Nessun argomento riservato", "account_upgrade_dialog_tier_features_messages_one": "{{messages}} messaggi giornalieri", - "account_upgrade_dialog_reservations_warning_one": "Il livello selezionato consente meno argomenti riservati rispetto al livello corrente. Prima di cambiare il livello, si prega di eliminare almeno una prenotazione. È possibile rimuovere le prenotazioni nel Impostazioni.", + "account_upgrade_dialog_reservations_warning_one": "Il livello selezionato consente meno argomenti riservati rispetto al livello corrente. Prima di cambiare il livello, si prega di eliminare almeno una prenotazione. È possibile rimuovere le prenotazioni nel Impostazioni.", "alert_notification_permission_denied_title": "Le notifiche sono bloccate", "alert_notification_permission_denied_description": "Per favore riabilitale nel tuo browser", "subscribe_dialog_subscribe_use_another_background_info": "Le notifiche dagli altri server non saranno ricevute quando la web app non è in esecuzione", From 8fb7f61dea77e5a3a125084a34bb349008e7c5f0 Mon Sep 17 00:00:00 2001 From: Jithin James Date: Fri, 24 Apr 2026 06:32:51 +0200 Subject: [PATCH 106/126] Added translation using Weblate (Malayalam) --- web/public/static/langs/ml.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/ml.json diff --git a/web/public/static/langs/ml.json b/web/public/static/langs/ml.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/ml.json @@ -0,0 +1 @@ +{} From 33a67cf05b855fa32441ef29e0e89920b4df6dbc Mon Sep 17 00:00:00 2001 From: Ryan02I5 <209603985+Ryan02I5@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:22:26 +0900 Subject: [PATCH 107/126] Add OCI Notifications ntfy relay integration --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index a23a61be..eeb1ca36 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -107,6 +107,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [ntfy-long-zsh-command](https://github.com/robfox92/ntfy-long-zsh-command) - Notifies you once a long-running command completes (zsh) - [ntfy-shellscripts](https://github.com/nickexyz/ntfy-shellscripts) - A few scripts for the ntfy project (Shell) - [alertmanager-ntfy-relay](https://github.com/therobbielee/alertmanager-ntfy-relay) - ntfy.sh relay for Alertmanager (Go) +- [oci-notifications-ntfy-relay](https://github.com/Ryan02I5/oci-notifications-ntfy-relay) - Minimal OCI Notifications / Oracle Functions relay to ntfy topics (Python) - [QuickStatus](https://github.com/corneliusroot/QuickStatus) - A shell script to alert to any immediate problems upon login (Shell) - [ntfy.el](https://github.com/shombando/ntfy) - Send notifications from Emacs (Emacs) - [backup-projects](https://gist.github.com/anthonyaxenov/826ba65abbabd5b00196bc3e6af76002) - Stupidly simple backup script for own projects (Shell) From 802c0a4c303d76db911d5f83c17320651efdf84d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Mon, 27 Apr 2026 16:49:33 -0400 Subject: [PATCH 108/126] Bump --- docs/releases.md | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 088b2a3a..5caa3c12 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,10 +8,29 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release |------------------|---------|--------------| | ntfy server | v2.22.0 | Apr 21, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | -| ntfy iOS app | v1.3 | Nov 26, 2023 | +| ntfy iOS app | v1.5.1 | Apr 27, 2026 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy iOS app v1.5.1 +Released April 27, 2026 + +This release continues the iOS stability push from v1.4.1, with improved background polling reliability, better error +handling and logging, and a few small UI fixes. The minimum supported iOS version is now iOS 15. + +**Features:** + +* Bump minimum iOS version to iOS 15 ([ntfy-ios#36](https://github.com/binwiederhier/ntfy-ios/pull/36), thanks to [@am7590](https://github.com/am7590) for the contribution) + +**Bug fixes + maintenance:** + +* Improve background poll reliability by waiting for polls to finish before calling the fetch completion handler, and saving notifications on the Core Data context queue ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Make `poll_request` parsing more tolerant and surface concrete poll errors instead of failing silently ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Poll subscriptions when the subscribed topics list appears, for more reactive updates ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Fix bug where tapping "Add user" a second time would briefly open and then dismiss the add user view ([ntfy-ios#35](https://github.com/binwiederhier/ntfy-ios/pull/35), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Split `SettingsView` into separate files to improve readability ([ntfy-ios#35](https://github.com/binwiederhier/ntfy-ios/pull/35), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Add Firebase subscribe/unsubscribe logging to aid debugging ([ntfy-ios#34](https://github.com/binwiederhier/ntfy-ios/pull/34), thanks to [@am7590](https://github.com/am7590) for the contribution) + ## ntfy server v2.22.0 Released April 21, 2026 @@ -21,6 +40,16 @@ Released April 21, 2026 * Fix web app not allowing access tokens to be changed to never expire ([#1693](https://github.com/binwiederhier/ntfy/issues/1693)/[#1694](https://github.com/binwiederhier/ntfy/pull/1694), thanks to [@lastsamurai26](https://github.com/lastsamurai26) for reporting and to [@ShipItAndPray](https://github.com/ShipItAndPray) for fixing) * Fix web app crashing on account page for tokens without a last access time ([#1651](https://github.com/binwiederhier/ntfy/issues/1651), [#1684](https://github.com/binwiederhier/ntfy/issues/1684), thanks to [@Pulsar7](https://github.com/Pulsar7) and [@rzhli](https://github.com/rzhli) for reporting) +## ntfy iOS app v1.4.1 +Released April 14, 2026 + +This is the first iOS release in 3 years, focusing on stability fixes as per the [iOS improvement plan](https://github.com/binwiederhier/ntfy/issues/1680). + +**Bug fixes + maintenance:** + +* Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution) + ### ntfy server v2.21.0 Released March 30, 2026 @@ -1879,12 +1908,3 @@ especially when paired with increaseing the server-side `keepalive-interval` in * Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution) * Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting) - -## ntfy iOS app v1.4.1 (UNRELEASED) - -This is the first iOS release in 3 years, focusing on stability fixes as per the [iOS improvement plan](https://github.com/binwiederhier/ntfy/issues/1680). - -**Bug fixes + maintenance:** - -* Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution) -* Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution) From a5cf3c0b74cc9e7552f2b7d9da232e44842cbc66 Mon Sep 17 00:00:00 2001 From: Brekit Date: Thu, 30 Apr 2026 23:31:11 +0200 Subject: [PATCH 109/126] Added translation using Weblate (Belarusian) --- web/public/static/langs/be.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/be.json diff --git a/web/public/static/langs/be.json b/web/public/static/langs/be.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/be.json @@ -0,0 +1 @@ +{} From 8f2f69a5126add476688032873ea3620b9523311 Mon Sep 17 00:00:00 2001 From: Darjan Zlobec Date: Mon, 4 May 2026 22:50:48 +0200 Subject: [PATCH 110/126] Added translation using Weblate (Slovenian) --- web/public/static/langs/sl.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 web/public/static/langs/sl.json diff --git a/web/public/static/langs/sl.json b/web/public/static/langs/sl.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/web/public/static/langs/sl.json @@ -0,0 +1 @@ +{} From c037e78bd6a1c8f14725e7cb2d63c369d7b7f2c7 Mon Sep 17 00:00:00 2001 From: Darjan Zlobec Date: Mon, 4 May 2026 23:02:20 +0200 Subject: [PATCH 111/126] Translated using Weblate (Slovenian) Currently translated at 0.4% (2 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sl/ --- web/public/static/langs/sl.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/sl.json b/web/public/static/langs/sl.json index 0967ef42..5628f12e 100644 --- a/web/public/static/langs/sl.json +++ b/web/public/static/langs/sl.json @@ -1 +1,4 @@ -{} +{ + "common_cancel": "Prekliči", + "common_save": "Shrani" +} From 1af07233a9db51470b38aa473350ba8874d7e7df Mon Sep 17 00:00:00 2001 From: Darjan Zlobec Date: Mon, 4 May 2026 23:40:05 +0200 Subject: [PATCH 112/126] Translated using Weblate (Slovenian) Currently translated at 28.0% (114 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/sl/ --- web/public/static/langs/sl.json | 114 +++++++++++++++++++++++++++++++- 1 file changed, 113 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/sl.json b/web/public/static/langs/sl.json index 5628f12e..b13208c1 100644 --- a/web/public/static/langs/sl.json +++ b/web/public/static/langs/sl.json @@ -1,4 +1,116 @@ { "common_cancel": "Prekliči", - "common_save": "Shrani" + "common_save": "Shrani", + "common_add": "Dodaj", + "common_back": "Nazaj", + "common_copy_to_clipboard": "Kopiraj v odložišče", + "signup_title": "Ustvari ntfy račun", + "signup_form_username": "Uporabniško ime", + "signup_form_password": "Geslo", + "signup_form_confirm_password": "Potrditev gesla", + "signup_form_button_submit": "Registracija", + "signup_form_toggle_password_visibility": "Prikaži geslo", + "signup_already_have_account": "Že imate račun? Prijavite se!", + "signup_disabled": "Registracija je onemogočena", + "signup_error_username_taken": "Uporabniško ime {{username}} je zasedeno", + "signup_error_creation_limit_reached": "Omejitev registracije novih računov je presežena", + "login_title": "Prijava v vaš ntfy račun", + "login_form_button_submit": "Prijava", + "login_link_signup": "Registracija", + "login_disabled": "Prijava je onemogočena", + "action_bar_show_menu": "Prikaži menu", + "action_bar_logo_alt": "ntfy logotip", + "action_bar_settings": "Nastavitve", + "action_bar_account": "Račun", + "action_bar_change_display_name": "Spremenite prikazno ime", + "action_bar_reservation_add": "Rezerviraj temo", + "action_bar_reservation_edit": "Spremenite rezervacijo", + "action_bar_reservation_delete": "Odstranite rezervacijo", + "action_bar_reservation_limit_reached": "Omejitev dosežena", + "action_bar_send_test_notification": "Pošljite testno obvestilo", + "action_bar_clear_notifications": "Počistite vsa obvestila", + "action_bar_mute_notifications": "Izklopite zvok obvestil", + "action_bar_unmute_notifications": "Vklopite zvok obvestil", + "action_bar_unsubscribe": "Odjava", + "action_bar_toggle_mute": "Vklopite/izklopite zvok obvestil", + "action_bar_toggle_action_menu": "Odprite/zaprite akcijski menu", + "action_bar_profile_title": "Profil", + "action_bar_profile_settings": "Nastavitve", + "action_bar_profile_logout": "Odjavite se", + "action_bar_sign_in": "Prijavite se", + "action_bar_sign_up": "Registrirajte se", + "message_bar_type_message": "Vpišite sporočilo", + "message_bar_error_publishing": "Napaka pri objavi obvestila", + "message_bar_show_dialog": "Prikaži pogovorno okno za objavo", + "message_bar_publish": "Objavi sporočilo", + "nav_topics_title": "Naročene teme", + "nav_button_all_notifications": "Vsa obvestila", + "nav_button_account": "Račun", + "nav_button_settings": "Nastavitve", + "nav_button_documentation": "Dokumentacija", + "nav_button_publish_message": "Objavi obvestilo", + "publish_dialog_title_no_topic": "Objavi obvestilo", + "publish_dialog_progress_uploading": "Nalaganje …", + "publish_dialog_progress_uploading_detail": "Nalaganje {{loaded}}/{{total}} ({{percent}}%) …", + "publish_dialog_message_published": "Obvestilo objavljeno", + "publish_dialog_attachment_limits_file_and_quota_reached": "presega {{fileSizeLimit}} omejitev velikosti datoteke in kvote, {{remainingBytes}} še preostalo", + "publish_dialog_attachment_limits_file_reached": "presega {{fileSizeLimit}} omejitev velikosti datoteke", + "publish_dialog_attachment_limits_quota_reached": "presega kvoto, {{remainingBytes}} še preostalo", + "publish_dialog_emoji_picker_show": "Izberite emoji", + "publish_dialog_priority_min": "Najnižja prioriteta", + "publish_dialog_priority_low": "Nizka prioriteta", + "publish_dialog_priority_default": "Privzeta prioriteta", + "publish_dialog_priority_high": "Visoka prioriteta", + "publish_dialog_priority_max": "Najvišja prioriteta", + "publish_dialog_base_url_label": "URL storitve", + "publish_dialog_base_url_placeholder": "URL storitve, npr. https://example.com", + "publish_dialog_topic_label": "Naziv teme", + "publish_dialog_topic_placeholder": "Naziv teme, npr. janez_alarmi", + "publish_dialog_topic_reset": "Ponastavitev temo", + "publish_dialog_title_label": "Naslov", + "publish_dialog_title_placeholder": "Naslov obvestila, npr. Primanjkuje prostora", + "publish_dialog_message_label": "Sporočilo", + "publish_dialog_message_placeholder": "Vpišite sporočilo", + "publish_dialog_tags_label": "Značke", + "publish_dialog_tags_placeholder": "Z vejico ločen seznam značk, npr. opozorilo, srv1-kopija", + "publish_dialog_priority_label": "Prioriteta", + "publish_dialog_click_label": "URL za klik", + "publish_dialog_click_placeholder": "URL ki se odpre, ko kliknete na obvestilo", + "publish_dialog_click_reset": "Odstranite URL za klik", + "publish_dialog_email_label": "E-naslov", + "publish_dialog_email_placeholder": "E-naslov za posredovanje obvestil, npr. janez@example.com", + "publish_dialog_email_reset": "Odstranite e-naslov za posredovanje", + "publish_dialog_call_label": "Telefonski klic", + "publish_dialog_call_item": "Pokličite telefonsko številko {{number}}", + "publish_dialog_call_reset": "Odstranite telefonski klic", + "publish_dialog_attach_label": "URL priponke", + "publish_dialog_attach_placeholder": "Pripnite datoteko preko URL povezave, npr. https://f-droid.org/F-Droid.apk", + "publish_dialog_attach_reset": "Odstranite URL priponke", + "publish_dialog_filename_label": "Ime datoteke", + "publish_dialog_filename_placeholder": "Ime priponke", + "publish_dialog_delay_label": "Zamik", + "publish_dialog_delay_placeholder": "Zamik dostave, npr. {{unixTimestamp}}, {{relativeTime}}, ali \"{{naturalLanguage}}\" (v angleškem jeziku)", + "publish_dialog_delay_reset": "Odstranite zamik dostave", + "publish_dialog_other_features": "Ostale funkcije:", + "publish_dialog_chip_click_label": "URL za klik", + "publish_dialog_chip_email_label": "Posredujte na e-naslov", + "publish_dialog_chip_call_label": "Telefonski klic", + "publish_dialog_chip_call_no_verified_numbers_tooltip": "Ni verificiranih telefonskih številk", + "publish_dialog_chip_attach_url_label": "Pripnite datoteko preko URL", + "publish_dialog_chip_attach_file_label": "Pripnite lokalno datoteko", + "publish_dialog_chip_delay_label": "Zamik dostave", + "publish_dialog_chip_topic_label": "Spremenite temo", + "publish_dialog_details_examples_description": "Za vzorčne primere in natančnejše opise vseh funkcij pošiljanja se posvetujte z dokumentacijo.", + "publish_dialog_button_cancel_sending": "Prekličite pošiljanje", + "publish_dialog_button_cancel": "Prekliči", + "publish_dialog_button_send": "Pošlji", + "publish_dialog_checkbox_markdown": "Oblikovanje kot Markdown", + "publish_dialog_checkbox_publish_another": "Objavite še eno", + "publish_dialog_attached_file_title": "Priponka:", + "publish_dialog_attached_file_filename_placeholder": "Ime priponke", + "publish_dialog_attached_file_remove": "Odstranite priponko", + "publish_dialog_drop_file_here": "Povleci in spusti", + "emoji_picker_search_placeholder": "Išči emoji", + "emoji_picker_search_clear": "Ponastavi iskanje", + "subscribe_dialog_subscribe_title": "Naročite se na temo" } From 3af7087af3dcac2915b2d64765ea2d3af54404e0 Mon Sep 17 00:00:00 2001 From: Vadym Nekhai Date: Thu, 7 May 2026 13:31:30 +0200 Subject: [PATCH 113/126] Translated using Weblate (Ukrainian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/ --- web/public/static/langs/uk.json | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/web/public/static/langs/uk.json b/web/public/static/langs/uk.json index e3e04a19..29b4996f 100644 --- a/web/public/static/langs/uk.json +++ b/web/public/static/langs/uk.json @@ -11,7 +11,7 @@ "nav_button_muted": "Сповіщення вимкнено", "nav_button_connecting": "підключення", "alert_notification_permission_required_title": "Сповіщення вимкнено", - "alert_notification_permission_required_description": "Дозвольте браузеру показувати сповіщення.", + "alert_notification_permission_required_description": "Дозвольте браузеру показувати сповіщення на робочому столі", "alert_notification_permission_required_button": "Дозволити", "alert_not_supported_title": "Сповіщення не підтримуються", "notifications_list_item": "Сповіщення", @@ -34,11 +34,11 @@ "publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts", "publish_dialog_topic_reset": "Скинути тему", "publish_dialog_title_label": "Заголовок", - "publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад Сповіщення про дисковий простір", + "publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостатньо місця на диску", "publish_dialog_message_label": "Повідомлення", "publish_dialog_message_placeholder": "Введіть повідомлення", "publish_dialog_tags_label": "Теги", - "publish_dialog_tags_placeholder": "Список тегів розділений комою, наприклад warning, srv1-backup", + "publish_dialog_tags_placeholder": "Список тегів розділений комою, наприклад, warning, srv1-backup", "publish_dialog_click_placeholder": "URL-адреса, яка відкривається після натискання сповіщення", "publish_dialog_email_label": "Електронна пошта", "publish_dialog_attach_placeholder": "Прикріпіть файл за URL-адресою, наприклад https://f-droid.org/F-Droid.apk", @@ -300,7 +300,7 @@ "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} загальне сховище", "account_upgrade_dialog_tier_current_label": "Поточний", "account_upgrade_dialog_tier_selected_label": "Вибране", - "account_upgrade_dialog_cancel_warning": "Це скасує вашу підписку і знизить версію вашого облікового запису {{date}}. У цю дату резервування тем, а також повідомлення, кешовані на сервері , буде видалено.", + "account_upgrade_dialog_cancel_warning": "Ця дія скасує вашу підписку, і знизить версію вашого облікового запису {{date}}. Відповідно, в цю дату, резервування тем, а також повідомлення, кешовані на сервері, буде видалено.", "account_upgrade_dialog_tier_features_reservations_other": "{{reservations}} зарезервовані теми", "account_upgrade_dialog_tier_features_no_reservations": "Немає зарезервованих тем", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} повідомлень в день", @@ -397,12 +397,14 @@ "prefs_notifications_web_push_disabled_description": "Сповіщення надходитимуть якщо вебзастосунок запущений (за допомоги WebSocket)", "prefs_notifications_web_push_enabled": "Увімкнено для {{server}}", "prefs_notifications_web_push_disabled": "Вимкнено", - "prefs_appearance_theme_title": "Тема", + "prefs_appearance_theme_title": "Тема оформлення", "prefs_appearance_theme_system": "Система (за замовчуванням)", "prefs_appearance_theme_light": "Світлий режим", "error_boundary_button_reload_ntfy": "Перезавантажити ntfy", "web_push_subscription_expiring_title": "Сповіщення буде призупинено", "web_push_subscription_expiring_body": "Відкрийте ntfy, щоб продовжити отримувати сповіщення", "web_push_unknown_notification_body": "Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку", - "alert_notification_ios_install_required_title": "потрібно встановити на iOS" + "alert_notification_ios_install_required_title": "Необхідне встановлення на iOS", + "account_basics_cannot_edit_or_delete_provisioned_user": "Автоматично створеного користувача не можна редагувати чи видалити", + "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити" } From 3758472345d9034fb2048f52a26fe9392315ed03 Mon Sep 17 00:00:00 2001 From: "Kristijan \\\"Fremen\\\" Velkovski" Date: Thu, 7 May 2026 07:37:38 +0200 Subject: [PATCH 114/126] Translated using Weblate (Macedonian) Currently translated at 24.0% (98 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/ --- web/public/static/langs/mk.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/web/public/static/langs/mk.json b/web/public/static/langs/mk.json index bb19fe1a..a8fcba29 100644 --- a/web/public/static/langs/mk.json +++ b/web/public/static/langs/mk.json @@ -92,5 +92,9 @@ "notifications_click_open_button": "Отвори линк", "notifications_actions_open_url_title": "Оди на {{url}}", "notifications_actions_not_supported": "Дејството не е поддржано во веб-апликацијата", - "notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}" + "notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}", + "notifications_none_for_any_title": "Не сте добиле никакви известувања.", + "notifications_actions_failed_notification": "Неуспешно дејство", + "notifications_none_for_topic_title": "Сè уште не сте добиле никакви известувања за оваа тема.", + "publish_dialog_filename_label": "Име на фајл" } From bdad542fc0df353aca155debb784302293a18cc8 Mon Sep 17 00:00:00 2001 From: Vincent Vu <172068404+rubixvi@users.noreply.github.com> Date: Tue, 12 May 2026 21:30:27 +1000 Subject: [PATCH 115/126] Add Ntfy App to integrations list --- docs/integrations.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/integrations.md b/docs/integrations.md index a23a61be..4672af29 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -98,6 +98,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had - [Daily Fact Ntfy](https://github.com/thiswillbeyourgithub/Daily_Fact_Ntfy) - Generate [llm](https://github.com/simonw/llm) generated fact every day about any topic you're interested in. - [ntfyexec](https://github.com/alecthomas/ntfyexec) - Send a notification through ntfy.sh if a command fails - [Ntfy Desktop](https://github.com/emmaexe/ntfyDesktop) - Fully featured desktop client for Linux, built with Qt and C++. +- [Ntfy App](https://github.com/rubix-studios-pty-ltd/ntfy-app) - Tauri/Rust desktop client for Windows, Linux and MacOS with push notifications. ## Projects + scripts From c15a242d1a434e0e56a56d4798fe825959ecdfb4 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 12 May 2026 20:37:30 -0400 Subject: [PATCH 116/126] Release notes --- docs/releases.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 5caa3c12..90ac3853 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,10 +8,21 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release |------------------|---------|--------------| | ntfy server | v2.22.0 | Apr 21, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | -| ntfy iOS app | v1.5.1 | Apr 27, 2026 | +| ntfy iOS app | v1.6.0 | May 12, 2026 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy iOS app v1.6.0 +Released May 12, 2026 + +**Bug fixes + maintenance:** + +* Fix crash in iOS v1.5.1 ([#1736](https://github.com/binwiederhier/ntfy/issues/1736), thanks to [@russ-who](https://github.com/russ-who) for reporting and [@am7590](https://github.com/am7590) for fixing) + +**Features:** + +* Tap a notification to open its click URL, or copy the message text if no click URL is set; inline URLs in message text are now tappable as well ([ntfy-ios#37](https://github.com/binwiederhier/ntfy-ios/pull/37), thanks to [@am7590](https://github.com/am7590) for the contribution) + ## ntfy iOS app v1.5.1 Released April 27, 2026 @@ -50,7 +61,7 @@ This is the first iOS release in 3 years, focusing on stability fixes as per the * Fix crash when deleting notifications ([ntfy-ios#33](https://github.com/binwiederhier/ntfy-ios/pull/33), [#1642](https://github.com/binwiederhier/ntfy/issues/1642), [#377](https://github.com/binwiederhier/ntfy/issues/377), thanks to [@am7590](https://github.com/am7590) for the contribution) * Fix topic normalization for base URLs and refresh list after sending test notification ([ntfy-ios#32](https://github.com/binwiederhier/ntfy-ios/pull/32), [#337](https://github.com/binwiederhier/ntfy/issues/337), thanks to [@am7590](https://github.com/am7590) for the contribution) -### ntfy server v2.21.0 +## 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 @@ -62,7 +73,7 @@ ntfy.sh won't be able to send emails unless the email address was verified ahead * 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 +## 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. @@ -71,7 +82,7 @@ This is a small bugfix release that only affects high volume S3 backends that st * [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 +## 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 From d6397fc5e589e6ec28cd930ea6fc2c636fdcdb2d Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Tue, 12 May 2026 20:42:17 -0400 Subject: [PATCH 117/126] Bump --- go.mod | 32 +- go.sum | 68 ++-- web/package-lock.json | 889 ++++++++++++++++++++---------------------- 3 files changed, 483 insertions(+), 506 deletions(-) diff --git a/go.mod b/go.mod index 495d0267..5268efbd 100644 --- a/go.mod +++ b/go.mod @@ -7,19 +7,19 @@ require ( cloud.google.com/go/storage v1.62.1 // indirect github.com/BurntSushi/toml v1.6.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect - github.com/emersion/go-smtp v0.18.0 + github.com/emersion/go-smtp v0.24.0 github.com/gabriel-vasile/mimetype v1.4.13 github.com/gorilla/websocket v1.5.3 - github.com/mattn/go-sqlite3 v1.14.42 + github.com/mattn/go-sqlite3 v1.14.44 github.com/olebedev/when v1.1.0 github.com/stretchr/testify v1.11.1 github.com/urfave/cli/v2 v2.27.7 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.51.0 golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 - golang.org/x/term v0.42.0 + golang.org/x/term v0.43.0 golang.org/x/time v0.15.0 - google.golang.org/api v0.276.0 + google.golang.org/api v0.279.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -34,19 +34,19 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/prometheus/client_golang v1.23.2 github.com/stripe/stripe-go/v74 v74.30.0 - golang.org/x/sys v0.43.0 - golang.org/x/text v0.36.0 + golang.org/x/sys v0.44.0 + golang.org/x/text v0.37.0 ) require ( - cel.dev/expr v0.25.1 // indirect + cel.dev/expr v0.25.2 // indirect cloud.google.com/go v0.123.0 // indirect cloud.google.com/go/auth v0.20.0 // 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/iam v1.9.0 // indirect - cloud.google.com/go/longrunning v0.11.0 // indirect - cloud.google.com/go/monitoring v1.27.0 // indirect + cloud.google.com/go/iam v1.11.0 // indirect + cloud.google.com/go/longrunning v1.0.0 // indirect + cloud.google.com/go/monitoring v1.29.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.56.0 // indirect @@ -95,12 +95,12 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/net v0.54.0 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect - google.golang.org/genproto v0.0.0-20260420184626-e10c466a9529 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect - google.golang.org/grpc v1.80.0 // indirect + google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect + google.golang.org/grpc v1.81.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3d0ad875..4f182c7e 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= +cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= 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/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= @@ -10,18 +10,18 @@ cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdB cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= -cloud.google.com/go/iam v1.9.0 h1:89wyjxT6DL4b5rk/Nk8eBC9DHqf+JiMstrn5IEYxFw4= -cloud.google.com/go/iam v1.9.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= -cloud.google.com/go/logging v1.16.0 h1:MMNgYRvZ/pEwiNSkcoJTKWfAbAJDqCqAMJiarZx+/CI= -cloud.google.com/go/logging v1.16.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= -cloud.google.com/go/longrunning v0.11.0 h1:fE4XVLJQj+gRnw1HrbDyQXXgC0aiqY3wxP7DDU4cWk0= -cloud.google.com/go/longrunning v0.11.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= -cloud.google.com/go/monitoring v1.27.0 h1:BhYwMqao+e5Nn7JtWMM9m6zRtKtVUK6kJWMizXChkLU= -cloud.google.com/go/monitoring v1.27.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= +cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= +cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= +cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k= +cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= +cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMhRVVdY= +cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM= +cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= +cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8= cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= -cloud.google.com/go/trace v1.14.0 h1:jUtnmOrNcu5XJNk4Gz0fv+v5sM0weaOa3z5MPQUjRXs= -cloud.google.com/go/trace v1.14.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= +cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= +cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= @@ -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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= -github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -193,8 +193,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -209,8 +209,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -234,8 +234,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -245,8 +245,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -258,8 +258,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -272,18 +272,18 @@ 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= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY= -google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw= +google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= +google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= 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/genproto v0.0.0-20260420184626-e10c466a9529 h1:QoMBg0moLIlB/eucPzc+ID5SgPZWuirtjAn3l8nW2Dg= -google.golang.org/genproto v0.0.0-20260420184626-e10c466a9529/go.mod h1:EjLmDZ8liSLBrCTK5vP+bGIxRQHE3ovGvOI0CzGk1PI= -google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 h1:zUWMZsvo/IJcD1t6MNCPO/azZTwz0TvwCBqr5aifoVY= -google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529/go.mod h1:a5OGAgyRr4lqco7AG9hQM9Fwh0N2ZV4grR0eXFEsXQg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554= +google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM= +google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc= +google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/web/package-lock.json b/web/package-lock.json index 50804f89..4c51ae99 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -12,7 +12,7 @@ "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.4.2", - "@mui/material": "*", + "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", "humanize-duration": "^3.27.3", @@ -20,8 +20,8 @@ "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^3.0.5", "js-base64": "^3.7.2", - "react": "*", - "react-dom": "*", + "react": "latest", + "react-dom": "latest", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-remark": "^2.1.0", @@ -60,9 +60,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { @@ -154,9 +154,9 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", + "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", "dev": true, "license": "MIT", "dependencies": { @@ -165,7 +165,7 @@ "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", + "@babel/traverse": "^7.29.0", "semver": "^6.3.1" }, "engines": { @@ -395,9 +395,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -458,6 +458,23 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz", + "integrity": "sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", @@ -977,9 +994,9 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", + "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", "dev": true, "license": "MIT", "dependencies": { @@ -1444,19 +1461,20 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", - "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "version": "7.29.5", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.5.tgz", + "integrity": "sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.29.0", + "@babel/compat-data": "^7.29.3", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.3", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", @@ -1488,7 +1506,7 @@ "@babel/plugin-transform-member-expression-literals": "^7.27.1", "@babel/plugin-transform-modules-amd": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-systemjs": "^7.29.4", "@babel/plugin-transform-modules-umd": "^7.27.1", "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", "@babel/plugin-transform-new-target": "^7.27.1", @@ -2666,10 +2684,37 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/plugin-node-resolve": { - "version": "15.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", - "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", "dev": true, "license": "MIT", "dependencies": { @@ -2691,19 +2736,41 @@ } } }, - "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", "dev": true, "license": "MIT", "dependencies": { - "serialize-javascript": "^6.0.1", + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", "smob": "^1.0.0", "terser": "^5.17.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "rollup": "^2.0.0||^3.0.0||^4.0.0" @@ -2738,9 +2805,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", - "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", "cpu": [ "arm" ], @@ -2752,9 +2819,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", - "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", "cpu": [ "arm64" ], @@ -2766,9 +2833,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", - "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", "cpu": [ "arm64" ], @@ -2780,9 +2847,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", - "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", "cpu": [ "x64" ], @@ -2794,9 +2861,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", - "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", "cpu": [ "arm64" ], @@ -2808,9 +2875,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", - "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", "cpu": [ "x64" ], @@ -2822,13 +2889,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", - "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2836,13 +2906,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", - "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2850,13 +2923,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", - "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2864,13 +2940,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", - "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2878,13 +2957,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", - "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2892,13 +2974,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", - "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2906,13 +2991,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", - "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2920,13 +3008,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", - "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2934,13 +3025,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", - "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2948,13 +3042,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", - "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2962,13 +3059,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", - "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2976,13 +3076,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", - "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2990,13 +3093,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", - "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3004,9 +3110,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", - "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", "cpu": [ "x64" ], @@ -3018,9 +3124,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", - "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "cpu": [ "arm64" ], @@ -3032,9 +3138,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", - "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "cpu": [ "arm64" ], @@ -3046,9 +3152,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", - "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "cpu": [ "ia32" ], @@ -3060,9 +3166,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", - "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "cpu": [ "x64" ], @@ -3074,9 +3180,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", - "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", "cpu": [ "x64" ], @@ -3094,17 +3200,20 @@ "dev": true, "license": "MIT" }, - "node_modules/@surma/rollup-plugin-off-main-thread": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", - "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", "dev": true, "license": "Apache-2.0", "dependencies": { - "ejs": "^3.1.6", - "json5": "^2.2.0", - "magic-string": "^0.25.0", - "string.prototype.matchall": "^4.0.6" + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" } }, "node_modules/@types/babel__core": { @@ -3227,9 +3336,9 @@ "license": "MIT" }, "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "dev": true, "license": "ISC" }, @@ -3278,9 +3387,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3548,9 +3657,9 @@ } }, "node_modules/axe-core": { - "version": "4.11.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz", - "integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==", + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", "dev": true, "license": "MPL-2.0", "engines": { @@ -3642,9 +3751,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.20", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", - "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3766,9 +3875,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001788", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", - "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", "dev": true, "funding": [ { @@ -4203,9 +4312,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.341", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.341.tgz", - "integrity": "sha512-1sZTssferjgDgaqRTc0ieP+ozzpOy7LQTPTtEW3yQFn4+ORdIAZWV5BthXPyHF7YqLvFJCUPhNhdAJQYlYUgiw==", + "version": "1.5.354", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz", + "integrity": "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg==", "dev": true, "license": "ISC" }, @@ -4931,6 +5040,19 @@ "node": ">=0.10.0" } }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -4959,9 +5081,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -5562,9 +5684,9 @@ } }, "node_modules/i18next-http-backend": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.5.tgz", - "integrity": "sha512-QaWHnsxieEDcqKe+vo/RFqpiIFRi/KBqlOSPcUlvinBaISCeiTRCbtrazHAjtHtsLC66oDsROAH8frWkQzfMMQ==", + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.6.tgz", + "integrity": "sha512-mBOqy8993jtqAoj6XaI1XeC/8/9v6EPS+681ziegrPvTB0DoaCY7PpTS0SpY56qLMoS4OI1TZEM2Zf59zNh05w==", "license": "MIT", "dependencies": { "cross-fetch": "4.1.0" @@ -5791,12 +5913,12 @@ } }, "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "hasown": "^2.0.3" }, "engines": { "node": ">= 0.4" @@ -6424,13 +6546,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -6475,13 +6590,13 @@ } }, "node_modules/magic-string": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", - "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "sourcemap-codec": "^1.4.8" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/math-intrinsics": { @@ -6678,9 +6793,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -6743,9 +6858,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "dev": true, "license": "MIT" }, @@ -7058,9 +7173,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7106,9 +7221,9 @@ } }, "node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -7234,35 +7349,25 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", - "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.5" + "react": "^19.2.6" } }, "node_modules/react-i18next": { @@ -7300,9 +7405,9 @@ } }, "node_modules/react-is": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", - "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "license": "MIT" }, "node_modules/react-refresh": { @@ -7596,9 +7701,9 @@ } }, "node_modules/rollup": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", - "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", "dev": true, "license": "MIT", "dependencies": { @@ -7612,31 +7717,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.2", - "@rollup/rollup-android-arm64": "4.60.2", - "@rollup/rollup-darwin-arm64": "4.60.2", - "@rollup/rollup-darwin-x64": "4.60.2", - "@rollup/rollup-freebsd-arm64": "4.60.2", - "@rollup/rollup-freebsd-x64": "4.60.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", - "@rollup/rollup-linux-arm-musleabihf": "4.60.2", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", - "@rollup/rollup-linux-arm64-musl": "4.60.2", - "@rollup/rollup-linux-loong64-gnu": "4.60.2", - "@rollup/rollup-linux-loong64-musl": "4.60.2", - "@rollup/rollup-linux-ppc64-gnu": "4.60.2", - "@rollup/rollup-linux-ppc64-musl": "4.60.2", - "@rollup/rollup-linux-riscv64-gnu": "4.60.2", - "@rollup/rollup-linux-riscv64-musl": "4.60.2", - "@rollup/rollup-linux-s390x-gnu": "4.60.2", - "@rollup/rollup-linux-x64-gnu": "4.60.2", - "@rollup/rollup-linux-x64-musl": "4.60.2", - "@rollup/rollup-openbsd-x64": "4.60.2", - "@rollup/rollup-openharmony-arm64": "4.60.2", - "@rollup/rollup-win32-arm64-msvc": "4.60.2", - "@rollup/rollup-win32-ia32-msvc": "4.60.2", - "@rollup/rollup-win32-x64-gnu": "4.60.2", - "@rollup/rollup-win32-x64-msvc": "4.60.2", + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" } }, @@ -7684,27 +7789,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -7757,13 +7841,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/set-function-length": { @@ -7977,14 +8061,6 @@ "node": ">=0.10.0" } }, - "node_modules/sourcemap-codec": { - "version": "1.4.8", - "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", - "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", - "deprecated": "Please use @jridgewell/sourcemap-codec instead", - "dev": true, - "license": "MIT" - }, "node_modules/space-separated-tokens": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", @@ -8323,9 +8399,9 @@ } }, "node_modules/terser": { - "version": "5.46.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", - "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "version": "5.47.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz", + "integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -8859,17 +8935,17 @@ } }, "node_modules/vite-plugin-pwa": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.2.0.tgz", - "integrity": "sha512-a2xld+SJshT9Lgcv8Ji4+srFJL4k/1bVbd1x06JIkvecpQkwkvCncD1+gSzcdm3s+owWLpMJerG3aN5jupJEVw==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", + "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.6", "pretty-bytes": "^6.1.1", "tinyglobby": "^0.2.10", - "workbox-build": "^7.4.0", - "workbox-window": "^7.4.0" + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" }, "engines": { "node": ">=16.0.0" @@ -8879,9 +8955,9 @@ }, "peerDependencies": { "@vite-pwa/assets-generator": "^1.0.0", - "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", - "workbox-build": "^7.4.0", - "workbox-window": "^7.4.0" + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" }, "peerDependenciesMeta": { "@vite-pwa/assets-generator": { @@ -9040,30 +9116,30 @@ } }, "node_modules/workbox-background-sync": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", - "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-broadcast-update": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", - "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-build": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", - "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", "dev": true, "license": "MIT", "dependencies": { @@ -9071,39 +9147,39 @@ "@babel/core": "^7.24.4", "@babel/preset-env": "^7.11.0", "@babel/runtime": "^7.11.2", - "@rollup/plugin-babel": "^5.2.0", - "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-replace": "^2.4.1", - "@rollup/plugin-terser": "^0.4.3", - "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", "ajv": "^8.6.0", "common-tags": "^1.8.0", + "eta": "^4.5.1", "fast-json-stable-stringify": "^2.1.0", "fs-extra": "^9.0.1", "glob": "^11.0.1", - "lodash": "^4.17.20", "pretty-bytes": "^5.3.0", - "rollup": "^2.79.2", + "rollup": "^4.53.3", "source-map": "^0.8.0-beta.0", "stringify-object": "^3.3.0", "strip-comments": "^2.0.1", "tempy": "^0.6.0", "upath": "^1.2.0", - "workbox-background-sync": "7.4.0", - "workbox-broadcast-update": "7.4.0", - "workbox-cacheable-response": "7.4.0", - "workbox-core": "7.4.0", - "workbox-expiration": "7.4.0", - "workbox-google-analytics": "7.4.0", - "workbox-navigation-preload": "7.4.0", - "workbox-precaching": "7.4.0", - "workbox-range-requests": "7.4.0", - "workbox-recipes": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0", - "workbox-streams": "7.4.0", - "workbox-sw": "7.4.0", - "workbox-window": "7.4.0" + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" }, "engines": { "node": ">=20.0.0" @@ -9126,73 +9202,10 @@ "ajv": ">=8" } }, - "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", - "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.10.4", - "@rollup/pluginutils": "^3.1.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "@types/babel__core": "^7.1.9", - "rollup": "^1.20.0||^2.0.0" - }, - "peerDependenciesMeta": { - "@types/babel__core": { - "optional": true - } - } - }, - "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", - "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^3.1.0", - "magic-string": "^0.25.7" - }, - "peerDependencies": { - "rollup": "^1.20.0 || ^2.0.0" - } - }, - "node_modules/workbox-build/node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/workbox-build/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { @@ -9217,9 +9230,9 @@ } }, "node_modules/workbox-build/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -9229,13 +9242,6 @@ "node": "18 || 20 || >=22" } }, - "node_modules/workbox-build/node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/glob": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", @@ -9284,19 +9290,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/workbox-build/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -9310,22 +9303,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/workbox-build/node_modules/rollup": { - "version": "2.80.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", - "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", - "dev": true, - "license": "MIT", - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=10.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, "node_modules/workbox-build/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -9370,140 +9347,140 @@ } }, "node_modules/workbox-cacheable-response": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", - "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-core": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", - "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", "dev": true, "license": "MIT" }, "node_modules/workbox-expiration": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", - "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", "dev": true, "license": "MIT", "dependencies": { "idb": "^7.0.1", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-google-analytics": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", - "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-background-sync": "7.4.0", - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-navigation-preload": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", - "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-precaching": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", - "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-range-requests": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", - "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-recipes": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", - "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", "dev": true, "license": "MIT", "dependencies": { - "workbox-cacheable-response": "7.4.0", - "workbox-core": "7.4.0", - "workbox-expiration": "7.4.0", - "workbox-precaching": "7.4.0", - "workbox-routing": "7.4.0", - "workbox-strategies": "7.4.0" + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" } }, "node_modules/workbox-routing": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", - "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-strategies": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", - "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/workbox-streams": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", - "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", "dev": true, "license": "MIT", "dependencies": { - "workbox-core": "7.4.0", - "workbox-routing": "7.4.0" + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" } }, "node_modules/workbox-sw": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", - "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", "dev": true, "license": "MIT" }, "node_modules/workbox-window": { - "version": "7.4.0", - "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", - "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", "dev": true, "license": "MIT", "dependencies": { "@types/trusted-types": "^2.0.2", - "workbox-core": "7.4.0" + "workbox-core": "7.4.1" } }, "node_modules/wrappy": { From df2ce34dc02d0d7897ac27d5dcadbf3af6eb9733 Mon Sep 17 00:00:00 2001 From: Sam Smith Date: Tue, 12 May 2026 16:48:30 +0200 Subject: [PATCH 118/126] Translated using Weblate (Russian) Currently translated at 100.0% (407 of 407 strings) Translation: ntfy/Web app Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/ --- web/public/static/langs/ru.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/public/static/langs/ru.json b/web/public/static/langs/ru.json index 7fcf78a0..43bb432e 100644 --- a/web/public/static/langs/ru.json +++ b/web/public/static/langs/ru.json @@ -9,7 +9,7 @@ "notifications_none_for_any_description": "Чтобы отправить уведомление на тему, просто сделаете PUT или POST-запрос на её URL-адрес. Вот пример с использованием одной из ваших тем.", "notifications_no_subscriptions_title": "Похоже, что у вас ещё нет подписок.", "alert_notification_permission_required_description": "Предоставьте браузеру разрешение на отображение уведомлений на рабочем столе", - "notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого Вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.", + "notifications_no_subscriptions_description": "Нажмите на ссылку \"{{linktext}}\", чтобы создать или подписаться на тему. После этого вы сможете отправлять сообщения используя PUT или POST-запросы и получать уведомления здесь.", "notifications_example": "Пример", "notifications_more_details": "Для более подробной информации, посетите наш сайт или документацию.", "notifications_loading": "Идет загрузка уведомлений …", @@ -66,9 +66,9 @@ "notifications_click_open_button": "Открыть ссылку", "subscribe_dialog_subscribe_title": "Подписаться на тему", "publish_dialog_button_cancel": "Отмена", - "subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки Вы сможете отправлять уведомления используя PUT/POST-запросы.", + "subscribe_dialog_subscribe_description": "Темы могут быть не защищены паролем, поэтому укажите сложное имя. После подписки вы сможете отправлять уведомления используя PUT/POST-запросы.", "prefs_users_description": "Вы можете управлять пользователями для защищённых тем. Учтите, что имя учётные данные хранятся в локальном хранилище браузера.", - "error_boundary_description": "Это не должно было случиться. Нам очень жаль.
Если Вы можете уделить минуту своего времени, пожалуйста сообщите об этом на GitHub, или дайте нам знать через Discord или Matrix.", + "error_boundary_description": "Это не должно было случиться. Нам очень жаль.
Если вы можете уделить минуту своего времени, пожалуйста сообщите об этом на GitHub, или дайте нам знать через Discord или Matrix.", "publish_dialog_email_placeholder": "Адрес для пересылки уведомления. Например, phil@example.com", "publish_dialog_attach_placeholder": "Прикрепите файл по URL. Например, https://f-droid.org/F-Droid.apk", "publish_dialog_filename_label": "Имя файла", @@ -155,19 +155,19 @@ "action_bar_show_menu": "Показать меню", "action_bar_logo_alt": "Логотип ntfy", "emoji_picker_search_clear": "Сбросить поиск", - "account_upgrade_dialog_cancel_warning": "Это действие отменит Вашу подписку и переведет Вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше будут удалены.", + "account_upgrade_dialog_cancel_warning": "Это действие отменит вашу подписку и переведет вашую учетную запись на бесплатное обслуживание {{date}}. При наступлении этой даты, все резервирования и сообщения в кэше будут удалены.", "account_tokens_table_create_token_button": "Создать токен доступа", "account_tokens_table_last_origin_tooltip": "С IP-адреса {{ip}}, нажмите для подробностей", "account_tokens_dialog_title_edit": "Изменить токен доступа", "account_delete_dialog_button_cancel": "Отмена", - "account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У Вас не будет доступа к порталу оплаты.", + "account_delete_dialog_billing_warning": "Удаление учетной записи также отменяет все платные подписки. У вас не будет доступа к порталу оплаты.", "account_delete_dialog_description": "Это действие безвозвратно удалит вашу учётную запись, включая все данные, хранящиеся на сервере. После удаления имя пользователя вашей учётной записи не будет доступно для регистрации в течение 7 дней. Если вы точно хотите продолжить, пожалуйста, введите свой пароль ниже.", "account_delete_dialog_label": "Пароль", "reservation_delete_dialog_action_keep_description": "Сообщения и вложения которые находятся в кэше сервера станут доступны всем, кто знает имя темы.", "prefs_reservations_table": "Список зарезервированных тем", "prefs_reservations_table_access_header": "Доступ", "prefs_reservations_table_everyone_write_only": "Я могу публиковать и подписываться, все остальные могут публиковать", - "prefs_reservations_dialog_description": "Резервирование дает Вам возможность управлять темой и настраивать правила доступа к ней для пользователей.", + "prefs_reservations_dialog_description": "Резервирование дает вам возможность управлять темой и настраивать правила доступа к ней для пользователей.", "reservation_delete_dialog_action_delete_title": "Удалить сообщения в кэше и вложения", "reservation_delete_dialog_action_delete_description": "Сообщения в кэше и вложения будут безвозвратно удалены. Это действие невозможно отменить.", "prefs_reservations_table_not_subscribed": "Не подписан", @@ -178,10 +178,10 @@ "prefs_reservations_dialog_title_delete": "Удалить резервирование", "prefs_reservations_dialog_title_edit": "Изменение резервированной темы", "prefs_reservations_table_topic_header": "Тема", - "prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с Вашей учетной записью.", + "prefs_users_description_no_sync": "Пользователи и пароли не синхронизируются с вашей учетной записью.", "prefs_users_delete_button": "Удалить пользователя", "prefs_users_table_cannot_delete_or_edit": "Невозможно удалить или редактировать залогиненного пользователя", - "account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, пожалуйста удалите хотя бы одну зарезервированную тему. Вы можете это сделать в Настройках.", + "account_upgrade_dialog_reservations_warning_one": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у вас на данный момент. Перед сменой подписки, пожалуйста удалите хотя бы одну зарезервированную тему. Вы можете это сделать в Настройках.", "account_upgrade_dialog_proration_info": "Пересчёт оплаты: при расширении подписки, разница в цене от текущей спишется сразу. При упрощении подписки, неиспользованные средства пойдут в оплату баланса по следующим счетам.", "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} на файл", "account_tokens_table_never_expires": "Никогда", @@ -191,7 +191,7 @@ "error_boundary_unsupported_indexeddb_title": "Работа в приватном режиме не поддерживается", "account_tokens_dialog_button_create": "Создать токен", "account_tokens_delete_dialog_submit_button": "Безвозвратно удалить токен", - "account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у Вас на данный момент. Перед сменой подписки, пожалуйста удалите хотя бы {{count}} зарезервированных тем. Вы можете это сделать в Настройках.", + "account_upgrade_dialog_reservations_warning_other": "Выбранная подписка разрешает меньше зарезервированных тем, чем есть у вас на данный момент. Перед сменой подписки, пожалуйста удалите хотя бы {{count}} зарезервированных тем. Вы можете это сделать в Настройках.", "account_upgrade_dialog_tier_features_messages_other": "{{messages}} сообщений в день", "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} суммарный объем", "account_upgrade_dialog_tier_selected_label": "Выбранная", @@ -268,7 +268,7 @@ "notifications_attachment_file_document": "другой тип файла", "notifications_actions_not_supported": "Действие не поддерживается в веб-приложении", "display_name_dialog_title": "Изменить псевдоним", - "display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке Ваших подписок. Это помогает легче находить темы со сложными именами.", + "display_name_dialog_description": "Создайте псевдоним для темы, который будет отображаться в списке ваших подписок. Это помогает легче находить темы со сложными именами.", "reserve_dialog_checkbox_label": "Зарезервировать тему и настроить доступ", "publish_dialog_emoji_picker_show": "Выбрать смайлик", "publish_dialog_click_reset": "Удалить ссылку", @@ -312,7 +312,7 @@ "account_upgrade_dialog_button_cancel_subscription": "Отменить подписку", "account_upgrade_dialog_button_update_subscription": "Изменить подписку", "account_tokens_title": "Токены доступа", - "account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные Вашей учетной записи. Смотрите документацию чтобы узнать больше.", + "account_tokens_description": "Используйте токены доступа для публикации и подписки через ntfy API чтобы не пересылать данные вашей учетной записи. Смотрите документацию чтобы узнать больше.", "account_tokens_table_token_header": "Токен", "account_tokens_table_label_header": "Название", "account_tokens_table_last_access_header": "Последний доступ", @@ -340,7 +340,7 @@ "account_basics_password_dialog_confirm_password_label": "Подтвердите пароль", "account_basics_password_dialog_button_submit": "Сменить пароль", "account_basics_tier_title": "Тип учётной записи", - "error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается Вашим браузером в приватном режиме.

Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в этом отчете на GitHub или связавшись с нами через Discord или Matrix.", + "error_boundary_unsupported_indexeddb_description": "Веб-приложение ntfy использует IndexedDB, который не поддерживается вашим браузером в приватном режиме.

Хотя это и не лучший вариант, использовать веб-приложение ntfy в приватном режиме не имеет особого смысла, так как все данные храняться в локальном хранилище браузера. Вы можете узнать больше в этом отчете на GitHub или связавшись с нами через Discord или Matrix.", "account_basics_tier_interval_monthly": "ежемесячно", "account_basics_tier_interval_yearly": "ежегодно", "account_upgrade_dialog_interval_yearly": "Ежегодно", From b5ff765bb7c52463f104b91b9451ae1fb1d5ff6b Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 17 May 2026 09:01:39 -0400 Subject: [PATCH 119/126] Bump --- go.mod | 4 +- go.sum | 8 +- web/package-lock.json | 319 +++++++++++---------------- web/package.json | 4 - web/src/app/utils.js | 30 ++- web/src/components/Account.jsx | 8 +- web/src/components/ErrorBoundary.jsx | 38 +--- 7 files changed, 167 insertions(+), 244 deletions(-) diff --git a/go.mod b/go.mod index 5268efbd..c3732a06 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi require github.com/pkg/errors v0.9.1 // indirect require ( - firebase.google.com/go/v4 v4.19.0 + firebase.google.com/go/v4 v4.20.0 github.com/SherClockHolmes/webpush-go v1.4.0 github.com/jackc/pgx/v5 v5.9.2 github.com/microcosm-cc/bluemonday v1.0.27 @@ -100,7 +100,7 @@ require ( google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect - google.golang.org/grpc v1.81.0 // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4f182c7e..7010ba7b 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,8 @@ cloud.google.com/go/storage v1.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYX cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= -firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8= -firebase.google.com/go/v4 v4.19.0/go.mod h1:P7UfBpzc8+Z3MckX79+zsWzKVfpGryr6HLbAe7gCWfs= +firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU= +firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= @@ -282,8 +282,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= -google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/web/package-lock.json b/web/package-lock.json index 4c51ae99..ebb55f34 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,19 +15,15 @@ "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", - "humanize-duration": "^3.27.3", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^3.0.5", - "js-base64": "^3.7.2", "react": "latest", "react-dom": "latest", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-remark": "^2.1.0", "react-router-dom": "^6.2.2", - "stacktrace-gps": "^3.0.4", - "stacktrace-js": "^2.0.2", "stylis": "^4.3.0", "stylis-plugin-rtl": "^2.1.1" }, @@ -2805,9 +2801,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", - "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -2819,9 +2815,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", - "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -2833,9 +2829,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", - "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], @@ -2847,9 +2843,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", - "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], @@ -2861,9 +2857,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", - "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -2875,9 +2871,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", - "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -2889,9 +2885,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", - "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -2906,9 +2902,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", - "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -2923,9 +2919,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", - "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], @@ -2940,9 +2936,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", - "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -2957,9 +2953,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", - "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], @@ -2974,9 +2970,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", - "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -2991,9 +2987,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", - "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], @@ -3008,9 +3004,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", - "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -3025,9 +3021,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", - "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -3042,9 +3038,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", - "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -3059,9 +3055,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", - "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -3076,9 +3072,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", - "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], @@ -3093,9 +3089,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", - "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -3110,9 +3106,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", - "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -3124,9 +3120,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", - "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -3138,9 +3134,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", - "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], @@ -3152,9 +3148,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", - "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -3166,9 +3162,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", - "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -3180,9 +3176,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", - "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], @@ -3751,9 +3747,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.29", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", - "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3875,9 +3871,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001792", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", - "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", "dev": true, "funding": [ { @@ -4312,9 +4308,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.354", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.354.tgz", - "integrity": "sha512-JaBHwWcfIdmSAfWM5l3uwjGd431j8YEMikZ+K/2nXVuBqJKyZ0f+2h4n4JY5AyNiZmnY9qQr2RU3v9DxDmHMNg==", + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", "dev": true, "license": "ISC" }, @@ -4334,15 +4330,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/error-stack-parser": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", - "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", - "license": "MIT", - "dependencies": { - "stackframe": "^1.3.4" - } - }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -4718,14 +4705,14 @@ } }, "node_modules/eslint-import-resolver-node/node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", @@ -4916,14 +4903,14 @@ } }, "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", + "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", @@ -5642,15 +5629,6 @@ "void-elements": "3.1.0" } }, - "node_modules/humanize-duration": { - "version": "3.33.2", - "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz", - "integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==", - "license": "Unlicense", - "funding": { - "url": "https://github.com/sponsors/EvanHahn" - } - }, "node_modules/i18next": { "version": "21.10.0", "resolved": "https://registry.npmjs.org/i18next/-/i18next-21.10.0.tgz", @@ -6354,12 +6332,6 @@ "node": ">=10" } }, - "node_modules/js-base64": { - "version": "3.7.8", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", - "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", - "license": "BSD-3-Clause" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7701,9 +7673,9 @@ } }, "node_modules/rollup": { - "version": "4.60.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", - "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -7717,31 +7689,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.3", - "@rollup/rollup-android-arm64": "4.60.3", - "@rollup/rollup-darwin-arm64": "4.60.3", - "@rollup/rollup-darwin-x64": "4.60.3", - "@rollup/rollup-freebsd-arm64": "4.60.3", - "@rollup/rollup-freebsd-x64": "4.60.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", - "@rollup/rollup-linux-arm-musleabihf": "4.60.3", - "@rollup/rollup-linux-arm64-gnu": "4.60.3", - "@rollup/rollup-linux-arm64-musl": "4.60.3", - "@rollup/rollup-linux-loong64-gnu": "4.60.3", - "@rollup/rollup-linux-loong64-musl": "4.60.3", - "@rollup/rollup-linux-ppc64-gnu": "4.60.3", - "@rollup/rollup-linux-ppc64-musl": "4.60.3", - "@rollup/rollup-linux-riscv64-gnu": "4.60.3", - "@rollup/rollup-linux-riscv64-musl": "4.60.3", - "@rollup/rollup-linux-s390x-gnu": "4.60.3", - "@rollup/rollup-linux-x64-gnu": "4.60.3", - "@rollup/rollup-linux-x64-musl": "4.60.3", - "@rollup/rollup-openbsd-x64": "4.60.3", - "@rollup/rollup-openharmony-arm64": "4.60.3", - "@rollup/rollup-win32-arm64-msvc": "4.60.3", - "@rollup/rollup-win32-ia32-msvc": "4.60.3", - "@rollup/rollup-win32-x64-gnu": "4.60.3", - "@rollup/rollup-win32-x64-msvc": "4.60.3", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, @@ -8012,9 +7984,9 @@ } }, "node_modules/smob": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", - "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", "dev": true, "license": "MIT", "engines": { @@ -8071,51 +8043,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/stack-generator": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", - "integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==", - "license": "MIT", - "dependencies": { - "stackframe": "^1.3.4" - } - }, - "node_modules/stackframe": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", - "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", - "license": "MIT" - }, - "node_modules/stacktrace-gps": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz", - "integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==", - "license": "MIT", - "dependencies": { - "source-map": "0.5.6", - "stackframe": "^1.3.4" - } - }, - "node_modules/stacktrace-gps/node_modules/source-map": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz", - "integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stacktrace-js": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz", - "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==", - "license": "MIT", - "dependencies": { - "error-stack-parser": "^2.0.6", - "stack-generator": "^2.0.5", - "stacktrace-gps": "^3.0.4" - } - }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index f40bba69..4e0a16ac 100644 --- a/web/package.json +++ b/web/package.json @@ -18,19 +18,15 @@ "@mui/material": "latest", "dexie": "^3.2.1", "dexie-react-hooks": "^1.1.1", - "humanize-duration": "^3.27.3", "i18next": "^21.6.14", "i18next-browser-languagedetector": "^6.1.4", "i18next-http-backend": "^3.0.5", - "js-base64": "^3.7.2", "react": "latest", "react-dom": "latest", "react-i18next": "^11.16.2", "react-infinite-scroll-component": "^6.1.0", "react-remark": "^2.1.0", "react-router-dom": "^6.2.2", - "stacktrace-gps": "^3.0.4", - "stacktrace-js": "^2.0.2", "stylis": "^4.3.0", "stylis-plugin-rtl": "^2.1.1" }, diff --git a/web/src/app/utils.js b/web/src/app/utils.js index d6467eb7..0940906f 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -1,4 +1,3 @@ -import { Base64 } from "js-base64"; import beep from "../sounds/beep.mp3"; import juntos from "../sounds/juntos.mp3"; import pristine from "../sounds/pristine.mp3"; @@ -63,9 +62,14 @@ export const unmatchedTags = (tags) => { return tags.filter((tag) => !(tag in emojisMapped)); }; -export const encodeBase64 = (s) => Base64.encode(s); +export const encodeBase64 = (s) => { + const bytes = new TextEncoder().encode(s); + let binary = ""; + for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +}; -export const encodeBase64Url = (s) => Base64.encodeURI(s); +export const encodeBase64Url = (s) => encodeBase64(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); export const bearerAuth = (token) => `Bearer ${token}`; @@ -151,6 +155,26 @@ export const formatShortDateTime = (timestamp, language) => export const formatShortDate = (timestamp, language) => new Intl.DateTimeFormat(getKebabCaseLangStr(language), { dateStyle: "short" }).format(new Date(timestamp * 1000)); +export const formatShortDuration = (ms, language) => { + const seconds = Math.round(ms / 1000); + const units = [ + { unit: "year", s: 31536000 }, + { unit: "month", s: 2592000 }, + { unit: "week", s: 604800 }, + { unit: "day", s: 86400 }, + { unit: "hour", s: 3600 }, + { unit: "minute", s: 60 }, + { unit: "second", s: 1 }, + ]; + const match = units.find((u) => seconds >= u.s) ?? units[units.length - 1]; + const value = Math.round(seconds / match.s); + return new Intl.NumberFormat(getKebabCaseLangStr(language), { + style: "unit", + unit: match.unit, + unitDisplay: "long", + }).format(value); +}; + export const formatBytes = (bytes, decimals = 2) => { if (bytes === 0) return "0 bytes"; const k = 1024; diff --git a/web/src/components/Account.jsx b/web/src/components/Account.jsx index 29fc2b68..42402d41 100644 --- a/web/src/components/Account.jsx +++ b/web/src/components/Account.jsx @@ -39,13 +39,12 @@ import EditIcon from "@mui/icons-material/Edit"; import { Trans, useTranslation } from "react-i18next"; import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined"; -import humanizeDuration from "humanize-duration"; import CelebrationIcon from "@mui/icons-material/Celebration"; import CloseIcon from "@mui/icons-material/Close"; import { ContentCopy, Public } from "@mui/icons-material"; import AddIcon from "@mui/icons-material/Add"; import routes from "./routes"; -import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, openUrl } from "../app/utils"; +import { copyToClipboard, formatBytes, formatShortDate, formatShortDateTime, formatShortDuration, openUrl } from "../app/utils"; import accountApi, { LimitBasis, Role, SubscriptionInterval, SubscriptionStatus } from "../app/AccountApi"; import { Pref, PrefGroup } from "./Pref"; import db from "../app/db"; @@ -896,10 +895,7 @@ const Stats = () => { title={t("account_usage_attachment_storage_title")} description={t("account_usage_attachment_storage_description", { filesize: formatBytes(account.limits.attachment_file_size), - expiry: humanizeDuration(account.limits.attachment_expiry_duration * 1000, { - language: i18n.resolvedLanguage, - fallbacks: ["en"], - }), + expiry: formatShortDuration(account.limits.attachment_expiry_duration * 1000, i18n.resolvedLanguage), })} >
diff --git a/web/src/components/ErrorBoundary.jsx b/web/src/components/ErrorBoundary.jsx index 92e2f83b..554a5f5b 100644 --- a/web/src/components/ErrorBoundary.jsx +++ b/web/src/components/ErrorBoundary.jsx @@ -1,6 +1,5 @@ import * as React from "react"; -import StackTrace from "stacktrace-js"; -import { CircularProgress, Link, Button } from "@mui/material"; +import { Link, Button } from "@mui/material"; import { Trans, withTranslation } from "react-i18next"; import { copyToClipboard } from "../app/utils"; @@ -9,8 +8,7 @@ class ErrorBoundaryImpl extends React.Component { super(props); this.state = { error: false, - originalStack: null, - niceStack: null, + stack: null, unsupportedIndexedDB: false, }; } @@ -32,23 +30,17 @@ class ErrorBoundaryImpl extends React.Component { } handleError(error, info) { - // Immediately render original stack trace - const prettierOriginalStack = info.componentStack + const componentStack = info.componentStack .trim() .split("\n") .map((line) => ` at ${line}`) .join("\n"); + const parts = [error.toString()]; + if (error.stack) parts.push(error.stack); + parts.push(componentStack); this.setState({ error: true, - originalStack: `${error.toString()}\n${prettierOriginalStack}`, - }); - - // Fetch additional info and a better stack trace - StackTrace.fromError(error).then((stack) => { - console.error("[ErrorBoundary] Stacktrace fetched", stack); - const stackString = stack.map((el) => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n"); - const niceStack = `${error.toString()}\n${stackString}`; - this.setState({ niceStack }); + stack: parts.join("\n"), }); } @@ -60,12 +52,7 @@ class ErrorBoundaryImpl extends React.Component { } copyStack() { - let stack = ""; - if (this.state.niceStack) { - stack += `${this.state.niceStack}\n\n`; - } - stack += `${this.state.originalStack}\n`; - copyToClipboard(stack); + copyToClipboard(`${this.state.stack}\n`); } renderUnsupportedIndexedDB() { @@ -112,14 +99,7 @@ class ErrorBoundaryImpl extends React.Component {

{t("error_boundary_stack_trace")}

- {this.state.niceStack ? ( -
{this.state.niceStack}
- ) : ( - <> - {t("error_boundary_gathering_info")} - - )} -
{this.state.originalStack}
+
{this.state.stack}
); } From 578abdfe081a97ed9f606705ccfa90e2ce060fbc Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 17 May 2026 09:30:44 -0400 Subject: [PATCH 120/126] Make sure fallback to language works --- web/src/app/utils.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 0940906f..a6fa07b0 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -142,9 +142,11 @@ export const hashCode = (s) => { /** * convert `i18n.language` style str (e.g.: `en_US`) to kebab-case (e.g.: `en-US`), - * which is expected by `` and `Intl.DateTimeFormat` + * which is expected by `` and `Intl.DateTimeFormat`. Falls back to "en" + * if the input is missing or not a string. */ -export const getKebabCaseLangStr = (language) => language.replace(/_/g, "-"); +export const getKebabCaseLangStr = (language) => + typeof language === "string" && language.length > 0 ? language.replace(/_/g, "-") : "en"; export const formatShortDateTime = (timestamp, language) => new Intl.DateTimeFormat(getKebabCaseLangStr(language), { @@ -168,11 +170,17 @@ export const formatShortDuration = (ms, language) => { ]; const match = units.find((u) => seconds >= u.s) ?? units[units.length - 1]; const value = Math.round(seconds / match.s); - return new Intl.NumberFormat(getKebabCaseLangStr(language), { - style: "unit", - unit: match.unit, - unitDisplay: "long", - }).format(value); + // [lang, "en"] makes Intl fall back to English for well-formed-but-unsupported tags; + // the try/catch covers malformed tags (RangeError) so the web app never crashes here. + try { + return new Intl.NumberFormat([getKebabCaseLangStr(language), "en"], { + style: "unit", + unit: match.unit, + unitDisplay: "long", + }).format(value); + } catch { + return new Intl.NumberFormat("en", { style: "unit", unit: match.unit, unitDisplay: "long" }).format(value); + } }; export const formatBytes = (bytes, decimals = 2) => { From 160c916ce0ea86c5cf7d02d3e867beb438a9118c Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 17 May 2026 09:32:38 -0400 Subject: [PATCH 121/126] Release notes --- docs/releases.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/releases.md b/docs/releases.md index 90ac3853..036b2120 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1893,6 +1893,12 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +## ntfy server v2.23.x (UNRELEASED) + +**Bug fixes + maintenance:** + +* Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint + ## ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out From 29113402ce84cd90afae433752ce9ff6bb687bd1 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 17 May 2026 09:57:41 -0400 Subject: [PATCH 122/126] Low: Fix self-XSS in SVGs --- docs/releases.md | 1 + web/src/app/notificationUtils.js | 2 +- web/src/app/utils.js | 3 +-- web/src/components/AttachmentIcon.jsx | 28 ++++++++++++++++----------- web/src/components/PublishDialog.jsx | 3 ++- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 036b2120..1d4d8590 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1898,6 +1898,7 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release **Bug fixes + maintenance:** * Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint +* Restrict the publish dialog's local file preview to safe image types (png/jpg/gif/webp) to prevent same-origin script execution from blob URLs when previewing a crafted SVG ([GHSA-j8hr-p342-xrmh](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-j8hr-p342-xrmh), thanks to [@Venukamatchi](https://github.com/Venukamatchi) for reporting) ## ntfy Android v1.25.x (UNRELEASED) diff --git a/web/src/app/notificationUtils.js b/web/src/app/notificationUtils.js index 070f8231..2453075a 100644 --- a/web/src/app/notificationUtils.js +++ b/web/src/app/notificationUtils.js @@ -35,7 +35,7 @@ export const formatMessage = (m) => { return m.message || ""; }; -const imageRegex = /\.(png|jpe?g|gif|webp)$/i; +export const imageRegex = /\.(png|jpe?g|gif|webp)$/i; export const isImage = (attachment) => { if (!attachment) return false; diff --git a/web/src/app/utils.js b/web/src/app/utils.js index a6fa07b0..2a62aa38 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -145,8 +145,7 @@ export const hashCode = (s) => { * which is expected by `` and `Intl.DateTimeFormat`. Falls back to "en" * if the input is missing or not a string. */ -export const getKebabCaseLangStr = (language) => - typeof language === "string" && language.length > 0 ? language.replace(/_/g, "-") : "en"; +export const getKebabCaseLangStr = (language) => (typeof language === "string" && language.length > 0 ? language.replace(/_/g, "-") : "en"); export const formatShortDateTime = (timestamp, language) => new Intl.DateTimeFormat(getKebabCaseLangStr(language), { diff --git a/web/src/components/AttachmentIcon.jsx b/web/src/components/AttachmentIcon.jsx index b7bf6b50..0cdcb0b0 100644 --- a/web/src/components/AttachmentIcon.jsx +++ b/web/src/components/AttachmentIcon.jsx @@ -31,18 +31,24 @@ const AttachmentIcon = (props) => { imageFile = fileDocument; imageLabel = t("notifications_attachment_file_document"); } + const icon = ( + + ); + if (!props.href) { + return icon; + } return ( - - + + {icon} ); }; diff --git a/web/src/components/PublishDialog.jsx b/web/src/components/PublishDialog.jsx index 912810b3..c263861a 100644 --- a/web/src/components/PublishDialog.jsx +++ b/web/src/components/PublishDialog.jsx @@ -30,6 +30,7 @@ import priority3 from "../img/priority-3.svg"; import priority4 from "../img/priority-4.svg"; import priority5 from "../img/priority-5.svg"; import { formatBytes, maybeWithAuth, topicShortUrl, topicUrl, validTopic, validUrl } from "../app/utils"; +import { imageRegex } from "../app/notificationUtils"; import AttachmentIcon from "./AttachmentIcon"; import DialogFooter from "./DialogFooter"; import api from "../app/Api"; @@ -805,7 +806,7 @@ const AttachmentBox = (props) => { borderRadius: "4px", }} > - + Date: Sun, 17 May 2026 11:33:07 -0400 Subject: [PATCH 123/126] Add visitor-topic-creation-limit-* options --- cmd/serve.go | 10 +++++ docs/config.md | 15 +++++++ docs/releases.md | 4 ++ server/config.go | 6 +++ server/errors.go | 1 + server/server.go | 30 ++++++++----- server/server.yml | 9 ++++ server/server_account.go | 4 +- server/server_middleware.go | 2 +- server/server_test.go | 63 +++++++++++++++++++++++++++- server/server_webpush.go | 2 +- server/visitor.go | 84 +++++++++++++++++++++++-------------- 12 files changed, 183 insertions(+), 47 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 0c0b1139..0cfc1cbc 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -93,6 +93,8 @@ var flagsServe = append( altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-message-daily-limit", Aliases: []string{"visitor_message_daily_limit"}, EnvVars: []string{"NTFY_VISITOR_MESSAGE_DAILY_LIMIT"}, Value: server.DefaultVisitorMessageDailyLimit, Usage: "max messages per visitor per day, derived from request limit if unset"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorEmailLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-topic-creation-limit-burst", Aliases: []string{"visitor_topic_creation_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST"}, Value: server.DefaultVisitorTopicCreationLimitBurst, Usage: "burst of new topic creations per visitor (0 = disabled)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), @@ -207,6 +209,8 @@ func execServe(c *cli.Context) error { visitorMessageDailyLimit := c.Int("visitor-message-daily-limit") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") visitorEmailLimitReplenishStr := c.String("visitor-email-limit-replenish") + visitorTopicCreationLimitBurst := c.Int("visitor-topic-creation-limit-burst") + visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish") visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") behindProxy := c.Bool("behind-proxy") @@ -252,6 +256,10 @@ func execServe(c *cli.Context) error { if err != nil { return fmt.Errorf("invalid visitor email limit replenish: %s", visitorEmailLimitReplenishStr) } + visitorTopicCreationLimitReplenish, err := util.ParseDuration(visitorTopicCreationLimitReplenishStr) + if err != nil { + return fmt.Errorf("invalid visitor topic creation limit replenish: %s", visitorTopicCreationLimitReplenishStr) + } webPushExpiryDuration, err := util.ParseDuration(webPushExpiryDurationStr) if err != nil { return fmt.Errorf("invalid web push expiry duration: %s", webPushExpiryDurationStr) @@ -497,6 +505,8 @@ func execServe(c *cli.Context) error { conf.VisitorMessageDailyLimit = visitorMessageDailyLimit conf.VisitorEmailLimitBurst = visitorEmailLimitBurst conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish + conf.VisitorTopicCreationLimitBurst = visitorTopicCreationLimitBurst + conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 conf.BehindProxy = behindProxy diff --git a/docs/config.md b/docs/config.md index 003e380c..c934143a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1919,6 +1919,17 @@ are enabled): * `visitor-email-limit-burst` is the initial bucket of emails each visitor has. This defaults to 16. * `visitor-email-limit-replenish` is the rate at which the bucket is refilled (one email per x). Defaults to 1h. +### Topic creation limits +To mitigate topic-enumeration / squatting attacks (where a single source pokes thousands of guessable +topic names to inflate the server's in-memory topic map), there is a per-visitor limit on how many *new* +topics each visitor can cause to be created. Touching topics that already exist in memory does not consume +a token; only first-time insertions do. + +* `visitor-topic-creation-limit-burst` is the initial bucket of new-topic tokens. Set to 0 to disable + the limit entirely. Defaults to 100. +* `visitor-topic-creation-limit-replenish` is the rate at which the bucket is refilled (one new topic per x). + Defaults to 1m. + ### Firebase limits If [Firebase is configured](#firebase-fcm), all messages are also published to a Firebase topic (unless `Firebase: no` is set). Firebase enforces [its own limits](https://firebase.google.com/docs/cloud-messaging/concept-options#topics_throttling) @@ -2309,6 +2320,8 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP/CIDR list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | | `visitor-subscriber-rate-limiting` | `NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING` | *bool* | `false` | Rate limiting: Enables subscriber-based rate limiting | +| `visitor-topic-creation-limit-burst` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST` | *number* | 100 | Rate limiting: Initial bucket of new topic creations per visitor. 0 disables the limit. | +| `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). | | `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | | `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | | `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | @@ -2416,6 +2429,8 @@ OPTIONS: --visitor-message-daily-limit value, --visitor_message_daily_limit value max messages per visitor per day, derived from request limit if unset (default: 0) [$NTFY_VISITOR_MESSAGE_DAILY_LIMIT] --visitor-email-limit-burst value, --visitor_email_limit_burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] --visitor-email-limit-replenish value, --visitor_email_limit_replenish value interval at which burst limit is replenished (one per x) (default: "1h") [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] + --visitor-topic-creation-limit-burst value, --visitor_topic_creation_limit_burst value burst of new topic creations per visitor (0 = disabled) (default: 100) [$NTFY_VISITOR_TOPIC_CREATION_LIMIT_BURST] + --visitor-topic-creation-limit-replenish value, --visitor_topic_creation_limit_replenish value interval at which topic-creation tokens are refilled (one per x) (default: "1m") [$NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH] --visitor-prefix-bits-ipv4 value, --visitor_prefix_bits_ipv4 value number of bits of the IPv4 address to use for rate limiting (default: 32, full address) (default: 32) [$NTFY_VISITOR_PREFIX_BITS_IPV4] --visitor-prefix-bits-ipv6 value, --visitor_prefix_bits_ipv6 value number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) (default: 64) [$NTFY_VISITOR_PREFIX_BITS_IPV6] --behind-proxy, --behind_proxy, -P if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] diff --git a/docs/releases.md b/docs/releases.md index 1d4d8590..ae1fc6f8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1895,6 +1895,10 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## ntfy server v2.23.x (UNRELEASED) +**Features:** + +* Add per-visitor rate limit on new topic creations (`visitor-topic-creation-limit-burst` / `visitor-topic-creation-limit-replenish`, defaults 100 burst / 1m replenish) to mitigate topic-enumeration / squatting attacks that inflate the in-memory topic map + **Bug fixes + maintenance:** * Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint diff --git a/server/config.go b/server/config.go index f472930a..1cfed3fc 100644 --- a/server/config.go +++ b/server/config.go @@ -69,6 +69,8 @@ const ( DefaultVisitorMessageDailyLimit = 0 DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorTopicCreationLimitBurst = 100 + DefaultVisitorTopicCreationLimitReplenish = time.Minute DefaultVisitorAccountCreationLimitBurst = 3 DefaultVisitorAccountCreationLimitReplenish = 24 * time.Hour DefaultVisitorAuthFailureLimitBurst = 30 @@ -163,6 +165,8 @@ type Config struct { VisitorMessageDailyLimit int VisitorEmailLimitBurst int VisitorEmailLimitReplenish time.Duration + VisitorTopicCreationLimitBurst int // Burst of new topic creations per visitor + VisitorTopicCreationLimitReplenish time.Duration // Interval at which topic-creation tokens are refilled VisitorAccountCreationLimitBurst int VisitorAccountCreationLimitReplenish time.Duration VisitorAuthFailureLimitBurst int @@ -266,6 +270,8 @@ func NewConfig() *Config { VisitorMessageDailyLimit: DefaultVisitorMessageDailyLimit, VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, + VisitorTopicCreationLimitBurst: DefaultVisitorTopicCreationLimitBurst, + VisitorTopicCreationLimitReplenish: DefaultVisitorTopicCreationLimitReplenish, VisitorAccountCreationLimitBurst: DefaultVisitorAccountCreationLimitBurst, VisitorAccountCreationLimitReplenish: DefaultVisitorAccountCreationLimitReplenish, VisitorAuthFailureLimitBurst: DefaultVisitorAuthFailureLimitBurst, diff --git a/server/errors.go b/server/errors.go index aab51df4..3197d6ca 100644 --- a/server/errors.go +++ b/server/errors.go @@ -170,6 +170,7 @@ var ( errHTTPTooManyRequestsLimitMessages = &errHTTP{42908, http.StatusTooManyRequests, "limit reached: daily message quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPTooManyRequestsLimitAuthFailure = &errHTTP{42909, http.StatusTooManyRequests, "limit reached: too many auth failures", "https://ntfy.sh/docs/publish/#limitations", nil} // FIXME document limit errHTTPTooManyRequestsLimitCalls = &errHTTP{42910, http.StatusTooManyRequests, "limit reached: daily phone call quota reached", "https://ntfy.sh/docs/publish/#limitations", nil} + errHTTPTooManyRequestsLimitTopicCreation = &errHTTP{42911, http.StatusTooManyRequests, "limit reached: too many new topics, please wait", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", "", nil} errHTTPInternalErrorInvalidPath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid path", "", nil} errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/", nil} diff --git a/server/server.go b/server/server.go index 78a668bd..7ca0b4e7 100644 --- a/server/server.go +++ b/server/server.go @@ -1543,7 +1543,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * return errHTTPTooManyRequestsLimitSubscriptions } defer v.RemoveSubscription() - topics, topicsStr, err := s.topicsFromPath(r.URL.Path) + topics, topicsStr, err := s.topicsFromPath(v, r.URL.Path) if err != nil { return err } @@ -1646,7 +1646,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi defer v.RemoveSubscription() logvr(v, r).Tag(tagWebsocket).Debug("WebSocket connection opened") defer logvr(v, r).Tag(tagWebsocket).Debug("WebSocket connection closed") - topics, topicsStr, err := s.topicsFromPath(r.URL.Path) + topics, topicsStr, err := s.topicsFromPath(v, r.URL.Path) if err != nil { return err } @@ -1916,24 +1916,26 @@ func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visito } // topicFromPath returns the topic from a root path (e.g. /mytopic), creating it if it doesn't exist. -func (s *Server) topicFromPath(path string) (*topic, error) { +// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use). +func (s *Server) topicFromPath(v *visitor, path string) (*topic, error) { parts := strings.Split(path, "/") if len(parts) < 2 { return nil, errHTTPBadRequestTopicInvalid } - return s.topicFromID(parts[1]) + return s.topicFromID(v, parts[1]) } // topicsFromPath returns the topic from a root path (e.g. /mytopic,mytopic2), creating it if it doesn't exist. -func (s *Server) topicsFromPath(path string) ([]*topic, string, error) { +// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use). +func (s *Server) topicsFromPath(v *visitor, path string) ([]*topic, string, error) { parts := strings.Split(path, "/") if len(parts) < 2 { return nil, "", errHTTPBadRequestTopicInvalid } topicIDs := util.SplitNoEmpty(parts[1], ",") - topics, err := s.topicsFromIDs(topicIDs...) + topics, err := s.topicsFromIDs(v, topicIDs...) if err != nil { - return nil, "", errHTTPBadRequestTopicInvalid + return nil, "", err } return topics, parts[1], nil } @@ -1948,7 +1950,9 @@ func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) { } // topicsFromIDs returns the topics with the given IDs, creating them if they don't exist. -func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { +// If v is non-nil, its per-visitor topic-creation rate limiter is consulted before each new +// insertion into the in-memory topic map. Pass nil to bypass the limit (internal use only). +func (s *Server) topicsFromIDs(v *visitor, ids ...string) ([]*topic, error) { s.mu.Lock() defer s.mu.Unlock() topics := make([]*topic, 0) @@ -1960,6 +1964,9 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { if len(s.topics) >= s.config.TotalTopicLimit { return nil, errHTTPTooManyRequestsLimitTotalTopics } + if v != nil && !v.TopicCreationAllowed() { + return nil, errHTTPTooManyRequestsLimitTopicCreation + } s.topics[id] = newTopic(id) } topics = append(topics, s.topics[id]) @@ -1968,8 +1975,9 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { } // topicFromID returns the topic with the given ID, creating it if it doesn't exist. -func (s *Server) topicFromID(id string) (*topic, error) { - topics, err := s.topicsFromIDs(id) +// The visitor is consulted for the per-visitor topic-creation rate limit; pass nil to bypass (internal use). +func (s *Server) topicFromID(v *visitor, id string) (*topic, error) { + topics, err := s.topicsFromIDs(v, id) if err != nil { return nil, err } @@ -2239,7 +2247,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun if s.userManager == nil { return next(w, r, v) } - topics, _, err := s.topicsFromPath(r.URL.Path) + topics, _, err := s.topicsFromPath(v, r.URL.Path) if err != nil { return err } diff --git a/server/server.yml b/server/server.yml index 833f1bea..08161dc2 100644 --- a/server/server.yml +++ b/server/server.yml @@ -358,6 +358,15 @@ # visitor-email-limit-burst: 16 # visitor-email-limit-replenish: "1h" +# Rate limiting: Allowed new topic creations per visitor. A "creation" is when a request +# causes a previously-unknown topic ID to be added to the in-memory topic map. Touches of +# existing topics do not consume tokens. Mitigates topic-enumeration / squatting attacks. +# - visitor-topic-creation-limit-burst is the initial bucket of new-topic tokens (0 = disabled) +# - visitor-topic-creation-limit-replenish is the rate at which the bucket is refilled +# +# visitor-topic-creation-limit-burst: 100 +# visitor-topic-creation-limit-replenish: "1m" + # Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting # - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address) # - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) diff --git a/server/server_account.go b/server/server_account.go index 7def814d..7c5c03c3 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -485,7 +485,7 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ return err } // Kill existing subscribers - t, err := s.topicFromID(req.Topic) + t, err := s.topicFromID(v, req.Topic) if err != nil { return err } @@ -728,7 +728,7 @@ func (s *Server) publishSyncEvent(v *visitor) error { return nil } logv(v).Field("sync_topic", u.SyncTopic).Trace("Publishing sync event to user's sync topic") - syncTopic, err := s.topicFromID(u.SyncTopic) + syncTopic, err := s.topicFromID(nil, u.SyncTopic) // internal: no rate limit if err != nil { return err } diff --git a/server/server_middleware.go b/server/server_middleware.go index 07457f2f..b8e650c5 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -28,7 +28,7 @@ func (s *Server) limitRequests(next handleFunc) handleFunc { // limitRequestsWithTopic limits requests with a topic and stores the rate-limiting-subscriber and topic into request.Context func (s *Server) limitRequestsWithTopic(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - t, err := s.topicFromPath(r.URL.Path) + t, err := s.topicFromPath(v, r.URL.Path) if err != nil { return err } diff --git a/server/server_test.go b/server/server_test.go index 133517d9..bb4ddfba 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -2849,7 +2849,7 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) { messages := make([]*model.Message, 0) for i := 0; i < count; i++ { topicID := fmt.Sprintf("topic%d", i) - _, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array + _, err := s.topicsFromIDs(nil, topicID) // Add topic to internal s.topics array require.Nil(t, err) messages = append(messages, model.NewDefaultMessage(topicID, "some message")) } @@ -3148,6 +3148,67 @@ func TestServer_SubscriberRateLimiting_ProtectedTopics_WithDefaultReadWrite(t *t }) } +func TestServer_VisitorTopicCreationLimit(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) + c.VisitorTopicCreationLimitBurst = 5 + c.VisitorTopicCreationLimitReplenish = time.Hour // Effectively no refill during the test + s := newTestServer(t, c) + + // First 5 brand-new topics succeed + for i := 0; i < 5; i++ { + rr := request(t, s, "PUT", fmt.Sprintf("/fresh-topic-%d", i), "hi", nil) + require.Equal(t, 200, rr.Code) + } + // 6th brand-new topic is throttled (42911) + rr := request(t, s, "PUT", "/fresh-topic-6", "hi", nil) + require.Equal(t, 429, rr.Code) + require.Contains(t, rr.Body.String(), `"code":42911`) + + // Republishing to an existing topic doesn't consume a token + for i := 0; i < 3; i++ { + rr := request(t, s, "PUT", "/fresh-topic-0", "again", nil) + require.Equal(t, 200, rr.Code) + } + }) +} + +func TestServer_VisitorTopicCreationLimit_Refill(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + t.Parallel() + c := newTestConfig(t, databaseURL) + c.VisitorTopicCreationLimitBurst = 2 + c.VisitorTopicCreationLimitReplenish = 300 * time.Millisecond + s := newTestServer(t, c) + + // Burn the burst + for i := 0; i < 2; i++ { + rr := request(t, s, "PUT", fmt.Sprintf("/refill-topic-%d", i), "hi", nil) + require.Equal(t, 200, rr.Code) + } + rr := request(t, s, "PUT", "/refill-topic-blocked", "hi", nil) + require.Equal(t, 429, rr.Code) + + // Wait for a token to be replenished + time.Sleep(400 * time.Millisecond) + + rr = request(t, s, "PUT", "/refill-topic-after", "hi", nil) + require.Equal(t, 200, rr.Code) + }) +} + +func TestServer_VisitorTopicCreationLimit_Disabled(t *testing.T) { + forEachBackend(t, func(t *testing.T, databaseURL string) { + c := newTestConfig(t, databaseURL) + c.VisitorTopicCreationLimitBurst = 0 // 0 disables the limit + s := newTestServer(t, c) + for i := 0; i < 25; i++ { + rr := request(t, s, "PUT", fmt.Sprintf("/nolimit-topic-%d", i), "hi", nil) + require.Equal(t, 200, rr.Code) + } + }) +} + func TestServer_MessageHistoryAndStatsEndpoint(t *testing.T) { forEachBackend(t, func(t *testing.T, databaseURL string) { c := newTestConfig(t, databaseURL) diff --git a/server/server_webpush.go b/server/server_webpush.go index 70f34669..3b7f0f55 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -56,7 +56,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v * } else if len(req.Topics) > webPushTopicSubscribeLimit { return errHTTPBadRequestWebPushTopicCountTooHigh } - topics, err := s.topicsFromIDs(req.Topics...) + topics, err := s.topicsFromIDs(v, req.Topics...) if err != nil { return err } diff --git a/server/visitor.go b/server/visitor.go index d5e774d7..49a70bb1 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -53,22 +53,23 @@ const ( // visitor represents an API user, and its associated rate.Limiter used for rate limiting type visitor struct { - config *Config - messageCache *message.Cache - userManager *user.Manager // May be nil - ip netip.Addr // Visitor IP address - user *user.User // Only set if authenticated user, otherwise nil - requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) - messagesLimiter *util.FixedLimiter // Rate limiter for messages - emailsLimiter *util.RateLimiter // Rate limiter for emails - callsLimiter *util.FixedLimiter // Rate limiter for calls - subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) - bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads - accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil - authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil - firebase time.Time // Next allowed Firebase message - seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) - mu sync.RWMutex + config *Config + messageCache *message.Cache + userManager *user.Manager // May be nil + ip netip.Addr // Visitor IP address + user *user.User // Only set if authenticated user, otherwise nil + requestLimiter *rate.Limiter // Rate limiter for (almost) all requests (including messages) + messagesLimiter *util.FixedLimiter // Rate limiter for messages + emailsLimiter *util.RateLimiter // Rate limiter for emails + callsLimiter *util.FixedLimiter // Rate limiter for calls + subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) + topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map + bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads + accountLimiter *rate.Limiter // Rate limiter for account creation, may be nil + authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil + firebase time.Time // Next allowed Firebase message + seen time.Time // Last seen time of this visitor (needed for removal of stale visitors) + mu sync.RWMutex } type visitorInfo struct { @@ -123,21 +124,22 @@ func newVisitor(conf *Config, messageCache *message.Cache, userManager *user.Man calls = user.Stats.Calls } v := &visitor{ - config: conf, - messageCache: messageCache, - userManager: userManager, // May be nil - ip: ip, - user: user, - firebase: time.Unix(0, 0), - seen: time.Now(), - subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), - requestLimiter: nil, // Set in resetLimiters - messagesLimiter: nil, // Set in resetLimiters, may be nil - emailsLimiter: nil, // Set in resetLimiters - callsLimiter: nil, // Set in resetLimiters, may be nil - bandwidthLimiter: nil, // Set in resetLimiters - accountLimiter: nil, // Set in resetLimiters, may be nil - authLimiter: nil, // Set in resetLimiters, may be nil + config: conf, + messageCache: messageCache, + userManager: userManager, // May be nil + ip: ip, + user: user, + firebase: time.Unix(0, 0), + seen: time.Now(), + subscriptionLimiter: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), + requestLimiter: nil, // Set in resetLimiters + messagesLimiter: nil, // Set in resetLimiters, may be nil + emailsLimiter: nil, // Set in resetLimiters + callsLimiter: nil, // Set in resetLimiters, may be nil + topicCreationLimiter: nil, // Set in resetLimiters + bandwidthLimiter: nil, // Set in resetLimiters + accountLimiter: nil, // Set in resetLimiters, may be nil + authLimiter: nil, // Set in resetLimiters, may be nil } v.resetLimitersNoLock(messages, emails, calls, false) return v @@ -175,6 +177,10 @@ func (v *visitor) contextNoLock() log.Context { fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() } + if v.topicCreationLimiter != nil { + fields["visitor_topic_creation_limiter_limit"] = v.topicCreationLimiter.Limit() + fields["visitor_topic_creation_limiter_tokens"] = v.topicCreationLimiter.Tokens() + } if v.user != nil { fields["user_id"] = v.user.ID fields["user_name"] = v.user.Name @@ -246,6 +252,17 @@ func (v *visitor) SubscriptionAllowed() bool { return v.subscriptionLimiter.Allow() } +// TopicCreationAllowed returns true if the visitor is allowed to cause a new topic to be +// inserted into the server's in-memory topic map. Returns true if no limiter is configured. +func (v *visitor) TopicCreationAllowed() bool { + v.mu.RLock() // limiters could be replaced! + defer v.mu.RUnlock() + if v.topicCreationLimiter == nil { + return true + } + return v.topicCreationLimiter.Allow() +} + // AuthAllowed returns true if an auth request can be attempted (> 1 token available) func (v *visitor) AuthAllowed() bool { v.mu.RLock() // limiters could be replaced! @@ -385,6 +402,11 @@ func (v *visitor) resetLimitersNoLock(messages, emails, calls int64, enqueueUpda v.messagesLimiter = util.NewFixedLimiterWithValue(limits.MessageLimit, messages) v.emailsLimiter = util.NewRateLimiterWithValue(limits.EmailLimitReplenish, limits.EmailLimitBurst, emails) v.callsLimiter = util.NewFixedLimiterWithValue(limits.CallLimit, calls) + if v.config.VisitorTopicCreationLimitBurst > 0 && v.config.VisitorTopicCreationLimitReplenish > 0 { + v.topicCreationLimiter = rate.NewLimiter(rate.Every(v.config.VisitorTopicCreationLimitReplenish), v.config.VisitorTopicCreationLimitBurst) + } else { + v.topicCreationLimiter = nil // Disabled + } v.bandwidthLimiter = util.NewBytesLimiter(int(limits.AttachmentBandwidthLimit), oneDay) if v.user == nil { v.accountLimiter = rate.NewLimiter(rate.Every(v.config.VisitorAccountCreationLimitReplenish), v.config.VisitorAccountCreationLimitBurst) From a1d880aab91f938fac9d2bada24c96a1fcccff58 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 17 May 2026 17:27:38 -0400 Subject: [PATCH 124/126] Make logs easier to read --- server/visitor.go | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/server/visitor.go b/server/visitor.go index 49a70bb1..3d4622dd 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "math" "net/netip" "sync" "time" @@ -154,14 +155,13 @@ func (v *visitor) Context() log.Context { func (v *visitor) contextNoLock() log.Context { info := v.infoLightNoLock() fields := log.Context{ - "visitor_id": visitorID(v.ip, v.user, v.config), - "visitor_ip": v.ip.String(), - "visitor_seen": util.FormatTime(v.seen), - "visitor_messages": info.Stats.Messages, - "visitor_messages_limit": info.Limits.MessageLimit, - "visitor_messages_remaining": info.Stats.MessagesRemaining, - "visitor_request_limiter_limit": v.requestLimiter.Limit(), - "visitor_request_limiter_tokens": v.requestLimiter.Tokens(), + "visitor_id": visitorID(v.ip, v.user, v.config), + "visitor_ip": v.ip.String(), + "visitor_seen": util.FormatTime(v.seen), + "visitor_messages": info.Stats.Messages, + "visitor_messages_limit": info.Limits.MessageLimit, + "visitor_messages_remaining": info.Stats.MessagesRemaining, + "visitor_requests_remaining": int64(math.Floor(v.requestLimiter.Tokens())), } if v.config.SMTPSenderFrom != "" { fields["visitor_emails"] = info.Stats.Emails @@ -174,12 +174,10 @@ func (v *visitor) contextNoLock() log.Context { fields["visitor_calls_remaining"] = info.Stats.CallsRemaining } if v.authLimiter != nil { - fields["visitor_auth_limiter_limit"] = v.authLimiter.Limit() - fields["visitor_auth_limiter_tokens"] = v.authLimiter.Tokens() + fields["visitor_auth_attempts_remaining"] = int64(math.Floor(v.authLimiter.Tokens())) } if v.topicCreationLimiter != nil { - fields["visitor_topic_creation_limiter_limit"] = v.topicCreationLimiter.Limit() - fields["visitor_topic_creation_limiter_tokens"] = v.topicCreationLimiter.Tokens() + fields["visitor_topic_creations_remaining"] = int64(math.Floor(v.topicCreationLimiter.Tokens())) } if v.user != nil { fields["user_id"] = v.user.ID From 9302697a07fa278fe1b9b4fe6ab64a9777ab006f Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Sun, 17 May 2026 21:21:09 -0400 Subject: [PATCH 125/126] Install and release notes --- docs/install.md | 76 ++++++++++++++++++++++++------------------------ docs/releases.md | 25 ++++++++-------- 2 files changed, 51 insertions(+), 50 deletions(-) diff --git a/docs/install.md b/docs/install.md index ae50065e..5d65fd70 100644 --- a/docs/install.md +++ b/docs/install.md @@ -34,37 +34,37 @@ as a service starting at boot time. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_amd64.tar.gz - tar zxvf ntfy_2.22.0_linux_amd64.tar.gz - sudo cp -a ntfy_2.22.0_linux_amd64/ntfy /usr/local/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.22.0_linux_amd64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_amd64.tar.gz + tar zxvf ntfy_2.23.0_linux_amd64.tar.gz + sudo cp -a ntfy_2.23.0_linux_amd64/ntfy /usr/local/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv6.tar.gz - tar zxvf ntfy_2.22.0_linux_armv6.tar.gz - sudo cp -a ntfy_2.22.0_linux_armv6/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.22.0_linux_armv6/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv6.tar.gz + tar zxvf ntfy_2.23.0_linux_armv6.tar.gz + sudo cp -a ntfy_2.23.0_linux_armv6/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv7.tar.gz - tar zxvf ntfy_2.22.0_linux_armv7.tar.gz - sudo cp -a ntfy_2.22.0_linux_armv7/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.22.0_linux_armv7/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv7.tar.gz + tar zxvf ntfy_2.23.0_linux_armv7.tar.gz + sudo cp -a ntfy_2.23.0_linux_armv7/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_arm64.tar.gz - tar zxvf ntfy_2.22.0_linux_arm64.tar.gz - sudo cp -a ntfy_2.22.0_linux_arm64/ntfy /usr/bin/ntfy - sudo mkdir /etc/ntfy && sudo cp ntfy_2.22.0_linux_arm64/{client,server}/*.yml /etc/ntfy + wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_arm64.tar.gz + tar zxvf ntfy_2.23.0_linux_arm64.tar.gz + sudo cp -a ntfy_2.23.0_linux_arm64/ntfy /usr/bin/ntfy + sudo mkdir /etc/ntfy && sudo cp ntfy_2.23.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo ntfy serve ``` @@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic === "x86_64/amd64" ```bash - sudo mv ntfy_2.22.0_linux_amd64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.23.0_linux_amd64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "armv6" ```bash - sudo mv ntfy_2.22.0_linux_armv6/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.23.0_linux_armv6/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "armv7/armhf" ```bash - sudo mv ntfy_2.22.0_linux_armv7/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.23.0_linux_armv7/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` === "arm64" ```bash - sudo mv ntfy_2.22.0_linux_arm64/server/ntfy.service /etc/systemd/system/ + sudo mv ntfy_2.23.0_linux_arm64/server/ntfy.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/ntfy.service ``` @@ -118,25 +118,25 @@ Install the ntfy server service script: === "x86_64/amd64" ```bash - sudo mv ntfy_2.22.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.23.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "armv6" ```bash - sudo mv ntfy_2.22.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.23.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "armv7/armhf" ```bash - sudo mv ntfy_2.22.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.23.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` === "arm64" ```bash - sudo mv ntfy_2.22.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy + sudo mv ntfy_2.23.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy ``` @@ -204,7 +204,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -212,7 +212,7 @@ Manually installing the .deb file: === "armv6" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv6.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv6.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -220,7 +220,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -228,7 +228,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -238,28 +238,28 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv6" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv6.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv6.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` @@ -301,18 +301,18 @@ pkg install go-ntfy ## macOS 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.22.0/ntfy_2.22.0_darwin_all.tar.gz), +To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_darwin_all.tar.gz), 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 `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). ```bash -curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_darwin_all.tar.gz > ntfy_2.22.0_darwin_all.tar.gz -tar zxvf ntfy_2.22.0_darwin_all.tar.gz -sudo cp -a ntfy_2.22.0_darwin_all/ntfy /usr/local/bin/ntfy +curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_darwin_all.tar.gz > ntfy_2.23.0_darwin_all.tar.gz +tar zxvf ntfy_2.23.0_darwin_all.tar.gz +sudo cp -a ntfy_2.23.0_darwin_all/ntfy /usr/local/bin/ntfy mkdir ~/Library/Application\ Support/ntfy -cp ntfy_2.22.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml +cp ntfy_2.23.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml ntfy --help ``` @@ -333,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. To install, you can either -* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.22.0/ntfy_2.22.0_windows_amd64.zip), +* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.23.0/ntfy_2.23.0_windows_amd64.zip), 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` diff --git a/docs/releases.md b/docs/releases.md index ae1fc6f8..a81fc47c 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -6,12 +6,24 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release | Component | Version | Release date | |------------------|---------|--------------| -| ntfy server | v2.22.0 | Apr 21, 2026 | +| ntfy server | v2.23.0 | May 17, 2026 | | ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy iOS app | v1.6.0 | May 12, 2026 | Please check out the release notes for [upcoming releases](#not-released-yet) below. +## ntfy server v2.23.0 +Released May 17, 2026 + +**Features:** + +* Add per-visitor rate limit on new topic creations (`visitor-topic-creation-limit-burst` / `visitor-topic-creation-limit-replenish`, defaults 100 burst / 1m replenish) to mitigate topic-enumeration / squatting attacks that inflate the in-memory topic map + +**Bug fixes + maintenance:** + +* Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint +* Restrict the publish dialog's local file preview to safe image types (png/jpg/gif/webp) to prevent same-origin script execution from blob URLs when previewing a crafted SVG ([GHSA-j8hr-p342-xrmh](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-j8hr-p342-xrmh), thanks to [@Venukamatchi](https://github.com/Venukamatchi) for reporting) + ## ntfy iOS app v1.6.0 Released May 12, 2026 @@ -1893,17 +1905,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet -## ntfy server v2.23.x (UNRELEASED) - -**Features:** - -* Add per-visitor rate limit on new topic creations (`visitor-topic-creation-limit-burst` / `visitor-topic-creation-limit-replenish`, defaults 100 burst / 1m replenish) to mitigate topic-enumeration / squatting attacks that inflate the in-memory topic map - -**Bug fixes + maintenance:** - -* Remove `stacktrace-js`, `stacktrace-gps`, `humanize-duration`, and `js-base64` from the web app to reduce dependency and security footprint -* Restrict the publish dialog's local file preview to safe image types (png/jpg/gif/webp) to prevent same-origin script execution from blob URLs when previewing a crafted SVG ([GHSA-j8hr-p342-xrmh](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-j8hr-p342-xrmh), thanks to [@Venukamatchi](https://github.com/Venukamatchi) for reporting) - ## ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out From 47044c632e6b898b4b34478e9955fc5c608ff448 Mon Sep 17 00:00:00 2001 From: binwiederhier Date: Wed, 20 May 2026 15:26:15 -0400 Subject: [PATCH 126/126] iOS release notes --- docs/publish.md | 2 +- docs/releases.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/publish.md b/docs/publish.md index 5cef10cf..9ac600e9 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -932,7 +932,7 @@ Here's an example of how it will look on Android: ## Attachments -_Supported on:_ :material-android: :material-firefox: +_Supported on:_ :material-android: :material-apple: :material-firefox: You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded onto your phone (depending on size and setting automatically), and can be used from the Downloads folder. diff --git a/docs/releases.md b/docs/releases.md index a81fc47c..ce6c3bf0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1905,6 +1905,24 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release ## Not released yet +## ntfy iOS app v1.7.0 (UNRELEASED) + +This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS +feature gaps. Images sent via the `Attach` header (or as a PUT body) are now previewed inline in the notification banner +and inside the app, and other attachments can be downloaded, previewed via Quick Look, and shared from the notification +row. There's also a new "Download attachments" setting to control auto-download by size. + +**Features:** + +* Show image previews in notifications and inline in the notification list, with tap-to-zoom Quick Look preview and share sheet ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), [#276](https://github.com/binwiederhier/ntfy/issues/276), [#1226](https://github.com/binwiederhier/ntfy/issues/1226), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Download non-image attachments on demand with progress indication, persist them locally, and reuse files already fetched by the notification service extension ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Add "Download attachments" setting with size thresholds (Never, Under 100 KB / 500 KB / 1 MB / 5 MB / 10 MB / 50 MB, Always) to control automatic attachment downloads ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) + +**Bug fixes + maintenance:** + +* Improve background download reliability so attachments continue downloading when the app is suspended ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) +* Reorganize notification and subscription views into their own folders and split out `NotificationRowView` for readability ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution) + ## ntfy Android v1.25.x (UNRELEASED) This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out