Skip to content

Commit 40f990f

Browse files
Merge pull request #1050 from binwiederhier/message-size-limit+delay-limit
Message size limit+delay limit
2 parents a8c17c1 + 8931f25 commit 40f990f

15 files changed

+273
-126
lines changed

cmd/serve.go

+103-61
Large diffs are not rendered by default.

cmd/tier.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -366,9 +366,9 @@ func printTier(c *cli.Context, tier *user.Tier) {
366366
fmt.Fprintf(c.App.ErrWriter, "- Email limit: %d\n", tier.EmailLimit)
367367
fmt.Fprintf(c.App.ErrWriter, "- Phone call limit: %d\n", tier.CallLimit)
368368
fmt.Fprintf(c.App.ErrWriter, "- Reservation limit: %d\n", tier.ReservationLimit)
369-
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSize(tier.AttachmentFileSizeLimit))
370-
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSize(tier.AttachmentTotalSizeLimit))
369+
fmt.Fprintf(c.App.ErrWriter, "- Attachment file size limit: %s\n", util.FormatSizeHuman(tier.AttachmentFileSizeLimit))
370+
fmt.Fprintf(c.App.ErrWriter, "- Attachment total size limit: %s\n", util.FormatSizeHuman(tier.AttachmentTotalSizeLimit))
371371
fmt.Fprintf(c.App.ErrWriter, "- Attachment expiry duration: %s (%d seconds)\n", tier.AttachmentExpiryDuration.String(), int64(tier.AttachmentExpiryDuration.Seconds()))
372-
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSize(tier.AttachmentBandwidthLimit))
372+
fmt.Fprintf(c.App.ErrWriter, "- Attachment daily bandwidth limit: %s\n", util.FormatSizeHuman(tier.AttachmentBandwidthLimit))
373373
fmt.Fprintf(c.App.ErrWriter, "- Stripe prices (monthly/yearly): %s\n", prices)
374374
}

docs/config.md

+27-14
Large diffs are not rendered by default.

docs/publish.md

