From d6fbccab55ccf3962d430e2aed005b6789ad61ec Mon Sep 17 00:00:00 2001
From: Philipp Heckel <pheckel@datto.com>
Date: Thu, 9 Dec 2021 10:23:17 -0500
Subject: [PATCH] Add 'Cache: no' header, closes #41

---
 cmd/app.go                |  2 +-
 config/config.yml         |  4 +++
 docs/config.md            |  8 +++--
 docs/publish.md           | 55 +++++++++++++++++++++++++++++++-
 server/cache_mem.go       | 15 +++++++++
 server/cache_mem_test.go  | 14 ++++++++
 server/index.gohtml       |  4 +--
 server/server.go          | 27 ++++++++++------
 server/server_test.go     | 67 +++++++++++++++++++++++++++++++++++++++
 server/static/css/app.css | 13 +++++---
 server/static/js/app.js   |  4 +--
 11 files changed, 191 insertions(+), 22 deletions(-)

diff --git a/cmd/app.go b/cmd/app.go
index 50f8056..379a58a 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 dec13fb..972b4f9 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 <filename>.
 #
@@ -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 57a12a9..1f8a54d 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 375840e..4f86fe8 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
 </figure>
 
 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`.
   <figcaption>Detail view of notifications with tags</figcaption>
 </figure>
 
+## 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 0edcd48..9272ebd 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 0878fe8..a1c854d 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 22700b8..c3d7666 100644
--- a/server/index.gohtml
+++ b/server/index.gohtml
@@ -80,7 +80,7 @@
         There are <a href="docs/publish/">more features</a> related to publishing messages: You can set a
         <a href="docs/publish/#message-priority">notification priority</a>, a <a href="docs/publish/#message-title">title</a>,
         and <a href="docs/publish/#tags-emojis">tag messages</a>.
-        Here's an example using all of them:
+        Here's an example using some of them together:
     </p>
     <code>
         curl \<br/>
@@ -203,7 +203,7 @@
             Click the link to do so.
         </p>
         <p class="smallMarginBottom">
-            <b>Recent notifications</b> (cached for {{.CacheDuration}}):
+            <b>Recent notifications</b> ({{if .CacheDuration}}cached for {{.CacheDuration | durationToHuman}}{{else}}caching is disabled{{end}}):
         </p>
         <p id="detailNoNotifications">
             <i>You haven't received any notifications for this topic yet.</i>
diff --git a/server/server.go b/server/server.go
index 0edaab9..8b6c364 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 e4e9448..1513cb9 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(), `<script src=static/js/extra.js></script>`)
+}
+
+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 07dcc75..a339d49 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 b1cf440..c8f47a5 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;
-- 
GitLab