Add 'Cache: no' header, closes #41

pull/53/head
Philipp Heckel 2021-12-09 10:23:17 -05:00
parent d5be5d3e8c
commit d6fbccab55
11 changed files with 191 additions and 22 deletions

View File

@ -74,7 +74,7 @@ func execRun(c *cli.Context) error {
return errors.New("keepalive interval cannot be lower than five seconds") return errors.New("keepalive interval cannot be lower than five seconds")
} else if managerInterval < 5*time.Second { } else if managerInterval < 5*time.Second {
return errors.New("manager interval cannot be lower than five seconds") 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") return errors.New("cache duration cannot be lower than manager interval")
} else if keyFile != "" && !util.FileExists(keyFile) { } else if keyFile != "" && !util.FileExists(keyFile) {
return errors.New("if set, key file must exist") return errors.New("if set, key file must exist")

View File

@ -28,6 +28,8 @@
# If set, messages are cached in a local SQLite database instead of only in-memory. This # 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. # 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 # 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>. # 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. # Duration for which messages will be buffered before they are deleted.
# This is required to support the "since=..." and "poll=1" parameter. # 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 # cache-duration: 12h
# Interval in which keepalive messages are sent to the client. This is to prevent # Interval in which keepalive messages are sent to the client. This is to prevent

View File

@ -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). * `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**. **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 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). [`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. | | `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). | | `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-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. | | `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. | | `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* | 5000 | Rate limiting: Total number of topics before the server rejects new topics. |

View File

