From eb5b86ffe20a552c6f9e6a3a523539561741da74 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 2 Jan 2022 23:56:12 +0100 Subject: [PATCH 01/24] WIP: Attachments --- .gitignore | 1 + cmd/serve.go | 5 +- docs/publish.md | 1 + server/config.go | 10 ++- server/message.go | 23 ++++-- server/server.go | 128 ++++++++++++++++++++++++++----- server/server_test.go | 11 +-- util/content_type_writer.go | 41 ++++++++++ util/content_type_writer_test.go | 50 ++++++++++++ util/limit.go | 41 ++++++++++ util/limit_test.go | 63 ++++++++++++++- util/peak.go | 61 +++++++++++++++ util/peak_test.go | 55 +++++++++++++ 13 files changed, 444 insertions(+), 46 deletions(-) create mode 100644 util/content_type_writer.go create mode 100644 util/content_type_writer_test.go create mode 100644 util/peak.go create mode 100644 util/peak_test.go diff --git a/.gitignore b/.gitignore index 6dffcf55..6d12c730 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ build/ .idea/ server/docs/ tools/fbsend/fbsend +playground/ *.iml diff --git a/cmd/serve.go b/cmd/serve.go index 5545206f..f4161e1a 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -20,6 +20,7 @@ var flagsServe = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), @@ -69,6 +70,7 @@ func execServe(c *cli.Context) error { firebaseKeyFile := c.String("firebase-key-file") cacheFile := c.String("cache-file") cacheDuration := c.Duration("cache-duration") + attachmentCacheDir := c.String("attachment-cache-dir") keepaliveInterval := c.Duration("keepalive-interval") managerInterval := c.Duration("manager-interval") smtpSenderAddr := c.String("smtp-sender-addr") @@ -117,6 +119,7 @@ func execServe(c *cli.Context) error { conf.FirebaseKeyFile = firebaseKeyFile conf.CacheFile = cacheFile conf.CacheDuration = cacheDuration + conf.AttachmentCacheDir = attachmentCacheDir conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval conf.SMTPSenderAddr = smtpSenderAddr @@ -126,7 +129,7 @@ func execServe(c *cli.Context) error { conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix - conf.GlobalTopicLimit = globalTopicLimit + conf.TotalTopicLimit = globalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorRequestLimitBurst = visitorRequestLimitBurst conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish diff --git a/docs/publish.md b/docs/publish.md index ec017e05..46a5a334 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -886,3 +886,4 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | +| `X-UnifiedPush` | `UnifiedPush`, `up` | XXXXXXXXXXXXXXXX | diff --git a/server/config.go b/server/config.go index 30f937e9..68a911fb 100644 --- a/server/config.go +++ b/server/config.go @@ -14,6 +14,7 @@ const ( DefaultMinDelay = 10 * time.Second DefaultMaxDelay = 3 * 24 * time.Hour DefaultMessageLimit = 4096 + DefaultAttachmentSizeLimit = 5 * 1024 * 1024 DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery ) @@ -41,6 +42,8 @@ type Config struct { FirebaseKeyFile string CacheFile string CacheDuration time.Duration + AttachmentCacheDir string + AttachmentSizeLimit int64 KeepaliveInterval time.Duration ManagerInterval time.Duration AtSenderInterval time.Duration @@ -55,7 +58,8 @@ type Config struct { MessageLimit int MinDelay time.Duration MaxDelay time.Duration - GlobalTopicLimit int + TotalTopicLimit int + TotalAttachmentSizeLimit int64 VisitorRequestLimitBurst int VisitorRequestLimitReplenish time.Duration VisitorEmailLimitBurst int @@ -75,6 +79,8 @@ func NewConfig() *Config { FirebaseKeyFile: "", CacheFile: "", CacheDuration: DefaultCacheDuration, + AttachmentCacheDir: "", + AttachmentSizeLimit: DefaultAttachmentSizeLimit, KeepaliveInterval: DefaultKeepaliveInterval, ManagerInterval: DefaultManagerInterval, MessageLimit: DefaultMessageLimit, @@ -82,7 +88,7 @@ func NewConfig() *Config { MaxDelay: DefaultMaxDelay, AtSenderInterval: DefaultAtSenderInterval, FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, - GlobalTopicLimit: DefaultGlobalTopicLimit, + TotalTopicLimit: DefaultGlobalTopicLimit, VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, diff --git a/server/message.go b/server/message.go index ad870e09..2c3fb198 100644 --- a/server/message.go +++ b/server/message.go @@ -18,14 +18,21 @@ const ( // message represents a message published to a topic type message struct { - ID string `json:"id"` // Random message ID - Time int64 `json:"time"` // Unix time in seconds - Event string `json:"event"` // One of the above - Topic string `json:"topic"` - Priority int `json:"priority,omitempty"` - Tags []string `json:"tags,omitempty"` - Title string `json:"title,omitempty"` - Message string `json:"message,omitempty"` + ID string `json:"id"` // Random message ID + Time int64 `json:"time"` // Unix time in seconds + Event string `json:"event"` // One of the above + Topic string `json:"topic"` + Priority int `json:"priority,omitempty"` + Tags []string `json:"tags,omitempty"` + Title string `json:"title,omitempty"` + Message string `json:"message,omitempty"` + Attachment *attachment `json:"attachment,omitempty"` +} + +type attachment struct { + Name string `json:"name"` + Type string `json:"type"` + URL string `json:"url"` } // messageEncoder is a function that knows how to encode a message diff --git a/server/server.go b/server/server.go index 9cf76dea..b8ca70f7 100644 --- a/server/server.go +++ b/server/server.go @@ -15,14 +15,18 @@ import ( "html/template" "io" "log" + "mime" "net" "net/http" "net/http/httptest" + "os" + "path/filepath" "regexp" "strconv" "strings" "sync" "time" + "unicode/utf8" ) // TODO add "max messages in a topic" limit @@ -96,7 +100,8 @@ var ( staticRegex = regexp.MustCompile(`^/static/.+`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) - disallowedTopics = []string{"docs", "static"} + fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) + disallowedTopics = []string{"docs", "static", "file"} templateFnMap = template.FuncMap{ "durationToHuman": util.DurationToHuman, @@ -117,22 +122,26 @@ var ( docsStaticFs embed.FS docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} - errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} - errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} - errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} - errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} - errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} - errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} - errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} - errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} - errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} + errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} + errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} + errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} + errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} + errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} + errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} + errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} + errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} + errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40011, http.StatusBadRequest, "attachments disallowed", ""} + errHTTPBadRequestAttachmentsPublishDisallowed = &errHTTP{40011, http.StatusBadRequest, "invalid message: invalid encoding or too large, and attachments are not allowed", ""} + errHTTPBadRequestMessageTooLarge = &errHTTP{40013, http.StatusBadRequest, "invalid message: too large", ""} + errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} + errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} ) const ( @@ -163,6 +172,11 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } + if conf.AttachmentCacheDir != "" { + if err := os.MkdirAll(conf.AttachmentCacheDir, 0700); err != nil { + return nil, err + } + } return &Server{ config: conf, cache: cache, @@ -302,6 +316,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { return s.handleStatic(w, r) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.handleDocs(w, r) + } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) { + return s.handleFile(w, r) } else if r.Method == http.MethodOptions { return s.handleOptions(w, r) } else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) { @@ -357,17 +373,45 @@ func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error { return nil } +func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) error { + if s.config.AttachmentCacheDir == "" { + return errHTTPBadRequestAttachmentsDisallowed + } + matches := fileRegex.FindStringSubmatch(r.URL.Path) + if len(matches) != 2 { + return errHTTPInternalErrorInvalidFilePath + } + messageID := matches[1] + file := filepath.Join(s.config.AttachmentCacheDir, messageID) + stat, err := os.Stat(file) + if err != nil { + return errHTTPNotFound + } + w.Header().Set("Length", fmt.Sprintf("%d", stat.Size())) + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(util.NewContentTypeWriter(w), f) + return err +} + func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { t, err := s.topicFromPath(r.URL.Path) if err != nil { return err } - reader := io.LimitReader(r.Body, int64(s.config.MessageLimit)) - b, err := io.ReadAll(reader) + body, err := util.Peak(r.Body, s.config.MessageLimit) if err != nil { return err } - m := newDefaultMessage(t.ID, strings.TrimSpace(string(b))) + m := newDefaultMessage(t.ID, "") + if !body.LimitReached && utf8.Valid(body.PeakedBytes) { + m.Message = strings.TrimSpace(string(body.PeakedBytes)) + } else if err := s.writeAttachment(v, m, body); err != nil { + return err + } cache, firebase, email, err := s.parsePublishParams(r, m) if err != nil { return err @@ -478,6 +522,48 @@ func readParam(r *http.Request, names ...string) string { return "" } +func (s *Server) writeAttachment(v *visitor, m *message, body *util.PeakedReadCloser) error { + if s.config.AttachmentCacheDir == "" || !util.FileExists(s.config.AttachmentCacheDir) { + return errHTTPBadRequestAttachmentsPublishDisallowed + } + contentType := http.DetectContentType(body.PeakedBytes) + exts, err := mime.ExtensionsByType(contentType) + if err != nil { + return err + } + ext := ".bin" + if len(exts) > 0 { + ext = exts[0] + } + filename := fmt.Sprintf("attachment%s", ext) + file := filepath.Join(s.config.AttachmentCacheDir, m.ID) + f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer f.Close() + fileSizeLimiter := util.NewLimiter(s.config.AttachmentSizeLimit) + limitWriter := util.NewLimitWriter(f, fileSizeLimiter) + if _, err := io.Copy(limitWriter, body); err != nil { + os.Remove(file) + if err == util.ErrLimitReached { + return errHTTPBadRequestMessageTooLarge + } + return err + } + if err := f.Close(); err != nil { + os.Remove(file) + return err + } + m.Message = fmt.Sprintf("You received a file: %s", filename) + m.Attachment = &attachment{ + Name: filename, + Type: contentType, + URL: fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext), + } + return nil +} + func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error { encoder := func(msg *message) (string, error) { var buf bytes.Buffer @@ -691,7 +777,7 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { return nil, errHTTPBadRequestTopicDisallowed } if _, ok := s.topics[id]; !ok { - if len(s.topics) >= s.config.GlobalTopicLimit { + if len(s.topics) >= s.config.TotalTopicLimit { return nil, errHTTPTooManyRequestsLimitGlobalTopics } s.topics[id] = newTopic(id) diff --git a/server/server_test.go b/server/server_test.go index e713e604..f8a1a8a2 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -165,17 +165,8 @@ func TestServer_PublishLargeMessage(t *testing.T) { s := newTestServer(t, newTestConfig(t)) body := strings.Repeat("this is a large message", 5000) - truncated := body[0:4096] response := request(t, s, "PUT", "/mytopic", body, nil) - msg := toMessage(t, response.Body.String()) - require.NotEmpty(t, msg.ID) - require.Equal(t, truncated, msg.Message) - require.Equal(t, 4096, len(msg.Message)) - - response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) - messages := toMessages(t, response.Body.String()) - require.Equal(t, 1, len(messages)) - require.Equal(t, truncated, messages[0].Message) + require.Equal(t, 400, response.Code) } func TestServer_PublishPriority(t *testing.T) { diff --git a/util/content_type_writer.go b/util/content_type_writer.go new file mode 100644 index 00000000..fb3c43f8 --- /dev/null +++ b/util/content_type_writer.go @@ -0,0 +1,41 @@ +package util + +import ( + "net/http" + "strings" +) + +// ContentTypeWriter is an implementation of http.ResponseWriter that will detect the content type and set the +// Content-Type and (optionally) Content-Disposition headers accordingly. +// +// It will always set a Content-Type based on http.DetectContentType, but will never send the "text/html" +// content type. +type ContentTypeWriter struct { + w http.ResponseWriter + sniffed bool +} + +// NewContentTypeWriter creates a new ContentTypeWriter +func NewContentTypeWriter(w http.ResponseWriter) *ContentTypeWriter { + return &ContentTypeWriter{w, false} +} + +func (w *ContentTypeWriter) Write(p []byte) (n int, err error) { + if w.sniffed { + return w.w.Write(p) + } + // Detect and set Content-Type header + // Fix content types that we don't want to inline-render in the browser. In particular, + // we don't want to render HTML in the browser for security reasons. + contentType := http.DetectContentType(p) + if strings.HasPrefix(contentType, "text/html") { + contentType = strings.ReplaceAll(contentType, "text/html", "text/plain") + } else if contentType == "application/octet-stream" { + contentType = "" // Reset to let downstream http.ResponseWriter take care of it + } + if contentType != "" { + w.w.Header().Set("Content-Type", contentType) + } + w.sniffed = true + return w.w.Write(p) +} diff --git a/util/content_type_writer_test.go b/util/content_type_writer_test.go new file mode 100644 index 00000000..08dd751b --- /dev/null +++ b/util/content_type_writer_test.go @@ -0,0 +1,50 @@ +package util + +import ( + "crypto/rand" + "github.com/stretchr/testify/require" + "net/http/httptest" + "testing" +) + +func TestSniffWriter_WriteHTML(t *testing.T) { + rr := httptest.NewRecorder() + sw := NewContentTypeWriter(rr) + sw.Write([]byte("")) + require.Equal(t, "text/plain; charset=utf-8", rr.Header().Get("Content-Type")) +} + +func TestSniffWriter_WriteTwoWriteCalls(t *testing.T) { + rr := httptest.NewRecorder() + sw := NewContentTypeWriter(rr) + sw.Write([]byte{0x25, 0x50, 0x44, 0x46, 0x2d, 0x11, 0x22, 0x33}) + sw.Write([]byte("")) + require.Equal(t, "application/pdf", rr.Header().Get("Content-Type")) +} + +func TestSniffWriter_NoSniffWriterWriteHTML(t *testing.T) { + // This test just makes sure that without the sniff-w, we would get text/html + + rr := httptest.NewRecorder() + rr.Write([]byte("")) + require.Equal(t, "text/html; charset=utf-8", rr.Header().Get("Content-Type")) +} + +func TestSniffWriter_WriteHTMLSplitIntoTwoWrites(t *testing.T) { + // This test shows how splitting the HTML into two Write() calls will still yield text/plain + + rr := httptest.NewRecorder() + sw := NewContentTypeWriter(rr) + sw.Write([]byte("alert('hi')")) + require.Equal(t, "text/plain; charset=utf-8", rr.Header().Get("Content-Type")) +} + +func TestSniffWriter_WriteUnknownMimeType(t *testing.T) { + rr := httptest.NewRecorder() + sw := NewContentTypeWriter(rr) + randomBytes := make([]byte, 199) + rand.Read(randomBytes) + sw.Write(randomBytes) + require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type")) +} diff --git a/util/limit.go b/util/limit.go index e5561247..bac3c155 100644 --- a/util/limit.go +++ b/util/limit.go @@ -2,6 +2,7 @@ package util import ( "errors" + "io" "sync" ) @@ -58,3 +59,43 @@ func (l *Limiter) Value() int64 { defer l.mu.Unlock() return l.value } + +// Limit returns the defined limit +func (l *Limiter) Limit() int64 { + return l.limit +} + +// 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. +type LimitWriter struct { + w io.Writer + written int64 + limiters []*Limiter + mu sync.Mutex +} + +// NewLimitWriter creates a new LimitWriter +func NewLimitWriter(w io.Writer, limiters ...*Limiter) *LimitWriter { + return &LimitWriter{ + w: w, + limiters: limiters, + } +} + +// Write passes through all writes to the underlying writer until any of the given limiter's limit is reached +func (w *LimitWriter) Write(p []byte) (n int, err error) { + w.mu.Lock() + defer w.mu.Unlock() + for i := 0; i < len(w.limiters); i++ { + if err := w.limiters[i].Add(int64(len(p))); err != nil { + for j := i - 1; j >= 0; j-- { + w.limiters[j].Sub(int64(len(p))) + } + return 0, ErrLimitReached + } + } + n, err = w.w.Write(p) + w.written += int64(n) + return +} diff --git a/util/limit_test.go b/util/limit_test.go index f6d56c6d..4f07e00f 100644 --- a/util/limit_test.go +++ b/util/limit_test.go @@ -1,6 +1,7 @@ package util import ( + "bytes" "testing" ) @@ -17,14 +18,68 @@ func TestLimiter_Add(t *testing.T) { } } -func TestLimiter_AddSub(t *testing.T) { +func TestLimiter_AddSet(t *testing.T) { l := NewLimiter(10) l.Add(5) if l.Value() != 5 { t.Fatalf("expected value to be %d, got %d", 5, l.Value()) } - l.Sub(2) - if l.Value() != 3 { - t.Fatalf("expected value to be %d, got %d", 3, l.Value()) + l.Set(7) + if l.Value() != 7 { + t.Fatalf("expected value to be %d, got %d", 7, l.Value()) + } +} + +func TestLimitWriter_WriteNoLimiter(t *testing.T) { + var buf bytes.Buffer + lw := NewLimitWriter(&buf) + if _, err := lw.Write(make([]byte, 10)); err != nil { + t.Fatal(err) + } + if _, err := lw.Write(make([]byte, 1)); err != nil { + t.Fatal(err) + } + if buf.Len() != 11 { + t.Fatalf("expected buffer length to be %d, got %d", 11, buf.Len()) + } +} + +func TestLimitWriter_WriteOneLimiter(t *testing.T) { + var buf bytes.Buffer + l := NewLimiter(10) + lw := NewLimitWriter(&buf, l) + if _, err := lw.Write(make([]byte, 10)); err != nil { + t.Fatal(err) + } + if _, err := lw.Write(make([]byte, 1)); err != ErrLimitReached { + t.Fatalf("expected ErrLimitReached, got %#v", err) + } + if buf.Len() != 10 { + t.Fatalf("expected buffer length to be %d, got %d", 10, buf.Len()) + } + if l.Value() != 10 { + t.Fatalf("expected limiter value to be %d, got %d", 10, l.Value()) + } +} + +func TestLimitWriter_WriteTwoLimiters(t *testing.T) { + var buf bytes.Buffer + l1 := NewLimiter(11) + l2 := NewLimiter(9) + lw := NewLimitWriter(&buf, l1, l2) + if _, err := lw.Write(make([]byte, 8)); err != nil { + t.Fatal(err) + } + if _, err := lw.Write(make([]byte, 2)); err != ErrLimitReached { + t.Fatalf("expected ErrLimitReached, got %#v", err) + } + if buf.Len() != 8 { + t.Fatalf("expected buffer length to be %d, got %d", 8, buf.Len()) + } + if l1.Value() != 8 { + t.Fatalf("expected limiter 1 value to be %d, got %d", 8, l1.Value()) + } + if l2.Value() != 8 { + t.Fatalf("expected limiter 2 value to be %d, got %d", 8, l2.Value()) } } diff --git a/util/peak.go b/util/peak.go new file mode 100644 index 00000000..100c269b --- /dev/null +++ b/util/peak.go @@ -0,0 +1,61 @@ +package util + +import ( + "bytes" + "io" + "strings" +) + +// PeakedReadCloser is a ReadCloser that allows peaking into a stream and buffering it in memory. +// It can be instantiated using the Peak function. After a stream has been peaked, it can still be fully +// read by reading the PeakedReadCloser. It first drained from the memory buffer, and then from the remaining +// underlying reader. +type PeakedReadCloser struct { + PeakedBytes []byte + LimitReached bool + peaked io.Reader + underlying io.ReadCloser + closed bool +} + +// Peak reads the underlying ReadCloser into memory up until the limit and returns a PeakedReadCloser +func Peak(underlying io.ReadCloser, limit int) (*PeakedReadCloser, error) { + if underlying == nil { + underlying = io.NopCloser(strings.NewReader("")) + } + peaked := make([]byte, limit) + read, err := io.ReadFull(underlying, peaked) + if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF { + return nil, err + } + return &PeakedReadCloser{ + PeakedBytes: peaked[:read], + LimitReached: read == limit, + underlying: underlying, + peaked: bytes.NewReader(peaked[:read]), + closed: false, + }, nil +} + +// Read reads from the peaked bytes and then from the underlying stream +func (r *PeakedReadCloser) Read(p []byte) (n int, err error) { + if r.closed { + return 0, io.EOF + } + n, err = r.peaked.Read(p) + if err == io.EOF { + return r.underlying.Read(p) + } else if err != nil { + return 0, err + } + return +} + +// Close closes the underlying stream +func (r *PeakedReadCloser) Close() error { + if r.closed { + return io.EOF + } + r.closed = true + return r.underlying.Close() +} diff --git a/util/peak_test.go b/util/peak_test.go new file mode 100644 index 00000000..76995179 --- /dev/null +++ b/util/peak_test.go @@ -0,0 +1,55 @@ +package util + +import ( + "github.com/stretchr/testify/require" + "io" + "strings" + "testing" +) + +func TestPeak_LimitReached(t *testing.T) { + underlying := io.NopCloser(strings.NewReader("1234567890")) + peaked, err := Peak(underlying, 5) + if err != nil { + t.Fatal(err) + } + require.Equal(t, []byte("12345"), peaked.PeakedBytes) + require.Equal(t, true, peaked.LimitReached) + + all, err := io.ReadAll(peaked) + if err != nil { + t.Fatal(err) + } + require.Equal(t, []byte("1234567890"), all) + require.Equal(t, []byte("12345"), peaked.PeakedBytes) + require.Equal(t, true, peaked.LimitReached) +} + +func TestPeak_LimitNotReached(t *testing.T) { + underlying := io.NopCloser(strings.NewReader("1234567890")) + peaked, err := Peak(underlying, 15) + if err != nil { + t.Fatal(err) + } + all, err := io.ReadAll(peaked) + if err != nil { + t.Fatal(err) + } + require.Equal(t, []byte("1234567890"), all) + require.Equal(t, []byte("1234567890"), peaked.PeakedBytes) + require.Equal(t, false, peaked.LimitReached) +} + +func TestPeak_Nil(t *testing.T) { + peaked, err := Peak(nil, 15) + if err != nil { + t.Fatal(err) + } + all, err := io.ReadAll(peaked) + if err != nil { + t.Fatal(err) + } + require.Equal(t, []byte(""), all) + require.Equal(t, []byte(""), peaked.PeakedBytes) + require.Equal(t, false, peaked.LimitReached) +} From 38788bb2e99cff1ce5f357218629f9e7177fb6ba Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 4 Jan 2022 00:55:08 +0100 Subject: [PATCH 02/24] WIP: attachments --- cmd/serve.go | 2 +- docs/publish.md | 21 +++++++ go.mod | 2 + go.sum | 5 ++ server/cache_sqlite.go | 102 +++++++++++++++++++++++++----- server/config.go | 138 ++++++++++++++++++++++------------------- server/message.go | 8 ++- server/server.go | 133 +++++++++++++++++++++++++++------------ server/visitor.go | 8 ++- 9 files changed, 290 insertions(+), 129 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index f4161e1a..c5e3718e 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -30,7 +30,7 @@ var flagsServe = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "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", 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", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), - altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultGlobalTopicLimit, Usage: "total number of topics allowed"}), + altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), diff --git a/docs/publish.md b/docs/publish.md index 46a5a334..4bcbcbaa 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -592,6 +592,26 @@ Here's an example with a custom message, tags and a priority: file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull'); ``` +## Send files + URLs +``` +curl -T image.jpg ntfy.sh/howdy + +curl \ + -T flower.jpg \ + -H "Message: Here's a flower for you" \ + -H "Filename: flower.jpg" \ + ntfy.sh/howdy + +curl \ + -T files.zip \ + "ntfy.sh/howdy?m=Important+documents+attached" + +curl \ + -d "A link for you" \ + -H "Link: https://unifiedpush.org" \ + "ntfy.sh/howdy" +``` + ## E-mail notifications You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that you'd like to persist longer, or to blast-notify yourself on all possible channels. @@ -883,6 +903,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a | `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) | | `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) | | `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) | +| `X-Filename` | `Filename`, `file`, `f` | XXXXXXXXXXXXXXXX | | `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) | | `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | diff --git a/go.mod b/go.mod index fad88a46..6f620033 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/disintegration/imaging v1.6.2 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/envoyproxy/go-control-plane v0.10.1 // indirect github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect @@ -38,6 +39,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect go.opencensus.io v0.23.0 // indirect + golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect golang.org/x/text v0.3.7 // indirect diff --git a/go.sum b/go.sum index 91718f40..07ff72f4 100644 --- a/go.sum +++ b/go.sum @@ -89,6 +89,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= @@ -264,6 +266,9 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/server/cache_sqlite.go b/server/cache_sqlite.go index b0572895..352ad1af 100644 --- a/server/cache_sqlite.go +++ b/server/cache_sqlite.go @@ -22,27 +22,35 @@ const ( title TEXT NOT NULL, priority INT NOT NULL, tags TEXT NOT NULL, + attachment_name TEXT NOT NULL, + attachment_type TEXT NOT NULL, + attachment_size INT NOT NULL, + attachment_expires INT NOT NULL, + attachment_url TEXT NOT NULL, published INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); COMMIT; ` - insertMessageQuery = `INSERT INTO messages (id, time, topic, message, title, priority, tags, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` + insertMessageQuery = ` + INSERT INTO messages (id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, published) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ` pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1` selectMessagesSinceTimeQuery = ` - SELECT id, time, topic, message, title, priority, tags + SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time ASC ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT id, time, topic, message, title, priority, tags + SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url FROM messages WHERE topic = ? AND time >= ? ORDER BY time ASC ` selectMessagesDueQuery = ` - SELECT id, time, topic, message, title, priority, tags + SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url FROM messages WHERE time <= ? AND published = 0 ` @@ -54,7 +62,7 @@ const ( // Schema management queries const ( - currentSchemaVersion = 2 + currentSchemaVersion = 3 createSchemaVersionTableQuery = ` CREATE TABLE IF NOT EXISTS schemaVersion ( id INT PRIMARY KEY, @@ -78,6 +86,17 @@ const ( migrate1To2AlterMessagesTableQuery = ` ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1); ` + + // 2 -> 3 + migrate2To3AlterMessagesTableQuery = ` + BEGIN; + ALTER TABLE messages ADD COLUMN attachment_name TEXT NOT NULL; + ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL; + ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL; + ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL; + ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL; + COMMIT; + ` ) type sqliteCache struct { @@ -104,7 +123,32 @@ func (c *sqliteCache) AddMessage(m *message) error { return errUnexpectedMessageType } published := m.Time <= time.Now().Unix() - _, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message, m.Title, m.Priority, strings.Join(m.Tags, ","), published) + tags := strings.Join(m.Tags, ",") + var attachmentName, attachmentType, attachmentURL string + var attachmentSize, attachmentExpires int64 + if m.Attachment != nil { + attachmentName = m.Attachment.Name + attachmentType = m.Attachment.Type + attachmentSize = m.Attachment.Size + attachmentExpires = m.Attachment.Expires + attachmentURL = m.Attachment.URL + } + _, err := c.db.Exec( + insertMessageQuery, + m.ID, + m.Time, + m.Topic, + m.Message, + m.Title, + m.Priority, + tags, + attachmentName, + attachmentType, + attachmentSize, + attachmentExpires, + attachmentURL, + published, + ) return err } @@ -185,25 +229,36 @@ func readMessages(rows *sql.Rows) ([]*message, error) { defer rows.Close() messages := make([]*message, 0) for rows.Next() { - var timestamp int64 + var timestamp, attachmentSize, attachmentExpires int64 var priority int - var id, topic, msg, title, tagsStr string - if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr); err != nil { + var id, topic, msg, title, tagsStr, attachmentName, attachmentType, attachmentURL string + if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL); err != nil { return nil, err } var tags []string if tagsStr != "" { tags = strings.Split(tagsStr, ",") } + var att *attachment + if attachmentName != "" && attachmentURL != "" { + att = &attachment{ + Name: attachmentName, + Type: attachmentType, + Size: attachmentSize, + Expires: attachmentExpires, + URL: attachmentURL, + } + } messages = append(messages, &message{ - ID: id, - Time: timestamp, - Event: messageEvent, - Topic: topic, - Message: msg, - Title: title, - Priority: priority, - Tags: tags, + ID: id, + Time: timestamp, + Event: messageEvent, + Topic: topic, + Message: msg, + Title: title, + Priority: priority, + Tags: tags, + Attachment: att, }) } if err := rows.Err(); err != nil { @@ -241,6 +296,8 @@ func setupDB(db *sql.DB) error { return migrateFrom0(db) } else if schemaVersion == 1 { return migrateFrom1(db) + } else if schemaVersion == 2 { + return migrateFrom2(db) } return fmt.Errorf("unexpected schema version found: %d", schemaVersion) } @@ -280,5 +337,16 @@ func migrateFrom1(db *sql.DB) error { if _, err := db.Exec(updateSchemaVersion, 2); err != nil { return err } + return migrateFrom2(db) +} + +func migrateFrom2(db *sql.DB) error { + log.Print("Migrating cache database schema: from 2 to 3") + if _, err := db.Exec(migrate2To3AlterMessagesTableQuery); err != nil { + return err + } + if _, err := db.Exec(updateSchemaVersion, 3); err != nil { + return err + } return nil // Update this when a new version is added } diff --git a/server/config.go b/server/config.go index 68a911fb..d23109b3 100644 --- a/server/config.go +++ b/server/config.go @@ -15,85 +15,95 @@ const ( DefaultMaxDelay = 3 * 24 * time.Hour DefaultMessageLimit = 4096 DefaultAttachmentSizeLimit = 5 * 1024 * 1024 + DefaultAttachmentExpiryDuration = 3 * time.Hour DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery ) // Defines all the limits -// - global topic limit: max number of topics overall +// - total topic limit: max number of topics overall +// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP // - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds) // - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour) -// - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP +// - per visitor attachment size limit: const ( - DefaultGlobalTopicLimit = 5000 - DefaultVisitorRequestLimitBurst = 60 - DefaultVisitorRequestLimitReplenish = 10 * time.Second - DefaultVisitorEmailLimitBurst = 16 - DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorSubscriptionLimit = 30 + DefaultTotalTopicLimit = 5000 + DefaultVisitorSubscriptionLimit = 30 + DefaultVisitorRequestLimitBurst = 60 + DefaultVisitorRequestLimitReplenish = 10 * time.Second + DefaultVisitorEmailLimitBurst = 16 + DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorAttachmentBytesLimitBurst = 50 * 1024 * 1024 + DefaultVisitorAttachmentBytesLimitReplenish = time.Hour ) // Config is the main config struct for the application. Use New to instantiate a default config struct. type Config struct { - BaseURL string - ListenHTTP string - ListenHTTPS string - KeyFile string - CertFile string - FirebaseKeyFile string - CacheFile string - CacheDuration time.Duration - AttachmentCacheDir string - AttachmentSizeLimit int64 - KeepaliveInterval time.Duration - ManagerInterval time.Duration - AtSenderInterval time.Duration - FirebaseKeepaliveInterval time.Duration - SMTPSenderAddr string - SMTPSenderUser string - SMTPSenderPass string - SMTPSenderFrom string - SMTPServerListen string - SMTPServerDomain string - SMTPServerAddrPrefix string - MessageLimit int - MinDelay time.Duration - MaxDelay time.Duration - TotalTopicLimit int - TotalAttachmentSizeLimit int64 - VisitorRequestLimitBurst int - VisitorRequestLimitReplenish time.Duration - VisitorEmailLimitBurst int - VisitorEmailLimitReplenish time.Duration - VisitorSubscriptionLimit int - BehindProxy bool + BaseURL string + ListenHTTP string + ListenHTTPS string + KeyFile string + CertFile string + FirebaseKeyFile string + CacheFile string + CacheDuration time.Duration + AttachmentCacheDir string + AttachmentSizeLimit int64 + AttachmentExpiryDuration time.Duration + KeepaliveInterval time.Duration + ManagerInterval time.Duration + AtSenderInterval time.Duration + FirebaseKeepaliveInterval time.Duration + SMTPSenderAddr string + SMTPSenderUser string + SMTPSenderPass string + SMTPSenderFrom string + SMTPServerListen string + SMTPServerDomain string + SMTPServerAddrPrefix string + MessageLimit int + MinDelay time.Duration + MaxDelay time.Duration + TotalTopicLimit int + TotalAttachmentSizeLimit int64 + VisitorSubscriptionLimit int + VisitorRequestLimitBurst int + VisitorRequestLimitReplenish time.Duration + VisitorEmailLimitBurst int + VisitorEmailLimitReplenish time.Duration + VisitorAttachmentBytesLimitBurst int64 + VisitorAttachmentBytesLimitReplenish time.Duration + BehindProxy bool } // NewConfig instantiates a default new server config func NewConfig() *Config { return &Config{ - BaseURL: "", - ListenHTTP: DefaultListenHTTP, - ListenHTTPS: "", - KeyFile: "", - CertFile: "", - FirebaseKeyFile: "", - CacheFile: "", - CacheDuration: DefaultCacheDuration, - AttachmentCacheDir: "", - AttachmentSizeLimit: DefaultAttachmentSizeLimit, - KeepaliveInterval: DefaultKeepaliveInterval, - ManagerInterval: DefaultManagerInterval, - MessageLimit: DefaultMessageLimit, - MinDelay: DefaultMinDelay, - MaxDelay: DefaultMaxDelay, - AtSenderInterval: DefaultAtSenderInterval, - FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, - TotalTopicLimit: DefaultGlobalTopicLimit, - VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, - VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, - VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, - VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, - VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, - BehindProxy: false, + BaseURL: "", + ListenHTTP: DefaultListenHTTP, + ListenHTTPS: "", + KeyFile: "", + CertFile: "", + FirebaseKeyFile: "", + CacheFile: "", + CacheDuration: DefaultCacheDuration, + AttachmentCacheDir: "", + AttachmentSizeLimit: DefaultAttachmentSizeLimit, + AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, + KeepaliveInterval: DefaultKeepaliveInterval, + ManagerInterval: DefaultManagerInterval, + MessageLimit: DefaultMessageLimit, + MinDelay: DefaultMinDelay, + MaxDelay: DefaultMaxDelay, + AtSenderInterval: DefaultAtSenderInterval, + FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, + TotalTopicLimit: DefaultTotalTopicLimit, + VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, + VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, + VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, + VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, + VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, + VisitorAttachmentBytesLimitBurst: DefaultVisitorAttachmentBytesLimitBurst, + VisitorAttachmentBytesLimitReplenish: DefaultVisitorAttachmentBytesLimitReplenish, + BehindProxy: false, } } diff --git a/server/message.go b/server/message.go index 2c3fb198..d7baf59e 100644 --- a/server/message.go +++ b/server/message.go @@ -30,9 +30,11 @@ type message struct { } type attachment struct { - Name string `json:"name"` - Type string `json:"type"` - URL string `json:"url"` + Name string `json:"name"` + Type string `json:"type"` + Size int64 `json:"size"` + Expires int64 `json:"expires"` + URL string `json:"url"` } // messageEncoder is a function that knows how to encode a message diff --git a/server/server.go b/server/server.go index b8ca70f7..c5db6e38 100644 --- a/server/server.go +++ b/server/server.go @@ -9,6 +9,7 @@ import ( firebase "firebase.google.com/go" "firebase.google.com/go/messaging" "fmt" + "github.com/disintegration/imaging" "github.com/emersion/go-smtp" "google.golang.org/api/option" "heckel.io/ntfy/util" @@ -101,7 +102,8 @@ var ( staticRegex = regexp.MustCompile(`^/static/.+`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) - disallowedTopics = []string{"docs", "static", "file"} + previewRegex = regexp.MustCompile(`^/preview/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) + disallowedTopics = []string{"docs", "static", "file", "preview"} templateFnMap = template.FuncMap{ "durationToHuman": util.DurationToHuman, @@ -122,26 +124,26 @@ var ( docsStaticFs embed.FS docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} - errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} - errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} - errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} - errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} - errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} - errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} - errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} - errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} - errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40011, http.StatusBadRequest, "attachments disallowed", ""} - errHTTPBadRequestAttachmentsPublishDisallowed = &errHTTP{40011, http.StatusBadRequest, "invalid message: invalid encoding or too large, and attachments are not allowed", ""} - errHTTPBadRequestMessageTooLarge = &errHTTP{40013, http.StatusBadRequest, "invalid message: too large", ""} - errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} - errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} + errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} + errHTTPNotFoundTooLarge = &errHTTP{40402, http.StatusNotFound, "page not found: preview not available, file too large", ""} + errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} + errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} + errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} + errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} + errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} + errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} + errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} + errHTTPBadRequestInvalidMessage = &errHTTP{40011, http.StatusBadRequest, "invalid message: invalid encoding or too large, and attachments are not allowed", ""} + errHTTPBadRequestMessageTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid message: too large", ""} + errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} + errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} ) const ( @@ -226,6 +228,13 @@ func createFirebaseSubscriber(conf *Config) (subscriber, error) { "title": m.Title, "message": m.Message, } + if m.Attachment != nil { + data["attachment_name"] = m.Attachment.Name + data["attachment_type"] = m.Attachment.Type + data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size) + data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires) + data["attachment_url"] = m.Attachment.URL + } } _, err := msg.Send(context.Background(), &messaging.Message{ Topic: m.Topic, @@ -316,8 +325,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { return s.handleStatic(w, r) } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.handleDocs(w, r) - } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) { + } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { return s.handleFile(w, r) + } else if r.Method == http.MethodGet && previewRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { + return s.handlePreview(w, r) } else if r.Method == http.MethodOptions { return s.handleOptions(w, r) } else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) { @@ -375,7 +386,7 @@ func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) error { if s.config.AttachmentCacheDir == "" { - return errHTTPBadRequestAttachmentsDisallowed + return errHTTPInternalError } matches := fileRegex.FindStringSubmatch(r.URL.Path) if len(matches) != 2 { @@ -397,6 +408,39 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) error { return err } +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) error { + if s.config.AttachmentCacheDir == "" { + return errHTTPInternalError + } + matches := previewRegex.FindStringSubmatch(r.URL.Path) + if len(matches) != 2 { + return errHTTPInternalErrorInvalidFilePath + } + messageID := matches[1] + file := filepath.Join(s.config.AttachmentCacheDir, messageID) + stat, err := os.Stat(file) + if err != nil { + return errHTTPNotFound + } + if stat.Size() > 20*1024*1024 { + return errHTTPInternalError + } + img, err := imaging.Open(file) + if err != nil { + return errHTTPNotFoundTooLarge + } + var width, height int + if width >= height { + width = 200 + height = int(float32(img.Bounds().Dy()) / float32(img.Bounds().Dx()) * float32(width)) + } else { + height = 200 + width = int(float32(img.Bounds().Dx()) / float32(img.Bounds().Dy()) * float32(height)) + } + preview := imaging.Resize(img, width, height, imaging.Lanczos) + return imaging.Encode(w, preview, imaging.PNG) +} + func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { t, err := s.topicFromPath(r.URL.Path) if err != nil { @@ -409,8 +453,12 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito m := newDefaultMessage(t.ID, "") if !body.LimitReached && utf8.Valid(body.PeakedBytes) { m.Message = strings.TrimSpace(string(body.PeakedBytes)) - } else if err := s.writeAttachment(v, m, body); err != nil { - return err + } else if s.config.AttachmentCacheDir != "" { + if err := s.writeAttachment(r, v, m, body); err != nil { + return err + } + } else { + return errHTTPBadRequestInvalidMessage } cache, firebase, email, err := s.parsePublishParams(r, m) if err != nil { @@ -522,29 +570,30 @@ func readParam(r *http.Request, names ...string) string { return "" } -func (s *Server) writeAttachment(v *visitor, m *message, body *util.PeakedReadCloser) error { - if s.config.AttachmentCacheDir == "" || !util.FileExists(s.config.AttachmentCacheDir) { - return errHTTPBadRequestAttachmentsPublishDisallowed +func (s *Server) writeAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error { + if s.config.AttachmentCacheDir == "" { + return errHTTPBadRequestInvalidMessage } contentType := http.DetectContentType(body.PeakedBytes) - exts, err := mime.ExtensionsByType(contentType) - if err != nil { - return err - } ext := ".bin" - if len(exts) > 0 { + exts, err := mime.ExtensionsByType(contentType) + if err == nil && len(exts) > 0 { ext = exts[0] } - filename := fmt.Sprintf("attachment%s", ext) + filename := readParam(r, "x-filename", "filename", "file", "f") + if filename == "" { + filename = fmt.Sprintf("attachment%s", ext) + } file := filepath.Join(s.config.AttachmentCacheDir, m.ID) f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { return err } defer f.Close() - fileSizeLimiter := util.NewLimiter(s.config.AttachmentSizeLimit) - limitWriter := util.NewLimitWriter(f, fileSizeLimiter) - if _, err := io.Copy(limitWriter, body); err != nil { + maxSizeLimiter := util.NewLimiter(s.config.AttachmentSizeLimit) //FIXME visitor limit + limitWriter := util.NewLimitWriter(f, maxSizeLimiter) + size, err := io.Copy(limitWriter, body) + if err != nil { os.Remove(file) if err == util.ErrLimitReached { return errHTTPBadRequestMessageTooLarge @@ -555,11 +604,13 @@ func (s *Server) writeAttachment(v *visitor, m *message, body *util.PeakedReadCl os.Remove(file) return err } - m.Message = fmt.Sprintf("You received a file: %s", filename) + m.Message = fmt.Sprintf("You received a file: %s", filename) // May be overwritten later m.Attachment = &attachment{ - Name: filename, - Type: contentType, - URL: fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext), + Name: filename, + Type: contentType, + Size: size, + Expires: time.Now().Add(s.config.AttachmentExpiryDuration).Unix(), + URL: fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext), } return nil } diff --git a/server/visitor.go b/server/visitor.go index a1bab367..f772f2c0 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -24,8 +24,9 @@ type visitor struct { config *Config ip string requests *rate.Limiter - emails *rate.Limiter subscriptions *util.Limiter + emails *rate.Limiter + attachments *rate.Limiter seen time.Time mu sync.Mutex } @@ -35,9 +36,10 @@ func newVisitor(conf *Config, ip string) *visitor { config: conf, ip: ip, requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), - emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)), - seen: time.Now(), + emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), + //attachments: rate.NewLimiter(rate.Every(conf.VisitorAttachmentBytesLimitReplenish * 1024), conf.VisitorAttachmentBytesLimitBurst), + seen: time.Now(), } } From 2930c4ff62f47c2aa1086c966ceadc805421e784 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 4 Jan 2022 19:45:29 +0100 Subject: [PATCH 03/24] Preview URL --- server/cache_sqlite.go | 30 +++++++++++++++++------------- server/config.go | 7 +++++-- server/message.go | 11 ++++++----- server/server.go | 38 ++++++++++++++++++++------------------ util/util.go | 15 +++++++++++++++ 5 files changed, 63 insertions(+), 38 deletions(-) diff --git a/server/cache_sqlite.go b/server/cache_sqlite.go index 352ad1af..ee668f20 100644 --- a/server/cache_sqlite.go +++ b/server/cache_sqlite.go @@ -26,6 +26,7 @@ const ( attachment_type TEXT NOT NULL, attachment_size INT NOT NULL, attachment_expires INT NOT NULL, + attachment_preview_url TEXT NOT NULL, attachment_url TEXT NOT NULL, published INT NOT NULL ); @@ -33,24 +34,24 @@ const ( COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, published) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_preview_url, attachment_url, published) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1` selectMessagesSinceTimeQuery = ` - SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url + SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_preview_url, attachment_url FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time ASC ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url + SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_preview_url, attachment_url FROM messages WHERE topic = ? AND time >= ? ORDER BY time ASC ` selectMessagesDueQuery = ` - SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url + SELECT id, time, topic, message, title, priority, tags, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_preview_url, attachment_url FROM messages WHERE time <= ? AND published = 0 ` @@ -124,13 +125,14 @@ func (c *sqliteCache) AddMessage(m *message) error { } published := m.Time <= time.Now().Unix() tags := strings.Join(m.Tags, ",") - var attachmentName, attachmentType, attachmentURL string + var attachmentName, attachmentType, attachmentPreviewURL, attachmentURL string var attachmentSize, attachmentExpires int64 if m.Attachment != nil { attachmentName = m.Attachment.Name attachmentType = m.Attachment.Type attachmentSize = m.Attachment.Size attachmentExpires = m.Attachment.Expires + attachmentPreviewURL = m.Attachment.PreviewURL attachmentURL = m.Attachment.URL } _, err := c.db.Exec( @@ -146,6 +148,7 @@ func (c *sqliteCache) AddMessage(m *message) error { attachmentType, attachmentSize, attachmentExpires, + attachmentPreviewURL, attachmentURL, published, ) @@ -231,8 +234,8 @@ func readMessages(rows *sql.Rows) ([]*message, error) { for rows.Next() { var timestamp, attachmentSize, attachmentExpires int64 var priority int - var id, topic, msg, title, tagsStr, attachmentName, attachmentType, attachmentURL string - if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL); err != nil { + var id, topic, msg, title, tagsStr, attachmentName, attachmentType, attachmentPreviewURL, attachmentURL string + if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentPreviewURL, &attachmentURL); err != nil { return nil, err } var tags []string @@ -242,11 +245,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) { var att *attachment if attachmentName != "" && attachmentURL != "" { att = &attachment{ - Name: attachmentName, - Type: attachmentType, - Size: attachmentSize, - Expires: attachmentExpires, - URL: attachmentURL, + Name: attachmentName, + Type: attachmentType, + Size: attachmentSize, + Expires: attachmentExpires, + PreviewURL: attachmentPreviewURL, + URL: attachmentURL, } } messages = append(messages, &message{ diff --git a/server/config.go b/server/config.go index d23109b3..d997f800 100644 --- a/server/config.go +++ b/server/config.go @@ -13,8 +13,9 @@ const ( DefaultAtSenderInterval = 10 * time.Second DefaultMinDelay = 10 * time.Second DefaultMaxDelay = 3 * 24 * time.Hour - DefaultMessageLimit = 4096 - DefaultAttachmentSizeLimit = 5 * 1024 * 1024 + DefaultMessageLimit = 4096 // Bytes + DefaultAttachmentSizeLimit = 15 * 1024 * 1024 + DefaultAttachmentSizePreviewMax = 20 * 1024 * 1024 // Bytes DefaultAttachmentExpiryDuration = 3 * time.Hour DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery ) @@ -48,6 +49,7 @@ type Config struct { CacheDuration time.Duration AttachmentCacheDir string AttachmentSizeLimit int64 + AttachmentSizePreviewMax int64 AttachmentExpiryDuration time.Duration KeepaliveInterval time.Duration ManagerInterval time.Duration @@ -88,6 +90,7 @@ func NewConfig() *Config { CacheDuration: DefaultCacheDuration, AttachmentCacheDir: "", AttachmentSizeLimit: DefaultAttachmentSizeLimit, + AttachmentSizePreviewMax: DefaultAttachmentSizePreviewMax, AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, KeepaliveInterval: DefaultKeepaliveInterval, ManagerInterval: DefaultManagerInterval, diff --git a/server/message.go b/server/message.go index d7baf59e..8cd9cbb5 100644 --- a/server/message.go +++ b/server/message.go @@ -30,11 +30,12 @@ type message struct { } type attachment struct { - Name string `json:"name"` - Type string `json:"type"` - Size int64 `json:"size"` - Expires int64 `json:"expires"` - URL string `json:"url"` + Name string `json:"name"` + Type string `json:"type"` + Size int64 `json:"size"` + Expires int64 `json:"expires"` + PreviewURL string `json:"preview_url"` + URL string `json:"url"` } // messageEncoder is a function that knows how to encode a message diff --git a/server/server.go b/server/server.go index c5db6e38..49d4689b 100644 --- a/server/server.go +++ b/server/server.go @@ -16,7 +16,6 @@ import ( "html/template" "io" "log" - "mime" "net" "net/http" "net/http/httptest" @@ -233,6 +232,7 @@ func createFirebaseSubscriber(conf *Config) (subscriber, error) { data["attachment_type"] = m.Attachment.Type data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size) data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires) + data["attachment_preview_url"] = m.Attachment.PreviewURL data["attachment_url"] = m.Attachment.URL } } @@ -326,9 +326,9 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { } else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) { return s.handleDocs(w, r) } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { - return s.handleFile(w, r) + return s.withRateLimit(w, r, s.handleFile) } else if r.Method == http.MethodGet && previewRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { - return s.handlePreview(w, r) + return s.withRateLimit(w, r, s.handlePreview) } else if r.Method == http.MethodOptions { return s.handleOptions(w, r) } else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) { @@ -384,7 +384,7 @@ func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error { return nil } -func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) error { +func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, _ *visitor) error { if s.config.AttachmentCacheDir == "" { return errHTTPInternalError } @@ -408,7 +408,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request) error { return err } -func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) error { +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request, _ *visitor) error { if s.config.AttachmentCacheDir == "" { return errHTTPInternalError } @@ -422,12 +422,12 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) error { if err != nil { return errHTTPNotFound } - if stat.Size() > 20*1024*1024 { - return errHTTPInternalError + if stat.Size() > s.config.AttachmentSizePreviewMax { + return errHTTPNotFoundTooLarge } img, err := imaging.Open(file) if err != nil { - return errHTTPNotFoundTooLarge + return err } var width, height int if width >= height { @@ -438,7 +438,7 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) error { width = int(float32(img.Bounds().Dx()) / float32(img.Bounds().Dy()) * float32(height)) } preview := imaging.Resize(img, width, height, imaging.Lanczos) - return imaging.Encode(w, preview, imaging.PNG) + return imaging.Encode(w, preview, imaging.JPEG, imaging.JPEGQuality(80)) } func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { @@ -575,10 +575,11 @@ func (s *Server) writeAttachment(r *http.Request, v *visitor, m *message, body * return errHTTPBadRequestInvalidMessage } contentType := http.DetectContentType(body.PeakedBytes) - ext := ".bin" - exts, err := mime.ExtensionsByType(contentType) - if err == nil && len(exts) > 0 { - ext = exts[0] + ext := util.ExtensionByType(contentType) + fileURL := fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) + previewURL := "" + if strings.HasPrefix(contentType, "image/") { + previewURL = fmt.Sprintf("%s/preview/%s%s", s.config.BaseURL, m.ID, ext) } filename := readParam(r, "x-filename", "filename", "file", "f") if filename == "" { @@ -606,11 +607,12 @@ func (s *Server) writeAttachment(r *http.Request, v *visitor, m *message, body * } m.Message = fmt.Sprintf("You received a file: %s", filename) // May be overwritten later m.Attachment = &attachment{ - Name: filename, - Type: contentType, - Size: size, - Expires: time.Now().Add(s.config.AttachmentExpiryDuration).Unix(), - URL: fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext), + Name: filename, + Type: contentType, + Size: size, + Expires: time.Now().Add(s.config.AttachmentExpiryDuration).Unix(), + PreviewURL: previewURL, + URL: fileURL, } return nil } diff --git a/util/util.go b/util/util.go index 38e28d5b..160852ce 100644 --- a/util/util.go +++ b/util/util.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "math/rand" + "mime" "os" "strings" "sync" @@ -163,3 +164,17 @@ func ExpandHome(path string) string { func ShortTopicURL(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") } + +// ExtensionByType is a wrapper around mime.ExtensionByType with a few sensible corrections +func ExtensionByType(contentType string) string { + switch contentType { + case "image/jpeg": + return ".jpg" + default: + exts, err := mime.ExtensionsByType(contentType) + if err == nil && len(exts) > 0 { + return exts[0] + } + return ".bin" + } +} From 5eca20469f0d9882747cae8b8e100e23077e31ea Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 6 Jan 2022 01:04:56 +0100 Subject: [PATCH 04/24] Attachment size limit --- cmd/serve.go | 13 +++++++++++++ server/config.go | 2 +- server/message.go | 8 ++++---- util/util.go | 29 +++++++++++++++++++++++++++-- util/util_test.go | 31 +++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index c5e3718e..6540e7c8 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -21,6 +21,7 @@ var flagsServe = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_SIZE_LIMIT"}, DefaultText: "15M", Usage: "attachment size limit (e.g. 10k, 2M)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), @@ -71,6 +72,7 @@ func execServe(c *cli.Context) error { cacheFile := c.String("cache-file") cacheDuration := c.Duration("cache-duration") attachmentCacheDir := c.String("attachment-cache-dir") + attachmentSizeLimitStr := c.String("attachment-size-limit") keepaliveInterval := c.Duration("keepalive-interval") managerInterval := c.Duration("manager-interval") smtpSenderAddr := c.String("smtp-sender-addr") @@ -109,6 +111,16 @@ func execServe(c *cli.Context) error { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } + // Convert + attachmentSizeLimit := server.DefaultAttachmentSizeLimit + if attachmentSizeLimitStr != "" { + var err error + attachmentSizeLimit, err = util.ParseSize(attachmentSizeLimitStr) + if err != nil { + return err + } + } + // Run server conf := server.NewConfig() conf.BaseURL = baseURL @@ -120,6 +132,7 @@ func execServe(c *cli.Context) error { conf.CacheFile = cacheFile conf.CacheDuration = cacheDuration conf.AttachmentCacheDir = attachmentCacheDir + conf.AttachmentSizeLimit = attachmentSizeLimit conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval conf.SMTPSenderAddr = smtpSenderAddr diff --git a/server/config.go b/server/config.go index d997f800..6a57a4b1 100644 --- a/server/config.go +++ b/server/config.go @@ -14,7 +14,7 @@ const ( DefaultMinDelay = 10 * time.Second DefaultMaxDelay = 3 * 24 * time.Hour DefaultMessageLimit = 4096 // Bytes - DefaultAttachmentSizeLimit = 15 * 1024 * 1024 + DefaultAttachmentSizeLimit = int64(15 * 1024 * 1024) DefaultAttachmentSizePreviewMax = 20 * 1024 * 1024 // Bytes DefaultAttachmentExpiryDuration = 3 * time.Hour DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery diff --git a/server/message.go b/server/message.go index 55993568..b627bb39 100644 --- a/server/message.go +++ b/server/message.go @@ -32,10 +32,10 @@ type message struct { type attachment struct { Name string `json:"name"` - Type string `json:"type"` - Size int64 `json:"size"` - Expires int64 `json:"expires"` - PreviewURL string `json:"preview_url"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` + Expires int64 `json:"expires,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` URL string `json:"url"` } diff --git a/util/util.go b/util/util.go index 160852ce..b806fd05 100644 --- a/util/util.go +++ b/util/util.go @@ -6,6 +6,8 @@ import ( "math/rand" "mime" "os" + "regexp" + "strconv" "strings" "sync" "time" @@ -16,8 +18,9 @@ const ( ) var ( - random = rand.New(rand.NewSource(time.Now().UnixNano())) - randomMutex = sync.Mutex{} + random = rand.New(rand.NewSource(time.Now().UnixNano())) + randomMutex = sync.Mutex{} + sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`) errInvalidPriority = errors.New("invalid priority") ) @@ -178,3 +181,25 @@ func ExtensionByType(contentType string) string { return ".bin" } } + +// ParseSize parses a size string like 2K or 2M into bytes. If no unit is found, e.g. 123, bytes is assumed. +func ParseSize(s string) (int64, error) { + matches := sizeStrRegex.FindStringSubmatch(s) + if matches == nil { + return -1, fmt.Errorf("invalid size %s", s) + } + value, err := strconv.Atoi(matches[1]) + if err != nil { + return -1, fmt.Errorf("cannot convert number %s", matches[1]) + } + switch strings.ToUpper(matches[2]) { + case "G": + return int64(value) * 1024 * 1024 * 1024, nil + case "M": + return int64(value) * 1024 * 1024, nil + case "K": + return int64(value) * 1024, nil + default: + return int64(value), nil + } +} diff --git a/util/util_test.go b/util/util_test.go index 1a74dcdb..f60aa252 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -121,3 +121,34 @@ func TestShortTopicURL(t *testing.T) { require.Equal(t, "ntfy.sh/mytopic", ShortTopicURL("http://ntfy.sh/mytopic")) require.Equal(t, "lalala", ShortTopicURL("lalala")) } + +func TestParseSize_10GSuccess(t *testing.T) { + s, err := ParseSize("10G") + if err != nil { + t.Fatal(err) + } + require.Equal(t, 10*1024*1024*1024, s) +} + +func TestParseSize_10MUpperCaseSuccess(t *testing.T) { + s, err := ParseSize("10M") + if err != nil { + t.Fatal(err) + } + require.Equal(t, 10*1024*1024, s) +} + +func TestParseSize_10kLowerCaseSuccess(t *testing.T) { + s, err := ParseSize("10k") + if err != nil { + t.Fatal(err) + } + require.Equal(t, 10*1024, s) +} + +func TestParseSize_FailureInvalid(t *testing.T) { + _, err := ParseSize("not a size") + if err == nil { + t.Fatalf("expected error, but got none") + } +} From 9171e94e5a056f7b95ac3528cead7dfba362d051 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 6 Jan 2022 14:45:23 +0100 Subject: [PATCH 05/24] Fix file extension detection; fix HTTPS port --- server/server.go | 5 +++-- util/util.go | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/server/server.go b/server/server.go index 771d430d..f0bbf42d 100644 --- a/server/server.go +++ b/server/server.go @@ -293,7 +293,7 @@ func (s *Server) Run() error { errChan <- s.httpServer.ListenAndServe() }() if s.config.ListenHTTPS != "" { - s.httpsServer = &http.Server{Addr: s.config.ListenHTTP, Handler: mux} + s.httpsServer = &http.Server{Addr: s.config.ListenHTTPS, Handler: mux} go func() { errChan <- s.httpsServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile) }() @@ -479,7 +479,8 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } m := newDefaultMessage(t.ID, "") - if !body.LimitReached && utf8.Valid(body.PeakedBytes) { + filename := readParam(r, "x-filename", "filename", "file", "f") + if filename == "" && !body.LimitReached && utf8.Valid(body.PeakedBytes) { m.Message = strings.TrimSpace(string(body.PeakedBytes)) } else if s.config.AttachmentCacheDir != "" { if err := s.writeAttachment(r, v, m, body); err != nil { diff --git a/util/util.go b/util/util.go index b806fd05..887443bf 100644 --- a/util/util.go +++ b/util/util.go @@ -18,10 +18,10 @@ const ( ) var ( - random = rand.New(rand.NewSource(time.Now().UnixNano())) - randomMutex = sync.Mutex{} - sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`) - + random = rand.New(rand.NewSource(time.Now().UnixNano())) + randomMutex = sync.Mutex{} + sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`) + extRegex = regexp.MustCompile(`^\.[-_A-Za-z0-9]+$`) errInvalidPriority = errors.New("invalid priority") ) @@ -175,7 +175,7 @@ func ExtensionByType(contentType string) string { return ".jpg" default: exts, err := mime.ExtensionsByType(contentType) - if err == nil && len(exts) > 0 { + if err == nil && len(exts) > 0 && extRegex.MatchString(exts[0]) { return exts[0] } return ".bin" From c45a28e6af94e571de3aa6833d072e051f69418c Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 7 Jan 2022 14:49:28 +0100 Subject: [PATCH 06/24] Attachments limits; working visitor limit --- cmd/serve.go | 47 +++++++++++---- docs/publish.md | 21 +++++-- server/cache.go | 1 + server/cache_mem.go | 14 +++++ server/cache_sqlite.go | 53 ++++++++++------ server/config.go | 134 ++++++++++++++++++++--------------------- server/file_cache.go | 88 +++++++++++++++++++++++++++ server/message.go | 12 ++-- server/server.go | 103 +++++++++---------------------- 9 files changed, 287 insertions(+), 186 deletions(-) create mode 100644 server/file_cache.go diff --git a/cmd/serve.go b/cmd/serve.go index 6540e7c8..3380f433 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -21,7 +21,8 @@ var flagsServe = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_SIZE_LIMIT"}, DefaultText: "15M", Usage: "attachment size limit (e.g. 10k, 2M)"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "1G", Usage: "limit of the on-disk attachment cache"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), @@ -33,6 +34,7 @@ var flagsServe = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "50M", Usage: "total storage limit used for attachments per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), @@ -72,7 +74,8 @@ func execServe(c *cli.Context) error { cacheFile := c.String("cache-file") cacheDuration := c.Duration("cache-duration") attachmentCacheDir := c.String("attachment-cache-dir") - attachmentSizeLimitStr := c.String("attachment-size-limit") + attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") + attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") keepaliveInterval := c.Duration("keepalive-interval") managerInterval := c.Duration("manager-interval") smtpSenderAddr := c.String("smtp-sender-addr") @@ -82,8 +85,9 @@ func execServe(c *cli.Context) error { smtpServerListen := c.String("smtp-server-listen") smtpServerDomain := c.String("smtp-server-domain") smtpServerAddrPrefix := c.String("smtp-server-addr-prefix") - globalTopicLimit := c.Int("global-topic-limit") + totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") + visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") @@ -111,14 +115,18 @@ func execServe(c *cli.Context) error { return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set") } - // Convert - attachmentSizeLimit := server.DefaultAttachmentSizeLimit - if attachmentSizeLimitStr != "" { - var err error - attachmentSizeLimit, err = util.ParseSize(attachmentSizeLimitStr) - if err != nil { - return err - } + // Convert sizes to bytes + attachmentTotalSizeLimit, err := parseSize(attachmentTotalSizeLimitStr, server.DefaultAttachmentTotalSizeLimit) + if err != nil { + return err + } + attachmentFileSizeLimit, err := parseSize(attachmentFileSizeLimitStr, server.DefaultAttachmentFileSizeLimit) + if err != nil { + return err + } + visitorAttachmentTotalSizeLimit, err := parseSize(visitorAttachmentTotalSizeLimitStr, server.DefaultVisitorAttachmentTotalSizeLimit) + if err != nil { + return err } // Run server @@ -132,7 +140,8 @@ func execServe(c *cli.Context) error { conf.CacheFile = cacheFile conf.CacheDuration = cacheDuration conf.AttachmentCacheDir = attachmentCacheDir - conf.AttachmentSizeLimit = attachmentSizeLimit + conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit + conf.AttachmentFileSizeLimit = attachmentFileSizeLimit conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval conf.SMTPSenderAddr = smtpSenderAddr @@ -142,8 +151,9 @@ func execServe(c *cli.Context) error { conf.SMTPServerListen = smtpServerListen conf.SMTPServerDomain = smtpServerDomain conf.SMTPServerAddrPrefix = smtpServerAddrPrefix - conf.TotalTopicLimit = globalTopicLimit + conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit + conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit conf.VisitorRequestLimitBurst = visitorRequestLimitBurst conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish conf.VisitorEmailLimitBurst = visitorEmailLimitBurst @@ -159,3 +169,14 @@ func execServe(c *cli.Context) error { log.Printf("Exiting.") return nil } + +func parseSize(s string, defaultValue int64) (v int64, err error) { + if s == "" { + return defaultValue, nil + } + v, err = util.ParseSize(s) + if err != nil { + return 0, err + } + return v, nil +} diff --git a/docs/publish.md b/docs/publish.md index f5e4f5b4..fc5b804a 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -661,22 +661,31 @@ Here's an example that will open Reddit when the notification is clicked: ## Send files + URLs ``` +- Uploaded attachment +- External attachment +- Preview without attachment + + +# Send attachment curl -T image.jpg ntfy.sh/howdy +# Send attachment with custom message and filename curl \ -T flower.jpg \ -H "Message: Here's a flower for you" \ -H "Filename: flower.jpg" \ ntfy.sh/howdy +# Send attachment from another URL, with custom preview and message curl \ - -T files.zip \ + -H "Attachment: https://example.com/files.zip" \ + -H "Preview: https://example.com/filespreview.jpg" \ + "ntfy.sh/howdy?m=Important+documents+attached" + +# Send normal message with external image +curl \ + -H "Image: https://example.com/someimage.jpg" \ "ntfy.sh/howdy?m=Important+documents+attached" - -curl \ - -d "A link for you" \ - -H "Link: https://unifiedpush.org" \ - "ntfy.sh/howdy" ``` ## E-mail notifications diff --git a/server/cache.go b/server/cache.go index 64d517d0..7532ff7f 100644 --- a/server/cache.go +++ b/server/cache.go @@ -20,4 +20,5 @@ type cache interface { Topics() (map[string]*topic, error) Prune(olderThan time.Time) error MarkPublished(m *message) error + AttachmentsSize(owner string) (int64, error) } diff --git a/server/cache_mem.go b/server/cache_mem.go index 31c7bb97..91bcb38c 100644 --- a/server/cache_mem.go +++ b/server/cache_mem.go @@ -125,6 +125,20 @@ func (c *memCache) Prune(olderThan time.Time) error { return nil } +func (c *memCache) AttachmentsSize(owner string) (int64, error) { + c.mu.Lock() + defer c.mu.Unlock() + var size int64 + for topic := range c.messages { + for _, m := range c.messages[topic] { + if m.Attachment != nil && m.Attachment.Owner == owner { + size += m.Attachment.Size + } + } + } + return size, nil +} + func (c *memCache) pruneTopic(topic string, olderThan time.Time) { messages := make([]*message, 0) for _, m := range c.messages[topic] { diff --git a/server/cache_sqlite.go b/server/cache_sqlite.go index 99c4df66..4a52f281 100644 --- a/server/cache_sqlite.go +++ b/server/cache_sqlite.go @@ -27,32 +27,32 @@ const ( attachment_type TEXT NOT NULL, attachment_size INT NOT NULL, attachment_expires INT NOT NULL, - attachment_preview_url TEXT NOT NULL, attachment_url TEXT NOT NULL, + attachment_owner TEXT NOT NULL, published INT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic); COMMIT; ` insertMessageQuery = ` - INSERT INTO messages (id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_preview_url, attachment_url, published) + INSERT INTO messages (id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1` selectMessagesSinceTimeQuery = ` - SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_preview_url, attachment_url + SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner FROM messages WHERE topic = ? AND time >= ? AND published = 1 ORDER BY time ASC ` selectMessagesSinceTimeIncludeScheduledQuery = ` - SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_preview_url, attachment_url + SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner FROM messages WHERE topic = ? AND time >= ? ORDER BY time ASC ` selectMessagesDueQuery = ` - SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_preview_url, attachment_url + SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner FROM messages WHERE time <= ? AND published = 0 ` @@ -60,6 +60,7 @@ const ( selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` + selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ?` ) // Schema management queries @@ -97,7 +98,7 @@ const ( ALTER TABLE messages ADD COLUMN attachment_type TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN attachment_size INT NOT NULL DEFAULT('0'); ALTER TABLE messages ADD COLUMN attachment_expires INT NOT NULL DEFAULT('0'); - ALTER TABLE messages ADD COLUMN attachment_preview_url TEXT NOT NULL DEFAULT(''); + ALTER TABLE messages ADD COLUMN attachment_owner TEXT NOT NULL DEFAULT(''); ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT(''); COMMIT; ` @@ -128,15 +129,15 @@ func (c *sqliteCache) AddMessage(m *message) error { } published := m.Time <= time.Now().Unix() tags := strings.Join(m.Tags, ",") - var attachmentName, attachmentType, attachmentPreviewURL, attachmentURL string + var attachmentName, attachmentType, attachmentURL, attachmentOwner string var attachmentSize, attachmentExpires int64 if m.Attachment != nil { attachmentName = m.Attachment.Name attachmentType = m.Attachment.Type attachmentSize = m.Attachment.Size attachmentExpires = m.Attachment.Expires - attachmentPreviewURL = m.Attachment.PreviewURL attachmentURL = m.Attachment.URL + attachmentOwner = m.Attachment.Owner } _, err := c.db.Exec( insertMessageQuery, @@ -152,8 +153,8 @@ func (c *sqliteCache) AddMessage(m *message) error { attachmentType, attachmentSize, attachmentExpires, - attachmentPreviewURL, attachmentURL, + attachmentOwner, published, ) return err @@ -232,14 +233,32 @@ func (c *sqliteCache) Prune(olderThan time.Time) error { return err } +func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) { + rows, err := c.db.Query(selectAttachmentsSizeQuery, owner) + if err != nil { + return 0, err + } + defer rows.Close() + var size int64 + if !rows.Next() { + return 0, errors.New("no rows found") + } + if err := rows.Scan(&size); err != nil { + return 0, err + } else if err := rows.Err(); err != nil { + return 0, err + } + return size, nil +} + func readMessages(rows *sql.Rows) ([]*message, error) { defer rows.Close() messages := make([]*message, 0) for rows.Next() { var timestamp, attachmentSize, attachmentExpires int64 var priority int - var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentPreviewURL, attachmentURL string - if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &click, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentPreviewURL, &attachmentURL); err != nil { + var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner string + if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &click, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentOwner, &attachmentURL); err != nil { return nil, err } var tags []string @@ -249,12 +268,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) { var att *attachment if attachmentName != "" && attachmentURL != "" { att = &attachment{ - Name: attachmentName, - Type: attachmentType, - Size: attachmentSize, - Expires: attachmentExpires, - PreviewURL: attachmentPreviewURL, - URL: attachmentURL, + Name: attachmentName, + Type: attachmentType, + Size: attachmentSize, + Expires: attachmentExpires, + URL: attachmentURL, + Owner: attachmentOwner, } } messages = append(messages, &message{ diff --git a/server/config.go b/server/config.go index 6a57a4b1..76c38e3b 100644 --- a/server/config.go +++ b/server/config.go @@ -13,9 +13,9 @@ const ( DefaultAtSenderInterval = 10 * time.Second DefaultMinDelay = 10 * time.Second DefaultMaxDelay = 3 * 24 * time.Hour - DefaultMessageLimit = 4096 // Bytes - DefaultAttachmentSizeLimit = int64(15 * 1024 * 1024) - DefaultAttachmentSizePreviewMax = 20 * 1024 * 1024 // Bytes + DefaultMessageLimit = 4096 // Bytes + DefaultAttachmentTotalSizeLimit = int64(1024 * 1024 * 1024) // 1 GB + DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB DefaultAttachmentExpiryDuration = 3 * time.Hour DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery ) @@ -33,80 +33,78 @@ const ( DefaultVisitorRequestLimitReplenish = 10 * time.Second DefaultVisitorEmailLimitBurst = 16 DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorAttachmentBytesLimitBurst = 50 * 1024 * 1024 + DefaultVisitorAttachmentTotalSizeLimit = 50 * 1024 * 1024 DefaultVisitorAttachmentBytesLimitReplenish = time.Hour ) // Config is the main config struct for the application. Use New to instantiate a default config struct. type Config struct { - BaseURL string - ListenHTTP string - ListenHTTPS string - KeyFile string - CertFile string - FirebaseKeyFile string - CacheFile string - CacheDuration time.Duration - AttachmentCacheDir string - AttachmentSizeLimit int64 - AttachmentSizePreviewMax int64 - AttachmentExpiryDuration time.Duration - KeepaliveInterval time.Duration - ManagerInterval time.Duration - AtSenderInterval time.Duration - FirebaseKeepaliveInterval time.Duration - SMTPSenderAddr string - SMTPSenderUser string - SMTPSenderPass string - SMTPSenderFrom string - SMTPServerListen string - SMTPServerDomain string - SMTPServerAddrPrefix string - MessageLimit int - MinDelay time.Duration - MaxDelay time.Duration - TotalTopicLimit int - TotalAttachmentSizeLimit int64 - VisitorSubscriptionLimit int - VisitorRequestLimitBurst int - VisitorRequestLimitReplenish time.Duration - VisitorEmailLimitBurst int - VisitorEmailLimitReplenish time.Duration - VisitorAttachmentBytesLimitBurst int64 - VisitorAttachmentBytesLimitReplenish time.Duration - BehindProxy bool + BaseURL string + ListenHTTP string + ListenHTTPS string + KeyFile string + CertFile string + FirebaseKeyFile string + CacheFile string + CacheDuration time.Duration + AttachmentCacheDir string + AttachmentTotalSizeLimit int64 + AttachmentFileSizeLimit int64 + AttachmentExpiryDuration time.Duration + KeepaliveInterval time.Duration + ManagerInterval time.Duration + AtSenderInterval time.Duration + FirebaseKeepaliveInterval time.Duration + SMTPSenderAddr string + SMTPSenderUser string + SMTPSenderPass string + SMTPSenderFrom string + SMTPServerListen string + SMTPServerDomain string + SMTPServerAddrPrefix string + MessageLimit int + MinDelay time.Duration + MaxDelay time.Duration + TotalTopicLimit int + TotalAttachmentSizeLimit int64 + VisitorSubscriptionLimit int + VisitorAttachmentTotalSizeLimit int64 + VisitorRequestLimitBurst int + VisitorRequestLimitReplenish time.Duration + VisitorEmailLimitBurst int + VisitorEmailLimitReplenish time.Duration + BehindProxy bool } // NewConfig instantiates a default new server config func NewConfig() *Config { return &Config{ - BaseURL: "", - ListenHTTP: DefaultListenHTTP, - ListenHTTPS: "", - KeyFile: "", - CertFile: "", - FirebaseKeyFile: "", - CacheFile: "", - CacheDuration: DefaultCacheDuration, - AttachmentCacheDir: "", - AttachmentSizeLimit: DefaultAttachmentSizeLimit, - AttachmentSizePreviewMax: DefaultAttachmentSizePreviewMax, - AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, - KeepaliveInterval: DefaultKeepaliveInterval, - ManagerInterval: DefaultManagerInterval, - MessageLimit: DefaultMessageLimit, - MinDelay: DefaultMinDelay, - MaxDelay: DefaultMaxDelay, - AtSenderInterval: DefaultAtSenderInterval, - FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, - TotalTopicLimit: DefaultTotalTopicLimit, - VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, - VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, - VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, - VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, - VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, - VisitorAttachmentBytesLimitBurst: DefaultVisitorAttachmentBytesLimitBurst, - VisitorAttachmentBytesLimitReplenish: DefaultVisitorAttachmentBytesLimitReplenish, - BehindProxy: false, + BaseURL: "", + ListenHTTP: DefaultListenHTTP, + ListenHTTPS: "", + KeyFile: "", + CertFile: "", + FirebaseKeyFile: "", + CacheFile: "", + CacheDuration: DefaultCacheDuration, + AttachmentCacheDir: "", + AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, + AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, + AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, + KeepaliveInterval: DefaultKeepaliveInterval, + ManagerInterval: DefaultManagerInterval, + MessageLimit: DefaultMessageLimit, + MinDelay: DefaultMinDelay, + MaxDelay: DefaultMaxDelay, + AtSenderInterval: DefaultAtSenderInterval, + FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, + TotalTopicLimit: DefaultTotalTopicLimit, + VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, + VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit, + VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, + VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, + VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, + VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, + BehindProxy: false, } } diff --git a/server/file_cache.go b/server/file_cache.go new file mode 100644 index 00000000..e23718c6 --- /dev/null +++ b/server/file_cache.go @@ -0,0 +1,88 @@ +package server + +import ( + "errors" + "heckel.io/ntfy/util" + "io" + "log" + "os" + "path/filepath" + "regexp" + "sync" +) + +var ( + fileIDRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`) + errInvalidFileID = errors.New("invalid file ID") +) + +type fileCache struct { + dir string + totalSizeCurrent int64 + totalSizeLimit int64 + fileSizeLimit int64 + mu sync.Mutex +} + +func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileCache, error) { + if err := os.MkdirAll(dir, 0700); err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + var size int64 + for _, e := range entries { + info, err := e.Info() + if err != nil { + return nil, err + } + size += info.Size() + } + return &fileCache{ + dir: dir, + totalSizeCurrent: size, + totalSizeLimit: totalSizeLimit, + fileSizeLimit: fileSizeLimit, + }, nil +} + +func (c *fileCache) Write(id string, in io.Reader, limiters ...*util.Limiter) (int64, error) { + if !fileIDRegex.MatchString(id) { + return 0, errInvalidFileID + } + file := filepath.Join(c.dir, id) + f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + if err != nil { + return 0, err + } + defer f.Close() + log.Printf("remaining total: %d", c.remainingTotalSize()) + limiters = append(limiters, util.NewLimiter(c.remainingTotalSize()), util.NewLimiter(c.fileSizeLimit)) + 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 *fileCache) remainingTotalSize() int64 { + c.mu.Lock() + defer c.mu.Unlock() + remaining := c.totalSizeLimit - c.totalSizeCurrent + if remaining < 0 { + return 0 + } + return remaining +} diff --git a/server/message.go b/server/message.go index b627bb39..27695f14 100644 --- a/server/message.go +++ b/server/message.go @@ -31,12 +31,12 @@ type message struct { } type attachment struct { - Name string `json:"name"` - Type string `json:"type,omitempty"` - Size int64 `json:"size,omitempty"` - Expires int64 `json:"expires,omitempty"` - PreviewURL string `json:"preview_url,omitempty"` - URL string `json:"url"` + Name string `json:"name"` + Type string `json:"type,omitempty"` + Size int64 `json:"size,omitempty"` + Expires int64 `json:"expires,omitempty"` + URL string `json:"url"` + Owner string `json:"-"` // IP address of uploader, used for rate limiting } // messageEncoder is a function that knows how to encode a message diff --git a/server/server.go b/server/server.go index f0bbf42d..afd7d38d 100644 --- a/server/server.go +++ b/server/server.go @@ -9,7 +9,6 @@ import ( firebase "firebase.google.com/go" "firebase.google.com/go/messaging" "fmt" - "github.com/disintegration/imaging" "github.com/emersion/go-smtp" "google.golang.org/api/option" "heckel.io/ntfy/util" @@ -45,6 +44,7 @@ type Server struct { mailer mailer messages int64 cache cache + fileCache *fileCache closeChan chan bool mu sync.Mutex } @@ -101,8 +101,7 @@ var ( staticRegex = regexp.MustCompile(`^/static/.+`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) - previewRegex = regexp.MustCompile(`^/preview/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) - disallowedTopics = []string{"docs", "static", "file", "preview"} + disallowedTopics = []string{"docs", "static", "file"} templateFnMap = template.FuncMap{ "durationToHuman": util.DurationToHuman, @@ -124,7 +123,6 @@ var ( docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} - errHTTPNotFoundTooLarge = &errHTTP{40402, http.StatusNotFound, "page not found: preview not available, file too large", ""} errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} @@ -174,18 +172,21 @@ func New(conf *Config) (*Server, error) { if err != nil { return nil, err } + var fileCache *fileCache if conf.AttachmentCacheDir != "" { - if err := os.MkdirAll(conf.AttachmentCacheDir, 0700); err != nil { + fileCache, err = newFileCache(conf.AttachmentCacheDir, conf.AttachmentTotalSizeLimit, conf.AttachmentFileSizeLimit) + if err != nil { return nil, err } } return &Server{ - config: conf, - cache: cache, - firebase: firebaseSubscriber, - mailer: mailer, - topics: topics, - visitors: make(map[string]*visitor), + config: conf, + cache: cache, + fileCache: fileCache, + firebase: firebaseSubscriber, + mailer: mailer, + topics: topics, + visitors: make(map[string]*visitor), }, nil } @@ -234,7 +235,6 @@ func createFirebaseSubscriber(conf *Config) (subscriber, error) { data["attachment_type"] = m.Attachment.Type data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size) data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires) - data["attachment_preview_url"] = m.Attachment.PreviewURL data["attachment_url"] = m.Attachment.URL } } @@ -355,8 +355,6 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { return s.handleDocs(w, r) } else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { return s.withRateLimit(w, r, s.handleFile) - } else if r.Method == http.MethodGet && previewRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" { - return s.withRateLimit(w, r, s.handlePreview) } else if r.Method == http.MethodOptions { return s.handleOptions(w, r) } else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) { @@ -436,39 +434,6 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, _ *visitor) return err } -func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request, _ *visitor) error { - if s.config.AttachmentCacheDir == "" { - return errHTTPInternalError - } - matches := previewRegex.FindStringSubmatch(r.URL.Path) - if len(matches) != 2 { - return errHTTPInternalErrorInvalidFilePath - } - messageID := matches[1] - file := filepath.Join(s.config.AttachmentCacheDir, messageID) - stat, err := os.Stat(file) - if err != nil { - return errHTTPNotFound - } - if stat.Size() > s.config.AttachmentSizePreviewMax { - return errHTTPNotFoundTooLarge - } - img, err := imaging.Open(file) - if err != nil { - return err - } - var width, height int - if width >= height { - width = 200 - height = int(float32(img.Bounds().Dy()) / float32(img.Bounds().Dx()) * float32(width)) - } else { - height = 200 - width = int(float32(img.Bounds().Dx()) / float32(img.Bounds().Dy()) * float32(height)) - } - preview := imaging.Resize(img, width, height, imaging.Lanczos) - return imaging.Encode(w, preview, imaging.JPEG, imaging.JPEGQuality(80)) -} - func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { t, err := s.topicFromPath(r.URL.Path) if err != nil { @@ -482,7 +447,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito filename := readParam(r, "x-filename", "filename", "file", "f") if filename == "" && !body.LimitReached && utf8.Valid(body.PeakedBytes) { m.Message = strings.TrimSpace(string(body.PeakedBytes)) - } else if s.config.AttachmentCacheDir != "" { + } else if s.fileCache != nil { if err := s.writeAttachment(r, v, m, body); err != nil { return err } @@ -601,48 +566,34 @@ func readParam(r *http.Request, names ...string) string { } func (s *Server) writeAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error { - if s.config.AttachmentCacheDir == "" { - return errHTTPBadRequestInvalidMessage - } contentType := http.DetectContentType(body.PeakedBytes) ext := util.ExtensionByType(contentType) fileURL := fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) - previewURL := "" - if strings.HasPrefix(contentType, "image/") { - previewURL = fmt.Sprintf("%s/preview/%s%s", s.config.BaseURL, m.ID, ext) - } filename := readParam(r, "x-filename", "filename", "file", "f") if filename == "" { filename = fmt.Sprintf("attachment%s", ext) } - file := filepath.Join(s.config.AttachmentCacheDir, m.ID) - f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) + // TODO do not allowed delayed delivery for attachments + visitorAttachmentsSize, err := s.cache.AttachmentsSize(v.ip) if err != nil { return err } - defer f.Close() - maxSizeLimiter := util.NewLimiter(s.config.AttachmentSizeLimit) //FIXME visitor limit - limitWriter := util.NewLimitWriter(f, maxSizeLimiter) - size, err := io.Copy(limitWriter, body) - if err != nil { - os.Remove(file) - if err == util.ErrLimitReached { - return errHTTPBadRequestMessageTooLarge - } - return err - } - if err := f.Close(); err != nil { - os.Remove(file) + remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize + log.Printf("remaining visitor: %d", remainingVisitorAttachmentSize) + size, err := s.fileCache.Write(m.ID, body, util.NewLimiter(remainingVisitorAttachmentSize)) + if err == util.ErrLimitReached { + return errHTTPBadRequestMessageTooLarge + } else if err != nil { return err } m.Message = fmt.Sprintf("You received a file: %s", filename) // May be overwritten later m.Attachment = &attachment{ - Name: filename, - Type: contentType, - Size: size, - Expires: time.Now().Add(s.config.AttachmentExpiryDuration).Unix(), - PreviewURL: previewURL, - URL: fileURL, + Name: filename, + Type: contentType, + Size: size, + Expires: time.Now().Add(s.config.AttachmentExpiryDuration).Unix(), + URL: fileURL, + Owner: v.ip, // Important for attachment rate limiting } return nil } From e7c19a2bad5c59b869ae3f72fb263accd49cbdb7 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 7 Jan 2022 15:15:33 +0100 Subject: [PATCH 07/24] Expire attachments properly --- server/cache.go | 1 + server/cache_mem.go | 14 +++++++++ server/cache_sqlite.go | 25 ++++++++++++++-- server/file_cache.go | 65 ++++++++++++++++++++++++++++++++++-------- server/server.go | 10 +++++++ 5 files changed, 101 insertions(+), 14 deletions(-) diff --git a/server/cache.go b/server/cache.go index 7532ff7f..89db72c4 100644 --- a/server/cache.go +++ b/server/cache.go @@ -21,4 +21,5 @@ type cache interface { Prune(olderThan time.Time) error MarkPublished(m *message) error AttachmentsSize(owner string) (int64, error) + AttachmentsExpired() ([]string, error) } diff --git a/server/cache_mem.go b/server/cache_mem.go index 91bcb38c..04e57be9 100644 --- a/server/cache_mem.go +++ b/server/cache_mem.go @@ -139,6 +139,20 @@ func (c *memCache) AttachmentsSize(owner string) (int64, error) { return size, nil } +func (c *memCache) AttachmentsExpired() ([]string, error) { + c.mu.Lock() + defer c.mu.Unlock() + ids := make([]string, 0) + for topic := range c.messages { + for _, m := range c.messages[topic] { + if m.Attachment != nil && m.Attachment.Expires > 0 && m.Attachment.Expires < time.Now().Unix() { + ids = append(ids, m.ID) + } + } + } + return ids, nil +} + func (c *memCache) pruneTopic(topic string, olderThan time.Time) { messages := make([]*message, 0) for _, m := range c.messages[topic] { diff --git a/server/cache_sqlite.go b/server/cache_sqlite.go index 4a52f281..c8d97735 100644 --- a/server/cache_sqlite.go +++ b/server/cache_sqlite.go @@ -60,7 +60,8 @@ const ( selectMessagesCountQuery = `SELECT COUNT(*) FROM messages` selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?` selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic` - selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ?` + selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE attachment_owner = ? AND attachment_expires >= ?` + selectAttachmentsExpiredQuery = `SELECT id FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?` ) // Schema management queries @@ -234,7 +235,7 @@ func (c *sqliteCache) Prune(olderThan time.Time) error { } func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) { - rows, err := c.db.Query(selectAttachmentsSizeQuery, owner) + rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix()) if err != nil { return 0, err } @@ -251,6 +252,26 @@ func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) { return size, nil } +func (c *sqliteCache) AttachmentsExpired() ([]string, error) { + rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix()) + if err != nil { + 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 +} + func readMessages(rows *sql.Rows) ([]*message, error) { defer rows.Close() messages := make([]*message, 0) diff --git a/server/file_cache.go b/server/file_cache.go index e23718c6..244142cf 100644 --- a/server/file_cache.go +++ b/server/file_cache.go @@ -28,18 +28,10 @@ func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileC if err := os.MkdirAll(dir, 0700); err != nil { return nil, err } - entries, err := os.ReadDir(dir) + size, err := dirSize(dir) if err != nil { return nil, err } - var size int64 - for _, e := range entries { - info, err := e.Info() - if err != nil { - return nil, err - } - size += info.Size() - } return &fileCache{ dir: dir, totalSizeCurrent: size, @@ -58,8 +50,8 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...*util.Limiter) (i return 0, err } defer f.Close() - log.Printf("remaining total: %d", c.remainingTotalSize()) - limiters = append(limiters, util.NewLimiter(c.remainingTotalSize()), util.NewLimiter(c.fileSizeLimit)) + log.Printf("remaining total: %d", c.Remaining()) + limiters = append(limiters, util.NewLimiter(c.Remaining()), util.NewLimiter(c.fileSizeLimit)) limitWriter := util.NewLimitWriter(f, limiters...) size, err := io.Copy(limitWriter, in) if err != nil { @@ -77,7 +69,40 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...*util.Limiter) (i } -func (c *fileCache) remainingTotalSize() int64 { +func (c *fileCache) Remove(ids []string) error { + var firstErr error + for _, id := range ids { + if err := c.removeFile(id); err != nil { + if firstErr == nil { + firstErr = err // Continue despite error; we want to delete as many as we can + } + } + } + size, err := dirSize(c.dir) + if err != nil { + return err + } + c.mu.Lock() + c.totalSizeCurrent = size + c.mu.Unlock() + return firstErr +} + +func (c *fileCache) removeFile(id string) error { + if !fileIDRegex.MatchString(id) { + return errInvalidFileID + } + file := filepath.Join(c.dir, id) + return os.Remove(file) +} + +func (c *fileCache) Size() int64 { + c.mu.Lock() + defer c.mu.Unlock() + return c.totalSizeCurrent +} + +func (c *fileCache) Remaining() int64 { c.mu.Lock() defer c.mu.Unlock() remaining := c.totalSizeLimit - c.totalSizeCurrent @@ -86,3 +111,19 @@ func (c *fileCache) remainingTotalSize() int64 { } 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/server/server.go b/server/server.go index afd7d38d..c3ee81fa 100644 --- a/server/server.go +++ b/server/server.go @@ -832,6 +832,16 @@ func (s *Server) updateStatsAndPrune() { } } + // Delete expired attachments + ids, err := s.cache.AttachmentsExpired() + if err == nil { + if err := s.fileCache.Remove(ids); err != nil { + log.Printf("error while deleting attachments: %s", err.Error()) + } + } else { + log.Printf("error retrieving expired attachments: %s", err.Error()) + } + // Prune message cache olderThan := time.Now().Add(-1 * s.config.CacheDuration) if err := s.cache.Prune(olderThan); err != nil { From cefe276ce55cb5c29ede1c1fef5393194c1f6e44 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 8 Jan 2022 12:14:43 -0500 Subject: [PATCH 08/24] Tests for fileCache --- go.mod | 2 - go.sum | 5 --- server/file_cache.go | 9 +++-- server/file_cache_test.go | 83 +++++++++++++++++++++++++++++++++++++++ server/server.go | 14 ++++--- util/limit.go | 7 +--- util/util_test.go | 6 +-- 7 files changed, 101 insertions(+), 25 deletions(-) create mode 100644 server/file_cache_test.go diff --git a/go.mod b/go.mod index 6f620033..fad88a46 100644 --- a/go.mod +++ b/go.mod @@ -27,7 +27,6 @@ require ( github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/disintegration/imaging v1.6.2 // indirect github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/envoyproxy/go-control-plane v0.10.1 // indirect github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect @@ -39,7 +38,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect go.opencensus.io v0.23.0 // indirect - golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect golang.org/x/text v0.3.7 // indirect diff --git a/go.sum b/go.sum index 07ff72f4..91718f40 100644 --- a/go.sum +++ b/go.sum @@ -89,8 +89,6 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= -github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= @@ -266,9 +264,6 @@ golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EH golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ= -golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/server/file_cache.go b/server/file_cache.go index 244142cf..04ae76e4 100644 --- a/server/file_cache.go +++ b/server/file_cache.go @@ -4,7 +4,6 @@ import ( "errors" "heckel.io/ntfy/util" "io" - "log" "os" "path/filepath" "regexp" @@ -14,6 +13,7 @@ import ( var ( fileIDRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`) errInvalidFileID = errors.New("invalid file ID") + errFileExists = errors.New("file exists") ) type fileCache struct { @@ -45,12 +45,14 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...*util.Limiter) (i return 0, errInvalidFileID } 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() - log.Printf("remaining total: %d", c.Remaining()) limiters = append(limiters, util.NewLimiter(c.Remaining()), util.NewLimiter(c.fileSizeLimit)) limitWriter := util.NewLimitWriter(f, limiters...) size, err := io.Copy(limitWriter, in) @@ -66,10 +68,9 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...*util.Limiter) (i c.totalSizeCurrent += size c.mu.Unlock() return size, nil - } -func (c *fileCache) Remove(ids []string) error { +func (c *fileCache) Remove(ids ...string) error { var firstErr error for _, id := range ids { if err := c.removeFile(id); err != nil { diff --git a/server/file_cache_test.go b/server/file_cache_test.go new file mode 100644 index 00000000..a0a74085 --- /dev/null +++ b/server/file_cache_test.go @@ -0,0 +1,83 @@ +package server + +import ( + "bytes" + "fmt" + "github.com/stretchr/testify/require" + "heckel.io/ntfy/util" + "os" + "strings" + "testing" +) + +var ( + oneKilobyteArray = make([]byte, 1024) +) + +func TestFileCache_Write_Success(t *testing.T) { + dir, c := newTestFileCache(t) + size, err := c.Write("abc", strings.NewReader("normal file"), util.NewLimiter(999)) + require.Nil(t, err) + require.Equal(t, int64(11), size) + require.Equal(t, "normal file", readFile(t, dir+"/abc")) + 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("abc%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+"/abc1") + require.FileExists(t, dir+"/abc5") + + require.Nil(t, c.Remove("abc1", "abc5")) + require.NoFileExists(t, dir+"/abc1") + require.NoFileExists(t, dir+"/abc5") + 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("abc%d", i), bytes.NewReader(oneKilobyteArray)) + require.Nil(t, err) + require.Equal(t, int64(1024), size) + } + _, err := c.Write("abc11", bytes.NewReader(oneKilobyteArray)) + require.Equal(t, util.ErrLimitReached, err) + require.NoFileExists(t, dir+"/abc11") +} + +func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) { + dir, c := newTestFileCache(t) + _, err := c.Write("abc", bytes.NewReader(make([]byte, 1025))) + require.Equal(t, util.ErrLimitReached, err) + require.NoFileExists(t, dir+"/abc") +} + +func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) { + dir, c := newTestFileCache(t) + _, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewLimiter(1000)) + require.Equal(t, util.ErrLimitReached, err) + require.NoFileExists(t, dir+"/abc") +} + +func newTestFileCache(t *testing.T) (dir string, cache *fileCache) { + dir = t.TempDir() + cache, err := newFileCache(dir, 10*1024, 1*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/server.go b/server/server.go index c3ee81fa..6cd46154 100644 --- a/server/server.go +++ b/server/server.go @@ -833,13 +833,15 @@ func (s *Server) updateStatsAndPrune() { } // Delete expired attachments - ids, err := s.cache.AttachmentsExpired() - if err == nil { - if err := s.fileCache.Remove(ids); err != nil { - log.Printf("error while deleting attachments: %s", err.Error()) + if s.fileCache != nil { + ids, err := s.cache.AttachmentsExpired() + if err == nil { + if err := s.fileCache.Remove(ids...); err != nil { + log.Printf("error while deleting attachments: %s", err.Error()) + } + } else { + log.Printf("error retrieving expired attachments: %s", err.Error()) } - } else { - log.Printf("error retrieving expired attachments: %s", err.Error()) } // Prune message cache diff --git a/util/limit.go b/util/limit.go index bac3c155..f0a0c5a3 100644 --- a/util/limit.go +++ b/util/limit.go @@ -24,15 +24,12 @@ func NewLimiter(limit int64) *Limiter { } } -// Add adds n to the limiters internal value, but only if the limit has not been reached. If the limit would be +// Add adds n to the limiters internal value, but only if the limit has not been reached. If the limit was // exceeded after adding n, ErrLimitReached is returned. func (l *Limiter) Add(n int64) error { l.mu.Lock() defer l.mu.Unlock() - if l.limit == 0 { - l.value += n - return nil - } else if l.value+n <= l.limit { + if l.value+n <= l.limit { l.value += n return nil } else { diff --git a/util/util_test.go b/util/util_test.go index f60aa252..45ff3de6 100644 --- a/util/util_test.go +++ b/util/util_test.go @@ -127,7 +127,7 @@ func TestParseSize_10GSuccess(t *testing.T) { if err != nil { t.Fatal(err) } - require.Equal(t, 10*1024*1024*1024, s) + require.Equal(t, int64(10*1024*1024*1024), s) } func TestParseSize_10MUpperCaseSuccess(t *testing.T) { @@ -135,7 +135,7 @@ func TestParseSize_10MUpperCaseSuccess(t *testing.T) { if err != nil { t.Fatal(err) } - require.Equal(t, 10*1024*1024, s) + require.Equal(t, int64(10*1024*1024), s) } func TestParseSize_10kLowerCaseSuccess(t *testing.T) { @@ -143,7 +143,7 @@ func TestParseSize_10kLowerCaseSuccess(t *testing.T) { if err != nil { t.Fatal(err) } - require.Equal(t, 10*1024, s) + require.Equal(t, int64(10*1024), s) } func TestParseSize_FailureInvalid(t *testing.T) { From 44a9509cd602de898c73b2f560c8ac78e0707077 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 8 Jan 2022 15:47:08 -0500 Subject: [PATCH 09/24] Properly handle different attachment use cases --- docs/publish.md | 13 ++-- scripts/postinst.sh | 2 - server/server.go | 164 +++++++++++++++++++++++++++++--------------- server/util.go | 68 ++++++++++++++++++ server/util_test.go | 19 +++++ server/visitor.go | 4 +- util/limit.go | 7 +- 7 files changed, 202 insertions(+), 75 deletions(-) create mode 100644 server/util.go create mode 100644 server/util_test.go diff --git a/docs/publish.md b/docs/publish.md index fc5b804a..61d30411 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -666,26 +666,21 @@ Here's an example that will open Reddit when the notification is clicked: - Preview without attachment -# Send attachment +# Upload and send attachment curl -T image.jpg ntfy.sh/howdy -# Send attachment with custom message and filename +# Upload and send attachment with custom message and filename curl \ -T flower.jpg \ -H "Message: Here's a flower for you" \ -H "Filename: flower.jpg" \ ntfy.sh/howdy -# Send attachment from another URL, with custom preview and message +# Send external attachment from other URL, with custom message curl \ -H "Attachment: https://example.com/files.zip" \ - -H "Preview: https://example.com/filespreview.jpg" \ - "ntfy.sh/howdy?m=Important+documents+attached" - -# Send normal message with external image -curl \ - -H "Image: https://example.com/someimage.jpg" \ "ntfy.sh/howdy?m=Important+documents+attached" + ``` ## E-mail notifications diff --git a/scripts/postinst.sh b/scripts/postinst.sh index 04cc91e5..542df521 100755 --- a/scripts/postinst.sh +++ b/scripts/postinst.sh @@ -4,8 +4,6 @@ set -e # Restart systemd service if it was already running. Note that "deb-systemd-invoke try-restart" will # only act if the service is already running. If it's not running, it's a no-op. # -# TODO: This is only tested on Debian. -# if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then if [ -d /run/systemd/system ]; then # Create ntfy user/group diff --git a/server/server.go b/server/server.go index 6cd46154..82eddaec 100644 --- a/server/server.go +++ b/server/server.go @@ -102,6 +102,7 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`) disallowedTopics = []string{"docs", "static", "file"} + attachURLRegex = regexp.MustCompile(`^https?://`) templateFnMap = template.FuncMap{ "durationToHuman": util.DurationToHuman, @@ -122,25 +123,30 @@ var ( docsStaticFs embed.FS docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} - errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} - errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} - errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} - errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} - errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} - errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} - errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} - errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} - errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} - errHTTPBadRequestInvalidMessage = &errHTTP{40011, http.StatusBadRequest, "invalid message: invalid encoding or too large, and attachments are not allowed", ""} - errHTTPBadRequestMessageTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid message: too large", ""} - errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} - errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} + errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} + errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} + errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} + errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} + errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"} + errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"} + errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"} + errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} + errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} + errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} + errHTTPBadRequestMessageTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid message: too large", ""} + errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""} + errHTTPBadRequestAttachmentURLPeakGeneral = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachment URL peak failed", ""} + errHTTPBadRequestAttachmentURLPeakNon2xx = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment URL peak failed with non-2xx status code", ""} + errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40016, http.StatusBadRequest, "invalid request: attachments not allowed", ""} + errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40017, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""} + errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} + errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} ) const ( @@ -444,27 +450,15 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return err } m := newDefaultMessage(t.ID, "") - filename := readParam(r, "x-filename", "filename", "file", "f") - if filename == "" && !body.LimitReached && utf8.Valid(body.PeakedBytes) { - m.Message = strings.TrimSpace(string(body.PeakedBytes)) - } else if s.fileCache != nil { - if err := s.writeAttachment(r, v, m, body); err != nil { - return err - } - } else { - return errHTTPBadRequestInvalidMessage - } - cache, firebase, email, err := s.parsePublishParams(r, m) + cache, firebase, email, err := s.parsePublishParams(r, v, m) if err != nil { return err } - if email != "" { - if err := v.EmailAllowed(); err != nil { - return errHTTPTooManyRequestsLimitEmails - } + if err := maybePeakAttachmentURL(m); err != nil { + return err } - if s.mailer == nil && email != "" { - return errHTTPBadRequestEmailDisabled + if err := s.handlePublishBody(v, m, body); err != nil { + return err } if m.Message == "" { m.Message = emptyMessageBody @@ -503,12 +497,34 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito return nil } -func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email string, err error) { +func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, err error) { cache = readParam(r, "x-cache", "cache") != "no" firebase = readParam(r, "x-firebase", "firebase") != "no" - email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") m.Title = readParam(r, "x-title", "title", "t") m.Click = readParam(r, "x-click", "click") + attach := readParam(r, "x-attachment", "attachment", "attach", "a") + filename := readParam(r, "x-filename", "filename", "file", "f") + if attach != "" || filename != "" { + m.Attachment = &attachment{} + } + if attach != "" { + if !attachURLRegex.MatchString(attach) { + return false, false, "", errHTTPBadRequestAttachmentURLInvalid + } + m.Attachment.URL = attach + } + if filename != "" { + m.Attachment.Name = filename + } + email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") + if email != "" { + if err := v.EmailAllowed(); err != nil { + return false, false, "", errHTTPTooManyRequestsLimitEmails + } + } + if s.mailer == nil && email != "" { + return false, false, "", errHTTPBadRequestEmailDisabled + } messageStr := readParam(r, "x-message", "message", "m") if messageStr != "" { m.Message = messageStr @@ -565,13 +581,57 @@ func readParam(r *http.Request, names ...string) string { return "" } -func (s *Server) writeAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error { - contentType := http.DetectContentType(body.PeakedBytes) - ext := util.ExtensionByType(contentType) - fileURL := fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) - filename := readParam(r, "x-filename", "filename", "file", "f") - if filename == "" { - filename = fmt.Sprintf("attachment%s", ext) +// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message. +// +// 1. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic +// Body must be a message, because we attached an external URL +// 2. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic +// Body must be attachment, because we passed a filename +// 3. curl -T file.txt ntfy.sh/mytopic +// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message +// 4. curl -T file.txt ntfy.sh/mytopic +// If file.txt is > message limit, treat it as an attachment +func (s *Server) handlePublishBody(v *visitor, m *message, body *util.PeakedReadCloser) error { + if m.Attachment != nil && m.Attachment.URL != "" { + return s.handleBodyAsMessage(m, body) // Case 1 + } else if m.Attachment != nil && m.Attachment.Name != "" { + return s.handleBodyAsAttachment(v, m, body) // Case 2 + } else if !body.LimitReached && utf8.Valid(body.PeakedBytes) { + return s.handleBodyAsMessage(m, body) // Case 3 + } + return s.handleBodyAsAttachment(v, m, body) // Case 4 +} + +func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) error { + if !utf8.Valid(body.PeakedBytes) { + return errHTTPBadRequestMessageNotUTF8 + } + if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!) + m.Message = strings.TrimSpace(string(body.PeakedBytes)) // Truncates the message to the peak limit if required + } + return nil +} + +func (s *Server) handleBodyAsAttachment(v *visitor, m *message, body *util.PeakedReadCloser) error { + if s.fileCache == nil { + return errHTTPBadRequestAttachmentsDisallowed + } else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() { + return errHTTPBadRequestAttachmentsExpiryBeforeDelivery + } + if m.Attachment == nil { + m.Attachment = &attachment{} + } + var err error + m.Attachment.Owner = v.ip // Important for attachment rate limiting + m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix() + m.Attachment.Type = http.DetectContentType(body.PeakedBytes) + ext := util.ExtensionByType(m.Attachment.Type) + m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) + if m.Attachment.Name == "" { + m.Attachment.Name = fmt.Sprintf("attachment%s", ext) + } + if m.Message == "" { + m.Message = fmt.Sprintf("You received a file: %s", m.Attachment.Name) } // TODO do not allowed delayed delivery for attachments visitorAttachmentsSize, err := s.cache.AttachmentsSize(v.ip) @@ -579,22 +639,13 @@ func (s *Server) writeAttachment(r *http.Request, v *visitor, m *message, body * return err } remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize - log.Printf("remaining visitor: %d", remainingVisitorAttachmentSize) - size, err := s.fileCache.Write(m.ID, body, util.NewLimiter(remainingVisitorAttachmentSize)) + m.Attachment.Size, err = s.fileCache.Write(m.ID, body, util.NewLimiter(remainingVisitorAttachmentSize)) if err == util.ErrLimitReached { return errHTTPBadRequestMessageTooLarge } else if err != nil { return err } - m.Message = fmt.Sprintf("You received a file: %s", filename) // May be overwritten later - m.Attachment = &attachment{ - Name: filename, - Type: contentType, - Size: size, - Expires: time.Now().Add(s.config.AttachmentExpiryDuration).Unix(), - URL: fileURL, - Owner: v.ip, // Important for attachment rate limiting - } + return nil } @@ -965,7 +1016,6 @@ func (s *Server) sendDelayedMessages() error { log.Printf("unable to publish to Firebase: %v", err.Error()) } } - // TODO delayed email sending } if err := s.cache.MarkPublished(m); err != nil { return err diff --git a/server/util.go b/server/util.go new file mode 100644 index 00000000..b0f08172 --- /dev/null +++ b/server/util.go @@ -0,0 +1,68 @@ +package server + +import ( + "fmt" + "heckel.io/ntfy/util" + "io" + "net/http" + "net/url" + "path" + "strconv" + "time" +) + +const ( + peakAttachmentTimeout = 2500 * time.Millisecond + peakAttachmeantReadBytes = 128 +) + +func maybePeakAttachmentURL(m *message) error { + return maybePeakAttachmentURLInternal(m, peakAttachmentTimeout) +} + +func maybePeakAttachmentURLInternal(m *message, timeout time.Duration) error { + if m.Attachment == nil || m.Attachment.URL == "" { + return nil + } + client := http.Client{ + Timeout: timeout, + Transport: &http.Transport{ + DisableCompression: true, // Disable "Accept-Encoding: gzip", otherwise we won't get the Content-Length + Proxy: http.ProxyFromEnvironment, + }, + } + req, err := http.NewRequest(http.MethodGet, m.Attachment.URL, nil) + if err != nil { + return err + } + req.Header.Set("User-Agent", "ntfy") + resp, err := client.Do(req) + if err != nil { + return errHTTPBadRequestAttachmentURLPeakGeneral + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return errHTTPBadRequestAttachmentURLPeakNon2xx + } + if size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64); err == nil { + m.Attachment.Size = size + } + m.Attachment.Type = resp.Header.Get("Content-Type") + if m.Attachment.Type == "" || m.Attachment.Type == "application/octet-stream" { + buf := make([]byte, peakAttachmeantReadBytes) + io.ReadFull(resp.Body, buf) // Best effort: We don't care about the error + m.Attachment.Type = http.DetectContentType(buf) + } + if m.Attachment.Name == "" { + u, err := url.Parse(m.Attachment.URL) + if err != nil { + m.Attachment.Name = fmt.Sprintf("attachment%s", util.ExtensionByType(m.Attachment.Type)) + } else { + m.Attachment.Name = path.Base(u.Path) + if m.Attachment.Name == "." || m.Attachment.Name == "/" { + m.Attachment.Name = fmt.Sprintf("attachment%s", util.ExtensionByType(m.Attachment.Type)) + } + } + } + return nil +} diff --git a/server/util_test.go b/server/util_test.go new file mode 100644 index 00000000..a20cfb64 --- /dev/null +++ b/server/util_test.go @@ -0,0 +1,19 @@ +package server + +import ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestMaybePeakAttachmentURL_Success(t *testing.T) { + m := &message{ + Attachment: &attachment{ + URL: "https://ntfy.sh/static/img/ntfy.png", + }, + } + require.Nil(t, maybePeakAttachmentURL(m)) + require.Equal(t, "ntfy.png", m.Attachment.Name) + require.Equal(t, int64(3627), m.Attachment.Size) + require.Equal(t, "image/png", m.Attachment.Type) + require.Equal(t, int64(0), m.Attachment.Expires) +} diff --git a/server/visitor.go b/server/visitor.go index f772f2c0..63478798 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -26,7 +26,6 @@ type visitor struct { requests *rate.Limiter subscriptions *util.Limiter emails *rate.Limiter - attachments *rate.Limiter seen time.Time mu sync.Mutex } @@ -38,8 +37,7 @@ func newVisitor(conf *Config, ip string) *visitor { requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)), emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), - //attachments: rate.NewLimiter(rate.Every(conf.VisitorAttachmentBytesLimitReplenish * 1024), conf.VisitorAttachmentBytesLimitBurst), - seen: time.Now(), + seen: time.Now(), } } diff --git a/util/limit.go b/util/limit.go index f0a0c5a3..d7c3a8a6 100644 --- a/util/limit.go +++ b/util/limit.go @@ -29,12 +29,11 @@ func NewLimiter(limit int64) *Limiter { func (l *Limiter) Add(n int64) error { l.mu.Lock() defer l.mu.Unlock() - if l.value+n <= l.limit { - l.value += n - return nil - } else { + if l.value+n > l.limit { return ErrLimitReached } + l.value += n + return nil } // Sub subtracts a value from the limiters internal value From b5183612be85ec2f1102026229f224e610473676 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sun, 9 Jan 2022 22:06:31 -0500 Subject: [PATCH 10/24] Fix attachment pruning logging; .mp4 extension issue --- server/file_cache.go | 19 +++++-------------- util/util.go | 2 ++ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/server/file_cache.go b/server/file_cache.go index 04ae76e4..40346474 100644 --- a/server/file_cache.go +++ b/server/file_cache.go @@ -71,13 +71,12 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...*util.Limiter) (i } func (c *fileCache) Remove(ids ...string) error { - var firstErr error for _, id := range ids { - if err := c.removeFile(id); err != nil { - if firstErr == nil { - firstErr = err // Continue despite error; we want to delete as many as we can - } + if !fileIDRegex.MatchString(id) { + return errInvalidFileID } + file := filepath.Join(c.dir, id) + _ = os.Remove(file) // Best effort delete } size, err := dirSize(c.dir) if err != nil { @@ -86,15 +85,7 @@ func (c *fileCache) Remove(ids ...string) error { c.mu.Lock() c.totalSizeCurrent = size c.mu.Unlock() - return firstErr -} - -func (c *fileCache) removeFile(id string) error { - if !fileIDRegex.MatchString(id) { - return errInvalidFileID - } - file := filepath.Join(c.dir, id) - return os.Remove(file) + return nil } func (c *fileCache) Size() int64 { diff --git a/util/util.go b/util/util.go index 887443bf..ae4315b3 100644 --- a/util/util.go +++ b/util/util.go @@ -173,6 +173,8 @@ func ExtensionByType(contentType string) string { switch contentType { case "image/jpeg": return ".jpg" + case "video/mp4": + return ".mp4" default: exts, err := mime.ExtensionsByType(contentType) if err == nil && len(exts) > 0 && extRegex.MatchString(exts[0]) { From e8cb9e7fdee818e2b1d4aa69b6fc32a7fbe5e3e1 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 10 Jan 2022 13:38:51 -0500 Subject: [PATCH 11/24] Better mime type probing --- go.mod | 1 + go.sum | 3 +++ server/server.go | 18 +++++++++++------- server/util.go | 17 +++++++++-------- util/content_type_writer.go | 11 ++++++----- util/content_type_writer_test.go | 15 +++++++++++---- util/util.go | 27 ++++++++++++--------------- 7 files changed, 53 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index fad88a46..816766c2 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/envoyproxy/go-control-plane v0.10.1 // indirect github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect + github.com/gabriel-vasile/mimetype v1.4.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/google/go-cmp v0.5.6 // indirect diff --git a/go.sum b/go.sum index 91718f40..ef752ff8 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPO github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE= github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= +github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro= +github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -323,6 +325,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d h1:LO7XpTYMwTqxjLcGWPijK3vRXg1aWdlNOVOHRq45d7c= golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= diff --git a/server/server.go b/server/server.go index 82eddaec..abf7a379 100644 --- a/server/server.go +++ b/server/server.go @@ -150,9 +150,10 @@ var ( ) const ( - firebaseControlTopic = "~control" // See Android if changed - emptyMessageBody = "triggered" - fcmMessageLimit = 4000 // see maybeTruncateFCMMessage for details + firebaseControlTopic = "~control" // See Android if changed + emptyMessageBody = "triggered" + fcmMessageLimit = 4000 // see maybeTruncateFCMMessage for details + defaultAttachmentMessage = "You received a file: %s" ) // New instantiates a new Server. It creates the cache and adds a Firebase @@ -436,7 +437,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, _ *visitor) return err } defer f.Close() - _, err = io.Copy(util.NewContentTypeWriter(w), f) + _, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f) return err } @@ -609,6 +610,9 @@ func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) er if len(body.PeakedBytes) > 0 { // Empty body should not override message (publish via GET!) m.Message = strings.TrimSpace(string(body.PeakedBytes)) // Truncates the message to the peak limit if required } + if m.Attachment != nil && m.Attachment.Name != "" && m.Message == "" { + m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) + } return nil } @@ -622,16 +626,16 @@ func (s *Server) handleBodyAsAttachment(v *visitor, m *message, body *util.Peake m.Attachment = &attachment{} } var err error + var ext string m.Attachment.Owner = v.ip // Important for attachment rate limiting m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix() - m.Attachment.Type = http.DetectContentType(body.PeakedBytes) - ext := util.ExtensionByType(m.Attachment.Type) + m.Attachment.Type, ext = util.DetectContentType(body.PeakedBytes, m.Attachment.Name) m.Attachment.URL = fmt.Sprintf("%s/file/%s%s", s.config.BaseURL, m.ID, ext) if m.Attachment.Name == "" { m.Attachment.Name = fmt.Sprintf("attachment%s", ext) } if m.Message == "" { - m.Message = fmt.Sprintf("You received a file: %s", m.Attachment.Name) + m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } // TODO do not allowed delayed delivery for attachments visitorAttachmentsSize, err := s.cache.AttachmentsSize(v.ip) diff --git a/server/util.go b/server/util.go index b0f08172..d36e3976 100644 --- a/server/util.go +++ b/server/util.go @@ -12,8 +12,8 @@ import ( ) const ( - peakAttachmentTimeout = 2500 * time.Millisecond - peakAttachmeantReadBytes = 128 + peakAttachmentTimeout = 2500 * time.Millisecond + peakAttachmentReadBytes = 128 ) func maybePeakAttachmentURL(m *message) error { @@ -47,20 +47,21 @@ func maybePeakAttachmentURLInternal(m *message, timeout time.Duration) error { if size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64); err == nil { m.Attachment.Size = size } + buf := make([]byte, peakAttachmentReadBytes) + io.ReadFull(resp.Body, buf) // Best effort: We don't care about the error + mimeType, ext := util.DetectContentType(buf, m.Attachment.URL) m.Attachment.Type = resp.Header.Get("Content-Type") - if m.Attachment.Type == "" || m.Attachment.Type == "application/octet-stream" { - buf := make([]byte, peakAttachmeantReadBytes) - io.ReadFull(resp.Body, buf) // Best effort: We don't care about the error - m.Attachment.Type = http.DetectContentType(buf) + if m.Attachment.Type == "" { + m.Attachment.Type = mimeType } if m.Attachment.Name == "" { u, err := url.Parse(m.Attachment.URL) if err != nil { - m.Attachment.Name = fmt.Sprintf("attachment%s", util.ExtensionByType(m.Attachment.Type)) + m.Attachment.Name = fmt.Sprintf("attachment%s", ext) } else { m.Attachment.Name = path.Base(u.Path) if m.Attachment.Name == "." || m.Attachment.Name == "/" { - m.Attachment.Name = fmt.Sprintf("attachment%s", util.ExtensionByType(m.Attachment.Type)) + m.Attachment.Name = fmt.Sprintf("attachment%s", ext) } } } diff --git a/util/content_type_writer.go b/util/content_type_writer.go index fb3c43f8..7174f028 100644 --- a/util/content_type_writer.go +++ b/util/content_type_writer.go @@ -11,13 +11,14 @@ import ( // It will always set a Content-Type based on http.DetectContentType, but will never send the "text/html" // content type. type ContentTypeWriter struct { - w http.ResponseWriter - sniffed bool + w http.ResponseWriter + filename string + sniffed bool } // NewContentTypeWriter creates a new ContentTypeWriter -func NewContentTypeWriter(w http.ResponseWriter) *ContentTypeWriter { - return &ContentTypeWriter{w, false} +func NewContentTypeWriter(w http.ResponseWriter, filename string) *ContentTypeWriter { + return &ContentTypeWriter{w, filename, false} } func (w *ContentTypeWriter) Write(p []byte) (n int, err error) { @@ -27,7 +28,7 @@ func (w *ContentTypeWriter) Write(p []byte) (n int, err error) { // Detect and set Content-Type header // Fix content types that we don't want to inline-render in the browser. In particular, // we don't want to render HTML in the browser for security reasons. - contentType := http.DetectContentType(p) + contentType, _ := DetectContentType(p, w.filename) if strings.HasPrefix(contentType, "text/html") { contentType = strings.ReplaceAll(contentType, "text/html", "text/plain") } else if contentType == "application/octet-stream" { diff --git a/util/content_type_writer_test.go b/util/content_type_writer_test.go index 08dd751b..0fdf65cc 100644 --- a/util/content_type_writer_test.go +++ b/util/content_type_writer_test.go @@ -9,14 +9,14 @@ import ( func TestSniffWriter_WriteHTML(t *testing.T) { rr := httptest.NewRecorder() - sw := NewContentTypeWriter(rr) + sw := NewContentTypeWriter(rr, "") sw.Write([]byte("")) require.Equal(t, "text/plain; charset=utf-8", rr.Header().Get("Content-Type")) } func TestSniffWriter_WriteTwoWriteCalls(t *testing.T) { rr := httptest.NewRecorder() - sw := NewContentTypeWriter(rr) + sw := NewContentTypeWriter(rr, "") sw.Write([]byte{0x25, 0x50, 0x44, 0x46, 0x2d, 0x11, 0x22, 0x33}) sw.Write([]byte("")) require.Equal(t, "application/pdf", rr.Header().Get("Content-Type")) @@ -34,7 +34,7 @@ func TestSniffWriter_WriteHTMLSplitIntoTwoWrites(t *testing.T) { // This test shows how splitting the HTML into two Write() calls will still yield text/plain rr := httptest.NewRecorder() - sw := NewContentTypeWriter(rr) + sw := NewContentTypeWriter(rr, "") sw.Write([]byte("alert('hi')")) require.Equal(t, "text/plain; charset=utf-8", rr.Header().Get("Content-Type")) @@ -42,9 +42,16 @@ func TestSniffWriter_WriteHTMLSplitIntoTwoWrites(t *testing.T) { func TestSniffWriter_WriteUnknownMimeType(t *testing.T) { rr := httptest.NewRecorder() - sw := NewContentTypeWriter(rr) + sw := NewContentTypeWriter(rr, "") randomBytes := make([]byte, 199) rand.Read(randomBytes) sw.Write(randomBytes) require.Equal(t, "application/octet-stream", rr.Header().Get("Content-Type")) } + +func TestSniffWriter_WriteWithFilenameAPK(t *testing.T) { + rr := httptest.NewRecorder() + sw := NewContentTypeWriter(rr, "https://example.com/ntfy.apk") + sw.Write([]byte{0x50, 0x4B, 0x03, 0x04}) + require.Equal(t, "application/vnd.android.package-archive", rr.Header().Get("Content-Type")) +} diff --git a/util/util.go b/util/util.go index ae4315b3..c6e7623c 100644 --- a/util/util.go +++ b/util/util.go @@ -3,8 +3,8 @@ package util import ( "errors" "fmt" + "github.com/gabriel-vasile/mimetype" "math/rand" - "mime" "os" "regexp" "strconv" @@ -21,7 +21,6 @@ var ( random = rand.New(rand.NewSource(time.Now().UnixNano())) randomMutex = sync.Mutex{} sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`) - extRegex = regexp.MustCompile(`^\.[-_A-Za-z0-9]+$`) errInvalidPriority = errors.New("invalid priority") ) @@ -168,20 +167,18 @@ func ShortTopicURL(s string) string { return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://") } -// ExtensionByType is a wrapper around mime.ExtensionByType with a few sensible corrections -func ExtensionByType(contentType string) string { - switch contentType { - case "image/jpeg": - return ".jpg" - case "video/mp4": - return ".mp4" - default: - exts, err := mime.ExtensionsByType(contentType) - if err == nil && len(exts) > 0 && extRegex.MatchString(exts[0]) { - return exts[0] - } - return ".bin" +// DetectContentType probes the byte array b and returns mime type and file extension. +// The filename is only used to override certain special cases. +func DetectContentType(b []byte, filename string) (mimeType string, ext string) { + if strings.HasSuffix(strings.ToLower(filename), ".apk") { + return "application/vnd.android.package-archive", ".apk" } + m := mimetype.Detect(b) + mimeType, ext = m.String(), m.Extension() + if ext == "" { + ext = ".bin" + } + return } // ParseSize parses a size string like 2K or 2M into bytes. If no unit is found, e.g. 123, bytes is assumed. From 289a6fdd0f266e7839d11042734895739e3c19fa Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Mon, 10 Jan 2022 15:36:12 -0500 Subject: [PATCH 12/24] Add attachment expiry option --- cmd/serve.go | 3 +++ server/cache_sqlite.go | 2 +- server/config.go | 15 +++++++-------- server/server.go | 9 ++++----- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 3380f433..65719bbe 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -23,6 +23,7 @@ var flagsServe = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "1G", Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), + altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "manager-interval", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MANAGER_INTERVAL"}, Value: server.DefaultManagerInterval, Usage: "interval of for message pruning and stats printing"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}), @@ -76,6 +77,7 @@ func execServe(c *cli.Context) error { attachmentCacheDir := c.String("attachment-cache-dir") attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit") attachmentFileSizeLimitStr := c.String("attachment-file-size-limit") + attachmentExpiryDuration := c.Duration("attachment-expiry-duration") keepaliveInterval := c.Duration("keepalive-interval") managerInterval := c.Duration("manager-interval") smtpSenderAddr := c.String("smtp-sender-addr") @@ -142,6 +144,7 @@ func execServe(c *cli.Context) error { conf.AttachmentCacheDir = attachmentCacheDir conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit conf.AttachmentFileSizeLimit = attachmentFileSizeLimit + conf.AttachmentExpiryDuration = attachmentExpiryDuration conf.KeepaliveInterval = keepaliveInterval conf.ManagerInterval = managerInterval conf.SMTPSenderAddr = smtpSenderAddr diff --git a/server/cache_sqlite.go b/server/cache_sqlite.go index c8d97735..6b121f5b 100644 --- a/server/cache_sqlite.go +++ b/server/cache_sqlite.go @@ -279,7 +279,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) { var timestamp, attachmentSize, attachmentExpires int64 var priority int var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner string - if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &click, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentOwner, &attachmentURL); err != nil { + if err := rows.Scan(&id, ×tamp, &topic, &msg, &title, &priority, &tagsStr, &click, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL, &attachmentOwner); err != nil { return nil, err } var tags []string diff --git a/server/config.go b/server/config.go index 76c38e3b..69b8dcc5 100644 --- a/server/config.go +++ b/server/config.go @@ -27,14 +27,13 @@ const ( // - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour) // - per visitor attachment size limit: const ( - DefaultTotalTopicLimit = 5000 - DefaultVisitorSubscriptionLimit = 30 - DefaultVisitorRequestLimitBurst = 60 - DefaultVisitorRequestLimitReplenish = 10 * time.Second - DefaultVisitorEmailLimitBurst = 16 - DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorAttachmentTotalSizeLimit = 50 * 1024 * 1024 - DefaultVisitorAttachmentBytesLimitReplenish = time.Hour + DefaultTotalTopicLimit = 5000 + DefaultVisitorSubscriptionLimit = 30 + DefaultVisitorRequestLimitBurst = 60 + DefaultVisitorRequestLimitReplenish = 10 * time.Second + DefaultVisitorEmailLimitBurst = 16 + DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorAttachmentTotalSizeLimit = 50 * 1024 * 1024 ) // Config is the main config struct for the application. Use New to instantiate a default config struct. diff --git a/server/server.go b/server/server.go index abf7a379..1e1e96fa 100644 --- a/server/server.go +++ b/server/server.go @@ -637,7 +637,6 @@ func (s *Server) handleBodyAsAttachment(v *visitor, m *message, body *util.Peake if m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } - // TODO do not allowed delayed delivery for attachments visitorAttachmentsSize, err := s.cache.AttachmentsSize(v.ip) if err != nil { return err @@ -1015,10 +1014,10 @@ func (s *Server) sendDelayedMessages() error { if err := t.Publish(m); err != nil { log.Printf("unable to publish message %s to topic %s: %v", m.ID, m.Topic, err.Error()) } - if s.firebase != nil { - if err := s.firebase(m); err != nil { - log.Printf("unable to publish to Firebase: %v", err.Error()) - } + } + if s.firebase != nil { // Firebase subscribers may not show up in topics map + if err := s.firebase(m); err != nil { + log.Printf("unable to publish to Firebase: %v", err.Error()) } } if err := s.cache.MarkPublished(m); err != nil { From 68a324c20640e37c1a2b0b29e59b768f1d455068 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 11 Jan 2022 12:58:11 -0500 Subject: [PATCH 13/24] Fail early for too-large attachments --- server/server.go | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/server/server.go b/server/server.go index 1e1e96fa..aebc216a 100644 --- a/server/server.go +++ b/server/server.go @@ -139,7 +139,7 @@ var ( errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} - errHTTPBadRequestMessageTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid message: too large", ""} + errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large", ""} errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""} errHTTPBadRequestAttachmentURLPeakGeneral = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachment URL peak failed", ""} errHTTPBadRequestAttachmentURLPeakNon2xx = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment URL peak failed with non-2xx status code", ""} @@ -458,7 +458,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito if err := maybePeakAttachmentURL(m); err != nil { return err } - if err := s.handlePublishBody(v, m, body); err != nil { + if err := s.handlePublishBody(r, v, m, body); err != nil { return err } if m.Message == "" { @@ -592,15 +592,15 @@ func readParam(r *http.Request, names ...string) string { // If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message // 4. curl -T file.txt ntfy.sh/mytopic // If file.txt is > message limit, treat it as an attachment -func (s *Server) handlePublishBody(v *visitor, m *message, body *util.PeakedReadCloser) error { +func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error { if m.Attachment != nil && m.Attachment.URL != "" { return s.handleBodyAsMessage(m, body) // Case 1 } else if m.Attachment != nil && m.Attachment.Name != "" { - return s.handleBodyAsAttachment(v, m, body) // Case 2 + return s.handleBodyAsAttachment(r, v, m, body) // Case 2 } else if !body.LimitReached && utf8.Valid(body.PeakedBytes) { return s.handleBodyAsMessage(m, body) // Case 3 } - return s.handleBodyAsAttachment(v, m, body) // Case 4 + return s.handleBodyAsAttachment(r, v, m, body) // Case 4 } func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) error { @@ -616,16 +616,27 @@ func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) er return nil } -func (s *Server) handleBodyAsAttachment(v *visitor, m *message, body *util.PeakedReadCloser) error { +func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error { if s.fileCache == nil { return errHTTPBadRequestAttachmentsDisallowed } else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() { return errHTTPBadRequestAttachmentsExpiryBeforeDelivery } + visitorAttachmentsSize, err := s.cache.AttachmentsSize(v.ip) + if err != nil { + return err + } + remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize + 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 > remainingVisitorAttachmentSize || contentLength > s.config.AttachmentFileSizeLimit) { + return errHTTPBadRequestAttachmentTooLarge + } + } if m.Attachment == nil { m.Attachment = &attachment{} } - var err error var ext string m.Attachment.Owner = v.ip // Important for attachment rate limiting m.Attachment.Expires = time.Now().Add(s.config.AttachmentExpiryDuration).Unix() @@ -637,18 +648,12 @@ func (s *Server) handleBodyAsAttachment(v *visitor, m *message, body *util.Peake if m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } - visitorAttachmentsSize, err := s.cache.AttachmentsSize(v.ip) - if err != nil { - return err - } - remainingVisitorAttachmentSize := s.config.VisitorAttachmentTotalSizeLimit - visitorAttachmentsSize m.Attachment.Size, err = s.fileCache.Write(m.ID, body, util.NewLimiter(remainingVisitorAttachmentSize)) if err == util.ErrLimitReached { - return errHTTPBadRequestMessageTooLarge + return errHTTPBadRequestAttachmentTooLarge } else if err != nil { return err } - return nil } From f6b9ebb693987dfe21722f9baf310f594f51f43f Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 12 Jan 2022 11:05:04 -0500 Subject: [PATCH 14/24] Lots of tests --- server/server.go | 10 +- server/server_test.go | 218 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 7 deletions(-) diff --git a/server/server.go b/server/server.go index aebc216a..c5c398db 100644 --- a/server/server.go +++ b/server/server.go @@ -127,7 +127,7 @@ var ( errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitGlobalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} @@ -431,7 +431,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, _ *visitor) if err != nil { return errHTTPNotFound } - w.Header().Set("Length", fmt.Sprintf("%d", stat.Size())) + w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) f, err := os.Open(file) if err != nil { return err @@ -503,7 +503,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca firebase = readParam(r, "x-firebase", "firebase") != "no" m.Title = readParam(r, "x-title", "title", "t") m.Click = readParam(r, "x-click", "click") - attach := readParam(r, "x-attachment", "attachment", "attach", "a") + attach := readParam(r, "x-attach", "attach", "a") filename := readParam(r, "x-filename", "filename", "file", "f") if attach != "" || filename != "" { m.Attachment = &attachment{} @@ -617,7 +617,7 @@ func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) er } func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error { - if s.fileCache == nil { + if s.fileCache == nil || s.config.BaseURL == "" || s.config.AttachmentCacheDir == "" { return errHTTPBadRequestAttachmentsDisallowed } else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() { return errHTTPBadRequestAttachmentsExpiryBeforeDelivery @@ -871,7 +871,7 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) { } if _, ok := s.topics[id]; !ok { if len(s.topics) >= s.config.TotalTopicLimit { - return nil, errHTTPTooManyRequestsLimitGlobalTopics + return nil, errHTTPTooManyRequestsLimitTotalTopics } s.topics[id] = newTopic(id) } diff --git a/server/server_test.go b/server/server_test.go index 339c114a..90fbee00 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -7,6 +7,7 @@ import ( "firebase.google.com/go/messaging" "fmt" "github.com/stretchr/testify/require" + "heckel.io/ntfy/util" "net/http" "net/http/httptest" "os" @@ -163,7 +164,9 @@ func TestServer_StaticSites(t *testing.T) { } func TestServer_PublishLargeMessage(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + c := newTestConfig(t) + c.AttachmentCacheDir = "" // Disable attachments + s := newTestServer(t, c) body := strings.Repeat("this is a large message", 5000) response := request(t, s, "PUT", "/mytopic", body, nil) @@ -196,6 +199,9 @@ func TestServer_PublishPriority(t *testing.T) { response = request(t, s, "GET", "/mytopic/trigger?priority=urgent", "test", nil) require.Equal(t, 5, toMessage(t, response.Body.String()).Priority) + + response = request(t, s, "GET", "/mytopic/trigger?priority=INVALID", "test", nil) + require.Equal(t, 40007, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishNoCache(t *testing.T) { @@ -259,13 +265,28 @@ func TestServer_PublishAtTooShortDelay(t *testing.T) { func TestServer_PublishAtTooLongDelay(t *testing.T) { s := newTestServer(t, newTestConfig(t)) - response := request(t, s, "PUT", "/mytopic", "a message", map[string]string{ "In": "99999999h", }) require.Equal(t, 400, response.Code) } +func TestServer_PublishAtInvalidDelay(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?delay=INVALID", "a message", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 40004, err.Code) +} + +func TestServer_PublishAtTooLarge(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?x-in=99999h", "a message", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 40006, err.Code) +} + func TestServer_PublishAtAndPrune(t *testing.T) { s := newTestServer(t, newTestConfig(t)) @@ -347,6 +368,19 @@ func TestServer_PublishAndPollSince(t *testing.T) { messages := toMessages(t, response.Body.String()) require.Equal(t, 1, len(messages)) require.Equal(t, "test 2", messages[0].Message) + + response = request(t, s, "GET", "/mytopic/json?poll=1&since=10s", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 2, len(messages)) + require.Equal(t, "test 1", messages[0].Message) + + response = request(t, s, "GET", "/mytopic/json?poll=1&since=100ms", "", nil) + messages = toMessages(t, response.Body.String()) + require.Equal(t, 1, len(messages)) + require.Equal(t, "test 2", messages[0].Message) + + response = request(t, s, "GET", "/mytopic/json?poll=1&since=INVALID", "", nil) + require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code) } func TestServer_PublishViaGET(t *testing.T) { @@ -387,6 +421,13 @@ func TestServer_PublishFirebase(t *testing.T) { time.Sleep(500 * time.Millisecond) // Time for sends } +func TestServer_PublishInvalidTopic(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + s.mailer = &testMailer{} + response := request(t, s, "PUT", "/docs", "fail", nil) + require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code) +} + func TestServer_PollWithQueryFilters(t *testing.T) { s := newTestServer(t, newTestConfig(t)) @@ -640,9 +681,175 @@ func TestServer_MaybeTruncateFCMMessage_NotTooLong(t *testing.T) { require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"]) } +func TestServer_PublishAttachment(t *testing.T) { + content := util.RandomString(5000) // > 4096 + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", content, nil) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "attachment.txt", msg.Attachment.Name) + require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) + require.Equal(t, int64(5000), msg.Attachment.Size) + require.GreaterOrEqual(t, msg.Attachment.Expires, time.Now().Add(3*time.Hour).Unix()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.Equal(t, "", msg.Attachment.Owner) // Should never be returned + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "5000", response.Header().Get("Content-Length")) + require.Equal(t, content, response.Body.String()) +} + +func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + content := "this is an ATTACHMENT" + response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil) + msg := toMessage(t, response.Body.String()) + 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.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + require.Equal(t, "", msg.Attachment.Owner) // Should never be returned + require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID)) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, "21", response.Header().Get("Content-Length")) + require.Equal(t, content, response.Body.String()) +} + +func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "", map[string]string{ + "Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", + }) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "You received a file: Pink_flower.jpg", msg.Message) + require.Equal(t, "Pink_flower.jpg", msg.Attachment.Name) + require.Equal(t, "image/jpeg", msg.Attachment.Type) + require.Equal(t, int64(190173), msg.Attachment.Size) + require.Equal(t, int64(0), msg.Attachment.Expires) + require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) + require.Equal(t, "", msg.Attachment.Owner) +} + +func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", "This is a custom message", map[string]string{ + "X-Attach": "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", + "File": "some file.jpg", + }) + msg := toMessage(t, response.Body.String()) + require.Equal(t, "This is a custom message", msg.Message) + require.Equal(t, "some file.jpg", msg.Attachment.Name) + require.Equal(t, "image/jpeg", msg.Attachment.Type) + require.Equal(t, int64(190173), msg.Attachment.Size) + require.Equal(t, int64(0), msg.Attachment.Expires) + require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) + require.Equal(t, "", msg.Attachment.Owner) +} + +func TestServer_PublishAttachmentBadURL(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic?a=not+a+URL", "", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40013, err.Code) +} + +func TestServer_PublishAttachmentTooLargeContentLength(t *testing.T) { + content := util.RandomString(5000) // > 4096 + s := newTestServer(t, newTestConfig(t)) + response := request(t, s, "PUT", "/mytopic", content, map[string]string{ + "Content-Length": "20000000", + }) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40012, err.Code) +} + +func TestServer_PublishAttachmentTooLargeBodyAttachmentFileSizeLimit(t *testing.T) { + content := util.RandomString(5001) // > 5000, see below + c := newTestConfig(t) + c.AttachmentFileSizeLimit = 5000 + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", content, nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40012, err.Code) +} + +func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) { + c := newTestConfig(t) + c.AttachmentExpiryDuration = 10 * time.Minute + s := newTestServer(t, c) + response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), map[string]string{ + "Delay": "11 min", // > AttachmentExpiryDuration + }) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40017, err.Code) +} + +func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) { + c := newTestConfig(t) + c.VisitorAttachmentTotalSizeLimit = 10000 + s := newTestServer(t, c) + + response := request(t, s, "PUT", "/mytopic", util.RandomString(5000), nil) + msg := toMessage(t, response.Body.String()) + require.Equal(t, 200, response.Code) + require.Equal(t, "You received a file: attachment.txt", msg.Message) + require.Equal(t, int64(5000), msg.Attachment.Size) + + content := util.RandomString(5001) // 5000+5001 > , see below + response = request(t, s, "PUT", "/mytopic", content, nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 400, err.HTTPCode) + require.Equal(t, 40012, err.Code) +} + +func TestServer_PublishAttachmentAndPrune(t *testing.T) { + content := util.RandomString(5000) // > 4096 + + c := newTestConfig(t) + c.AttachmentExpiryDuration = time.Millisecond // Hack + s := newTestServer(t, c) + + // Publish and make sure we can retrieve it + response := request(t, s, "PUT", "/mytopic", content, nil) + println(response.Body.String()) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) + require.FileExists(t, file) + + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, content, response.Body.String()) + + // Prune and makes sure it's gone + time.Sleep(time.Second) // Sigh ... + s.updateStatsAndPrune() + require.NoFileExists(t, file) + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 404, response.Code) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() + conf.BaseURL = "http://127.0.0.1:12345" conf.CacheFile = filepath.Join(t.TempDir(), "cache.db") + conf.AttachmentCacheDir = t.TempDir() return conf } @@ -702,6 +909,13 @@ func toMessage(t *testing.T, s string) *message { return &m } +func tempFile(t *testing.T, length int) (filename string, content string) { + filename = filepath.Join(t.TempDir(), util.RandomString(10)) + content = util.RandomString(length) + require.Nil(t, os.WriteFile(filename, []byte(content), 0600)) + return +} + func toHTTPError(t *testing.T, s string) *errHTTP { var e errHTTP require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&e)) From c76e55a1c8b3f0b962a5885b5cb9d4f314e2087c Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 12 Jan 2022 17:03:28 -0500 Subject: [PATCH 15/24] Making RateLimiter and FixedLimiter, so they can both work with LimitWriter --- server/file_cache.go | 4 +- server/file_cache_test.go | 4 +- server/server.go | 2 +- server/server_test.go | 7 --- server/visitor.go | 8 ++-- util/limit.go | 71 ++++++++++++++++------------ util/limit_test.go | 98 ++++++++++++++++++++++++++++++--------- 7 files changed, 127 insertions(+), 67 deletions(-) diff --git a/server/file_cache.go b/server/file_cache.go index 40346474..ad4961cc 100644 --- a/server/file_cache.go +++ b/server/file_cache.go @@ -40,7 +40,7 @@ func newFileCache(dir string, totalSizeLimit int64, fileSizeLimit int64) (*fileC }, nil } -func (c *fileCache) Write(id string, in io.Reader, limiters ...*util.Limiter) (int64, error) { +func (c *fileCache) Write(id string, in io.Reader, limiters ...util.Limiter) (int64, error) { if !fileIDRegex.MatchString(id) { return 0, errInvalidFileID } @@ -53,7 +53,7 @@ func (c *fileCache) Write(id string, in io.Reader, limiters ...*util.Limiter) (i return 0, err } defer f.Close() - limiters = append(limiters, util.NewLimiter(c.Remaining()), util.NewLimiter(c.fileSizeLimit)) + limiters = append(limiters, util.NewFixedLimiter(c.Remaining()), util.NewFixedLimiter(c.fileSizeLimit)) limitWriter := util.NewLimitWriter(f, limiters...) size, err := io.Copy(limitWriter, in) if err != nil { diff --git a/server/file_cache_test.go b/server/file_cache_test.go index a0a74085..36d1d1a3 100644 --- a/server/file_cache_test.go +++ b/server/file_cache_test.go @@ -16,7 +16,7 @@ var ( func TestFileCache_Write_Success(t *testing.T) { dir, c := newTestFileCache(t) - size, err := c.Write("abc", strings.NewReader("normal file"), util.NewLimiter(999)) + size, err := c.Write("abc", 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+"/abc")) @@ -64,7 +64,7 @@ func TestFileCache_Write_FailedFileSizeLimit(t *testing.T) { func TestFileCache_Write_FailedAdditionalLimiter(t *testing.T) { dir, c := newTestFileCache(t) - _, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewLimiter(1000)) + _, err := c.Write("abc", bytes.NewReader(make([]byte, 1001)), util.NewFixedLimiter(1000)) require.Equal(t, util.ErrLimitReached, err) require.NoFileExists(t, dir+"/abc") } diff --git a/server/server.go b/server/server.go index c5c398db..33aacbd0 100644 --- a/server/server.go +++ b/server/server.go @@ -648,7 +648,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, if m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } - m.Attachment.Size, err = s.fileCache.Write(m.ID, body, util.NewLimiter(remainingVisitorAttachmentSize)) + m.Attachment.Size, err = s.fileCache.Write(m.ID, body, util.NewFixedLimiter(remainingVisitorAttachmentSize)) if err == util.ErrLimitReached { return errHTTPBadRequestAttachmentTooLarge } else if err != nil { diff --git a/server/server_test.go b/server/server_test.go index 90fbee00..0d81cb3c 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -909,13 +909,6 @@ func toMessage(t *testing.T, s string) *message { return &m } -func tempFile(t *testing.T, length int) (filename string, content string) { - filename = filepath.Join(t.TempDir(), util.RandomString(10)) - content = util.RandomString(length) - require.Nil(t, os.WriteFile(filename, []byte(content), 0600)) - return -} - func toHTTPError(t *testing.T, s string) *errHTTP { var e errHTTP require.Nil(t, json.NewDecoder(strings.NewReader(s)).Decode(&e)) diff --git a/server/visitor.go b/server/visitor.go index 63478798..26afa136 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -24,7 +24,7 @@ type visitor struct { config *Config ip string requests *rate.Limiter - subscriptions *util.Limiter + subscriptions util.Limiter emails *rate.Limiter seen time.Time mu sync.Mutex @@ -35,7 +35,7 @@ func newVisitor(conf *Config, ip string) *visitor { config: conf, ip: ip, requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), - subscriptions: util.NewLimiter(int64(conf.VisitorSubscriptionLimit)), + subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), seen: time.Now(), } @@ -62,7 +62,7 @@ func (v *visitor) EmailAllowed() error { func (v *visitor) SubscriptionAllowed() error { v.mu.Lock() defer v.mu.Unlock() - if err := v.subscriptions.Add(1); err != nil { + if err := v.subscriptions.Allow(1); err != nil { return errVisitorLimitReached } return nil @@ -71,7 +71,7 @@ func (v *visitor) SubscriptionAllowed() error { func (v *visitor) RemoveSubscription() { v.mu.Lock() defer v.mu.Unlock() - v.subscriptions.Sub(1) + v.subscriptions.Allow(-1) } func (v *visitor) Keepalive() { diff --git a/util/limit.go b/util/limit.go index d7c3a8a6..8df768ad 100644 --- a/util/limit.go +++ b/util/limit.go @@ -2,31 +2,39 @@ package util import ( "errors" + "golang.org/x/time/rate" "io" "sync" + "time" ) // ErrLimitReached is the error returned by the Limiter and LimitWriter when the predefined limit has been reached var ErrLimitReached = errors.New("limit reached") -// Limiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached -// ErrLimitReached will be returned. Limiter may be used by multiple goroutines. -type Limiter struct { +// Limiter is an interface that implements a rate limiting mechanism, e.g. based on time or a fixed value +type Limiter interface { + // Allow adds n to the limiters internal value, or returns ErrLimitReached if the limit has been reached + Allow(n int64) error +} + +// FixedLimiter is a helper that allows adding values up to a well-defined limit. Once the limit is reached +// ErrLimitReached will be returned. FixedLimiter may be used by multiple goroutines. +type FixedLimiter struct { value int64 limit int64 mu sync.Mutex } -// NewLimiter creates a new Limiter -func NewLimiter(limit int64) *Limiter { - return &Limiter{ +// NewFixedLimiter creates a new Limiter +func NewFixedLimiter(limit int64) *FixedLimiter { + return &FixedLimiter{ limit: limit, } } -// Add adds n to the limiters internal value, but only if the limit has not been reached. If the limit was +// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was // exceeded after adding n, ErrLimitReached is returned. -func (l *Limiter) Add(n int64) error { +func (l *FixedLimiter) Allow(n int64) error { l.mu.Lock() defer l.mu.Unlock() if l.value+n > l.limit { @@ -36,29 +44,34 @@ func (l *Limiter) Add(n int64) error { return nil } -// Sub subtracts a value from the limiters internal value -func (l *Limiter) Sub(n int64) { - l.Add(-n) +// RateLimiter is a Limiter that wraps a rate.Limiter, allowing a floating time-based limit. +type RateLimiter struct { + limiter *rate.Limiter } -// Set sets the value of the limiter to n. This function ignores the limit. It is meant to set the value -// based on reality. -func (l *Limiter) Set(n int64) { - l.mu.Lock() - l.value = n - l.mu.Unlock() +// NewRateLimiter creates a new RateLimiter +func NewRateLimiter(r rate.Limit, b int) *RateLimiter { + return &RateLimiter{ + limiter: rate.NewLimiter(r, b), + } } -// Value returns the internal value of the limiter -func (l *Limiter) Value() int64 { - l.mu.Lock() - defer l.mu.Unlock() - return l.value +// NewBytesLimiter creates a RateLimiter that is meant to be used for a bytes-per-interval limit, +// e.g. 250 MB per day. And example of the underlying idea can be found here: https://go.dev/play/p/0ljgzIZQ6dJ +func NewBytesLimiter(bytes int, interval time.Duration) *RateLimiter { + return NewRateLimiter(rate.Limit(bytes)*rate.Every(interval), bytes) } -// Limit returns the defined limit -func (l *Limiter) Limit() int64 { - return l.limit +// Allow adds n to the limiters internal value, but only if the limit has not been reached. If the limit was +// exceeded after adding n, ErrLimitReached is returned. +func (l *RateLimiter) Allow(n int64) error { + if n <= 0 { + return nil // No-op. Can't take back bytes you're written! + } + if !l.limiter.AllowN(time.Now(), int(n)) { + return ErrLimitReached + } + return nil } // LimitWriter implements an io.Writer that will pass through all Write calls to the underlying @@ -67,12 +80,12 @@ func (l *Limiter) Limit() int64 { type LimitWriter struct { w io.Writer written int64 - limiters []*Limiter + limiters []Limiter mu sync.Mutex } // NewLimitWriter creates a new LimitWriter -func NewLimitWriter(w io.Writer, limiters ...*Limiter) *LimitWriter { +func NewLimitWriter(w io.Writer, limiters ...Limiter) *LimitWriter { return &LimitWriter{ w: w, limiters: limiters, @@ -84,9 +97,9 @@ func (w *LimitWriter) Write(p []byte) (n int, err error) { w.mu.Lock() defer w.mu.Unlock() for i := 0; i < len(w.limiters); i++ { - if err := w.limiters[i].Add(int64(len(p))); err != nil { + if err := w.limiters[i].Allow(int64(len(p))); err != nil { for j := i - 1; j >= 0; j-- { - w.limiters[j].Sub(int64(len(p))) + w.limiters[j].Allow(-int64(len(p))) // Revert limiters limits if allowed } return 0, ErrLimitReached } diff --git a/util/limit_test.go b/util/limit_test.go index 4f07e00f..53e10b78 100644 --- a/util/limit_test.go +++ b/util/limit_test.go @@ -2,34 +2,51 @@ package util import ( "bytes" + "github.com/stretchr/testify/require" "testing" + "time" ) -func TestLimiter_Add(t *testing.T) { - l := NewLimiter(10) - if err := l.Add(5); err != nil { +func TestFixedLimiter_Add(t *testing.T) { + l := NewFixedLimiter(10) + if err := l.Allow(5); err != nil { t.Fatal(err) } - if err := l.Add(5); err != nil { + if err := l.Allow(5); err != nil { t.Fatal(err) } - if err := l.Add(5); err != ErrLimitReached { + if err := l.Allow(5); err != ErrLimitReached { t.Fatalf("expected ErrLimitReached, got %#v", err) } } -func TestLimiter_AddSet(t *testing.T) { - l := NewLimiter(10) - l.Add(5) - if l.Value() != 5 { - t.Fatalf("expected value to be %d, got %d", 5, l.Value()) +func TestFixedLimiter_AddSub(t *testing.T) { + l := NewFixedLimiter(10) + l.Allow(5) + if l.value != 5 { + t.Fatalf("expected value to be %d, got %d", 5, l.value) } - l.Set(7) - if l.Value() != 7 { - t.Fatalf("expected value to be %d, got %d", 7, l.Value()) + l.Allow(-2) + if l.value != 3 { + t.Fatalf("expected value to be %d, got %d", 7, l.value) } } +func TestBytesLimiter_Add_Simple(t *testing.T) { + l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h + require.Nil(t, l.Allow(100*1024*1024)) + require.Nil(t, l.Allow(100*1024*1024)) + require.Equal(t, ErrLimitReached, l.Allow(300*1024*1024)) +} + +func TestBytesLimiter_Add_Wait(t *testing.T) { + l := NewBytesLimiter(250*1024*1024, 24*time.Hour) // 250 MB per 24h (~ 303 bytes per 100ms) + require.Nil(t, l.Allow(250*1024*1024)) + require.Equal(t, ErrLimitReached, l.Allow(400)) + time.Sleep(200 * time.Millisecond) + require.Nil(t, l.Allow(400)) +} + func TestLimitWriter_WriteNoLimiter(t *testing.T) { var buf bytes.Buffer lw := NewLimitWriter(&buf) @@ -46,7 +63,7 @@ func TestLimitWriter_WriteNoLimiter(t *testing.T) { func TestLimitWriter_WriteOneLimiter(t *testing.T) { var buf bytes.Buffer - l := NewLimiter(10) + l := NewFixedLimiter(10) lw := NewLimitWriter(&buf, l) if _, err := lw.Write(make([]byte, 10)); err != nil { t.Fatal(err) @@ -57,15 +74,15 @@ func TestLimitWriter_WriteOneLimiter(t *testing.T) { if buf.Len() != 10 { t.Fatalf("expected buffer length to be %d, got %d", 10, buf.Len()) } - if l.Value() != 10 { - t.Fatalf("expected limiter value to be %d, got %d", 10, l.Value()) + if l.value != 10 { + t.Fatalf("expected limiter value to be %d, got %d", 10, l.value) } } func TestLimitWriter_WriteTwoLimiters(t *testing.T) { var buf bytes.Buffer - l1 := NewLimiter(11) - l2 := NewLimiter(9) + l1 := NewFixedLimiter(11) + l2 := NewFixedLimiter(9) lw := NewLimitWriter(&buf, l1, l2) if _, err := lw.Write(make([]byte, 8)); err != nil { t.Fatal(err) @@ -76,10 +93,47 @@ func TestLimitWriter_WriteTwoLimiters(t *testing.T) { if buf.Len() != 8 { t.Fatalf("expected buffer length to be %d, got %d", 8, buf.Len()) } - if l1.Value() != 8 { - t.Fatalf("expected limiter 1 value to be %d, got %d", 8, l1.Value()) + if l1.value != 8 { + t.Fatalf("expected limiter 1 value to be %d, got %d", 8, l1.value) } - if l2.Value() != 8 { - t.Fatalf("expected limiter 2 value to be %d, got %d", 8, l2.Value()) + if l2.value != 8 { + t.Fatalf("expected limiter 2 value to be %d, got %d", 8, l2.value) } } + +func TestLimitWriter_WriteTwoDifferentLimiters(t *testing.T) { + var buf bytes.Buffer + l1 := NewFixedLimiter(32) + l2 := NewBytesLimiter(8, 200*time.Millisecond) + lw := NewLimitWriter(&buf, l1, l2) + _, err := lw.Write(make([]byte, 8)) + require.Nil(t, err) + _, err = lw.Write(make([]byte, 4)) + require.Equal(t, ErrLimitReached, err) +} + +func TestLimitWriter_WriteTwoDifferentLimiters_Wait(t *testing.T) { + var buf bytes.Buffer + l1 := NewFixedLimiter(32) + l2 := NewBytesLimiter(8, 200*time.Millisecond) + lw := NewLimitWriter(&buf, l1, l2) + _, err := lw.Write(make([]byte, 8)) + require.Nil(t, err) + time.Sleep(250 * time.Millisecond) + _, err = lw.Write(make([]byte, 8)) + require.Nil(t, err) + _, err = lw.Write(make([]byte, 4)) + require.Equal(t, ErrLimitReached, err) +} + +func TestLimitWriter_WriteTwoDifferentLimiters_Wait_FixedLimiterFail(t *testing.T) { + var buf bytes.Buffer + l1 := NewFixedLimiter(11) // <<< This fails below + l2 := NewBytesLimiter(8, 200*time.Millisecond) + lw := NewLimitWriter(&buf, l1, l2) + _, err := lw.Write(make([]byte, 8)) + require.Nil(t, err) + time.Sleep(250 * time.Millisecond) + _, err = lw.Write(make([]byte, 8)) // <<< FixedLimiter fails + require.Equal(t, ErrLimitReached, err) +} From aa94410308e0f3695a1b6156adf362243a4b140c Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 12 Jan 2022 18:52:07 -0500 Subject: [PATCH 16/24] Daily traffic limit --- cmd/serve.go | 11 +++ docs/config.md | 12 ++-- server/config.go | 162 ++++++++++++++++++++++-------------------- server/server.go | 12 ++-- server/server.yml | 2 +- server/server_test.go | 49 ++++++++++++- server/visitor.go | 10 ++- 7 files changed, 170 insertions(+), 88 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 65719bbe..28ec5231 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -2,11 +2,13 @@ package cmd import ( "errors" + "fmt" "github.com/urfave/cli/v2" "github.com/urfave/cli/v2/altsrc" "heckel.io/ntfy/server" "heckel.io/ntfy/util" "log" + "math" "time" ) @@ -36,6 +38,7 @@ var flagsServe = []cli.Flag{ altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "50M", Usage: "total storage limit used for attachments per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-traffic-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_TRAFFIC_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload traffic limit per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), @@ -90,6 +93,7 @@ func execServe(c *cli.Context) error { totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") + visitorAttachmentDailyTrafficLimitStr := c.String("visitor-attachment-daily-traffic-limit") visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") @@ -130,6 +134,12 @@ func execServe(c *cli.Context) error { if err != nil { return err } + visitorAttachmentDailyTrafficLimit, err := parseSize(visitorAttachmentDailyTrafficLimitStr, server.DefaultVisitorAttachmentDailyTrafficLimit) + if err != nil { + return err + } else if visitorAttachmentDailyTrafficLimit > math.MaxInt { + return fmt.Errorf("config option visitor-attachment-daily-traffic-limit must be lower than %d", math.MaxInt) + } // Run server conf := server.NewConfig() @@ -157,6 +167,7 @@ func execServe(c *cli.Context) error { conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit + conf.VisitorAttachmentDailyTrafficLimit = int(visitorAttachmentDailyTrafficLimit) conf.VisitorRequestLimitBurst = visitorRequestLimitBurst conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish conf.VisitorEmailLimitBurst = visitorEmailLimitBurst diff --git a/docs/config.md b/docs/config.md index 5316d01f..69a8744e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -274,7 +274,7 @@ firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json" By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload. There are various limits and rate limits in place that you can use to configure the server. Let's do the easy ones first: -* `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 5000. +* `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 15,000. * `visitor-subscription-limit` is the number of subscriptions (open connections) per visitor. This value defaults to 30. A **visitor** is identified by its IP address (or the `X-Forwarded-For` header if `behind-proxy` is set). All config @@ -309,7 +309,7 @@ Depending on *how you run it*, here are a few limits that are relevant: ### For systemd services If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the -`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10000. You can override it +`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it by creating a `/etc/systemd/system/ntfy.service.d/override.conf` file. As far as I can tell, `/etc/security/limits.conf` is not relevant. @@ -322,7 +322,7 @@ is not relevant. ### Outside of systemd If you're running outside systemd, you may want to adjust your `/etc/security/limits.conf` file to -increase the `nofile` setting. Here's an example that increases the limit to 5000. You can find out the current setting +increase the `nofile` setting. Here's an example that increases the limit to 5,000. You can find out the current setting by running `ulimit -n`, or manually override it temporarily by running `ulimit -n 50000`. === "/etc/security/limits.conf" @@ -423,12 +423,16 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | | `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | 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. | -| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 5000 | Rate limiting: Total number of topics before the server rejects new topics. | +| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | | `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | 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* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | | `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 |Initial limit of e-mails per visitor | | `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | +xx daily traffic limit +xx DefaultVisitorAttachmentTotalSizeLimit +xx attachment cache dir +xx attachment The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. diff --git a/server/config.go b/server/config.go index 69b8dcc5..e304d849 100644 --- a/server/config.go +++ b/server/config.go @@ -4,7 +4,7 @@ import ( "time" ) -// Defines default config settings +// Defines default config settings (excluding limits, see below) const ( DefaultListenHTTP = ":80" DefaultCacheDuration = 12 * time.Hour @@ -13,97 +13,107 @@ const ( DefaultAtSenderInterval = 10 * time.Second DefaultMinDelay = 10 * time.Second DefaultMaxDelay = 3 * 24 * time.Hour - DefaultMessageLimit = 4096 // Bytes - DefaultAttachmentTotalSizeLimit = int64(1024 * 1024 * 1024) // 1 GB - DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB - DefaultAttachmentExpiryDuration = 3 * time.Hour DefaultFirebaseKeepaliveInterval = 3 * time.Hour // Not too frequently to save battery ) -// Defines all the limits +// Defines all global and per-visitor limits +// - message size limit: the max number of bytes for a message // - total topic limit: max number of topics overall +// - various attachment limits +const ( + DefaultMessageLengthLimit = 4096 // Bytes + DefaultTotalTopicLimit = 15000 + DefaultAttachmentTotalSizeLimit = int64(10 * 1024 * 1024 * 1024) // 10 GB + DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB + DefaultAttachmentExpiryDuration = 3 * time.Hour +) + +// Defines all per-visitor limits // - per visitor subscription limit: max number of subscriptions (active HTTP connections) per per-visitor/IP // - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds) // - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour) -// - per visitor attachment size limit: +// - per visitor attachment size limit: total per-visitor attachment size in bytes to be stored on the server +// - per visitor attachment daily traffic limit: number of bytes that can be transferred to/from the server const ( - DefaultTotalTopicLimit = 5000 - DefaultVisitorSubscriptionLimit = 30 - DefaultVisitorRequestLimitBurst = 60 - DefaultVisitorRequestLimitReplenish = 10 * time.Second - DefaultVisitorEmailLimitBurst = 16 - DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorAttachmentTotalSizeLimit = 50 * 1024 * 1024 + DefaultVisitorSubscriptionLimit = 30 + DefaultVisitorRequestLimitBurst = 60 + DefaultVisitorRequestLimitReplenish = 10 * time.Second + DefaultVisitorEmailLimitBurst = 16 + DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB + DefaultVisitorAttachmentDailyTrafficLimit = 500 * 1024 * 1024 // 500 MB ) // Config is the main config struct for the application. Use New to instantiate a default config struct. type Config struct { - BaseURL string - ListenHTTP string - ListenHTTPS string - KeyFile string - CertFile string - FirebaseKeyFile string - CacheFile string - CacheDuration time.Duration - AttachmentCacheDir string - AttachmentTotalSizeLimit int64 - AttachmentFileSizeLimit int64 - AttachmentExpiryDuration time.Duration - KeepaliveInterval time.Duration - ManagerInterval time.Duration - AtSenderInterval time.Duration - FirebaseKeepaliveInterval time.Duration - SMTPSenderAddr string - SMTPSenderUser string - SMTPSenderPass string - SMTPSenderFrom string - SMTPServerListen string - SMTPServerDomain string - SMTPServerAddrPrefix string - MessageLimit int - MinDelay time.Duration - MaxDelay time.Duration - TotalTopicLimit int - TotalAttachmentSizeLimit int64 - VisitorSubscriptionLimit int - VisitorAttachmentTotalSizeLimit int64 - VisitorRequestLimitBurst int - VisitorRequestLimitReplenish time.Duration - VisitorEmailLimitBurst int - VisitorEmailLimitReplenish time.Duration - BehindProxy bool + BaseURL string + ListenHTTP string + ListenHTTPS string + KeyFile string + CertFile string + FirebaseKeyFile string + CacheFile string + CacheDuration time.Duration + AttachmentCacheDir string + AttachmentTotalSizeLimit int64 + AttachmentFileSizeLimit int64 + AttachmentExpiryDuration time.Duration + KeepaliveInterval time.Duration + ManagerInterval time.Duration + AtSenderInterval time.Duration + FirebaseKeepaliveInterval time.Duration + SMTPSenderAddr string + SMTPSenderUser string + SMTPSenderPass string + SMTPSenderFrom string + SMTPServerListen string + SMTPServerDomain string + SMTPServerAddrPrefix string + MessageLimit int + MinDelay time.Duration + MaxDelay time.Duration + TotalTopicLimit int + TotalAttachmentSizeLimit int64 + VisitorSubscriptionLimit int + VisitorAttachmentTotalSizeLimit int64 + VisitorAttachmentDailyTrafficLimit int + VisitorRequestLimitBurst int + VisitorRequestLimitReplenish time.Duration + VisitorEmailLimitBurst int + VisitorEmailLimitReplenish time.Duration + BehindProxy bool } // NewConfig instantiates a default new server config func NewConfig() *Config { return &Config{ - BaseURL: "", - ListenHTTP: DefaultListenHTTP, - ListenHTTPS: "", - KeyFile: "", - CertFile: "", - FirebaseKeyFile: "", - CacheFile: "", - CacheDuration: DefaultCacheDuration, - AttachmentCacheDir: "", - AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, - AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, - AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, - KeepaliveInterval: DefaultKeepaliveInterval, - ManagerInterval: DefaultManagerInterval, - MessageLimit: DefaultMessageLimit, - MinDelay: DefaultMinDelay, - MaxDelay: DefaultMaxDelay, - AtSenderInterval: DefaultAtSenderInterval, - FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, - TotalTopicLimit: DefaultTotalTopicLimit, - VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, - VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit, - VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, - VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, - VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, - VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, - BehindProxy: false, + BaseURL: "", + ListenHTTP: DefaultListenHTTP, + ListenHTTPS: "", + KeyFile: "", + CertFile: "", + FirebaseKeyFile: "", + CacheFile: "", + CacheDuration: DefaultCacheDuration, + AttachmentCacheDir: "", + AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, + AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, + AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, + KeepaliveInterval: DefaultKeepaliveInterval, + ManagerInterval: DefaultManagerInterval, + MessageLimit: DefaultMessageLengthLimit, + MinDelay: DefaultMinDelay, + MaxDelay: DefaultMaxDelay, + AtSenderInterval: DefaultAtSenderInterval, + FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, + TotalTopicLimit: DefaultTotalTopicLimit, + VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, + VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit, + VisitorAttachmentDailyTrafficLimit: DefaultVisitorAttachmentDailyTrafficLimit, + VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, + VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, + VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, + VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, + BehindProxy: false, } } diff --git a/server/server.go b/server/server.go index 33aacbd0..08d89d41 100644 --- a/server/server.go +++ b/server/server.go @@ -139,12 +139,13 @@ var ( errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} - errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large", ""} + errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or traffic limit reached", ""} errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""} errHTTPBadRequestAttachmentURLPeakGeneral = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachment URL peak failed", ""} errHTTPBadRequestAttachmentURLPeakNon2xx = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment URL peak failed with non-2xx status code", ""} errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40016, http.StatusBadRequest, "invalid request: attachments not allowed", ""} errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40017, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""} + errHTTPTooManyRequestsAttachmentTrafficLimit = &errHTTP{42901, http.StatusTooManyRequests, "too many requests: daily traffic limit reached", "https://ntfy.sh/docs/publish/#limitations"} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} ) @@ -341,7 +342,7 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { if e, ok = err.(*errHTTP); !ok { e = errHTTPInternalError } - log.Printf("[%s] %s - %d - %s", r.RemoteAddr, r.Method, e.HTTPCode, err.Error()) + log.Printf("[%s] %s - %d - %d - %s", r.RemoteAddr, r.Method, e.HTTPCode, e.Code, err.Error()) w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.WriteHeader(e.HTTPCode) @@ -417,7 +418,7 @@ func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error { return nil } -func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, _ *visitor) error { +func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) error { if s.config.AttachmentCacheDir == "" { return errHTTPInternalError } @@ -431,6 +432,9 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, _ *visitor) if err != nil { return errHTTPNotFound } + if err := v.TrafficLimiter().Allow(stat.Size()); err != nil { + return errHTTPTooManyRequestsAttachmentTrafficLimit + } w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) f, err := os.Open(file) if err != nil { @@ -648,7 +652,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, if m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } - m.Attachment.Size, err = s.fileCache.Write(m.ID, body, util.NewFixedLimiter(remainingVisitorAttachmentSize)) + m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.TrafficLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize)) if err == util.ErrLimitReached { return errHTTPBadRequestAttachmentTooLarge } else if err != nil { diff --git a/server/server.yml b/server/server.yml index 847b7d63..a00acc2b 100644 --- a/server/server.yml +++ b/server/server.yml @@ -87,7 +87,7 @@ # Rate limiting: Total number of topics before the server rejects new topics. # -# global-topic-limit: 5000 +# global-topic-limit: 15000 # Rate limiting: Number of subscriptions per visitor (IP address) # diff --git a/server/server_test.go b/server/server_test.go index 0d81cb3c..03b7759e 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -826,7 +826,6 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) { // Publish and make sure we can retrieve it response := request(t, s, "PUT", "/mytopic", content, nil) - println(response.Body.String()) msg := toMessage(t, response.Body.String()) require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") file := filepath.Join(s.config.AttachmentCacheDir, msg.ID) @@ -845,6 +844,54 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) { require.Equal(t, 404, response.Code) } +func TestServer_PublishAttachmentTrafficLimit(t *testing.T) { + content := util.RandomString(5000) // > 4096 + + c := newTestConfig(t) + c.VisitorAttachmentDailyTrafficLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads + s := newTestServer(t, c) + + // Publish attachment + response := request(t, s, "PUT", "/mytopic", content, nil) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + + // Get it 4 times successfully + path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345") + for i := 1; i <= 4; i++ { // 4 successful downloads + response = request(t, s, "GET", path, "", nil) + require.Equal(t, 200, response.Code) + require.Equal(t, content, response.Body.String()) + } + + // And then fail with a 429 + response = request(t, s, "GET", path, "", nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 429, response.Code) + require.Equal(t, 42901, err.Code) +} + +func TestServer_PublishAttachmentTrafficLimitUploadOnly(t *testing.T) { + content := util.RandomString(5000) // > 4096 + + c := newTestConfig(t) + c.VisitorAttachmentDailyTrafficLimit = 5*5000 + 500 // 5 successful uploads + s := newTestServer(t, c) + + // 5 successful uploads + for i := 1; i <= 5; i++ { + response := request(t, s, "PUT", "/mytopic", content, nil) + msg := toMessage(t, response.Body.String()) + require.Contains(t, msg.Attachment.URL, "http://127.0.0.1:12345/file/") + } + + // And a failed one + response := request(t, s, "PUT", "/mytopic", content, nil) + err := toHTTPError(t, response.Body.String()) + require.Equal(t, 400, response.Code) + require.Equal(t, 40012, err.Code) +} + func newTestConfig(t *testing.T) *Config { conf := NewConfig() conf.BaseURL = "http://127.0.0.1:12345" diff --git a/server/visitor.go b/server/visitor.go index 26afa136..8ae56dcb 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -24,8 +24,9 @@ type visitor struct { config *Config ip string requests *rate.Limiter - subscriptions util.Limiter emails *rate.Limiter + subscriptions util.Limiter + traffic util.Limiter seen time.Time mu sync.Mutex } @@ -35,8 +36,9 @@ func newVisitor(conf *Config, ip string) *visitor { config: conf, ip: ip, requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), - subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), + subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), + traffic: util.NewBytesLimiter(conf.VisitorAttachmentDailyTrafficLimit, 24*time.Hour), seen: time.Now(), } } @@ -80,6 +82,10 @@ func (v *visitor) Keepalive() { v.seen = time.Now() } +func (v *visitor) TrafficLimiter() util.Limiter { + return v.traffic +} + func (v *visitor) Stale() bool { v.mu.Lock() defer v.mu.Unlock() From 38b28f9bf43e5281415575938b97f241e004a3ba Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Wed, 12 Jan 2022 21:24:48 -0500 Subject: [PATCH 17/24] CLI; docs docs docs --- client/client.go | 10 ++- client/options.go | 15 +++++ cmd/publish.go | 44 ++++++++++++- cmd/serve.go | 16 ++--- docs/config.md | 89 ++++++++++++++----------- docs/publish.md | 95 ++++++++++++++++++++++++--- server/config.go | 148 +++++++++++++++++++++--------------------- server/server.go | 28 ++++---- server/server_test.go | 10 +-- server/visitor.go | 8 +-- 10 files changed, 309 insertions(+), 154 deletions(-) diff --git a/client/client.go b/client/client.go index 81defdc9..b3bf7ab4 100644 --- a/client/client.go +++ b/client/client.go @@ -67,6 +67,12 @@ func New(config *Config) *Client { } // Publish sends a message to a specific topic, optionally using options. +// See PublishReader for details. +func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) { + return c.PublishReader(topic, strings.NewReader(message), options...) +} + +// PublishReader sends a message to a specific topic, optionally using options. // // A topic can be either a full URL (e.g. https://myhost.lan/mytopic), a short URL which is then prepended https:// // (e.g. myhost.lan -> https://myhost.lan), or a short name which is expanded using the default host in the @@ -74,9 +80,9 @@ func New(config *Config) *Client { // // To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache, // WithNoFirebase, and the generic WithHeader. -func (c *Client) Publish(topic, message string, options ...PublishOption) (*Message, error) { +func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) { topicURL := c.expandTopicURL(topic) - req, _ := http.NewRequest("POST", topicURL, strings.NewReader(message)) + req, _ := http.NewRequest("POST", topicURL, body) for _, option := range options { if err := option(req); err != nil { return nil, err diff --git a/client/options.go b/client/options.go index 716528d7..ccf6985b 100644 --- a/client/options.go +++ b/client/options.go @@ -16,6 +16,11 @@ type PublishOption = RequestOption // SubscribeOption is an option that can be passed to a Client.Subscribe or Client.Poll call type SubscribeOption = RequestOption +// WithMessage sets the notification message. This is an alternative way to passing the message body. +func WithMessage(message string) PublishOption { + return WithHeader("X-Message", message) +} + // WithTitle adds a title to a message func WithTitle(title string) PublishOption { return WithHeader("X-Title", title) @@ -50,6 +55,16 @@ func WithClick(url string) PublishOption { return WithHeader("X-Click", url) } +// WithAttach sets a URL that will be used by the client to download an attachment +func WithAttach(attach string) PublishOption { + return WithHeader("X-Attach", attach) +} + +// WithFilename sets a filename for the attachment, and/or forces the HTTP body to interpreted as an attachment +func WithFilename(filename string) PublishOption { + return WithHeader("X-Filename", filename) +} + // WithEmail instructs the server to also send the message to the given e-mail address func WithEmail(email string) PublishOption { return WithHeader("X-Email", email) diff --git a/cmd/publish.go b/cmd/publish.go index 7c71a1b2..c2860b43 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -5,6 +5,9 @@ import ( "fmt" "github.com/urfave/cli/v2" "heckel.io/ntfy/client" + "io" + "os" + "path/filepath" "strings" ) @@ -21,6 +24,9 @@ var cmdPublish = &cli.Command{ &cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, Usage: "comma separated list of tags and emojis"}, &cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"}, &cli.StringFlag{Name: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"}, + &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"}, + &cli.StringFlag{Name: "filename", Aliases: []string{"n"}, Usage: "Filename for the attachment"}, + &cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"}, &cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"}, &cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"}, &cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"}, @@ -37,6 +43,9 @@ Examples: ntfy pub --at=8:30am delayed_topic Laterzz # Send message at 8:30am ntfy pub -e phil@example.com alerts 'App is down!' # Also send email to phil@example.com ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked + ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment + ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment + cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment ntfy trigger mywebhook # Sending without message, useful for webhooks Please also check out the docs on publishing messages. Especially for the --tags and --delay options, @@ -59,6 +68,9 @@ func execPublish(c *cli.Context) error { tags := c.String("tags") delay := c.String("delay") click := c.String("click") + attach := c.String("attach") + filename := c.String("filename") + file := c.String("file") email := c.String("email") noCache := c.Bool("no-cache") noFirebase := c.Bool("no-firebase") @@ -82,7 +94,13 @@ func execPublish(c *cli.Context) error { options = append(options, client.WithDelay(delay)) } if click != "" { - options = append(options, client.WithClick(email)) + options = append(options, client.WithClick(click)) + } + if attach != "" { + options = append(options, client.WithAttach(attach)) + } + if filename != "" { + options = append(options, client.WithFilename(filename)) } if email != "" { options = append(options, client.WithEmail(email)) @@ -93,8 +111,30 @@ func execPublish(c *cli.Context) error { if noFirebase { options = append(options, client.WithNoFirebase()) } + var body io.Reader + if file == "" { + body = strings.NewReader(message) + } else { + if message != "" { + options = append(options, client.WithMessage(message)) + } + if file == "-" { + if filename == "" { + options = append(options, client.WithFilename("stdin")) + } + body = c.App.Reader + } else { + if filename == "" { + options = append(options, client.WithFilename(filepath.Base(file))) + } + body, err = os.Open(file) + if err != nil { + return err + } + } + } cl := client.New(conf) - m, err := cl.Publish(topic, message, options...) + m, err := cl.PublishReader(topic, body, options...) if err != nil { return err } diff --git a/cmd/serve.go b/cmd/serve.go index 28ec5231..3c33f2e7 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -23,7 +23,7 @@ var flagsServe = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "1G", Usage: "limit of the on-disk attachment cache"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "attachment-expiry-duration", Aliases: []string{"X"}, EnvVars: []string{"NTFY_ATTACHMENT_EXPIRY_DURATION"}, Value: server.DefaultAttachmentExpiryDuration, DefaultText: "3h", Usage: "duration after which uploaded attachments will be deleted (e.g. 3h, 20h)"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "keepalive-interval", Aliases: []string{"k"}, EnvVars: []string{"NTFY_KEEPALIVE_INTERVAL"}, Value: server.DefaultKeepaliveInterval, Usage: "interval of keepalive messages"}), @@ -37,8 +37,8 @@ var flagsServe = []cli.Flag{ altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-server-addr-prefix", EnvVars: []string{"NTFY_SMTP_SERVER_ADDR_PREFIX"}, Usage: "SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-')"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "50M", Usage: "total storage limit used for attachments per visitor"}), - altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-traffic-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_TRAFFIC_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload traffic limit per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: "100M", Usage: "total storage limit used for attachments per visitor"}), + altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-request-limit-replenish", EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: server.DefaultVisitorRequestLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}), @@ -93,7 +93,7 @@ func execServe(c *cli.Context) error { totalTopicLimit := c.Int("global-topic-limit") visitorSubscriptionLimit := c.Int("visitor-subscription-limit") visitorAttachmentTotalSizeLimitStr := c.String("visitor-attachment-total-size-limit") - visitorAttachmentDailyTrafficLimitStr := c.String("visitor-attachment-daily-traffic-limit") + visitorAttachmentDailyBandwidthLimitStr := c.String("visitor-attachment-daily-bandwidth-limit") visitorRequestLimitBurst := c.Int("visitor-request-limit-burst") visitorRequestLimitReplenish := c.Duration("visitor-request-limit-replenish") visitorEmailLimitBurst := c.Int("visitor-email-limit-burst") @@ -134,11 +134,11 @@ func execServe(c *cli.Context) error { if err != nil { return err } - visitorAttachmentDailyTrafficLimit, err := parseSize(visitorAttachmentDailyTrafficLimitStr, server.DefaultVisitorAttachmentDailyTrafficLimit) + visitorAttachmentDailyBandwidthLimit, err := parseSize(visitorAttachmentDailyBandwidthLimitStr, server.DefaultVisitorAttachmentDailyBandwidthLimit) if err != nil { return err - } else if visitorAttachmentDailyTrafficLimit > math.MaxInt { - return fmt.Errorf("config option visitor-attachment-daily-traffic-limit must be lower than %d", math.MaxInt) + } else if visitorAttachmentDailyBandwidthLimit > math.MaxInt { + return fmt.Errorf("config option visitor-attachment-daily-bandwidth-limit must be lower than %d", math.MaxInt) } // Run server @@ -167,7 +167,7 @@ func execServe(c *cli.Context) error { conf.TotalTopicLimit = totalTopicLimit conf.VisitorSubscriptionLimit = visitorSubscriptionLimit conf.VisitorAttachmentTotalSizeLimit = visitorAttachmentTotalSizeLimit - conf.VisitorAttachmentDailyTrafficLimit = int(visitorAttachmentDailyTrafficLimit) + conf.VisitorAttachmentDailyBandwidthLimit = int(visitorAttachmentDailyBandwidthLimit) conf.VisitorRequestLimitBurst = visitorRequestLimitBurst conf.VisitorRequestLimitReplenish = visitorRequestLimitReplenish conf.VisitorEmailLimitBurst = visitorEmailLimitBurst diff --git a/docs/config.md b/docs/config.md index 69a8744e..c78fc886 100644 --- a/docs/config.md +++ b/docs/config.md @@ -153,6 +153,7 @@ or the root domain: proxy_http_version 1.1; proxy_buffering off; + proxy_request_buffering off; proxy_redirect off; proxy_set_header Host $http_host; @@ -161,6 +162,8 @@ or the root domain: proxy_connect_timeout 3m; proxy_send_timeout 3m; proxy_read_timeout 3m; + + client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml } } @@ -179,8 +182,9 @@ or the root domain: location / { proxy_pass http://127.0.0.1:2586; proxy_http_version 1.1; - + proxy_buffering off; + proxy_request_buffering off; proxy_redirect off; proxy_set_header Host $http_host; @@ -189,6 +193,8 @@ or the root domain: proxy_connect_timeout 3m; proxy_send_timeout 3m; proxy_read_timeout 3m; + + client_max_body_size 20m; # Must be >= attachment-file-size-limit in /etc/ntfy/server.yml } } ``` @@ -413,7 +419,12 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `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). | | `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. | -| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | +| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | +| `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`. | +| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | 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. | | `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 | @@ -421,26 +432,24 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `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` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | -| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | 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. | | `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | | `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | +| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | 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 | Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | | `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | 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* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | -| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 |Initial limit of e-mails per visitor | +| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Initial limit of e-mails per visitor | | `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | -xx daily traffic limit -xx DefaultVisitorAttachmentTotalSizeLimit -xx attachment cache dir -xx attachment +| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | -The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. +The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. +The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. ## Command line options ``` $ ntfy serve --help NAME: - ntfy serve - Run the ntfy server + main serve - Run the ntfy server USAGE: ntfy serve [OPTIONS..] @@ -456,31 +465,37 @@ DESCRIPTION: ntfy serve --listen-http :8080 # Starts server with alternate port OPTIONS: - --config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] - --base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] - --listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] - --listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] - --key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE] - --cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE] - --firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] - --cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE] - --cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] - --keepalive-interval value, -k value interval of keepalive messages (default: 55s) [$NTFY_KEEPALIVE_INTERVAL] - --manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] - --smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] - --smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] - --smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] - --smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] - --smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] - --smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] - --smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] - --global-topic-limit value, -T value total number of topics allowed (default: 5000) [$NTFY_GLOBAL_TOPIC_LIMIT] - --visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] - --visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] - --visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] - --visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] - --visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] - --behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] - --help, -h show help (default: false) + --config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE] + --base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL] + --listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP] + --listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS] + --key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE] + --cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE] + --firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE] + --cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE] + --cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION] + --attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR] + --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, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT] + --attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION] + --keepalive-interval value, -k value interval of keepalive messages (default: 55s) [$NTFY_KEEPALIVE_INTERVAL] + --manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL] + --smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR] + --smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER] + --smtp-sender-pass value SMTP password (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_PASS] + --smtp-sender-from value SMTP sender address (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_FROM] + --smtp-server-listen value SMTP server address (ip:port) for incoming emails, e.g. :25 [$NTFY_SMTP_SERVER_LISTEN] + --smtp-server-domain value SMTP domain for incoming e-mail, e.g. ntfy.sh [$NTFY_SMTP_SERVER_DOMAIN] + --smtp-server-addr-prefix value SMTP email address prefix for topics to prevent spam (e.g. 'ntfy-') [$NTFY_SMTP_SERVER_ADDR_PREFIX] + --global-topic-limit value, -T value total number of topics allowed (default: 15000) [$NTFY_GLOBAL_TOPIC_LIMIT] + --visitor-subscription-limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] + --visitor-attachment-total-size-limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] + --visitor-attachment-daily-bandwidth-limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] + --visitor-request-limit-burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] + --visitor-request-limit-replenish value interval at which burst limit is replenished (one per x) (default: 10s) [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] + --visitor-email-limit-burst value initial limit of e-mails per visitor (default: 16) [$NTFY_VISITOR_EMAIL_LIMIT_BURST] + --visitor-email-limit-replenish value interval at which burst limit is replenished (one per x) (default: 1h0m0s) [$NTFY_VISITOR_EMAIL_LIMIT_REPLENISH] + --behind-proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY] + --help, -h show help (default: false) ``` diff --git a/docs/publish.md b/docs/publish.md index 61d30411..ace4c593 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -659,16 +659,81 @@ Here's an example that will open Reddit when the notification is clicked: ])); ``` -## Send files + URLs +## Attachments (send files) +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. + +There are two different ways to send attachments, either via PUT or by passing an external URL. + +**Upload attachments from your computer**: To send an attachment from your computer as a file, you can send it as the +PUT request body. If a message is greater than the maximum message size or consists of non-UTF-8 characters, the ntfy +server will automatically detect the mime type and size, and send the message as an attachment file. + +You can optionally pass a filename (or force attachment mode for small text-messages) by passing the `X-Filename` header +or query parameter (or any of its aliases `Filename`, `File` or `f`). + +Here's an example showing how to upload an image: + + +=== "Command line (curl)" + ``` + curl \ + -T flower.jpg \ + ntfy.sh/flowers + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --file=flower.jpg \ + flowers + ``` + +=== "HTTP" + ``` http + PUT /flowers HTTP/1.1 + Host: ntfy.sh + + + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/flowers', { + method: 'PUT', + body: document.getElementById("file").files[0] + }) + ``` + +=== "Go" + ``` go + file, _ := os.Open("flower.jpg") + req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file) + http.DefaultClient.Do(req) + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/flowers", + data=open("flower.jpg", 'rb')) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'content' => XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx + ] + ])); + ``` + ``` - Uploaded attachment - External attachment - Preview without attachment -# Upload and send attachment -curl -T image.jpg ntfy.sh/howdy - # Upload and send attachment with custom message and filename curl \ -T flower.jpg \ @@ -951,17 +1016,30 @@ to `no`. This will instruct the server not to forward messages to Firebase. ])); ``` +### UnifiedPush +!!! info + This setting is not relevant to users, only to app developers and people interested in [UnifiedPush](https://unifiedpush.org). + +[UnifiedPush](https://unifiedpush.org) is a standard for receiving push notifications without using the Google-owned +[Firebase Cloud Messaging (FCM)](https://firebase.google.com/docs/cloud-messaging) service. It puts push notifications +in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwarding messages to apps that support it. + +When publishing messages to a topic, apps using ntfy as a UnifiedPush distributor can set the `X-UnifiedPush` header or query +parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Firebase](#disable-firebase). As of today, this +option is equivalent to `Firebase: no`, but was introduced to allow future flexibility. + ## Limitations There are a few limitations to the API to prevent abuse and to keep the server healthy. Most of them you won't run into, but just in case, let's list them all: | Limit | Description | |---|---| -| **Message length** | Each message can be up to 4096 bytes long. Longer messages are truncated. | +| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are truncated. | | **Requests** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. | | **E-mails** | By default, the server is configured to allow sending 16 e-mails at once, and then refills the your allowed e-mail bucket at a rate of one per hour. You can read more about this in the [rate limiting](config.md#rate-limiting) section. | | **Subscription limits** | By default, the server allows each visitor to keep 30 connections to the server open. | -| **Total number of topics** | By default, the server is configured to allow 5,000 topics. The ntfy.sh server has higher limits though. | +| **Bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. | +| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | ## List of all parameters The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**, @@ -975,8 +1053,9 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a | `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) | | `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) | | `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) | -| `X-Filename` | `Filename`, `file`, `f` | XXXXXXXXXXXXXXXX | +| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments-send-files), as an alternative to PUT/POST-ing an attachment | +| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments-send-files) 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-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | -| `X-UnifiedPush` | `UnifiedPush`, `up` | XXXXXXXXXXXXXXXX | +| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, currently equivalent to `Firebase: no` | diff --git a/server/config.go b/server/config.go index e304d849..3843cdae 100644 --- a/server/config.go +++ b/server/config.go @@ -23,8 +23,8 @@ const ( const ( DefaultMessageLengthLimit = 4096 // Bytes DefaultTotalTopicLimit = 15000 - DefaultAttachmentTotalSizeLimit = int64(10 * 1024 * 1024 * 1024) // 10 GB - DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB + DefaultAttachmentTotalSizeLimit = int64(5 * 1024 * 1024 * 1024) // 5 GB + DefaultAttachmentFileSizeLimit = int64(15 * 1024 * 1024) // 15 MB DefaultAttachmentExpiryDuration = 3 * time.Hour ) @@ -33,87 +33,87 @@ const ( // - per visitor request limit: max number of PUT/GET/.. requests (here: 60 requests bucket, replenished at a rate of one per 10 seconds) // - per visitor email limit: max number of emails (here: 16 email bucket, replenished at a rate of one per hour) // - per visitor attachment size limit: total per-visitor attachment size in bytes to be stored on the server -// - per visitor attachment daily traffic limit: number of bytes that can be transferred to/from the server +// - per visitor attachment daily bandwidth limit: number of bytes that can be transferred to/from the server const ( - DefaultVisitorSubscriptionLimit = 30 - DefaultVisitorRequestLimitBurst = 60 - DefaultVisitorRequestLimitReplenish = 10 * time.Second - DefaultVisitorEmailLimitBurst = 16 - DefaultVisitorEmailLimitReplenish = time.Hour - DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB - DefaultVisitorAttachmentDailyTrafficLimit = 500 * 1024 * 1024 // 500 MB + DefaultVisitorSubscriptionLimit = 30 + DefaultVisitorRequestLimitBurst = 60 + DefaultVisitorRequestLimitReplenish = 10 * time.Second + DefaultVisitorEmailLimitBurst = 16 + DefaultVisitorEmailLimitReplenish = time.Hour + DefaultVisitorAttachmentTotalSizeLimit = 100 * 1024 * 1024 // 100 MB + DefaultVisitorAttachmentDailyBandwidthLimit = 500 * 1024 * 1024 // 500 MB ) // Config is the main config struct for the application. Use New to instantiate a default config struct. type Config struct { - BaseURL string - ListenHTTP string - ListenHTTPS string - KeyFile string - CertFile string - FirebaseKeyFile string - CacheFile string - CacheDuration time.Duration - AttachmentCacheDir string - AttachmentTotalSizeLimit int64 - AttachmentFileSizeLimit int64 - AttachmentExpiryDuration time.Duration - KeepaliveInterval time.Duration - ManagerInterval time.Duration - AtSenderInterval time.Duration - FirebaseKeepaliveInterval time.Duration - SMTPSenderAddr string - SMTPSenderUser string - SMTPSenderPass string - SMTPSenderFrom string - SMTPServerListen string - SMTPServerDomain string - SMTPServerAddrPrefix string - MessageLimit int - MinDelay time.Duration - MaxDelay time.Duration - TotalTopicLimit int - TotalAttachmentSizeLimit int64 - VisitorSubscriptionLimit int - VisitorAttachmentTotalSizeLimit int64 - VisitorAttachmentDailyTrafficLimit int - VisitorRequestLimitBurst int - VisitorRequestLimitReplenish time.Duration - VisitorEmailLimitBurst int - VisitorEmailLimitReplenish time.Duration - BehindProxy bool + BaseURL string + ListenHTTP string + ListenHTTPS string + KeyFile string + CertFile string + FirebaseKeyFile string + CacheFile string + CacheDuration time.Duration + AttachmentCacheDir string + AttachmentTotalSizeLimit int64 + AttachmentFileSizeLimit int64 + AttachmentExpiryDuration time.Duration + KeepaliveInterval time.Duration + ManagerInterval time.Duration + AtSenderInterval time.Duration + FirebaseKeepaliveInterval time.Duration + SMTPSenderAddr string + SMTPSenderUser string + SMTPSenderPass string + SMTPSenderFrom string + SMTPServerListen string + SMTPServerDomain string + SMTPServerAddrPrefix string + MessageLimit int + MinDelay time.Duration + MaxDelay time.Duration + TotalTopicLimit int + TotalAttachmentSizeLimit int64 + VisitorSubscriptionLimit int + VisitorAttachmentTotalSizeLimit int64 + VisitorAttachmentDailyBandwidthLimit int + VisitorRequestLimitBurst int + VisitorRequestLimitReplenish time.Duration + VisitorEmailLimitBurst int + VisitorEmailLimitReplenish time.Duration + BehindProxy bool } // NewConfig instantiates a default new server config func NewConfig() *Config { return &Config{ - BaseURL: "", - ListenHTTP: DefaultListenHTTP, - ListenHTTPS: "", - KeyFile: "", - CertFile: "", - FirebaseKeyFile: "", - CacheFile: "", - CacheDuration: DefaultCacheDuration, - AttachmentCacheDir: "", - AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, - AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, - AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, - KeepaliveInterval: DefaultKeepaliveInterval, - ManagerInterval: DefaultManagerInterval, - MessageLimit: DefaultMessageLengthLimit, - MinDelay: DefaultMinDelay, - MaxDelay: DefaultMaxDelay, - AtSenderInterval: DefaultAtSenderInterval, - FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, - TotalTopicLimit: DefaultTotalTopicLimit, - VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, - VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit, - VisitorAttachmentDailyTrafficLimit: DefaultVisitorAttachmentDailyTrafficLimit, - VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, - VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, - VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, - VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, - BehindProxy: false, + BaseURL: "", + ListenHTTP: DefaultListenHTTP, + ListenHTTPS: "", + KeyFile: "", + CertFile: "", + FirebaseKeyFile: "", + CacheFile: "", + CacheDuration: DefaultCacheDuration, + AttachmentCacheDir: "", + AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit, + AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit, + AttachmentExpiryDuration: DefaultAttachmentExpiryDuration, + KeepaliveInterval: DefaultKeepaliveInterval, + ManagerInterval: DefaultManagerInterval, + MessageLimit: DefaultMessageLengthLimit, + MinDelay: DefaultMinDelay, + MaxDelay: DefaultMaxDelay, + AtSenderInterval: DefaultAtSenderInterval, + FirebaseKeepaliveInterval: DefaultFirebaseKeepaliveInterval, + TotalTopicLimit: DefaultTotalTopicLimit, + VisitorSubscriptionLimit: DefaultVisitorSubscriptionLimit, + VisitorAttachmentTotalSizeLimit: DefaultVisitorAttachmentTotalSizeLimit, + VisitorAttachmentDailyBandwidthLimit: DefaultVisitorAttachmentDailyBandwidthLimit, + VisitorRequestLimitBurst: DefaultVisitorRequestLimitBurst, + VisitorRequestLimitReplenish: DefaultVisitorRequestLimitReplenish, + VisitorEmailLimitBurst: DefaultVisitorEmailLimitBurst, + VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish, + BehindProxy: false, } } diff --git a/server/server.go b/server/server.go index 08d89d41..f0de4b99 100644 --- a/server/server.go +++ b/server/server.go @@ -123,11 +123,6 @@ var ( docsStaticFs embed.FS docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} - errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} - errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} - errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"} errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""} errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""} @@ -139,22 +134,27 @@ var ( errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""} errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""} errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} - errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or traffic limit reached", ""} + errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""} errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""} errHTTPBadRequestAttachmentURLPeakGeneral = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachment URL peak failed", ""} errHTTPBadRequestAttachmentURLPeakNon2xx = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment URL peak failed with non-2xx status code", ""} errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40016, http.StatusBadRequest, "invalid request: attachments not allowed", ""} errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40017, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""} - errHTTPTooManyRequestsAttachmentTrafficLimit = &errHTTP{42901, http.StatusTooManyRequests, "too many requests: daily traffic limit reached", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} + errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"} + errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"} errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""} errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""} ) const ( - firebaseControlTopic = "~control" // See Android if changed - emptyMessageBody = "triggered" - fcmMessageLimit = 4000 // see maybeTruncateFCMMessage for details - defaultAttachmentMessage = "You received a file: %s" + firebaseControlTopic = "~control" // See Android if changed + emptyMessageBody = "triggered" // Used if message body is empty + defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment + fcmMessageLimit = 4000 // see maybeTruncateFCMMessage for details ) // New instantiates a new Server. It creates the cache and adds a Firebase @@ -432,8 +432,8 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor) if err != nil { return errHTTPNotFound } - if err := v.TrafficLimiter().Allow(stat.Size()); err != nil { - return errHTTPTooManyRequestsAttachmentTrafficLimit + if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil { + return errHTTPTooManyRequestsAttachmentBandwidthLimit } w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size())) f, err := os.Open(file) @@ -652,7 +652,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message, if m.Message == "" { m.Message = fmt.Sprintf(defaultAttachmentMessage, m.Attachment.Name) } - m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.TrafficLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize)) + m.Attachment.Size, err = s.fileCache.Write(m.ID, body, v.BandwidthLimiter(), util.NewFixedLimiter(remainingVisitorAttachmentSize)) if err == util.ErrLimitReached { return errHTTPBadRequestAttachmentTooLarge } else if err != nil { diff --git a/server/server_test.go b/server/server_test.go index 03b7759e..04ac5586 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -844,11 +844,11 @@ func TestServer_PublishAttachmentAndPrune(t *testing.T) { require.Equal(t, 404, response.Code) } -func TestServer_PublishAttachmentTrafficLimit(t *testing.T) { +func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) { content := util.RandomString(5000) // > 4096 c := newTestConfig(t) - c.VisitorAttachmentDailyTrafficLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads + c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 123 // A little more than 1 upload and 3 downloads s := newTestServer(t, c) // Publish attachment @@ -868,14 +868,14 @@ func TestServer_PublishAttachmentTrafficLimit(t *testing.T) { response = request(t, s, "GET", path, "", nil) err := toHTTPError(t, response.Body.String()) require.Equal(t, 429, response.Code) - require.Equal(t, 42901, err.Code) + require.Equal(t, 42905, err.Code) } -func TestServer_PublishAttachmentTrafficLimitUploadOnly(t *testing.T) { +func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) { content := util.RandomString(5000) // > 4096 c := newTestConfig(t) - c.VisitorAttachmentDailyTrafficLimit = 5*5000 + 500 // 5 successful uploads + c.VisitorAttachmentDailyBandwidthLimit = 5*5000 + 500 // 5 successful uploads s := newTestServer(t, c) // 5 successful uploads diff --git a/server/visitor.go b/server/visitor.go index 8ae56dcb..948fe44c 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -26,7 +26,7 @@ type visitor struct { requests *rate.Limiter emails *rate.Limiter subscriptions util.Limiter - traffic util.Limiter + bandwidth util.Limiter seen time.Time mu sync.Mutex } @@ -38,7 +38,7 @@ func newVisitor(conf *Config, ip string) *visitor { requests: rate.NewLimiter(rate.Every(conf.VisitorRequestLimitReplenish), conf.VisitorRequestLimitBurst), emails: rate.NewLimiter(rate.Every(conf.VisitorEmailLimitReplenish), conf.VisitorEmailLimitBurst), subscriptions: util.NewFixedLimiter(int64(conf.VisitorSubscriptionLimit)), - traffic: util.NewBytesLimiter(conf.VisitorAttachmentDailyTrafficLimit, 24*time.Hour), + bandwidth: util.NewBytesLimiter(conf.VisitorAttachmentDailyBandwidthLimit, 24*time.Hour), seen: time.Now(), } } @@ -82,8 +82,8 @@ func (v *visitor) Keepalive() { v.seen = time.Now() } -func (v *visitor) TrafficLimiter() util.Limiter { - return v.traffic +func (v *visitor) BandwidthLimiter() util.Limiter { + return v.bandwidth } func (v *visitor) Stale() bool { From 762333c28f0f8b7d7a94652f0a9073a2526cc6f7 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 13 Jan 2022 00:08:26 -0500 Subject: [PATCH 18/24] Docs docs docs --- cmd/serve.go | 2 ++ docs/config.md | 77 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/cmd/serve.go b/cmd/serve.go index 3c33f2e7..450fb610 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -119,6 +119,8 @@ func execServe(c *cli.Context) error { return errors.New("if smtp-sender-addr is set, base-url, smtp-sender-user, smtp-sender-pass and smtp-sender-from 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 == "" { + return errors.New("if attachment-cache-dir is set, base-url must also be set") } // Convert sizes to bytes diff --git a/docs/config.md b/docs/config.md index c78fc886..c608fd36 100644 --- a/docs/config.md +++ b/docs/config.md @@ -35,6 +35,43 @@ the message to the subscribers. Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#poll-for-messages), as well as the [`since=` parameter](subscribe/api.md#fetch-cached-messages). +## Attachments +If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments-send-files). 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. + +By default, attachments are stored in the disk-case **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 phone) 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-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): + +=== "/etc/ntfy/server.yml (minimal)" + ``` yaml + base-url: "https://ntfy.sh" + attachment-cache-dir: "/var/cache/ntfy/attachments" + ``` + +=== "/etc/ntfy/server.yml (all options)" + ``` yaml + base-url: "https://ntfy.sh" + attachment-cache-dir: "/var/cache/ntfy/attachments" + attachment-total-size-limit: "5G" + attachment-file-size-limit: "15M" + attachment-expiry-duration: "3h" + visitor-attachment-total-size-limit: "100M" + visitor-attachment-daily-bandwidth-limit: "500M" + ``` + +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. + ## E-mail notifications To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured, you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g. @@ -124,7 +161,7 @@ which lets you use [AWS Route 53](https://aws.amazon.com/route53/) as the challe HTTP challenge. I've found [this guide](https://nandovieira.com/using-lets-encrypt-in-development-with-nginx-and-aws-route53) to be incredibly helpful. -### nginx/Apache2 +### nginx/Apache2/caddy For your convenience, here's a working config that'll help configure things behind a proxy. In this example, ntfy runs on `:2586` and we proxy traffic to it. We also redirect HTTP to HTTPS for GET requests against a topic or the root domain: @@ -245,6 +282,19 @@ or the root domain: ``` +=== "caddy" + ``` + # Note that this config is most certainly incomplete. Please help out and let me know what's missing + # via Discord/Matrix or in a GitHub issue. + + ntfy.sh { + reverse_proxy 127.0.0.1:2586 + } + http://nfty.sh { + reverse_proxy 127.0.0.1:2586 + } + ``` + ## Firebase (FCM) !!! info Using Firebase is **optional** and only works if you modify and [build your own Android .apk](develop.md#android-app). @@ -278,14 +328,23 @@ firebase-key-file: "/etc/ntfy/ntfy-sh-firebase-adminsdk-ahnce-9f4d6f14b5.json" Otherwise, all visitors are rate limited as if they are one. By default, ntfy runs without authentication, so it is vitally important that we protect the server from abuse or overload. -There are various limits and rate limits in place that you can use to configure the server. Let's do the easy ones first: +There are various limits and rate limits in place that you can use to configure the server: + +* **Global limit**: A global limit applies across all visitors (IPs, clients, users) +* **Visitor limit**: A visitor limit only applies to a certain visitor. A **visitor** is identified by its IP address + (or the `X-Forwarded-For` header if `behind-proxy` is set). All config options that start with the word `visitor` apply + only on a per-visitor basis. + +During normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails +(e.g. when you reconnect after a connection drop), it shouldn't have any effect. + +### General limits +Let's do the easy limits first: * `global-topic-limit` defines the total number of topics before the server rejects new topics. It defaults to 15,000. * `visitor-subscription-limit` is the number of subscriptions (open connections) per visitor. This value defaults to 30. -A **visitor** is identified by its IP address (or the `X-Forwarded-For` header if `behind-proxy` is set). All config -options that start with the word `visitor` apply only on a per-visitor basis. - +### Request limits In addition to the limits above, there is a requests/second limit per visitor for all sensitive GET/PUT/POST requests. This limit uses a [token bucket](https://en.wikipedia.org/wiki/Token_bucket) (using Go's [rate package](https://pkg.go.dev/golang.org/x/time/rate)): @@ -296,15 +355,17 @@ request every 10s (defined by `visitor-request-limit-replenish`) * `visitor-request-limit-burst` is the initial bucket of requests each visitor has. This defaults to 60. * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s. +### Attachment limits + +XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx + +### E-mail limits Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications) 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. -During normal usage, you shouldn't encounter these limits at all, and even if you burst a few requests or emails -(e.g. when you reconnect after a connection drop), it shouldn't have any effect. - ## Tuning for scale If you're running ntfy for your home server, you probably don't need to worry about scale at all. In its default config, if it's not behind a proxy, the ntfy server can keep about **as many connections as the open file limit allows**. From 034c81288c825d4d8c08183b8aefbbdbe69e1b2f Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 13 Jan 2022 15:17:30 -0500 Subject: [PATCH 19/24] Docs docs docs --- cmd/publish.go | 2 +- docs/config.md | 81 ++++---- docs/publish.md | 152 ++++++++++---- .../android-screenshot-attachment-file.png | Bin 0 -> 52868 bytes .../android-screenshot-attachment-image.png | Bin 0 -> 159657 bytes server/cache_mem.go | 3 +- server/cache_mem_test.go | 4 + server/cache_sqlite_test.go | 4 + server/cache_test.go | 187 ++++++++++++------ server/server.yml | 30 ++- server/server_test.go | 24 ++- 11 files changed, 346 insertions(+), 141 deletions(-) create mode 100644 docs/static/img/android-screenshot-attachment-file.png create mode 100644 docs/static/img/android-screenshot-attachment-image.png diff --git a/cmd/publish.go b/cmd/publish.go index c2860b43..89e2ca90 100644 --- a/cmd/publish.go +++ b/cmd/publish.go @@ -25,7 +25,7 @@ var cmdPublish = &cli.Command{ &cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"}, &cli.StringFlag{Name: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"}, &cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"}, - &cli.StringFlag{Name: "filename", Aliases: []string{"n"}, Usage: "Filename for the attachment"}, + &cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, Usage: "Filename for the attachment"}, &cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"}, &cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"}, &cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"}, diff --git a/docs/config.md b/docs/config.md index c608fd36..7609b5b8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -36,13 +36,13 @@ Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscri [`since=` parameter](subscribe/api.md#fetch-cached-messages). ## Attachments -If desired, you may allow users to upload and [attach files to notifications](publish.md#attachments-send-files). To enable +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. -By default, attachments are stored in the disk-case **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 phone) to download -the file. The following config options are relevant to attachments: +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 +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 @@ -356,8 +356,15 @@ request every 10s (defined by `visitor-request-limit-replenish`) * `visitor-request-limit-replenish` is the rate at which the bucket is refilled (one request per x). Defaults to 10s. ### Attachment limits +Aside from the global file size and total attachment cache limits (see [above](#attachments)), there are two relevant +per-visitor limits: -XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx +* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M. + The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`, + see [publishing docs](publish.md#attachments)) do not count here. +* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor, + including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in + most cloud providers. This defaults to 500M. ### E-mail limits Similarly to the request limit, there is also an e-mail limit (only relevant if [e-mail notifications](#e-mail-notifications) @@ -470,38 +477,38 @@ Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `l CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). -| 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`. | -| `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). | -| `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. | -| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | -| `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`. | -| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | 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. | -| `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` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | -| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | -| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | -| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | 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 | Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | -| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | 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* | 10s | Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | -| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Initial limit of e-mails per visitor | -| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | -| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | +| 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`. | +| `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). | +| `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. | +| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. | +| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. | +| `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` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` | +| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | 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. | +| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. | +| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) | +| `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-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* | 10s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled | +| `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 | The format for a *duration* is: `(smh)`, e.g. 30s, 20m or 1h. The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. diff --git a/docs/publish.md b/docs/publish.md index ace4c593..2d5369ad 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -659,26 +659,33 @@ Here's an example that will open Reddit when the notification is clicked: ])); ``` -## Attachments (send files) +## Attachments 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. -There are two different ways to send attachments, either via PUT or by passing an external URL. +There are two different ways to send attachments: -**Upload attachments from your computer**: To send an attachment from your computer as a file, you can send it as the -PUT request body. If a message is greater than the maximum message size or consists of non-UTF-8 characters, the ntfy -server will automatically detect the mime type and size, and send the message as an attachment file. +* sending [a local file](#attach-local-file) via PUT, e.g. from `~/Flowers/flower.jpg` or `ringtone.mp3` +* or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk` -You can optionally pass a filename (or force attachment mode for small text-messages) by passing the `X-Filename` header -or query parameter (or any of its aliases `Filename`, `File` or `f`). +### Attach local file +To send an attachment from your computer as a file, you can send it as the PUT request body. If a message is greater +than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically +detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files +as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases +`Filename`, `File` or `f`). + +By default, and how ntfy.sh is configured, the **max attachment size is 15 MB** (with 100 MB total per visitor). +Attachments **expire after 3 hours**, which typically is plenty of time for the user to download it, or for the Android app +to auto-download it. Please also check out the [other limits below](#limitations). Here's an example showing how to upload an image: - === "Command line (curl)" ``` curl \ -T flower.jpg \ + -H "Filename: flower.jpg" \ ntfy.sh/flowers ``` @@ -693,6 +700,7 @@ Here's an example showing how to upload an image: ``` http PUT /flowers HTTP/1.1 Host: ntfy.sh + Filename: flower.jpg ``` @@ -701,7 +709,8 @@ Here's an example showing how to upload an image: ``` javascript fetch('https://ntfy.sh/flowers', { method: 'PUT', - body: document.getElementById("file").files[0] + body: document.getElementById("file").files[0], + headers: { 'Filename': 'flower.jpg' } }) ``` @@ -709,44 +718,108 @@ Here's an example showing how to upload an image: ``` go file, _ := os.Open("flower.jpg") req, _ := http.NewRequest("PUT", "https://ntfy.sh/flowers", file) + req.Header.Set("Filename", "flower.jpg") http.DefaultClient.Do(req) ``` === "Python" ``` python requests.put("https://ntfy.sh/flowers", - data=open("flower.jpg", 'rb')) + data=open("flower.jpg", 'rb'), + headers={ "Filename": "flower.jpg" }) ``` === "PHP" ``` php-inline - file_get_contents('https://ntfy.sh/reddit_alerts', false, stream_context_create([ + file_get_contents('https://ntfy.sh/flowers', false, stream_context_create([ 'http' => [ 'method' => 'PUT', - 'content' => XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXxx + 'header' => + "Content-Type: application/octet-stream\r\n" . // Does not matter + "Filename: flower.jpg", + 'content' => file_get_contents('flower.jpg') // Dangerous for large files ] ])); ``` -``` -- Uploaded attachment -- External attachment -- Preview without attachment +Here's what that looks like on Android: +
+ ![image attachment](static/img/android-screenshot-attachment-image.png){ width=500 } +
Image attachment sent from a local file
+
-# Upload and send attachment with custom message and filename -curl \ - -T flower.jpg \ - -H "Message: Here's a flower for you" \ - -H "Filename: flower.jpg" \ - ntfy.sh/howdy +### Attach file from a URL +Instead of sending a local file to your phone, you can use an external URL to specify where the attachment is hosted. +This could be a Google Drive or Dropbox link, or any other publicly available URL. The ntfy server will briefly probe +the URL to retrieve type and size for you. Since the files are externally hosted, the expiration or size limits from +above do not apply here. -# Send external attachment from other URL, with custom message -curl \ - -H "Attachment: https://example.com/files.zip" \ - "ntfy.sh/howdy?m=Important+documents+attached" +To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`) +to specify the attachment URL. It can be any type of file. + +Here's an example showing how to upload an image: + +=== "Command line (curl)" + ``` + curl \ + -X POST \ + -H "Attach: https://f-droid.org/F-Droid.apk" \ + ntfy.sh/mydownloads + ``` + +=== "ntfy CLI" + ``` + ntfy publish \ + --attach="https://f-droid.org/F-Droid.apk" \ + mydownloads + ``` + +=== "HTTP" + ``` http + POST /mydownloads HTTP/1.1 + Host: ntfy.sh + Attach: https://f-droid.org/F-Droid.apk + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mydownloads', { + method: 'POST', + headers: { 'Attach': 'https://f-droid.org/F-Droid.apk' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/mydownloads", file) + req.Header.Set("Attach", "https://f-droid.org/F-Droid.apk") + http.DefaultClient.Do(req) + ``` + +=== "Python" + ``` python + requests.put("https://ntfy.sh/mydownloads", + headers={ "Attach": "https://f-droid.org/F-Droid.apk" }) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mydownloads', false, stream_context_create([ + 'http' => [ + 'method' => 'PUT', + 'header' => + "Content-Type: text/plain\r\n" . // Does not matter + "Attach: https://f-droid.org/F-Droid.apk", + ] + ])); + ``` + +
+ ![file attachment](static/img/android-screenshot-attachment-file.png){ width=500 } +
File attachment sent from an external URL
+
-``` ## E-mail notifications You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that @@ -1029,17 +1102,20 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb option is equivalent to `Firebase: no`, but was introduced to allow future flexibility. ## Limitations -There are a few limitations to the API to prevent abuse and to keep the server healthy. Most of them you won't run into, +There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings +are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into, but just in case, let's list them all: -| Limit | Description | -|---|---| -| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are truncated. | -| **Requests** | By default, the server is configured to allow 60 requests at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. You can read more about this in the [rate limiting](config.md#rate-limiting) section. | -| **E-mails** | By default, the server is configured to allow sending 16 e-mails at once, and then refills the your allowed e-mail bucket at a rate of one per hour. You can read more about this in the [rate limiting](config.md#rate-limiting) section. | -| **Subscription limits** | By default, the server allows each visitor to keep 30 connections to the server open. | -| **Bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. | -| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | +| Limit | Description | +|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). | +| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. | +| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. | +| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | +| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. | +| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | +| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. | +| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | ## List of all parameters The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**, @@ -1053,8 +1129,8 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a | `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) | | `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) | | `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) | -| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments-send-files), as an alternative to PUT/POST-ing an attachment | -| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments-send-files) filename, as it appears in the client | +| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment | +| `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-Cache` | `Cache` | Allows disabling [message caching](#message-caching) | | `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) | diff --git a/docs/static/img/android-screenshot-attachment-file.png b/docs/static/img/android-screenshot-attachment-file.png new file mode 100644 index 0000000000000000000000000000000000000000..f151c936a76fe51ada5fcb4b71b4b47b7441f79f GIT binary patch literal 52868 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ᙝ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 ~%#+;`WXZN7LTeY`w;m>xr3 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 E0NB~hD*ylh literal 0 HcmV?d00001 diff --git a/server/cache_mem.go b/server/cache_mem.go index 04e57be9..96c9831e 100644 --- a/server/cache_mem.go +++ b/server/cache_mem.go @@ -131,7 +131,8 @@ func (c *memCache) AttachmentsSize(owner string) (int64, error) { var size int64 for topic := range c.messages { for _, m := range c.messages[topic] { - if m.Attachment != nil && m.Attachment.Owner == owner { + counted := m.Attachment != nil && m.Attachment.Owner == owner && m.Attachment.Expires > time.Now().Unix() + if counted { size += m.Attachment.Size } } diff --git a/server/cache_mem_test.go b/server/cache_mem_test.go index 831703a0..6e37ab48 100644 --- a/server/cache_mem_test.go +++ b/server/cache_mem_test.go @@ -25,6 +25,10 @@ func TestMemCache_Prune(t *testing.T) { testCachePrune(t, newMemCache()) } +func TestMemCache_Attachments(t *testing.T) { + testCacheAttachments(t, newMemCache()) +} + func TestMemCache_NopCache(t *testing.T) { c := newNopCache() assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message"))) diff --git a/server/cache_sqlite_test.go b/server/cache_sqlite_test.go index 384da256..a512e6b2 100644 --- a/server/cache_sqlite_test.go +++ b/server/cache_sqlite_test.go @@ -29,6 +29,10 @@ func TestSqliteCache_Prune(t *testing.T) { testCachePrune(t, newSqliteTestCache(t)) } +func TestSqliteCache_Attachments(t *testing.T) { + testCacheAttachments(t, newSqliteTestCache(t)) +} + func TestSqliteCache_Migration_From0(t *testing.T) { filename := newSqliteTestCacheFile(t) db, err := sql.Open("sqlite3", filename) diff --git a/server/cache_test.go b/server/cache_test.go index 1eae0919..71ba5497 100644 --- a/server/cache_test.go +++ b/server/cache_test.go @@ -1,7 +1,7 @@ package server import ( - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" "time" ) @@ -13,71 +13,71 @@ func testCacheMessages(t *testing.T, c cache) { m2 := newDefaultMessage("mytopic", "my other message") m2.Time = 2 - assert.Nil(t, c.AddMessage(m1)) - assert.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message"))) - assert.Nil(t, c.AddMessage(m2)) + require.Nil(t, c.AddMessage(m1)) + require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message"))) + require.Nil(t, c.AddMessage(m2)) // Adding invalid - assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added! - assert.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added! + require.Equal(t, errUnexpectedMessageType, c.AddMessage(newKeepaliveMessage("mytopic"))) // These should not be added! + require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added! // mytopic: count count, err := c.MessageCount("mytopic") - assert.Nil(t, err) - assert.Equal(t, 2, count) + require.Nil(t, err) + require.Equal(t, 2, count) // mytopic: since all messages, _ := c.Messages("mytopic", sinceAllMessages, false) - assert.Equal(t, 2, len(messages)) - assert.Equal(t, "my message", messages[0].Message) - assert.Equal(t, "mytopic", messages[0].Topic) - assert.Equal(t, messageEvent, messages[0].Event) - assert.Equal(t, "", messages[0].Title) - assert.Equal(t, 0, messages[0].Priority) - assert.Nil(t, messages[0].Tags) - assert.Equal(t, "my other message", messages[1].Message) + require.Equal(t, 2, len(messages)) + require.Equal(t, "my message", messages[0].Message) + require.Equal(t, "mytopic", messages[0].Topic) + require.Equal(t, messageEvent, messages[0].Event) + require.Equal(t, "", messages[0].Title) + require.Equal(t, 0, messages[0].Priority) + require.Nil(t, messages[0].Tags) + require.Equal(t, "my other message", messages[1].Message) // mytopic: since none messages, _ = c.Messages("mytopic", sinceNoMessages, false) - assert.Empty(t, messages) + require.Empty(t, messages) // mytopic: since 2 messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false) - assert.Equal(t, 1, len(messages)) - assert.Equal(t, "my other message", messages[0].Message) + require.Equal(t, 1, len(messages)) + require.Equal(t, "my other message", messages[0].Message) // example: count count, err = c.MessageCount("example") - assert.Nil(t, err) - assert.Equal(t, 1, count) + require.Nil(t, err) + require.Equal(t, 1, count) // example: since all messages, _ = c.Messages("example", sinceAllMessages, false) - assert.Equal(t, "my example message", messages[0].Message) + require.Equal(t, "my example message", messages[0].Message) // non-existing: count count, err = c.MessageCount("doesnotexist") - assert.Nil(t, err) - assert.Equal(t, 0, count) + require.Nil(t, err) + require.Equal(t, 0, count) // non-existing: since all messages, _ = c.Messages("doesnotexist", sinceAllMessages, false) - assert.Empty(t, messages) + require.Empty(t, messages) } func testCacheTopics(t *testing.T, c cache) { - assert.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message"))) - assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1"))) - assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2"))) - assert.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3"))) + require.Nil(t, c.AddMessage(newDefaultMessage("topic1", "my example message"))) + require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 1"))) + require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 2"))) + require.Nil(t, c.AddMessage(newDefaultMessage("topic2", "message 3"))) topics, err := c.Topics() if err != nil { t.Fatal(err) } - assert.Equal(t, 2, len(topics)) - assert.Equal(t, "topic1", topics["topic1"].ID) - assert.Equal(t, "topic2", topics["topic2"].ID) + require.Equal(t, 2, len(topics)) + require.Equal(t, "topic1", topics["topic1"].ID) + require.Equal(t, "topic2", topics["topic2"].ID) } func testCachePrune(t *testing.T, c cache) { @@ -90,23 +90,23 @@ func testCachePrune(t *testing.T, c cache) { m3 := newDefaultMessage("another_topic", "and another one") m3.Time = 1 - assert.Nil(t, c.AddMessage(m1)) - assert.Nil(t, c.AddMessage(m2)) - assert.Nil(t, c.AddMessage(m3)) - assert.Nil(t, c.Prune(time.Unix(2, 0))) + require.Nil(t, c.AddMessage(m1)) + require.Nil(t, c.AddMessage(m2)) + require.Nil(t, c.AddMessage(m3)) + require.Nil(t, c.Prune(time.Unix(2, 0))) count, err := c.MessageCount("mytopic") - assert.Nil(t, err) - assert.Equal(t, 1, count) + require.Nil(t, err) + require.Equal(t, 1, count) count, err = c.MessageCount("another_topic") - assert.Nil(t, err) - assert.Equal(t, 0, count) + require.Nil(t, err) + require.Equal(t, 0, count) messages, err := c.Messages("mytopic", sinceAllMessages, false) - assert.Nil(t, err) - assert.Equal(t, 1, len(messages)) - assert.Equal(t, "my other message", messages[0].Message) + require.Nil(t, err) + require.Equal(t, 1, len(messages)) + require.Equal(t, "my other message", messages[0].Message) } func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) { @@ -114,12 +114,12 @@ func testCacheMessagesTagsPrioAndTitle(t *testing.T, c cache) { m.Tags = []string{"tag1", "tag2"} m.Priority = 5 m.Title = "some title" - assert.Nil(t, c.AddMessage(m)) + require.Nil(t, c.AddMessage(m)) messages, _ := c.Messages("mytopic", sinceAllMessages, false) - assert.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags) - assert.Equal(t, 5, messages[0].Priority) - assert.Equal(t, "some title", messages[0].Title) + require.Equal(t, []string{"tag1", "tag2"}, messages[0].Tags) + require.Equal(t, 5, messages[0].Priority) + require.Equal(t, "some title", messages[0].Title) } func testCacheMessagesScheduled(t *testing.T, c cache) { @@ -130,20 +130,93 @@ func testCacheMessagesScheduled(t *testing.T, c cache) { m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2! m4 := newDefaultMessage("mytopic2", "message 4") m4.Time = time.Now().Add(time.Minute).Unix() - assert.Nil(t, c.AddMessage(m1)) - assert.Nil(t, c.AddMessage(m2)) - assert.Nil(t, c.AddMessage(m3)) + require.Nil(t, c.AddMessage(m1)) + require.Nil(t, c.AddMessage(m2)) + require.Nil(t, c.AddMessage(m3)) messages, _ := c.Messages("mytopic", sinceAllMessages, false) // exclude scheduled - assert.Equal(t, 1, len(messages)) - assert.Equal(t, "message 1", messages[0].Message) + require.Equal(t, 1, len(messages)) + require.Equal(t, "message 1", messages[0].Message) messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled - assert.Equal(t, 3, len(messages)) - assert.Equal(t, "message 1", messages[0].Message) - assert.Equal(t, "message 3", messages[1].Message) // Order! - assert.Equal(t, "message 2", messages[2].Message) + require.Equal(t, 3, len(messages)) + require.Equal(t, "message 1", messages[0].Message) + require.Equal(t, "message 3", messages[1].Message) // Order! + require.Equal(t, "message 2", messages[2].Message) messages, _ = c.MessagesDue() - assert.Empty(t, messages) + require.Empty(t, messages) +} + +func testCacheAttachments(t *testing.T, c cache) { + expires1 := time.Now().Add(-4 * time.Hour).Unix() + m := newDefaultMessage("mytopic", "flower for you") + m.ID = "m1" + m.Attachment = &attachment{ + Name: "flower.jpg", + Type: "image/jpeg", + Size: 5000, + Expires: expires1, + URL: "https://ntfy.sh/file/AbDeFgJhal.jpg", + Owner: "1.2.3.4", + } + require.Nil(t, c.AddMessage(m)) + + expires2 := time.Now().Add(2 * time.Hour).Unix() // Future + m = newDefaultMessage("mytopic", "sending you a car") + m.ID = "m2" + m.Attachment = &attachment{ + Name: "car.jpg", + Type: "image/jpeg", + Size: 10000, + Expires: expires2, + URL: "https://ntfy.sh/file/aCaRURL.jpg", + Owner: "1.2.3.4", + } + require.Nil(t, c.AddMessage(m)) + + expires3 := time.Now().Add(1 * time.Hour).Unix() // Future + m = newDefaultMessage("another-topic", "sending you another car") + m.ID = "m3" + m.Attachment = &attachment{ + Name: "another-car.jpg", + Type: "image/jpeg", + Size: 20000, + Expires: expires3, + URL: "https://ntfy.sh/file/zakaDHFW.jpg", + Owner: "1.2.3.4", + } + require.Nil(t, c.AddMessage(m)) + + messages, err := c.Messages("mytopic", sinceAllMessages, false) + require.Nil(t, err) + require.Equal(t, 2, len(messages)) + + require.Equal(t, "flower for you", messages[0].Message) + require.Equal(t, "flower.jpg", messages[0].Attachment.Name) + require.Equal(t, "image/jpeg", messages[0].Attachment.Type) + require.Equal(t, int64(5000), messages[0].Attachment.Size) + require.Equal(t, expires1, messages[0].Attachment.Expires) + require.Equal(t, "https://ntfy.sh/file/AbDeFgJhal.jpg", messages[0].Attachment.URL) + require.Equal(t, "1.2.3.4", messages[0].Attachment.Owner) + + require.Equal(t, "sending you a car", messages[1].Message) + require.Equal(t, "car.jpg", messages[1].Attachment.Name) + require.Equal(t, "image/jpeg", messages[1].Attachment.Type) + require.Equal(t, int64(10000), messages[1].Attachment.Size) + require.Equal(t, expires2, messages[1].Attachment.Expires) + require.Equal(t, "https://ntfy.sh/file/aCaRURL.jpg", messages[1].Attachment.URL) + require.Equal(t, "1.2.3.4", messages[1].Attachment.Owner) + + size, err := c.AttachmentsSize("1.2.3.4") + require.Nil(t, err) + require.Equal(t, int64(30000), size) + + size, err = c.AttachmentsSize("5.6.7.8") + require.Nil(t, err) + require.Equal(t, int64(0), size) + + ids, err := c.AttachmentsExpired() + require.Nil(t, err) + require.Equal(t, []string{"m1"}, ids) } diff --git a/server/server.yml b/server/server.yml index a00acc2b..d1650474 100644 --- a/server/server.yml +++ b/server/server.yml @@ -36,7 +36,7 @@ # # You can disable the cache entirely by setting this to 0. # -# cache-duration: 12h +# cache-duration: "12h" # If set, the X-Forwarded-For header is used to determine the visitor IP address # instead of the remote address of the connection. @@ -46,6 +46,19 @@ # # behind-proxy: false +# 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-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-total-size-limit: "5G" +# attachment-file-size-limit: "15M" +# attachment-expiry-duration: "3h" + # If enabled, allow outgoing e-mail notifications via the 'X-Email' header. If this header is set, # messages will additionally be sent out as e-mail using an external SMTP server. As of today, only # SMTP servers with plain text auth and STARTLS are supported. Please also refer to the rate limiting settings @@ -78,12 +91,12 @@ # # Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. # -# keepalive-interval: 30s +# keepalive-interval: "30s" # Interval in which the manager prunes old messages, deletes topics # and prints the stats. # -# manager-interval: 1m +# manager-interval: "1m" # Rate limiting: Total number of topics before the server rejects new topics. # @@ -98,11 +111,18 @@ # - visitor-request-limit-replenish is the rate at which the bucket is refilled # # visitor-request-limit-burst: 60 -# visitor-request-limit-replenish: 10s +# visitor-request-limit-replenish: "10s" # Rate limiting: Allowed emails per visitor: # - visitor-email-limit-burst is the initial bucket of emails each visitor has # - visitor-email-limit-replenish is the rate at which the bucket is refilled # # visitor-email-limit-burst: 16 -# visitor-email-limit-replenish: 1h +# visitor-email-limit-replenish: "1h" + +# Rate limiting: Attachment size and bandwidth limits per visitor: +# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor +# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor +# +# visitor-attachment-total-size-limit: "100M" +# visitor-attachment-daily-bandwidth-limit: "500M" diff --git a/server/server_test.go b/server/server_test.go index 04ac5586..4867e4a1 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -699,12 +699,21 @@ func TestServer_PublishAttachment(t *testing.T) { require.Equal(t, 200, response.Code) require.Equal(t, "5000", response.Header().Get("Content-Length")) require.Equal(t, content, response.Body.String()) + + // Slightly unrelated cross-test: make sure we add an owner for internal attachments + size, err := s.cache.AttachmentsSize("9.9.9.9") // See request() + require.Nil(t, err) + require.Equal(t, int64(5000), size) } func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { - s := newTestServer(t, newTestConfig(t)) + c := newTestConfig(t) + c.BehindProxy = true + s := newTestServer(t, c) content := "this is an ATTACHMENT" - response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, nil) + response := request(t, s, "PUT", "/mytopic?f=myfile.txt", content, map[string]string{ + "X-Forwarded-For": "1.2.3.4", + }) msg := toMessage(t, response.Body.String()) require.Equal(t, "myfile.txt", msg.Attachment.Name) require.Equal(t, "text/plain; charset=utf-8", msg.Attachment.Type) @@ -719,6 +728,11 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) { require.Equal(t, 200, response.Code) require.Equal(t, "21", response.Header().Get("Content-Length")) require.Equal(t, content, response.Body.String()) + + // Slightly unrelated cross-test: make sure we add an owner for internal attachments + size, err := s.cache.AttachmentsSize("1.2.3.4") + require.Nil(t, err) + require.Equal(t, int64(21), size) } func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { @@ -734,6 +748,11 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { require.Equal(t, int64(0), msg.Attachment.Expires) require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) require.Equal(t, "", msg.Attachment.Owner) + + // Slightly unrelated cross-test: make sure we don't add an owner for external attachments + size, err := s.cache.AttachmentsSize("127.0.0.1") + require.Nil(t, err) + require.Equal(t, int64(0), size) } func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { @@ -914,6 +933,7 @@ func request(t *testing.T, s *Server, method, url, body string, headers map[stri if err != nil { t.Fatal(err) } + req.RemoteAddr = "9.9.9.9" // Used for tests for k, v := range headers { req.Header.Set(k, v) } From 6a7b20e4e3956762da773f6f6f18f54c6a453ff6 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 13 Jan 2022 15:47:34 -0500 Subject: [PATCH 20/24] Docs --- docs/config.md | 2 +- docs/publish.md | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/config.md b/docs/config.md index 7609b5b8..0556f15e 100644 --- a/docs/config.md +++ b/docs/config.md @@ -517,7 +517,7 @@ The format for a *size* is: `(GMK)`, e.g. 1G, 200M or 4000k. ``` $ ntfy serve --help NAME: - main serve - Run the ntfy server + ntfy serve - Run the ntfy server USAGE: ntfy serve [OPTIONS..] diff --git a/docs/publish.md b/docs/publish.md index 2d5369ad..e3ef363b 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -660,7 +660,7 @@ Here's an example that will open Reddit when the notification is clicked: ``` ## Attachments -You can send images and other files to your phone as attachments to a notification. The attachments are then downloaded +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. There are two different ways to send attachments: @@ -669,7 +669,7 @@ There are two different ways to send attachments: * or by [passing an external URL](#attach-file-from-a-url) as an attachment, e.g. `https://f-droid.org/F-Droid.apk` ### Attach local file -To send an attachment from your computer as a file, you can send it as the PUT request body. If a message is greater +To **send a file from your computer** as an attachment, you can send it as the PUT request body. If a message is greater than the maximum message size (4,096 bytes) or consists of non UTF-8 characters, the ntfy server will automatically detect the mime type and size, and send the message as an attachment file. To send smaller text-only messages or files as attachments, you must pass a filename by passing the `X-Filename` header or query parameter (or any of its aliases @@ -701,6 +701,7 @@ Here's an example showing how to upload an image: PUT /flowers HTTP/1.1 Host: ntfy.sh Filename: flower.jpg + Content-Type: 52312 ``` @@ -750,15 +751,13 @@ Here's what that looks like on Android: ### Attach file from a URL -Instead of sending a local file to your phone, you can use an external URL to specify where the attachment is hosted. +Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted. This could be a Google Drive or Dropbox link, or any other publicly available URL. The ntfy server will briefly probe the URL to retrieve type and size for you. Since the files are externally hosted, the expiration or size limits from above do not apply here. To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`) -to specify the attachment URL. It can be any type of file. - -Here's an example showing how to upload an image: +to specify the attachment URL. It can be any type of file. Here's an example showing how to upload an image: === "Command line (curl)" ``` @@ -820,7 +819,6 @@ Here's an example showing how to upload an image:

File attachment sent from an external URL
- ## E-mail notifications You can forward messages to e-mail by specifying an address in the header. This can be useful for messages that you'd like to persist longer, or to blast-notify yourself on all possible channels. From c3170e1eb6e31fbc6a97833de6bf0b5abff739d8 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 13 Jan 2022 16:14:35 -0500 Subject: [PATCH 21/24] Bump version --- docs/install.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/install.md b/docs/install.md index f1bc6cd5..3911bc45 100644 --- a/docs/install.md +++ b/docs/install.md @@ -26,21 +26,21 @@ deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_x86_64.tar.gz + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_x86_64.tar.gz sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy sudo ./ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_armv7.tar.gz + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.tar.gz sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy sudo ./ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_arm64.tar.gz + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.tar.gz sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy sudo ./ntfy serve ``` @@ -88,7 +88,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -96,7 +96,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -104,7 +104,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -114,21 +114,21 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.11.2/ntfy_1.11.2_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` From 51583f5d289a26a9e1a83de8170c35f0b2c108ff Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 13 Jan 2022 17:16:04 -0500 Subject: [PATCH 22/24] Attachments dir in package --- .goreleaser.yml | 2 ++ scripts/postinst.sh | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index d30b177b..74863ea5 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -59,6 +59,8 @@ nfpms: dst: /lib/systemd/system/ntfy-client.service - dst: /var/cache/ntfy type: dir + - dst: /var/cache/ntfy/attachments + type: dir - dst: /usr/share/ntfy/logo.png src: server/static/img/ntfy.png scripts: diff --git a/scripts/postinst.sh b/scripts/postinst.sh index 542df521..8e19d659 100755 --- a/scripts/postinst.sh +++ b/scripts/postinst.sh @@ -8,8 +8,8 @@ if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then if [ -d /run/systemd/system ]; then # Create ntfy user/group id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy - chown ntfy.ntfy /var/cache/ntfy - chmod 700 /var/cache/ntfy + chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments + chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments # Hack to change permissions on cache file configfile="/etc/ntfy/server.yml" From e50779664d8fd662fc38b17bfcd666c888f7d82c Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 14 Jan 2022 12:13:14 -0500 Subject: [PATCH 23/24] Remove peaking, addresses #93 --- server/server.go | 25 +++++++++++----- server/server_test.go | 12 ++++---- server/util.go | 69 ------------------------------------------- server/util_test.go | 19 ------------ 4 files changed, 24 insertions(+), 101 deletions(-) delete mode 100644 server/util.go delete mode 100644 server/util_test.go diff --git a/server/server.go b/server/server.go index f0de4b99..f0250d96 100644 --- a/server/server.go +++ b/server/server.go @@ -18,7 +18,9 @@ import ( "net" "net/http" "net/http/httptest" + "net/url" "os" + "path" "path/filepath" "regexp" "strconv" @@ -459,9 +461,6 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito if err != nil { return err } - if err := maybePeakAttachmentURL(m); err != nil { - return err - } if err := s.handlePublishBody(r, v, m, body); err != nil { return err } @@ -507,19 +506,31 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca firebase = readParam(r, "x-firebase", "firebase") != "no" m.Title = readParam(r, "x-title", "title", "t") m.Click = readParam(r, "x-click", "click") - attach := readParam(r, "x-attach", "attach", "a") filename := readParam(r, "x-filename", "filename", "file", "f") + attach := readParam(r, "x-attach", "attach", "a") if attach != "" || filename != "" { m.Attachment = &attachment{} } + if filename != "" { + m.Attachment.Name = filename + } if attach != "" { if !attachURLRegex.MatchString(attach) { return false, false, "", errHTTPBadRequestAttachmentURLInvalid } m.Attachment.URL = attach - } - if filename != "" { - m.Attachment.Name = filename + if m.Attachment.Name == "" { + u, err := url.Parse(m.Attachment.URL) + if err == nil { + m.Attachment.Name = path.Base(u.Path) + if m.Attachment.Name == "." || m.Attachment.Name == "/" { + m.Attachment.Name = "" + } + } + } + if m.Attachment.Name == "" { + m.Attachment.Name = "attachment" + } } email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e") if email != "" { diff --git a/server/server_test.go b/server/server_test.go index 4867e4a1..7043a7cd 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -743,10 +743,10 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) { msg := toMessage(t, response.Body.String()) require.Equal(t, "You received a file: Pink_flower.jpg", msg.Message) require.Equal(t, "Pink_flower.jpg", msg.Attachment.Name) - require.Equal(t, "image/jpeg", msg.Attachment.Type) - require.Equal(t, int64(190173), msg.Attachment.Size) - require.Equal(t, int64(0), msg.Attachment.Expires) require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) + require.Equal(t, "", msg.Attachment.Type) + require.Equal(t, int64(0), msg.Attachment.Size) + require.Equal(t, int64(0), msg.Attachment.Expires) require.Equal(t, "", msg.Attachment.Owner) // Slightly unrelated cross-test: make sure we don't add an owner for external attachments @@ -764,10 +764,10 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) { msg := toMessage(t, response.Body.String()) require.Equal(t, "This is a custom message", msg.Message) require.Equal(t, "some file.jpg", msg.Attachment.Name) - require.Equal(t, "image/jpeg", msg.Attachment.Type) - require.Equal(t, int64(190173), msg.Attachment.Size) - require.Equal(t, int64(0), msg.Attachment.Expires) require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL) + require.Equal(t, "", msg.Attachment.Type) + require.Equal(t, int64(0), msg.Attachment.Size) + require.Equal(t, int64(0), msg.Attachment.Expires) require.Equal(t, "", msg.Attachment.Owner) } diff --git a/server/util.go b/server/util.go deleted file mode 100644 index d36e3976..00000000 --- a/server/util.go +++ /dev/null @@ -1,69 +0,0 @@ -package server - -import ( - "fmt" - "heckel.io/ntfy/util" - "io" - "net/http" - "net/url" - "path" - "strconv" - "time" -) - -const ( - peakAttachmentTimeout = 2500 * time.Millisecond - peakAttachmentReadBytes = 128 -) - -func maybePeakAttachmentURL(m *message) error { - return maybePeakAttachmentURLInternal(m, peakAttachmentTimeout) -} - -func maybePeakAttachmentURLInternal(m *message, timeout time.Duration) error { - if m.Attachment == nil || m.Attachment.URL == "" { - return nil - } - client := http.Client{ - Timeout: timeout, - Transport: &http.Transport{ - DisableCompression: true, // Disable "Accept-Encoding: gzip", otherwise we won't get the Content-Length - Proxy: http.ProxyFromEnvironment, - }, - } - req, err := http.NewRequest(http.MethodGet, m.Attachment.URL, nil) - if err != nil { - return err - } - req.Header.Set("User-Agent", "ntfy") - resp, err := client.Do(req) - if err != nil { - return errHTTPBadRequestAttachmentURLPeakGeneral - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return errHTTPBadRequestAttachmentURLPeakNon2xx - } - if size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64); err == nil { - m.Attachment.Size = size - } - buf := make([]byte, peakAttachmentReadBytes) - io.ReadFull(resp.Body, buf) // Best effort: We don't care about the error - mimeType, ext := util.DetectContentType(buf, m.Attachment.URL) - m.Attachment.Type = resp.Header.Get("Content-Type") - if m.Attachment.Type == "" { - m.Attachment.Type = mimeType - } - if m.Attachment.Name == "" { - u, err := url.Parse(m.Attachment.URL) - if err != nil { - m.Attachment.Name = fmt.Sprintf("attachment%s", ext) - } else { - m.Attachment.Name = path.Base(u.Path) - if m.Attachment.Name == "." || m.Attachment.Name == "/" { - m.Attachment.Name = fmt.Sprintf("attachment%s", ext) - } - } - } - return nil -} diff --git a/server/util_test.go b/server/util_test.go deleted file mode 100644 index a20cfb64..00000000 --- a/server/util_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package server - -import ( - "github.com/stretchr/testify/require" - "testing" -) - -func TestMaybePeakAttachmentURL_Success(t *testing.T) { - m := &message{ - Attachment: &attachment{ - URL: "https://ntfy.sh/static/img/ntfy.png", - }, - } - require.Nil(t, maybePeakAttachmentURL(m)) - require.Equal(t, "ntfy.png", m.Attachment.Name) - require.Equal(t, int64(3627), m.Attachment.Size) - require.Equal(t, "image/png", m.Attachment.Type) - require.Equal(t, int64(0), m.Attachment.Expires) -} From a75f74b4712b60fa1113a82c64cfeb10b7dcdae7 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Fri, 14 Jan 2022 12:23:58 -0500 Subject: [PATCH 24/24] Bump version; update docs --- docs/install.md | 18 +++++++++--------- docs/publish.md | 5 ++--- server/server.go | 6 ++---- server/server_test.go | 2 +- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/docs/install.md b/docs/install.md index 3911bc45..35a0e323 100644 --- a/docs/install.md +++ b/docs/install.md @@ -26,21 +26,21 @@ deb/rpm packages. === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_x86_64.tar.gz + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_x86_64.tar.gz sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy sudo ./ntfy serve ``` === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.tar.gz + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.tar.gz sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy sudo ./ntfy serve ``` === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.tar.gz + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.tar.gz sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy sudo ./ntfy serve ``` @@ -88,7 +88,7 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_amd64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_amd64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -96,7 +96,7 @@ Manually installing the .deb file: === "armv7/armhf" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -104,7 +104,7 @@ Manually installing the .deb file: === "arm64" ```bash - wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.deb + wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.deb sudo dpkg -i ntfy_*.deb sudo systemctl enable ntfy sudo systemctl start ntfy @@ -114,21 +114,21 @@ Manually installing the .deb file: === "x86_64/amd64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_amd64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_amd64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "armv7/armhf" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_armv7.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` === "arm64" ```bash - sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.rpm + sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.1/ntfy_1.12.1_linux_arm64.rpm sudo systemctl enable ntfy sudo systemctl start ntfy ``` diff --git a/docs/publish.md b/docs/publish.md index e3ef363b..063deeb1 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -752,9 +752,8 @@ Here's what that looks like on Android: ### Attach file from a URL Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted. -This could be a Google Drive or Dropbox link, or any other publicly available URL. The ntfy server will briefly probe -the URL to retrieve type and size for you. Since the files are externally hosted, the expiration or size limits from -above do not apply here. +This could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are +externally hosted, the expiration or size limits from above do not apply here. To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`) to specify the attachment URL. It can be any type of file. Here's an example showing how to upload an image: diff --git a/server/server.go b/server/server.go index f0250d96..8d8af37b 100644 --- a/server/server.go +++ b/server/server.go @@ -138,10 +138,8 @@ var ( errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""} errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""} errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""} - errHTTPBadRequestAttachmentURLPeakGeneral = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachment URL peak failed", ""} - errHTTPBadRequestAttachmentURLPeakNon2xx = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment URL peak failed with non-2xx status code", ""} - errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40016, http.StatusBadRequest, "invalid request: attachments not allowed", ""} - errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40017, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""} + errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""} + errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""} errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"} errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"} diff --git a/server/server_test.go b/server/server_test.go index 7043a7cd..492edf91 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -814,7 +814,7 @@ func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) { err := toHTTPError(t, response.Body.String()) require.Equal(t, 400, response.Code) require.Equal(t, 400, err.HTTPCode) - require.Equal(t, 40017, err.Code) + require.Equal(t, 40015, err.Code) } func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) {