diff --git a/cmd/app.go b/cmd/app.go index 50f80564..379a58a0 100644 --- a/cmd/app.go +++ b/cmd/app.go @@ -74,7 +74,7 @@ func execRun(c *cli.Context) error { return errors.New("keepalive interval cannot be lower than five seconds") } else if managerInterval < 5*time.Second { return errors.New("manager interval cannot be lower than five seconds") - } else if cacheDuration < managerInterval { + } else if cacheDuration > 0 && cacheDuration < managerInterval { return errors.New("cache duration cannot be lower than manager interval") } else if keyFile != "" && !util.FileExists(keyFile) { return errors.New("if set, key file must exist") diff --git a/config/config.yml b/config/config.yml index dec13fbb..972b4f94 100644 --- a/config/config.yml +++ b/config/config.yml @@ -28,6 +28,8 @@ # 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. # +# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0. +# # Note: If you are running ntfy with systemd, make sure this cache file is owned by the # ntfy user and group by running: chown ntfy.ntfy . # @@ -36,6 +38,8 @@ # Duration for which messages will be buffered before they are deleted. # This is required to support the "since=..." and "poll=1" parameter. # +# You can disable the cache entirely by setting this to 0. +# # cache-duration: 12h # Interval in which keepalive messages are sent to the client. This is to prevent diff --git a/docs/config.md b/docs/config.md index 57a12a96..1f8a54d2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -26,7 +26,11 @@ restart**. You can override this behavior using the following config settings: * `cache-file`: if set, ntfy will store messages in a SQLite based cache (default is empty, which means in-memory cache). **This is required if you'd like messages to be retained across restarts**. -* `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`) +* `cache-duration`: defines the duration for which messages are stored in the cache (default is `12h`). + +You can also entirely disable the cache by setting `cache-duration` to `0`. When the cache is disabled, messages are only +passed on to the connected subscribers, but never stored on disk or even kept in memory longer than is needed to forward +the message to the subscribers. Subscribers can retrieve cached messaging using the [`poll=1` parameter](subscribe/api.md#polling), as well as the [`since=` parameter](subscribe/api.md#fetching-cached-messages). @@ -302,7 +306,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`). | `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. | +| `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. | | `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 30s | 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. | diff --git a/docs/publish.md b/docs/publish.md index 375840ec..4f86fe81 100644 --- a/docs/publish.md +++ b/docs/publish.md @@ -49,7 +49,7 @@ If you have the [Android app](subscribe/phone.md) installed on your phone, this There are more features related to publishing messages: You can set a [notification priority](#message-priority), -a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an example that uses all of them at once: +a [title](#message-title), and [tag messages](#tags-emojis) 🥳 🎉. Here's an example that uses some of them at together: === "Command line (curl)" ``` @@ -332,3 +332,56 @@ them with a comma, e.g. `tag1,tag2,tag3`.
Detail view of notifications with tags
+## Message caching +By default, the ntfy server caches messages on disk for 12 hours (see [message caching](config.md#message-cache)), so +all messages you publish are stored server-side for a little while. The reason for this is to overcome temporary +client-side network disruptions, but arguably this feature also may raise privacy concerns. + +To avoid messages being cached server-side entirely, you can set `X-Cache` header (or its alias: `Cache`) to `no`. +This will make sure that your message is not cached on the server, even if server-side caching is enabled. Messages +are still delivered to connected subscribers, but [`since=`](subscribe/api.md#fetching-cached-messages) and +[`poll=1`](subscribe/api.md#polling) won't return the message anymore. + +=== "Command line (curl)" + ``` + curl -H "X-Cache: no" -d "This message won't be stored server-side" ntfy.sh/mytopic + curl -H "Cache: no" -d "This message won't be stored server-side" ntfy.sh/mytopic + ``` + +=== "HTTP" + ``` http + POST /mytopic HTTP/1.1 + Host: ntfy.sh + Cache: no + + This message won't be stored server-side + ``` + +=== "JavaScript" + ``` javascript + fetch('https://ntfy.sh/mytopic', { + method: 'POST', + body: 'This message won't be stored server-side', + headers: { 'Cache': 'no' } + }) + ``` + +=== "Go" + ``` go + req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic", strings.NewReader("This message won't be stored server-side")) + req.Header.Set("Cache", "no") + http.DefaultClient.Do(req) + ``` + +=== "PHP" + ``` php-inline + file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => + "Content-Type: text/plain\r\n" . + "Cache: no", + 'content' => 'This message won't be stored server-side' + ] + ])); + ``` diff --git a/server/cache_mem.go b/server/cache_mem.go index 0edcd488..9272ebd2 100644 --- a/server/cache_mem.go +++ b/server/cache_mem.go @@ -7,20 +7,35 @@ import ( type memCache struct { messages map[string][]*message + nop bool mu sync.Mutex } var _ cache = (*memCache)(nil) +// newMemCache creates an in-memory cache func newMemCache() *memCache { return &memCache{ messages: make(map[string][]*message), + nop: false, + } +} + +// newNopCache creates an in-memory cache that discards all messages; +// it is always empty and can be used if caching is entirely disabled +func newNopCache() *memCache { + return &memCache{ + messages: make(map[string][]*message), + nop: true, } } func (s *memCache) AddMessage(m *message) error { s.mu.Lock() defer s.mu.Unlock() + if s.nop { + return nil + } if m.Event != messageEvent { return errUnexpectedMessageType } diff --git a/server/cache_mem_test.go b/server/cache_mem_test.go index 0878fe80..a1c854d1 100644 --- a/server/cache_mem_test.go +++ b/server/cache_mem_test.go @@ -1,6 +1,7 @@ package server import ( + "github.com/stretchr/testify/assert" "testing" ) @@ -19,3 +20,16 @@ func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) { func TestMemCache_Prune(t *testing.T) { testCachePrune(t, newMemCache()) } + +func TestMemCache_NopCache(t *testing.T) { + c := newNopCache() + assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message"))) + + messages, err := c.Messages("mytopic", sinceAllMessages) + assert.Nil(t, err) + assert.Empty(t, messages) + + topics, err := c.Topics() + assert.Nil(t, err) + assert.Empty(t, topics) +} diff --git a/server/index.gohtml b/server/index.gohtml index 22700b8a..c3d7666a 100644 --- a/server/index.gohtml +++ b/server/index.gohtml @@ -80,7 +80,7 @@ There are more features related to publishing messages: You can set a notification priority, a title, and tag messages. - Here's an example using all of them: + Here's an example using some of them together:

curl \
@@ -203,7 +203,7 @@ Click the link to do so.

- Recent notifications (cached for {{.CacheDuration}}): + Recent notifications ({{if .CacheDuration}}cached for {{.CacheDuration | durationToHuman}}{{else}}caching is disabled{{end}}):

You haven't received any notifications for this topic yet. diff --git a/server/server.go b/server/server.go index 0edaab98..8b6c364d 100644 --- a/server/server.go +++ b/server/server.go @@ -49,7 +49,7 @@ func (e errHTTP) Error() string { type indexPage struct { Topic string - CacheDuration string + CacheDuration time.Duration } type sinceTime time.Time @@ -85,9 +85,13 @@ var ( docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) disallowedTopics = []string{"docs", "static"} + templateFnMap = template.FuncMap{ + "durationToHuman": util.DurationToHuman, + } + //go:embed "index.gohtml" indexSource string - indexTemplate = template.Must(template.New("index").Parse(indexSource)) + indexTemplate = template.Must(template.New("index").Funcs(templateFnMap).Parse(indexSource)) //go:embed "example.html" exampleSource string @@ -139,7 +143,9 @@ func New(conf *config.Config) (*Server, error) { } func createCache(conf *config.Config) (cache, error) { - if conf.CacheFile != "" { + if conf.CacheDuration == 0 { + return newNopCache(), nil + } else if conf.CacheFile != "" { return newSqliteCache(conf.CacheFile) } return newMemCache(), nil @@ -241,7 +247,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error { return indexTemplate.Execute(w, &indexPage{ Topic: r.URL.Path[1:], - CacheDuration: util.DurationToHuman(s.config.CacheDuration), + CacheDuration: s.config.CacheDuration, }) } @@ -278,15 +284,17 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito if m.Message == "" { return errHTTPBadRequest } - title, priority, tags := parseHeaders(r.Header) + title, priority, tags, cache := parseHeaders(r.Header) m.Title = title m.Priority = priority m.Tags = tags if err := t.Publish(m); err != nil { return err } - if err := s.cache.AddMessage(m); err != nil { - return err + if cache { + if err := s.cache.AddMessage(m); err != nil { + return err + } } w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests if err := json.NewEncoder(w).Encode(m); err != nil { @@ -298,7 +306,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, _ *visito return nil } -func parseHeaders(header http.Header) (title string, priority int, tags []string) { +func parseHeaders(header http.Header) (title string, priority int, tags []string, cache bool) { title = readHeader(header, "x-title", "title", "ti", "t") priorityStr := readHeader(header, "x-priority", "priority", "prio", "p") if priorityStr != "" { @@ -324,7 +332,8 @@ func parseHeaders(header http.Header) (title string, priority int, tags []string tags = append(tags, strings.TrimSpace(s)) } } - return title, priority, tags + cache = readHeader(header, "x-cache", "cache") != "no" + return title, priority, tags, cache } func readHeader(header http.Header, names ...string) string { diff --git a/server/server_test.go b/server/server_test.go index e4e9448f..1513cb90 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -150,7 +150,74 @@ func TestServer_StaticSites(t *testing.T) { assert.Equal(t, 200, rr.Code) assert.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`) assert.Contains(t, rr.Body.String(), ``) +} +func TestServer_PublishNoCache(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic", "this message is not cached", map[string]string{ + "Cache": "no", + }) + msg := toMessage(t, response.Body.String()) + assert.NotEmpty(t, msg.ID) + assert.Equal(t, "this message is not cached", msg.Message) + + response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + assert.Empty(t, messages) +} + +func TestServer_PublishAndMultiPoll(t *testing.T) { + s := newTestServer(t, newTestConfig(t)) + + response := request(t, s, "PUT", "/mytopic1", "message 1", nil) + msg := toMessage(t, response.Body.String()) + assert.NotEmpty(t, msg.ID) + assert.Equal(t, "mytopic1", msg.Topic) + assert.Equal(t, "message 1", msg.Message) + + response = request(t, s, "PUT", "/mytopic2", "message 2", nil) + msg = toMessage(t, response.Body.String()) + assert.NotEmpty(t, msg.ID) + assert.Equal(t, "mytopic2", msg.Topic) + assert.Equal(t, "message 2", msg.Message) + + response = request(t, s, "GET", "/mytopic1/json?poll=1", "", nil) + messages := toMessages(t, response.Body.String()) + assert.Equal(t, 1, len(messages)) + assert.Equal(t, "mytopic1", messages[0].Topic) + assert.Equal(t, "message 1", messages[0].Message) + + response = request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1", "", nil) + messages = toMessages(t, response.Body.String()) + assert.Equal(t, 2, len(messages)) + assert.Equal(t, "mytopic1", messages[0].Topic) + assert.Equal(t, "message 1", messages[0].Message) + assert.Equal(t, "mytopic2", messages[1].Topic) + assert.Equal(t, "message 2", messages[1].Message) +} + +func TestServer_PublishWithNopCache(t *testing.T) { + c := newTestConfig(t) + c.CacheDuration = 0 + s := newTestServer(t, c) + + subscribeRR := httptest.NewRecorder() + subscribeCancel := subscribe(t, s, "/mytopic/json", subscribeRR) + + publishRR := request(t, s, "PUT", "/mytopic", "my first message", nil) + assert.Equal(t, 200, publishRR.Code) + + subscribeCancel() + messages := toMessages(t, subscribeRR.Body.String()) + assert.Equal(t, 2, len(messages)) + assert.Equal(t, openEvent, messages[0].Event) + assert.Equal(t, messageEvent, messages[1].Event) + assert.Equal(t, "my first message", messages[1].Message) + + response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil) + messages = toMessages(t, response.Body.String()) + assert.Empty(t, messages) } func newTestConfig(t *testing.T) *config.Config { diff --git a/server/static/css/app.css b/server/static/css/app.css index 07dcc75b..a339d490 100644 --- a/server/static/css/app.css +++ b/server/static/css/app.css @@ -470,11 +470,19 @@ li { margin-bottom: 20px; } +#detail .detailDate { + margin-bottom: 2px; +} + #detail .detailDate, #detail .detailTags { color: #888; font-size: 0.9em; } +#detail .detailTags { + margin-top: 2px; +} + #detail .detailDate img { width: 20px; height: 20px; @@ -483,11 +491,6 @@ li { #detail .detailTitle { font-weight: bold; - font-size: 1.1em; -} - -#detail .detailMessage { - font-size: 1.1em; } #detail #detailMain { diff --git a/server/static/js/app.js b/server/static/js/app.js index b1cf4401..c8f47a5e 100644 --- a/server/static/js/app.js +++ b/server/static/js/app.js @@ -294,7 +294,7 @@ const formatTitle = (m) => { const formatTitleA = (m) => { const emojiList = toEmojis(m.tags); - if (emojiList) { + if (emojiList.length > 0) { return `${emojiList.join(" ")} ${m.title}`; } else { return m.title; @@ -306,7 +306,7 @@ const formatMessage = (m) => { return m.message; } else { const emojiList = toEmojis(m.tags); - if (emojiList) { + if (emojiList.length > 0) { return `${emojiList.join(" ")} ${m.message}`; } else { return m.message;