+2-3
Original file line numberDiff line numberDiff line change
@@ -738,9 +738,8 @@ Usage is pretty straight forward. You can set the delivery time using the `X-Del
738738
`3h`, `2 days`), or a natural language time string (e.g. `10am`, `8:30pm`, `tomorrow, 3pm`, `Tuesday, 7am`,
739739
[and more](https://github.com/olebedev/when)).
740740

741-
As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can currently
742-
not be configured otherwise ([let me know](https://github.com/binwiederhier/ntfy/issues) if you'd like to change
743-
these limits).
741+
As of today, the minimum delay you can set is **10 seconds** and the maximum delay is **3 days**. This can be configured
742+
with the `message-delay-limit` option).
744743

745744
For the purposes of [message caching](config.md#message-cache), scheduled messages are kept in the cache until 12 hours
746745
after they were delivered (or whatever the server-side cache duration is set to). For instance, if a message is scheduled

docs/releases.md

+9-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Many thanks to [@tcaputi](https://github.com/tcaputi) for fixing the issues, and
1313

1414
* UI not updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267)/[#402](https://github.com/binwiederhier/ntfy/issues/402), thanks to [@tcaputi](https://github.com/tcaputi))
1515

16-
### ntfy server v2.8.0
16+
## ntfy server v2.8.0
1717
Released November 19, 2023
1818

1919
This release brings a handful of random bug fixes: two unrelated access control list fixes, a fix around web app crashes for languages with underscores in the language code (e.g. `zh_Hant`, `zh_Hans`, `pt_BR`, ...), a workaround for the `Priority` header (often used in Cloudflare setups), and support among others support for HTML-only emails (finally), web app crash fixes
@@ -1315,6 +1315,14 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
13151315

13161316
### ntfy server v2.9.0
13171317

1318+
!!! info
1319+
**Breaking change**: The `Rate-Topics` header was removed due to a [DoS issue](https://github.com/binwiederhier/ntfy/issues/1048). This only affects installations with `visitor-subscriber-rate-limiting: true`, which is not the default and likely very rarely used.
1320+
1321+
**Features:**
1322+
1323+
* Support for larger message delays with `message-delay-limit` (see [message limits](config.md#message-limits), [#1050](https://github.com/binwiederhier/ntfy/pull/1050)/[#1019](https://github.com/binwiederhier/ntfy/issues/1019), thanks to [@MrChadMWood](https://github.com/MrChadMWood) for reporting)
1324+
* Support for larger message body sizes with `message-size-limit` (use at your own risk, see [message limits](config.md#message-limits), [#836](https://github.com/binwiederhier/ntfy/pull/836)/[#1050](https://github.com/binwiederhier/ntfy/pull/1050), thanks to [@zhzy0077](https://github.com/zhzy0077) for implementing this, and to [@nkjshlsqja7331](https://github.com/nkjshlsqja7331) for reporting)
1325+
13181326
**Bug fixes + maintenance:**
13191327

13201328
* Remove `Rate-Topics` header due to DoS security issue if `visitor-subscriber-rate-limiting: true` ([#1048](https://github.com/binwiederhier/ntfy/issues/1048))

server/config.go

+10-9
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@ import (
1212
const (
1313
DefaultListenHTTP = ":80"
1414
DefaultCacheDuration = 12 * time.Hour
15+
DefaultCacheBatchTimeout = time.Duration(0)
1516
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
1617
DefaultManagerInterval = time.Minute
1718
DefaultDelayedSenderInterval = 10 * time.Second
18-
DefaultMinDelay = 10 * time.Second
19-
DefaultMaxDelay = 3 * 24 * time.Hour
19+
DefaultMessageDelayMin = 10 * time.Second
20+
DefaultMessageDelayMax = 3 * 24 * time.Hour
2021
DefaultFirebaseKeepaliveInterval = 3 * time.Hour // ~control topic (Android), not too frequently to save battery
2122
DefaultFirebasePollInterval = 20 * time.Minute // ~poll topic (iOS), max. 2-3 times per hour (see docs)
2223
DefaultFirebaseQuotaExceededPenaltyDuration = 10 * time.Minute // Time that over-users are locked out of Firebase if it returns "quota exceeded"
@@ -34,7 +35,7 @@ const (
3435
// - total topic limit: max number of topics overall
3536
// - various attachment limits
3637
const (
37-
DefaultMessageLengthLimit = 4096 // Bytes
38+
DefaultMessageSizeLimit = 4096 // Bytes; note that FCM/APNS have a limit of ~4 KB for the entire message
3839
DefaultTotalTopicLimit = 15000
3940
DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB
4041
DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB
@@ -122,9 +123,9 @@ type Config struct {
122123
MetricsEnable bool
123124
MetricsListenHTTP string
124125
ProfileListenHTTP string
125-
MessageLimit int
126-
MinDelay time.Duration
127-
MaxDelay time.Duration
126+
MessageDelayMin time.Duration
127+
MessageDelayMax time.Duration
128+
MessageSizeLimit int
128129
TotalTopicLimit int
129130
TotalAttachmentSizeLimit int64
130131
VisitorSubscriptionLimit int
@@ -211,9 +212,9 @@ func NewConfig() *Config {
211212
TwilioPhoneNumber: "",
212213
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
213214
TwilioVerifyService: "",
214-
MessageLimit: DefaultMessageLengthLimit,
215-
MinDelay: DefaultMinDelay,
216-
MaxDelay: DefaultMaxDelay,
215+
MessageSizeLimit: DefaultMessageSizeLimit,
216+
MessageDelayMin: DefaultMessageDelayMin,
217+
MessageDelayMax: DefaultMessageDelayMax,
217218
TotalTopicLimit: DefaultTotalTopicLimit,
218219
TotalAttachmentSizeLimit: 0,
219220
VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit,

server/server.go

+5-5
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
733733
if err != nil {
734734
return nil, err
735735
}
736-
body, err := util.Peek(r.Body, s.config.MessageLimit)
736+
body, err := util.Peek(r.Body, s.config.MessageSizeLimit)
737737
if err != nil {
738738
return nil, err
739739
}
@@ -996,9 +996,9 @@ func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, fi
996996
delay, err := util.ParseFutureTime(delayStr, time.Now())
997997
if err != nil {
998998
return false, false, "", "", false, errHTTPBadRequestDelayCannotParse
999-
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
999+
} else if delay.Unix() < time.Now().Add(s.config.MessageDelayMin).Unix() {
10001000
return false, false, "", "", false, errHTTPBadRequestDelayTooSmall
1001-
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
1001+
} else if delay.Unix() > time.Now().Add(s.config.MessageDelayMax).Unix() {
10021002
return false, false, "", "", false, errHTTPBadRequestDelayTooLarge
10031003
}
10041004
m.Time = delay.Unix()
@@ -1754,7 +1754,7 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
17541754
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
17551755
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
17561756
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
1757-
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageLimit*2, false) // 2x to account for JSON format overhead
1757+
m, err := readJSONWithLimit[publishMessage](r.Body, s.config.MessageSizeLimit*2, false) // 2x to account for JSON format overhead
17581758
if err != nil {
17591759
return err
17601760
}
@@ -1812,7 +1812,7 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
18121812

18131813
func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
18141814
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
1815-
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)
1815+
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageSizeLimit)
18161816
if err != nil {
18171817
logvr(v, r).Tag(tagMatrix).Err(err).Debug("Invalid Matrix request")
18181818
if e, ok := err.(*errMatrixPushkeyRejected); ok {

server/server.yml

+10
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,16 @@
236236
# upstream-base-url:
237237
# upstream-access-token:
238238

239+
# Configures message-specific limits
240+
#
241+
# - message-size-limit defines the max size of a message body. Please note message sizes >4K are NOT RECOMMENDED,
242+
# and largely untested. If FCM and/or APNS is used, the limit should stay 4K, because their limits are around that size.
243+
# If you increase this size limit regardless, FCM and APNS will NOT work for large messages.
244+
# - message-delay-limit defines the max delay of a message when using the "Delay" header.
245+
#
246+
# message-size-limit: "4k"
247+
# message-delay-limit: "3d"
248+
239249
# Rate limiting: Total number of topics before the server rejects new topics.
240250
#
241251
# global-topic-limit: 15000

server/server_account_test.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -718,11 +718,11 @@ func TestAccount_Reservation_Delete_Messages_And_Attachments(t *testing.T) {
718718
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser))
719719
require.Nil(t, s.userManager.AddTier(&user.Tier{
720720
Code: "starter",
721-
MessageLimit: 10,
721+
MessageSizeLimit: 10,
722722
}))
723723
require.Nil(t, s.userManager.AddTier(&user.Tier{
724724
Code: "pro",
725-
MessageLimit: 20,
725+
MessageSizeLimit: 20,
726726
}))
727727
require.Nil(t, s.userManager.ChangeTier("phil", "starter"))
728728

server/smtp_server.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -150,8 +150,8 @@ func (s *smtpSession) Data(r io.Reader) error {
150150
return err
151151
}
152152
body = strings.TrimSpace(body)
153-
if len(body) > conf.MessageLimit {
154-
body = body[:conf.MessageLimit]
153+
if len(body) > conf.MessageSizeLimit {
154+
body = body[:conf.MessageSizeLimit]
155155
}
156156
m := newDefaultMessage(s.topic, body)
157157
subject := strings.TrimSpace(msg.Header.Get("Subject"))

server/visitor.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ const (
3030
visitorDefaultCallsLimit = int64(0)
3131
)
3232

33-
// Constants used to convert a tier-user's MessageLimit (see user.Tier) into adequate request limiter
33+
// Constants used to convert a tier-user's MessageSizeLimit (see user.Tier) into adequate request limiter
3434
// values (token bucket). This is only used to increase the values in server.yml, never decrease them.
3535
//
36-
// Example: Assuming a user.Tier's MessageLimit is 10,000:
36+
// Example: Assuming a user.Tier's MessageSizeLimit is 10,000:
3737
// - the allowed burst is 500 (= 10,000 * 5%), which is < 1000 (the max)
3838
// - the replenish rate is 2 * 10,000 / 24 hours
3939
const (

util/time.go

+29-11
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010
)
1111

1212
var (
13-
errUnparsableTime = errors.New("unable to parse time")
14-
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
13+
errInvalidDuration = errors.New("unable to parse duration")
14+
durationStrRegex = regexp.MustCompile(`(?i)^(\d+)\s*(d|days?|h|hours?|m|mins?|minutes?|s|secs?|seconds?)$`)
1515
)
1616

1717
const (
@@ -51,7 +51,7 @@ func ParseFutureTime(s string, now time.Time) (time.Time, error) {
5151
if err == nil {
5252
return t, nil
5353
}
54-
return time.Time{}, errUnparsableTime
54+
return time.Time{}, errInvalidDuration
5555
}
5656

5757
// ParseDuration is like time.ParseDuration, except that it also understands days (d), which
@@ -65,7 +65,7 @@ func ParseDuration(s string) (time.Duration, error) {
6565
if matches != nil {
6666
number, err := strconv.Atoi(matches[1])
6767
if err != nil {
68-
return 0, errUnparsableTime
68+
return 0, errInvalidDuration
6969
}
7070
switch unit := matches[2][0:1]; unit {
7171
case "d":
@@ -77,44 +77,62 @@ func ParseDuration(s string) (time.Duration, error) {
7777
case "s":
7878
return time.Duration(number) * time.Second, nil
7979
default:
80-
return 0, errUnparsableTime
80+
return 0, errInvalidDuration
8181
}
8282
}
83-
return 0, errUnparsableTime
83+
return 0, errInvalidDuration
84+
}
85+
86+
// FormatDuration formats a time.Duration into a human-readable string, e.g. "2d", "20h", "30m", "40s".
87+
// It rounds to the largest unit that is not zero, thereby effectively rounding down.
88+
func FormatDuration(d time.Duration) string {
89+
if d >= 24*time.Hour {
90+
return strconv.Itoa(int(d/(24*time.Hour))) + "d"
91+
}
92+
if d >= time.Hour {
93+
return strconv.Itoa(int(d/time.Hour)) + "h"
94+
}
95+
if d >= time.Minute {
96+
return strconv.Itoa(int(d/time.Minute)) + "m"
97+
}
98+
if d >= time.Second {
99+
return strconv.Itoa(int(d/time.Second)) + "s"
100+
}
101+
return "0s"
84102
}
85103

86104
func parseFromDuration(s string, now time.Time) (time.Time, error) {
87105
d, err := ParseDuration(s)
88106
if err == nil {
89107
return now.Add(d), nil
90108
}
91-
return time.Time{}, errUnparsableTime
109+
return time.Time{}, errInvalidDuration
92110
}
93111

94112
func parseUnixTime(s string, now time.Time) (time.Time, error) {
95113
t, err := strconv.Atoi(s)
96114
if err != nil {
97115
return time.Time{}, err
98116
} else if int64(t) < now.Unix() {
99-
return time.Time{}, errUnparsableTime
117+
return time.Time{}, errInvalidDuration
100118
}
101119
return time.Unix(int64(t), 0).UTC(), nil
102120
}
103121

104122
func parseNaturalTime(s string, now time.Time) (time.Time, error) {
105123
r, err := when.EN.Parse(s, now) // returns "nil, nil" if no matches!
106124
if err != nil || r == nil {
107-
return time.Time{}, errUnparsableTime
125+
return time.Time{}, errInvalidDuration
108126
} else if r.Time.After(now) {
109127
return r.Time, nil
110128
}
111129
// Hack: If the time is parsable, but not in the future,
112130
// simply append "tomorrow, " to it.
113131
r, err = when.EN.Parse("tomorrow, "+s, now) // returns "nil, nil" if no matches!
114132
if err != nil || r == nil {
115-
return time.Time{}, errUnparsableTime
133+
return time.Time{}, errInvalidDuration
116134
} else if r.Time.After(now) {
117135
return r.Time, nil
118136
}
119-
return time.Time{}, errUnparsableTime
137+
return time.Time{}, errInvalidDuration
120138
}

util/time_test.go

+24
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,27 @@ func TestParseDuration(t *testing.T) {
9292
require.Nil(t, err)
9393
require.Equal(t, time.Duration(0), d)
9494
}
95+
96+
func TestFormatDuration(t *testing.T) {
97+
values := []struct {
98+
duration time.Duration
99+
expected string
100+
}{
101+
{24 * time.Second, "24s"},
102+
{56 * time.Minute, "56m"},
103+
{time.Hour, "1h"},
104+
{2 * time.Hour, "2h"},
105+
{24 * time.Hour, "1d"},
106+
{3 * 24 * time.Hour, "3d"},
107+
}
108+
for _, value := range values {
109+
require.Equal(t, value.expected, FormatDuration(value.duration))
110+
d, err := ParseDuration(FormatDuration(value.duration))
111+
require.Nil(t, err)
112+
require.Equalf(t, value.duration, d, "duration does not match: %v != %v", value.duration, d)
113+
}
114+
}
115+
116+
func TestFormatDuration_Rounded(t *testing.T) {
117+
require.Equal(t, "1d", FormatDuration(47*time.Hour))
118+
}

util/util.go

+20-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"errors"
88
"fmt"
99
"io"
10+
"math"
1011
"math/rand"
1112
"net/netip"
1213
"os"
@@ -215,6 +216,8 @@ func ParseSize(s string) (int64, error) {
215216
return -1, fmt.Errorf("cannot convert number %s", matches[1])
216217
}
217218
switch strings.ToUpper(matches[2]) {
219+
case "T":
220+
return int64(value) * 1024 * 1024 * 1024 * 1024, nil
218221
case "G":
219222
return int64(value) * 1024 * 1024 * 1024, nil
220223
case "M":
@@ -226,8 +229,23 @@ func ParseSize(s string) (int64, error) {
226229
}
227230
}
228231

229-
// FormatSize formats bytes into a human-readable notation, e.g. 2.1 MB
232+
// FormatSize formats the size in a way that it can be parsed by ParseSize.
233+
// It does not include decimal places. Uneven sizes are rounded down.
230234
func FormatSize(b int64) string {
235+
const unit = 1024
236+
if b < unit {
237+
return fmt.Sprintf("%d", b)
238+
}
239+
div, exp := int64(unit), 0
240+
for n := b / unit; n >= unit; n /= unit {
241+
div *= unit
242+
exp++
243+
}
244+
return fmt.Sprintf("%d%c", int(math.Floor(float64(b)/float64(div))), "KMGT"[exp])
245+
}
246+
247+
// FormatSizeHuman formats bytes into a human-readable notation, e.g. 2.1 MB
248+
func FormatSizeHuman(b int64) string {
231249
const unit = 1024
232250
if b < unit {
233251
return fmt.Sprintf("%d bytes", b)
@@ -237,7 +255,7 @@ func FormatSize(b int64) string {
237255
div *= unit
238256
exp++
239257
}
240-
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
258+
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGT"[exp])
241259
}
242260

243261
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the

0 commit comments

Comments
 (0)