@ -49,7 +49,7 @@ If you have the [Android app](subscribe/phone.md) installed on your phone, this
</figure> </figure>
There are more features related to publishing messages: You can set a [notification priority](#message-priority), 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)" === "Command line (curl)"
``` ```
@ -332,3 +332,56 @@ them with a comma, e.g. `tag1,tag2,tag3`.
<figcaption>Detail view of notifications with tags</figcaption> <figcaption>Detail view of notifications with tags</figcaption>
</figure> </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'
]
]));
```

View File

@ -7,20 +7,35 @@ import (
type memCache struct { type memCache struct {
messages map[string][]*message messages map[string][]*message
nop bool
mu sync.Mutex mu sync.Mutex
} }
var _ cache = (*memCache)(nil) var _ cache = (*memCache)(nil)
// newMemCache creates an in-memory cache
func newMemCache() *memCache { func newMemCache() *memCache {
return &memCache{ return &memCache{
messages: make(map[string][]*message), 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 { func (s *memCache) AddMessage(m *message) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.nop {
return nil
}
if m.Event != messageEvent { if m.Event != messageEvent {
return errUnexpectedMessageType return errUnexpectedMessageType
} }

View File

@ -1,6 +1,7 @@
package server package server
import ( import (
"github.com/stretchr/testify/assert"
"testing" "testing"
) )
@ -19,3 +20,16 @@ func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
func TestMemCache_Prune(t *testing.T) { func TestMemCache_Prune(t *testing.T) {
testCachePrune(t, newMemCache()) 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)
}

View File

@ -80,7 +80,7 @@
There are <a href="docs/publish/">more features</a> related to publishing messages: You can set a 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>, <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>. 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> </p>
<code> <code>
curl \<br/> curl \<br/>
@ -203,7 +203,7 @@
Click the link to do so. Click the link to do so.
</p> </p>
<p class="smallMarginBottom"> <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>
<p id="detailNoNotifications"> <p id="detailNoNotifications">
<i>You haven't received any notifications for this topic yet.</i> <i>You haven't received any notifications for this topic yet.</i>

View File

@ -49,7 +49,7 @@ func (e errHTTP) Error() string {
type indexPage struct { type indexPage struct {
Topic string Topic string
CacheDuration string CacheDuration time.Duration
} }
type sinceTime time.Time type sinceTime time.Time
@ -85,9 +85,13 @@ var (
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`) docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
disallowedTopics = []string{"docs", "static"} disallowedTopics = []string{"docs", "static"}
templateFnMap = template.FuncMap{
"durationToHuman": util.DurationToHuman,
}
//go:embed "index.gohtml" //go:embed "index.gohtml"
indexSource string indexSource string
indexTemplate = template.Must(template.New("index").Parse(indexSource)) indexTemplate = template.Must(template.New("index").Funcs(templateFnMap).Parse(indexSource))
//go:embed "example.html" //go:embed "example.html"
exampleSource string exampleSource string
@ -139,7 +143,9 @@ func New(conf *config.Config) (*Server, error) {
} }
func createCache(conf *config.Config) (cache, 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 newSqliteCache(conf.CacheFile)
} }
return newMemCache(), nil 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 { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
return indexTemplate.Execute(w, &indexPage{ return indexTemplate.Execute(w, &indexPage{
Topic: r.URL.Path[1:], 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 == "" { if m.Message == "" {
return errHTTPBadRequest return errHTTPBadRequest
} }
title, priority, tags := parseHeaders(r.Header) title, priority, tags, cache := parseHeaders(r.Header)
m.Title = title m.Title = title
m.Priority = priority m.Priority = priority
m.Tags = tags m.Tags = tags
if err := t.Publish(m); err != nil { if err := t.Publish(m); err != nil {
return err return err
} }
if err := s.cache.AddMessage(m); err != nil { if cache {
return err if err := s.cache.AddMessage(m); err != nil {
return err
}
} }
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if err := json.NewEncoder(w).Encode(m); err != nil { 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 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") title = readHeader(header, "x-title", "title", "ti", "t")
priorityStr := readHeader(header, "x-priority", "priority", "prio", "p") priorityStr := readHeader(header, "x-priority", "priority", "prio", "p")
if priorityStr != "" { if priorityStr != "" {
@ -324,7 +332,8 @@ func parseHeaders(header http.Header) (title string, priority int, tags []string
tags = append(tags, strings.TrimSpace(s)) 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 { func readHeader(header http.Header, names ...string) string {

View File

@ -150,7 +150,74 @@ func TestServer_StaticSites(t *testing.T) {
assert.Equal(t, 200, rr.Code) assert.Equal(t, 200, rr.Code)
assert.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`) 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>`) 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 { func newTestConfig(t *testing.T) *config.Config {

View File

@ -470,11 +470,19 @@ li {
margin-bottom: 20px; margin-bottom: 20px;
} }
#detail .detailDate {
margin-bottom: 2px;
}
#detail .detailDate, #detail .detailTags { #detail .detailDate, #detail .detailTags {
color: #888; color: #888;
font-size: 0.9em; font-size: 0.9em;
} }
#detail .detailTags {
margin-top: 2px;
}
#detail .detailDate img { #detail .detailDate img {
width: 20px; width: 20px;
height: 20px; height: 20px;
@ -483,11 +491,6 @@ li {
#detail .detailTitle { #detail .detailTitle {
font-weight: bold; font-weight: bold;
font-size: 1.1em;
}
#detail .detailMessage {
font-size: 1.1em;
} }
#detail #detailMain { #detail #detailMain {

View File

@ -294,7 +294,7 @@ const formatTitle = (m) => {
const formatTitleA = (m) => { const formatTitleA = (m) => {
const emojiList = toEmojis(m.tags); const emojiList = toEmojis(m.tags);
if (emojiList) { if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.title}`; return `${emojiList.join(" ")} ${m.title}`;
} else { } else {
return m.title; return m.title;
@ -306,7 +306,7 @@ const formatMessage = (m) => {
return m.message; return m.message;
} else { } else {
const emojiList = toEmojis(m.tags); const emojiList = toEmojis(m.tags);
if (emojiList) { if (emojiList.length > 0) {
return `${emojiList.join(" ")} ${m.message}`; return `${emojiList.join(" ")} ${m.message}`;
} else { } else {
return m.message; return m.message;