Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a6aca4c07 | ||
|
|
08f0d5fd1f | ||
|
|
750be7f07e | ||
|
|
70538783d8 | ||
|
|
09336fa1e4 | ||
|
|
c124434429 | ||
|
|
0544a6f00d | ||
|
|
7b186af765 | ||
|
|
3f978bc45f | ||
|
|
488aeb119b | ||
|
|
160c72997f | ||
|
|
ccb9da9333 | ||
|
|
840cb5b182 | ||
|
|
04ee6b8be2 | ||
|
|
8c8a1685b2 | ||
|
|
28e6f8a0f6 | ||
|
|
d9e5e08af5 | ||
|
|
60980df26b | ||
|
|
d3462d2905 | ||
|
|
0aefcf29ef | ||
|
|
55c021796e | ||
|
|
4aad98256a | ||
|
|
30b13cbdbc | ||
|
|
6d140d6a86 | ||
|
|
9757983046 | ||
|
|
5bed926323 | ||
|
|
1d2f3f72e4 | ||
|
|
3a76e4733c | ||
|
|
a4fbb1b4c5 | ||
|
|
94296e7dd8 | ||
|
|
dc7ca6e405 | ||
|
|
09b128f27a | ||
|
|
acde2e5b6e | ||
|
|
420e35c33c | ||
|
|
c5ce51f242 | ||
|
|
2743c96694 | ||
|
|
36ccfac787 | ||
|
|
e27d5719f0 | ||
|
|
1a3816c1ff | ||
|
|
52a55f71e6 | ||
|
|
b5670d9a71 | ||
|
|
e7bd3abadc | ||
|
|
5878d7e5a6 | ||
|
|
3bce0ad4ae | ||
|
|
695e029147 | ||
|
|
08846e4cc2 | ||
|
|
f9219d2d96 | ||
|
|
7dfb2d50c7 | ||
|
|
349872bdb3 | ||
|
|
39f4613719 | ||
|
|
effc1f42eb | ||
|
|
23d275acec | ||
|
|
8036aa2942 | ||
|
|
f23c7a2dbf | ||
|
|
17e5af654b | ||
|
|
0909354a6c | ||
|
|
cda9dfa9d0 | ||
|
|
018fa816e2 | ||
|
|
efa6d03ba5 | ||
|
|
1ed4ebaf03 | ||
|
|
10c69a722f | ||
|
|
324500d0b3 | ||
|
|
4cd30c35ce | ||
|
|
e79dbf4d00 | ||
|
|
e29a18a076 | ||
|
|
f17df1e926 | ||
|
|
c21737d546 | ||
|
|
6dc4e441e4 | ||
|
|
7d93b0596b | ||
|
|
8b32cfaaff | ||
|
|
18b91cf250 | ||
|
|
4af9c07577 | ||
|
|
fb90ab480a | ||
|
|
d705d3c3b1 | ||
|
|
ee743a7b01 | ||
|
|
e422c2c479 | ||
|
|
aa79fe2861 | ||
|
|
530f55c234 | ||
|
|
6d343c0f1a | ||
|
|
1599793de2 | ||
|
|
42016f48ff | ||
|
|
f9e22dcaa9 | ||
|
|
703f94a25f | ||
|
|
0958c1d527 | ||
|
|
fef46823eb | ||
|
|
48523a2269 | ||
|
|
202c4ac4b3 | ||
|
|
1536201e9a | ||
|
|
3fac1c3432 | ||
|
|
415ab57749 | ||
|
|
c57fac283e | ||
|
|
2eff8d6b47 | ||
|
|
40be2a9153 | ||
|
|
4ba23390b5 | ||
|
|
dd1a85e733 | ||
|
|
c6c3caec39 | ||
|
|
8c0f3b2304 | ||
|
|
c859f866b8 | ||
|
|
b497063af4 | ||
|
|
1fe598a966 | ||
|
|
31e7aa24bc | ||
|
|
4c4e689af4 | ||
|
|
43326be637 | ||
|
|
7e1a71b694 | ||
|
|
b89c18e83d | ||
|
|
f4f5edb230 |
6
.github/workflows/test.yaml
vendored
@@ -8,12 +8,18 @@ jobs:
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '1.17.x'
|
||||
- name: Install node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '16'
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y python3-pip curl
|
||||
- name: Build docs (required for tests)
|
||||
run: make docs
|
||||
- name: Build web app (required for tests)
|
||||
run: make web
|
||||
- name: Run tests, formatting, vetting and linting
|
||||
run: make check
|
||||
- name: Run coverage
|
||||
|
||||
2
.gitignore
vendored
@@ -2,6 +2,8 @@ dist/
|
||||
build/
|
||||
.idea/
|
||||
server/docs/
|
||||
server/site/
|
||||
tools/fbsend/fbsend
|
||||
playground/
|
||||
*.iml
|
||||
node_modules/
|
||||
|
||||
@@ -74,7 +74,7 @@ nfpms:
|
||||
- dst: /var/lib/ntfy
|
||||
type: dir
|
||||
- dst: /usr/share/ntfy/logo.png
|
||||
src: server/static/img/ntfy.png
|
||||
src: web/public/static/img/ntfy.png
|
||||
scripts:
|
||||
preinstall: "scripts/preinst.sh"
|
||||
postinstall: "scripts/postinst.sh"
|
||||
|
||||
31
Makefile
@@ -38,12 +38,34 @@ help:
|
||||
|
||||
|
||||
# Documentation
|
||||
|
||||
docs-deps: .PHONY
|
||||
pip3 install -r requirements.txt
|
||||
|
||||
docs: docs-deps
|
||||
mkdocs build
|
||||
|
||||
|
||||
# Web app
|
||||
|
||||
web-deps:
|
||||
cd web \
|
||||
&& npm install \
|
||||
&& node_modules/svgo/bin/svgo src/img/*.svg
|
||||
|
||||
web-build:
|
||||
cd web \
|
||||
&& npm run build \
|
||||
&& mv build/index.html build/app.html \
|
||||
&& rm -rf ../server/site \
|
||||
&& mv build ../server/site \
|
||||
&& rm \
|
||||
../server/site/config.js \
|
||||
../server/site/asset-manifest.json
|
||||
|
||||
web: web-deps web-build
|
||||
|
||||
|
||||
# Test/check targets
|
||||
|
||||
check: test fmt-check vet lint staticcheck
|
||||
@@ -94,7 +116,7 @@ staticcheck: .PHONY
|
||||
|
||||
# Building targets
|
||||
|
||||
build-deps: docs
|
||||
build-deps: docs web
|
||||
which arm-linux-gnueabi-gcc || { echo "ERROR: ARMv6/v7 cross compiler not installed. On Ubuntu, run: apt install gcc-arm-linux-gnueabi"; exit 1; }
|
||||
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
||||
|
||||
@@ -105,8 +127,9 @@ build-snapshot: build-deps
|
||||
goreleaser build --snapshot --rm-dist --debug
|
||||
|
||||
build-simple: clean
|
||||
mkdir -p dist/ntfy_linux_amd64 server/docs
|
||||
touch server/docs/dummy
|
||||
mkdir -p dist/ntfy_linux_amd64 server/docs server/site
|
||||
touch server/docs/index.html
|
||||
touch server/site/app.html
|
||||
export CGO_ENABLED=1
|
||||
go build \
|
||||
-o dist/ntfy_linux_amd64/ntfy \
|
||||
@@ -115,7 +138,7 @@ build-simple: clean
|
||||
"-linkmode=external -extldflags=-static -s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
|
||||
|
||||
clean: .PHONY
|
||||
rm -rf dist build server/docs
|
||||
rm -rf dist build server/docs server/site
|
||||
|
||||
|
||||
# Releasing targets
|
||||
|
||||
14
README.md
@@ -47,13 +47,21 @@ The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GP
|
||||
|
||||
Third party libraries and resources:
|
||||
* [github.com/urfave/cli/v2](https://github.com/urfave/cli/v2) (MIT) is used to drive the CLI
|
||||
* [Mixkit sound](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) used as notification sound
|
||||
* [Lato Font](https://www.latofonts.com/) (OFL) is used as a font in the Web UI
|
||||
* [Mixkit sounds](https://mixkit.co/free-sound-effects/notification/) (Mixkit Free License) are used as notification sounds
|
||||
* [Sounds from notificationsounds.com](https://notificationsounds.com) (Creative Commons Attribution) are used as notification sounds
|
||||
* [Roboto Font](https://fonts.google.com/specimen/Roboto) (Apache 2.0) is used as a font in everything web
|
||||
* [React](https://reactjs.org/) (MIT) is used for the web app
|
||||
* [Material UI components](https://mui.com/) (MIT) are used in the web app
|
||||
* [MUI dashboard template](https://github.com/mui/material-ui/tree/master/docs/data/material/getting-started/templates/dashboard) (MIT) was used as a basis for the web app
|
||||
* [Dexie.js](https://github.com/dexie/Dexie.js) (Apache 2.0) is used for web app persistence in IndexedDB
|
||||
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
|
||||
* [go-smtp](https://github.com/emersion/go-smtp) (MIT) is used to receive e-mails
|
||||
* [stretchr/testify](https://github.com/stretchr/testify) (MIT) is used for unit and integration tests
|
||||
* [github.com/mattn/go-sqlite3](https://github.com/mattn/go-sqlite3) (MIT) is used to provide the persistent message cache
|
||||
* [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
|
||||
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
|
||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)
|
||||
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) as a lightbox on the landing page
|
||||
* [HTTP middleware for gzip compression](https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7) (MIT) is used for serving static files
|
||||
* [Regex for auto-linking](https://github.com/bryanwoods/autolink-js) (MIT) is used to highlight links (the library is not used)
|
||||
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)
|
||||
* [Linked tabs in mkdocs](https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs)
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
# command: 'echo "$message"'
|
||||
# if:
|
||||
# priority: high,urgent
|
||||
# - topic: secret
|
||||
# command: 'notify-send "$m"'
|
||||
# user: phill
|
||||
# password: mypass
|
||||
#
|
||||
# Variables:
|
||||
# Variable Aliases Description
|
||||
|
||||
@@ -14,9 +14,11 @@ const (
|
||||
type Config struct {
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
Subscribe []struct {
|
||||
Topic string `yaml:"topic"`
|
||||
Command string `yaml:"command"`
|
||||
If map[string]string `yaml:"if"`
|
||||
Topic string `yaml:"topic"`
|
||||
User string `yaml:"user"`
|
||||
Password string `yaml:"password"`
|
||||
Command string `yaml:"command"`
|
||||
If map[string]string `yaml:"if"`
|
||||
} `yaml:"subscribe"`
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ func TestConfig_Load(t *testing.T) {
|
||||
require.Nil(t, os.WriteFile(filename, []byte(`
|
||||
default-host: http://localhost
|
||||
subscribe:
|
||||
- topic: no-command
|
||||
- topic: no-command-with-auth
|
||||
user: phil
|
||||
password: mypass
|
||||
- topic: echo-this
|
||||
command: 'echo "Message received: $message"'
|
||||
- topic: alerts
|
||||
@@ -26,8 +28,10 @@ subscribe:
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "http://localhost", conf.DefaultHost)
|
||||
require.Equal(t, 3, len(conf.Subscribe))
|
||||
require.Equal(t, "no-command", conf.Subscribe[0].Topic)
|
||||
require.Equal(t, "no-command-with-auth", conf.Subscribe[0].Topic)
|
||||
require.Equal(t, "", conf.Subscribe[0].Command)
|
||||
require.Equal(t, "phil", conf.Subscribe[0].User)
|
||||
require.Equal(t, "mypass", conf.Subscribe[0].Password)
|
||||
require.Equal(t, "echo-this", conf.Subscribe[1].Topic)
|
||||
require.Equal(t, `echo "Message received: $message"`, conf.Subscribe[1].Command)
|
||||
require.Equal(t, "alerts", conf.Subscribe[2].Topic)
|
||||
|
||||
@@ -33,6 +33,7 @@ var flagsServe = []cli.Flag{
|
||||
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: "web-root", EnvVars: []string{"NTFY_WEB_ROOT"}, Value: "app", Usage: "sets web root to landing page (home) or web app (app)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-addr", EnvVars: []string{"NTFY_SMTP_SENDER_ADDR"}, Usage: "SMTP server address (host:port) for outgoing emails"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-user", EnvVars: []string{"NTFY_SMTP_SENDER_USER"}, Usage: "SMTP user (if e-mail sending is enabled)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "smtp-sender-pass", EnvVars: []string{"NTFY_SMTP_SENDER_PASS"}, Usage: "SMTP password (if e-mail sending is enabled)"}),
|
||||
@@ -93,6 +94,7 @@ func execServe(c *cli.Context) error {
|
||||
attachmentExpiryDuration := c.Duration("attachment-expiry-duration")
|
||||
keepaliveInterval := c.Duration("keepalive-interval")
|
||||
managerInterval := c.Duration("manager-interval")
|
||||
webRoot := c.String("web-root")
|
||||
smtpSenderAddr := c.String("smtp-sender-addr")
|
||||
smtpSenderUser := c.String("smtp-sender-user")
|
||||
smtpSenderPass := c.String("smtp-sender-pass")
|
||||
@@ -136,9 +138,12 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if set, base-url must start with http:// or https://")
|
||||
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
|
||||
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
|
||||
} else if !util.InStringList([]string{"app", "home"}, webRoot) {
|
||||
return errors.New("if set, web-root must be 'home' or 'app'")
|
||||
}
|
||||
|
||||
// Default auth permissions
|
||||
webRootIsApp := webRoot == "app"
|
||||
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
|
||||
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
|
||||
|
||||
@@ -200,6 +205,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.AttachmentExpiryDuration = attachmentExpiryDuration
|
||||
conf.KeepaliveInterval = keepaliveInterval
|
||||
conf.ManagerInterval = managerInterval
|
||||
conf.WebRootIsApp = webRootIsApp
|
||||
conf.SMTPSenderAddr = smtpSenderAddr
|
||||
conf.SMTPSenderUser = smtpSenderUser
|
||||
conf.SMTPSenderPass = smtpSenderPass
|
||||
|
||||
@@ -23,6 +23,7 @@ var cmdSubscribe = &cli.Command{
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.BoolFlag{Name: "from-config", Aliases: []string{"C"}, Usage: "read subscriptions from config file (service mode)"},
|
||||
&cli.BoolFlag{Name: "poll", Aliases: []string{"p"}, Usage: "return events and exit, do not listen for new events"},
|
||||
&cli.BoolFlag{Name: "scheduled", Aliases: []string{"sched", "S"}, Usage: "also return scheduled/delayed events"},
|
||||
@@ -40,6 +41,7 @@ ntfy subscribe TOPIC
|
||||
ntfy subscribe mytopic # Prints JSON for incoming messages for ntfy.sh/mytopic
|
||||
ntfy sub home.lan/backups # Subscribe to topic on different server
|
||||
ntfy sub --poll home.lan/backups # Just query for latest messages and exit
|
||||
ntfy sub -u phil:mypass secret # Subscribe with username/password
|
||||
|
||||
ntfy subscribe TOPIC COMMAND
|
||||
This executes COMMAND for every incoming messages. The message fields are passed to the
|
||||
@@ -81,6 +83,7 @@ func execSubscribe(c *cli.Context) error {
|
||||
}
|
||||
cl := client.New(conf)
|
||||
since := c.String("since")
|
||||
user := c.String("user")
|
||||
poll := c.Bool("poll")
|
||||
scheduled := c.Bool("scheduled")
|
||||
fromConfig := c.Bool("from-config")
|
||||
@@ -93,6 +96,23 @@ func execSubscribe(c *cli.Context) error {
|
||||
if since != "" {
|
||||
options = append(options, client.WithSince(since))
|
||||
}
|
||||
if user != "" {
|
||||
var pass string
|
||||
parts := strings.SplitN(user, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
user = parts[0]
|
||||
pass = parts[1]
|
||||
} else {
|
||||
fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
|
||||
p, err := util.ReadPassword(c.App.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pass = string(p)
|
||||
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
}
|
||||
if poll {
|
||||
options = append(options, client.WithPoll())
|
||||
}
|
||||
@@ -142,6 +162,9 @@ func doSubscribe(c *cli.Context, cl *client.Client, conf *client.Config, topic,
|
||||
for filter, value := range s.If {
|
||||
topicOptions = append(topicOptions, client.WithFilter(filter, value))
|
||||
}
|
||||
if s.User != "" && s.Password != "" {
|
||||
topicOptions = append(topicOptions, client.WithBasicAuth(s.User, s.Password))
|
||||
}
|
||||
subscriptionID := cl.Subscribe(s.Topic, topicOptions...)
|
||||
commands[subscriptionID] = s.Command
|
||||
}
|
||||
|
||||
@@ -717,42 +717,43 @@ 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`. |
|
||||
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
||||
| `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. |
|
||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | - | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||
| `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* | 45s | 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* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||
| `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 |
|
||||
| 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`. |
|
||||
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
|
||||
| `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. |
|
||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||
| `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* | 45s | 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. |
|
||||
| `web-root` | `NTFY_WEB_ROOT` | `app` or `home` | `app` | Sets web root to landing page (home) or web app (app) |
|
||||
| `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* | 5s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
|
||||
| `visitor-request-limit-exempt-hosts` | `NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS` | *comma-separated host/IP list* | - | Rate limiting: List of hostnames and IPs to be exempt from request rate limiting |
|
||||
| `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: `<number>(smh)`, e.g. 30s, 20m or 1h.
|
||||
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
|
||||
@@ -798,6 +799,7 @@ OPTIONS:
|
||||
--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: 45s) [$NTFY_KEEPALIVE_INTERVAL]
|
||||
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
|
||||
--web-root value sets web root to landing page (home) or web app (app) (default: "app") [$NTFY_WEB_ROOT]
|
||||
--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]
|
||||
|
||||
@@ -4,6 +4,14 @@ This page is used to list deprecation notices for ntfy. Deprecated commands and
|
||||
|
||||
## Active deprecations
|
||||
|
||||
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
||||
> since 2022-02-27
|
||||
|
||||
In about 3 months, the Android app will start using `since=<id>` instead of `since=<timestamp>`, which means that it will
|
||||
not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
|
||||
|
||||
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
|
||||
|
||||
### Running server via `ntfy` (instead of `ntfy serve`)
|
||||
> since 2021-12-17
|
||||
|
||||
|
||||
@@ -75,3 +75,21 @@ One of my co-workers uses the following Ansible task to let him know when things
|
||||
method: POST
|
||||
body: "{{ inventory_hostname }} reseeding complete"
|
||||
```
|
||||
|
||||
## Watchtower notifications (shoutrrr)
|
||||
You can use `shoutrrr` generic webhook support to send watchtower notifications to your ntfy topic.
|
||||
|
||||
Example docker-compose.yml:
|
||||
```yml
|
||||
services:
|
||||
watchtower:
|
||||
image: containrrr/watchtower
|
||||
environment:
|
||||
- WATCHTOWER_NOTIFICATIONS=shoutrrr
|
||||
- WATCHTOWER_NOTIFICATION_URL=generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates
|
||||
```
|
||||
|
||||
Or, if you only want to send notifications using shoutrrr:
|
||||
```
|
||||
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||
```
|
||||
|
||||
@@ -26,21 +26,21 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.15.0/ntfy_1.15.0_linux_x86_64.tar.gz
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.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.15.0/ntfy_1.15.0_linux_armv7.tar.gz
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.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.15.0/ntfy_1.15.0_linux_arm64.tar.gz
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.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.15.0/ntfy_1.15.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.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.15.0/ntfy_1.15.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.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.15.0/ntfy_1.15.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.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.15.0/ntfy_1.15.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.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.15.0/ntfy_1.15.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.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.15.0/ntfy_1.15.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.17.0/ntfy_1.17.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
216
docs/releases.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# Release notes
|
||||
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
|
||||
## ntfy server v1.17.0
|
||||
Released Mar 11, 2022
|
||||
|
||||
**Features & bug fixes:**
|
||||
|
||||
* Replace [web app](https://ntfy.sh/app) with a React/MUI-based web app from the 21st century (#111)
|
||||
* Web UI broken with auth (#132, thanks for reporting @arminus)
|
||||
* Send static web resources as `Content-Encoding: gzip`, i.e. docs and web app (no ticket)
|
||||
* Add support for auth via `?auth=...` query param, used by WebSocket in web app (no ticket)
|
||||
|
||||
## ntfy server v1.16.0
|
||||
Released Feb 27, 2022
|
||||
|
||||
**Features & Bug fixes:**
|
||||
|
||||
* Add [auth support](https://ntfy.sh/docs/subscribe/cli/#authentication) for subscribing with CLI (#147/#148, thanks @lrabane)
|
||||
* Add support for [?since=<id>](https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages) (#151, thanks for reporting @nachotp)
|
||||
|
||||
**Documentation:**
|
||||
|
||||
* Add [watchtower/shoutrr examples](https://ntfy.sh/docs/examples/#watchtower-notifications-shoutrrr) (#150, thanks @rogeliodh)
|
||||
* Add [release notes](https://ntfy.sh/docs/releases/)
|
||||
|
||||
**Technical notes:**
|
||||
|
||||
* As of this release, message IDs will be 12 characters long (as opposed to 10 characters). This is to be able to
|
||||
distinguish them from Unix timestamps for #151.
|
||||
|
||||
## ntfy Android app v1.9.1
|
||||
Released Feb 16, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* Share to topic feature (#131, thanks u/emptymatrix for reporting)
|
||||
* Ability to pick a default server (#127, thanks to @poblabs for reporting and testing)
|
||||
* Automatically delete notifications (#71, thanks @arjan-s for reporting)
|
||||
* Dark theme: Improvements around style and contrast (#119, thanks @kzshantonu for reporting)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Do not attempt to download attachments if they are already expired (#135)
|
||||
* Fixed crash in AddFragment as seen per stack trace in Play Console (no ticket)
|
||||
|
||||
**Other thanks:**
|
||||
|
||||
* Thanks to @rogeliodh, @cmeis and @poblabs for testing
|
||||
|
||||
## ntfy server v1.15.0
|
||||
Released Feb 14, 2022
|
||||
|
||||
**Features & bug fixes:**
|
||||
|
||||
* Compress binaries with `upx` (#137)
|
||||
* Add `visitor-request-limit-exempt-hosts` to exempt friendly hosts from rate limits (#144)
|
||||
* Double default requests per second limit from 1 per 10s to 1 per 5s (no ticket)
|
||||
* Convert `\n` to new line for `X-Message` header as prep for sharing feature (see #136)
|
||||
* Reduce bcrypt cost to 10 to make auth timing more reasonable on slow servers (no ticket)
|
||||
* Docs update to include [public test topics](https://ntfy.sh/docs/publish/#public-topics) (no ticket)
|
||||
|
||||
## ntfy server v1.14.1
|
||||
Released Feb 9, 2022
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Fix ARMv8 Docker build (#113, thanks to @djmaze)
|
||||
* No other significant changes
|
||||
|
||||
## ntfy Android app v1.8.1
|
||||
Released Feb 6, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support [auth / access control](https://ntfy.sh/docs/config/#access-control) (#19, thanks to @cmeis, @drsprite/@poblabs,
|
||||
@gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)
|
||||
* Export/upload log now allows censored/uncensored logs (no ticket)
|
||||
* Removed wake lock (except for notification dispatching, no ticket)
|
||||
* Swipe to remove notifications (#117)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Fix download issues on SDK 29 "Movement not allowed" (#116, thanks Jakob)
|
||||
* Fix for Android 12 crashes (#124, thanks @eskilop)
|
||||
* Fix WebSocket retry logic bug with multiple servers (no ticket)
|
||||
* Fix race in refresh logic leading to duplicate connections (no ticket)
|
||||
* Fix scrolling issue in subscribe to topic dialog (#131, thanks @arminus)
|
||||
* Fix base URL text field color in dark mode, and size with large fonts (no ticket)
|
||||
* Fix action bar color in dark mode (make black, no ticket)
|
||||
|
||||
**Notes:**
|
||||
|
||||
* Foundational work for per-subscription settings
|
||||
|
||||
## ntfy server v1.14.0
|
||||
Released Feb 3, 2022
|
||||
|
||||
**Features**:
|
||||
|
||||
* Server-side for [authentication & authorization](https://ntfy.sh/docs/config/#access-control) (#19, thanks for testing @cmeis, and for input from @gedw99, @karmanyaahm, @Mek101, @gc-ss, @julianfoad, @nmoseman, Jakob, PeterCxy, Techlosopher)
|
||||
* Support `NTFY_TOPIC` env variable in `ntfy publish` (#103)
|
||||
|
||||
**Bug fixes**:
|
||||
|
||||
* Binary UnifiedPush messages should not be converted to attachments (part 1, #101)
|
||||
|
||||
**Docs**:
|
||||
|
||||
* Clarification regarding attachments (#118, thanks @xnumad)
|
||||
|
||||
## ntfy Android app v1.7.1
|
||||
Released Jan 21, 2022
|
||||
|
||||
**New features:**
|
||||
|
||||
* Battery improvements: wakelock disabled by default (#76)
|
||||
* Dark mode: Allow changing app appearance (#102)
|
||||
* Report logs: Copy/export logs to help troubleshooting (#94)
|
||||
* WebSockets (experimental): Use WebSockets to subscribe to topics (#96, #100, #97)
|
||||
* Show battery optimization banner (#105)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* (Partial) support for binary UnifiedPush messages (#101)
|
||||
|
||||
**Notes:**
|
||||
|
||||
* The foreground wakelock is now disabled by default
|
||||
* The service restarter is now scheduled every 3h instead of every 6h
|
||||
|
||||
## ntfy server v1.13.0
|
||||
Released Jan 16, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Websockets](https://ntfy.sh/docs/subscribe/api/#websockets) endpoint
|
||||
* Listen on Unix socket, see [config option](https://ntfy.sh/docs/config/#config-options) `listen-unix`
|
||||
|
||||
## ntfy Android app v1.6.0
|
||||
Released Jan 14, 2022
|
||||
|
||||
**New features:**
|
||||
|
||||
* Attachments: Send files to the phone (#25, #15)
|
||||
* Click action: Add a click action URL to notifications (#85)
|
||||
* Battery optimization: Allow disabling persistent wake-lock (#76, thanks @MatMaul)
|
||||
* Recognize imported user CA certificate for self-hosted servers (#87, thanks @keith24)
|
||||
* Remove mentions of "instant delivery" from F-Droid to make it less confusing (no ticket)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Subscription "muted until" was not always respected (#90)
|
||||
* Fix two stack traces reported by Play console vitals (no ticket)
|
||||
* Truncate FCM messages >4,000 bytes, prefer instant messages (#84)
|
||||
|
||||
## ntfy server v1.12.1
|
||||
Released Jan 14, 2022
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Fix security issue with attachment peaking (#93)
|
||||
|
||||
## ntfy server v1.12.0
|
||||
Released Jan 13, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* [Attachments](https://ntfy.sh/docs/publish/#attachments) (#25, #15)
|
||||
* [Click action](https://ntfy.sh/docs/publish/#click-action) (#85)
|
||||
* Increase FCM priority for high/max priority messages (#70)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Make postinst script work properly for rpm-based systems (#83, thanks @cmeis)
|
||||
* Truncate FCM messages longer than 4000 bytes (#84)
|
||||
* Fix `listen-https` port (no ticket)
|
||||
|
||||
## ntfy Android app v1.5.2
|
||||
Released Jan 3, 2022
|
||||
|
||||
**New features:**
|
||||
|
||||
* Allow using ntfy as UnifiedPush distributor (#9)
|
||||
* Support for longer message up to 4096 bytes (#77)
|
||||
* Minimum priority: show notifications only if priority X or higher (#79)
|
||||
* Allowing disabling broadcasts in global settings (#80)
|
||||
|
||||
**Bug fixes:**
|
||||
|
||||
* Allow int/long extras for SEND_MESSAGE intent (#57)
|
||||
* Various battery improvement fixes (#76)
|
||||
|
||||
## ntfy server v1.11.2
|
||||
Released Jan 1, 2022
|
||||
|
||||
**Features & bug fixes:**
|
||||
|
||||
* Increase message limit to 4096 bytes (4k) #77
|
||||
* Docs for [UnifiedPush](https://unifiedpush.org) #9
|
||||
* Increase keepalive interval to 55s #76
|
||||
* Increase Firebase keepalive to 3 hours #76
|
||||
|
||||
## ntfy server v1.10.0
|
||||
Released Dec 28, 2021
|
||||
|
||||
**Features & bug fixes:**
|
||||
|
||||
* [Publish messages via e-mail](ntfy.sh/docs/publish/#e-mail-publishing) #66
|
||||
* Server-side work to support [unifiedpush.org](https://unifiedpush.org) #64
|
||||
* Fixing the Santa bug #65
|
||||
|
||||
## Older releases
|
||||
For older releases, check out the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
|
||||
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
|
||||
6
docs/static/css/extra.css
vendored
@@ -1,7 +1,7 @@
|
||||
:root {
|
||||
--md-primary-fg-color: #3a9784;
|
||||
--md-primary-fg-color--light: #3a9784;
|
||||
--md-primary-fg-color--dark: #3a9784;
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color--light: #338574;
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
}
|
||||
|
||||
.md-header__button.md-logo :is(img, svg) {
|
||||
|
||||
BIN
docs/static/img/web-detail.png
vendored
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 473 KiB |
BIN
docs/static/img/web-subscribe.png
vendored
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 76 KiB |
@@ -247,11 +247,13 @@ curl -s "ntfy.sh/mytopic/json?poll=1"
|
||||
### Fetch cached messages
|
||||
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
|
||||
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
|
||||
the `since=` query parameter. It takes either a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`)
|
||||
or `all` (all cached messages).
|
||||
the `since=` query parameter. It takes a duration (e.g. `10m` or `30s`), a Unix timestamp (e.g. `1635528757`),
|
||||
a message ID (e.g. `nFS3knfcQ1xe`), or `all` (all cached messages).
|
||||
|
||||
```
|
||||
curl -s "ntfy.sh/mytopic/json?since=10m"
|
||||
curl -s "ntfy.sh/mytopic/json?since=1645970742"
|
||||
curl -s "ntfy.sh/mytopic/json?since=nFS3knfcQ1xe"
|
||||
```
|
||||
|
||||
### Fetch scheduled messages
|
||||
@@ -395,7 +397,6 @@ Here's an example for each message type:
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
=== "Poll request message"
|
||||
``` json
|
||||
{
|
||||
@@ -413,6 +414,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
|
||||
| Parameter | Aliases (case-insensitive) | Description |
|
||||
|-------------|----------------------------|---------------------------------------------------------------------------------|
|
||||
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
|
||||
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
|
||||
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
|
||||
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
|
||||
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
|
||||
|
||||
@@ -196,3 +196,27 @@ EOF
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ntfy-client
|
||||
```
|
||||
|
||||
|
||||
### Authentication
|
||||
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
|
||||
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
|
||||
To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
|
||||
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
|
||||
your password.
|
||||
|
||||
You can either add your username and password to the configuration file:
|
||||
=== "~/.config/ntfy/client.yml"
|
||||
```yaml
|
||||
- topic: secret
|
||||
command: 'notify-send "$m"'
|
||||
user: phill
|
||||
password: mypass
|
||||
```
|
||||
|
||||
Or with the `ntfy subscibe` command:
|
||||
```
|
||||
ntfy subscribe \
|
||||
-u phil:mypass \
|
||||
ntfy.example.com/mysecrets
|
||||
```
|
||||
|
||||
@@ -6,9 +6,9 @@ keep a connection open and listen for incoming notifications.
|
||||
To learn how to send messages, check out the [publishing page](../publish.md).
|
||||
|
||||
<div id="web-screenshots" class="screenshots">
|
||||
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
|
||||
<a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a>
|
||||
<a href="../../static/img/web-detail.png"><img src="../../static/img/web-detail.png"/></a>
|
||||
<a href="../../static/img/web-notification.png"><img src="../../static/img/web-notification.png"/></a>
|
||||
<a href="../../static/img/web-subscribe.png"><img src="../../static/img/web-subscribe.png"/></a>
|
||||
</div>
|
||||
|
||||
To keep receiving desktop notifications from ntfy, you need to keep the website open. What I do, and what I highly recommend,
|
||||
|
||||
@@ -82,8 +82,9 @@ nav:
|
||||
- "Other things":
|
||||
- "FAQs": faq.md
|
||||
- "Examples": examples.md
|
||||
- "Emojis 🥳 🎉": emojis.md
|
||||
- "Release notes": releases.md
|
||||
- "Deprecation notices": deprecations.md
|
||||
- "Emojis 🥳 🎉": emojis.md
|
||||
- "Development": develop.md
|
||||
- "Privacy policy": privacy.md
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ fi
|
||||
if [[ "$1" == *.js ]]; then
|
||||
echo -n "// This file is generated by scripts/emoji-convert.sh to reduce the size
|
||||
// Original data source: https://github.com/github/gemoji/blob/master/db/emoji.json
|
||||
const rawEmojis = " > "$1"
|
||||
export const rawEmojis = " > "$1"
|
||||
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1"
|
||||
elif [[ "$1" == *.md ]]; then
|
||||
echo "# Emoji reference
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnexpectedMessageType = errors.New("unexpected message type")
|
||||
)
|
||||
|
||||
// cache implements a cache for messages of type "message" events,
|
||||
// i.e. message structs with the Event messageEvent.
|
||||
type cache interface {
|
||||
AddMessage(m *message) error
|
||||
Messages(topic string, since sinceTime, scheduled bool) ([]*message, error)
|
||||
MessagesDue() ([]*message, error)
|
||||
MessageCount(topic string) (int, error)
|
||||
Topics() (map[string]*topic, error)
|
||||
Prune(olderThan time.Time) error
|
||||
MarkPublished(m *message) error
|
||||
AttachmentsSize(owner string) (int64, error)
|
||||
AttachmentsExpired() ([]string, error)
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type memCache struct {
|
||||
messages map[string][]*message
|
||||
scheduled map[string]*message // Message ID -> 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),
|
||||
scheduled: 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),
|
||||
scheduled: make(map[string]*message),
|
||||
nop: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *memCache) AddMessage(m *message) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.nop {
|
||||
return nil
|
||||
}
|
||||
if m.Event != messageEvent {
|
||||
return errUnexpectedMessageType
|
||||
}
|
||||
if _, ok := c.messages[m.Topic]; !ok {
|
||||
c.messages[m.Topic] = make([]*message, 0)
|
||||
}
|
||||
delayed := m.Time > time.Now().Unix()
|
||||
if delayed {
|
||||
c.scheduled[m.ID] = m
|
||||
}
|
||||
c.messages[m.Topic] = append(c.messages[m.Topic], m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *memCache) Messages(topic string, since sinceTime, scheduled bool) ([]*message, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, ok := c.messages[topic]; !ok || since.IsNone() {
|
||||
return make([]*message, 0), nil
|
||||
}
|
||||
messages := make([]*message, 0)
|
||||
for _, m := range c.messages[topic] {
|
||||
_, messageScheduled := c.scheduled[m.ID]
|
||||
include := m.Time >= since.Time().Unix() && (!messageScheduled || scheduled)
|
||||
if include {
|
||||
messages = append(messages, m)
|
||||
}
|
||||
}
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Time < messages[j].Time
|
||||
})
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (c *memCache) MessagesDue() ([]*message, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
messages := make([]*message, 0)
|
||||
for _, m := range c.scheduled {
|
||||
due := time.Now().Unix() >= m.Time
|
||||
if due {
|
||||
messages = append(messages, m)
|
||||
}
|
||||
}
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Time < messages[j].Time
|
||||
})
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (c *memCache) MarkPublished(m *message) error {
|
||||
c.mu.Lock()
|
||||
delete(c.scheduled, m.ID)
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *memCache) MessageCount(topic string) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if _, ok := c.messages[topic]; !ok {
|
||||
return 0, nil
|
||||
}
|
||||
return len(c.messages[topic]), nil
|
||||
}
|
||||
|
||||
func (c *memCache) Topics() (map[string]*topic, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
topics := make(map[string]*topic)
|
||||
for topic := range c.messages {
|
||||
topics[topic] = newTopic(topic)
|
||||
}
|
||||
return topics, nil
|
||||
}
|
||||
|
||||
func (c *memCache) Prune(olderThan time.Time) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for topic := range c.messages {
|
||||
c.pruneTopic(topic, olderThan)
|
||||
}
|
||||
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] {
|
||||
counted := m.Attachment != nil && m.Attachment.Owner == owner && m.Attachment.Expires > time.Now().Unix()
|
||||
if counted {
|
||||
size += m.Attachment.Size
|
||||
}
|
||||
}
|
||||
}
|
||||
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] {
|
||||
if m.Time >= olderThan.Unix() {
|
||||
messages = append(messages, m)
|
||||
}
|
||||
}
|
||||
c.messages[topic] = messages
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newMemCache())
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newMemCache())
|
||||
}
|
||||
|
||||
func TestMemCache_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newMemCache())
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newMemCache())
|
||||
}
|
||||
|
||||
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")))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Empty(t, messages)
|
||||
|
||||
topics, err := c.Topics()
|
||||
assert.Nil(t, err)
|
||||
assert.Empty(t, topics)
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSqliteCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestSqliteCache_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
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)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 0" schema
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(1024) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename)
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
require.Equal(t, "some message 5", messages[5].Message)
|
||||
require.Equal(t, "", messages[5].Title)
|
||||
require.Nil(t, messages[5].Tags)
|
||||
require.Equal(t, 0, messages[5].Priority)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 1" schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(512) NOT NULL,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags VARCHAR(256) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename)
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
// Add delayed message
|
||||
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
|
||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||
require.Nil(t, c.AddMessage(delayedMessage))
|
||||
|
||||
// 10, not 11!
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
|
||||
// 11!
|
||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 11, len(messages))
|
||||
}
|
||||
|
||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
|
||||
var schemaVersion int
|
||||
require.Nil(t, rows.Scan(&schemaVersion))
|
||||
require.Equal(t, currentSchemaVersion, schemaVersion)
|
||||
require.Nil(t, rows.Close())
|
||||
}
|
||||
|
||||
func newSqliteTestCache(t *testing.T) *sqliteCache {
|
||||
c, err := newSqliteCache(newSqliteTestCacheFile(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFile(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "cache.db")
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFromFile(t *testing.T, filename string) *sqliteCache {
|
||||
c, err := newSqliteCache(filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testCacheMessages(t *testing.T, c cache) {
|
||||
m1 := newDefaultMessage("mytopic", "my message")
|
||||
m1.Time = 1
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = 2
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
|
||||
// Adding invalid
|
||||
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")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, count)
|
||||
|
||||
// mytopic: since all
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
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)
|
||||
require.Empty(t, messages)
|
||||
|
||||
// mytopic: since 2
|
||||
messages, _ = c.Messages("mytopic", sinceTime(time.Unix(2, 0)), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// example: count
|
||||
count, err = c.MessageCount("example")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
// example: since all
|
||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||
require.Equal(t, "my example message", messages[0].Message)
|
||||
|
||||
// non-existing: count
|
||||
count, err = c.MessageCount("doesnotexist")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
|
||||
// non-existing: since all
|
||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func testCacheTopics(t *testing.T, c cache) {
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
m1 := newDefaultMessage("mytopic", "my message")
|
||||
m1.Time = 1
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = 2
|
||||
|
||||
m3 := newDefaultMessage("another_topic", "and another one")
|
||||
m3.Time = 1
|
||||
|
||||
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")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
count, err = c.MessageCount("another_topic")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
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) {
|
||||
m := newDefaultMessage("mytopic", "some message")
|
||||
m.Tags = []string{"tag1", "tag2"}
|
||||
m.Priority = 5
|
||||
m.Title = "some title"
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
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) {
|
||||
m1 := newDefaultMessage("mytopic", "message 1")
|
||||
m2 := newDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = time.Now().Add(time.Hour).Unix()
|
||||
m3 := newDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
||||
m4 := newDefaultMessage("mytopic2", "message 4")
|
||||
m4.Time = time.Now().Add(time.Minute).Unix()
|
||||
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
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
|
||||
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
|
||||
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()
|
||||
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)
|
||||
}
|
||||
@@ -64,6 +64,7 @@ type Config struct {
|
||||
AttachmentExpiryDuration time.Duration
|
||||
KeepaliveInterval time.Duration
|
||||
ManagerInterval time.Duration
|
||||
WebRootIsApp bool
|
||||
AtSenderInterval time.Duration
|
||||
FirebaseKeepaliveInterval time.Duration
|
||||
SMTPSenderAddr string
|
||||
|
||||
@@ -5,17 +5,23 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
_ "github.com/mattn/go-sqlite3" // SQLite driver
|
||||
"heckel.io/ntfy/util"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnexpectedMessageType = errors.New("unexpected message type")
|
||||
)
|
||||
|
||||
// Messages cache
|
||||
const (
|
||||
createMessagesTableQuery = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
time INT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
@@ -32,42 +38,57 @@ const (
|
||||
encoding TEXT NOT NULL,
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||
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_url, attachment_owner, encoding, published)
|
||||
INSERT INTO messages (mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
ORDER BY time ASC
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time ASC
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND (id > ? OR published = 0)
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesDueQuery = `
|
||||
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
|
||||
FROM messages
|
||||
WHERE time <= ? AND published = 0
|
||||
ORDER BY time, id
|
||||
`
|
||||
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE id = ?`
|
||||
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
||||
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 = ? AND attachment_expires >= ?`
|
||||
selectAttachmentsExpiredQuery = `SELECT id FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
||||
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
||||
)
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 4
|
||||
currentSchemaVersion = 5
|
||||
createSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
@@ -108,15 +129,52 @@ const (
|
||||
migrate3To4AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
|
||||
`
|
||||
|
||||
// 4 -> 5
|
||||
migrate4To5AlterMessagesTableQuery = `
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
time INT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags TEXT NOT NULL,
|
||||
click 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,
|
||||
attachment_owner TEXT NOT NULL,
|
||||
encoding TEXT NOT NULL,
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages_new (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages_new (topic);
|
||||
INSERT
|
||||
INTO messages_new (
|
||||
mid, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
||||
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
|
||||
SELECT
|
||||
id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type,
|
||||
attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published
|
||||
FROM messages;
|
||||
DROP TABLE messages;
|
||||
ALTER TABLE messages_new RENAME TO messages;
|
||||
COMMIT;
|
||||
`
|
||||
)
|
||||
|
||||
type sqliteCache struct {
|
||||
db *sql.DB
|
||||
type messageCache struct {
|
||||
db *sql.DB
|
||||
nop bool
|
||||
}
|
||||
|
||||
var _ cache = (*sqliteCache)(nil)
|
||||
|
||||
func newSqliteCache(filename string) (*sqliteCache, error) {
|
||||
// newSqliteCache creates a SQLite file-backed cache
|
||||
func newSqliteCache(filename string, nop bool) (*messageCache, error) {
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -124,15 +182,40 @@ func newSqliteCache(filename string) (*sqliteCache, error) {
|
||||
if err := setupCacheDB(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sqliteCache{
|
||||
db: db,
|
||||
return &messageCache{
|
||||
db: db,
|
||||
nop: nop,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *sqliteCache) AddMessage(m *message) error {
|
||||
// newMemCache creates an in-memory cache
|
||||
func newMemCache() (*messageCache, error) {
|
||||
return newSqliteCache(createMemoryFilename(), 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() (*messageCache, error) {
|
||||
return newSqliteCache(createMemoryFilename(), true)
|
||||
}
|
||||
|
||||
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
||||
// From mattn/go-sqlite3: "Each connection to ":memory:" opens a brand new in-memory
|
||||
// sql database, so if the stdlib's sql engine happens to open another connection and
|
||||
// you've only specified ":memory:", that connection will see a brand new database.
|
||||
// A workaround is to use "file::memory:?cache=shared" (or "file:foobar?mode=memory&cache=shared").
|
||||
// Every connection to this string will point to the same in-memory database."
|
||||
func createMemoryFilename() string {
|
||||
return fmt.Sprintf("file:%s?mode=memory&cache=shared", util.RandomString(10))
|
||||
}
|
||||
|
||||
func (c *messageCache) AddMessage(m *message) error {
|
||||
if m.Event != messageEvent {
|
||||
return errUnexpectedMessageType
|
||||
}
|
||||
if c.nop {
|
||||
return nil
|
||||
}
|
||||
published := m.Time <= time.Now().Unix()
|
||||
tags := strings.Join(m.Tags, ",")
|
||||
var attachmentName, attachmentType, attachmentURL, attachmentOwner string
|
||||
@@ -167,10 +250,16 @@ func (c *sqliteCache) AddMessage(m *message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sqliteCache) Messages(topic string, since sinceTime, scheduled bool) ([]*message, error) {
|
||||
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||
if since.IsNone() {
|
||||
return make([]*message, 0), nil
|
||||
} else if since.IsID() {
|
||||
return c.messagesSinceID(topic, since, scheduled)
|
||||
}
|
||||
return c.messagesSinceTime(topic, since, scheduled)
|
||||
}
|
||||
|
||||
func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if scheduled {
|
||||
@@ -184,7 +273,33 @@ func (c *sqliteCache) Messages(topic string, since sinceTime, scheduled bool) ([
|
||||
return readMessages(rows)
|
||||
}
|
||||
|
||||
func (c *sqliteCache) MessagesDue() ([]*message, error) {
|
||||
func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||
idrows, err := c.db.Query(selectRowIDFromMessageID, topic, since.ID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer idrows.Close()
|
||||
if !idrows.Next() {
|
||||
return c.messagesSinceTime(topic, sinceAllMessages, scheduled)
|
||||
}
|
||||
var rowID int64
|
||||
if err := idrows.Scan(&rowID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idrows.Close()
|
||||
var rows *sql.Rows
|
||||
if scheduled {
|
||||
rows, err = c.db.Query(selectMessagesSinceIDIncludeScheduledQuery, topic, rowID)
|
||||
} else {
|
||||
rows, err = c.db.Query(selectMessagesSinceIDQuery, topic, rowID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return readMessages(rows)
|
||||
}
|
||||
|
||||
func (c *messageCache) MessagesDue() ([]*message, error) {
|
||||
rows, err := c.db.Query(selectMessagesDueQuery, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -192,12 +307,12 @@ func (c *sqliteCache) MessagesDue() ([]*message, error) {
|
||||
return readMessages(rows)
|
||||
}
|
||||
|
||||
func (c *sqliteCache) MarkPublished(m *message) error {
|
||||
func (c *messageCache) MarkPublished(m *message) error {
|
||||
_, err := c.db.Exec(updateMessagePublishedQuery, m.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sqliteCache) MessageCount(topic string) (int, error) {
|
||||
func (c *messageCache) MessageCount(topic string) (int, error) {
|
||||
rows, err := c.db.Query(selectMessageCountForTopicQuery, topic)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -215,7 +330,7 @@ func (c *sqliteCache) MessageCount(topic string) (int, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (c *sqliteCache) Topics() (map[string]*topic, error) {
|
||||
func (c *messageCache) Topics() (map[string]*topic, error) {
|
||||
rows, err := c.db.Query(selectTopicsQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -235,12 +350,12 @@ func (c *sqliteCache) Topics() (map[string]*topic, error) {
|
||||
return topics, nil
|
||||
}
|
||||
|
||||
func (c *sqliteCache) Prune(olderThan time.Time) error {
|
||||
func (c *messageCache) Prune(olderThan time.Time) error {
|
||||
_, err := c.db.Exec(pruneMessagesQuery, olderThan.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) {
|
||||
func (c *messageCache) AttachmentsSize(owner string) (int64, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsSizeQuery, owner, time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -258,7 +373,7 @@ func (c *sqliteCache) AttachmentsSize(owner string) (int64, error) {
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (c *sqliteCache) AttachmentsExpired() ([]string, error) {
|
||||
func (c *messageCache) AttachmentsExpired() ([]string, error) {
|
||||
rows, err := c.db.Query(selectAttachmentsExpiredQuery, time.Now().Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -373,6 +488,8 @@ func setupCacheDB(db *sql.DB) error {
|
||||
return migrateFrom2(db)
|
||||
} else if schemaVersion == 3 {
|
||||
return migrateFrom3(db)
|
||||
} else if schemaVersion == 4 {
|
||||
return migrateFrom4(db)
|
||||
}
|
||||
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
|
||||
}
|
||||
@@ -434,5 +551,16 @@ func migrateFrom3(db *sql.DB) error {
|
||||
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
|
||||
return err
|
||||
}
|
||||
return migrateFrom4(db)
|
||||
}
|
||||
|
||||
func migrateFrom4(db *sql.DB) error {
|
||||
log.Print("Migrating cache database schema: from 4 to 5")
|
||||
if _, err := db.Exec(migrate4To5AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(updateSchemaVersion, 5); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil // Update this when a new version is added
|
||||
}
|
||||
496
server/message_cache_test.go
Normal file
@@ -0,0 +1,496 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSqliteCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Messages(t *testing.T) {
|
||||
testCacheMessages(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessages(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "my message")
|
||||
m1.Time = 1
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = 2
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(newDefaultMessage("example", "my example message")))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
|
||||
// Adding invalid
|
||||
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")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, count)
|
||||
|
||||
// mytopic: since all
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
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)
|
||||
require.Empty(t, messages)
|
||||
|
||||
// mytopic: since m1 (by ID)
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m1.ID), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, m2.ID, messages[0].ID)
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
require.Equal(t, "mytopic", messages[0].Topic)
|
||||
|
||||
// mytopic: since 2
|
||||
messages, _ = c.Messages("mytopic", newSinceTime(2), false)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// example: count
|
||||
count, err = c.MessageCount("example")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
// example: since all
|
||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||
require.Equal(t, "my example message", messages[0].Message)
|
||||
|
||||
// non-existing: count
|
||||
count, err = c.MessageCount("doesnotexist")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
|
||||
// non-existing: since all
|
||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesScheduled(t *testing.T) {
|
||||
testCacheMessagesScheduled(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesScheduled(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "message 1")
|
||||
m2 := newDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = time.Now().Add(time.Hour).Unix()
|
||||
m3 := newDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Minute).Unix() // earlier than m2!
|
||||
m4 := newDefaultMessage("mytopic2", "message 4")
|
||||
m4.Time = time.Now().Add(time.Minute).Unix()
|
||||
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
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
|
||||
messages, _ = c.Messages("mytopic", sinceAllMessages, true) // include scheduled
|
||||
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()
|
||||
require.Empty(t, messages)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Topics(t *testing.T) {
|
||||
testCacheTopics(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheTopics(t *testing.T, c *messageCache) {
|
||||
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)
|
||||
}
|
||||
require.Equal(t, 2, len(topics))
|
||||
require.Equal(t, "topic1", topics["topic1"].ID)
|
||||
require.Equal(t, "topic2", topics["topic2"].ID)
|
||||
}
|
||||
|
||||
func TestSqliteCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesTagsPrioAndTitle(t *testing.T) {
|
||||
testCacheMessagesTagsPrioAndTitle(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesTagsPrioAndTitle(t *testing.T, c *messageCache) {
|
||||
m := newDefaultMessage("mytopic", "some message")
|
||||
m.Tags = []string{"tag1", "tag2"}
|
||||
m.Priority = 5
|
||||
m.Title = "some title"
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
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 TestSqliteCache_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_MessagesSinceID(t *testing.T) {
|
||||
testCacheMessagesSinceID(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheMessagesSinceID(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "message 1")
|
||||
m1.Time = 100
|
||||
m2 := newDefaultMessage("mytopic", "message 2")
|
||||
m2.Time = 200
|
||||
m3 := newDefaultMessage("mytopic", "message 3")
|
||||
m3.Time = time.Now().Add(time.Hour).Unix() // Scheduled, in the future, later than m7 and m5
|
||||
m4 := newDefaultMessage("mytopic", "message 4")
|
||||
m4.Time = 400
|
||||
m5 := newDefaultMessage("mytopic", "message 5")
|
||||
m5.Time = time.Now().Add(time.Minute).Unix() // Scheduled, in the future, later than m7
|
||||
m6 := newDefaultMessage("mytopic", "message 6")
|
||||
m6.Time = 600
|
||||
m7 := newDefaultMessage("mytopic", "message 7")
|
||||
m7.Time = 700
|
||||
|
||||
require.Nil(t, c.AddMessage(m1))
|
||||
require.Nil(t, c.AddMessage(m2))
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
require.Nil(t, c.AddMessage(m4))
|
||||
require.Nil(t, c.AddMessage(m5))
|
||||
require.Nil(t, c.AddMessage(m6))
|
||||
require.Nil(t, c.AddMessage(m7))
|
||||
|
||||
// Case 1: Since ID exists, exclude scheduled
|
||||
messages, _ := c.Messages("mytopic", newSinceID(m2.ID), false)
|
||||
require.Equal(t, 3, len(messages))
|
||||
require.Equal(t, "message 4", messages[0].Message)
|
||||
require.Equal(t, "message 6", messages[1].Message) // Not scheduled m3/m5!
|
||||
require.Equal(t, "message 7", messages[2].Message)
|
||||
|
||||
// Case 2: Since ID exists, include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m2.ID), true)
|
||||
require.Equal(t, 5, len(messages))
|
||||
require.Equal(t, "message 4", messages[0].Message)
|
||||
require.Equal(t, "message 6", messages[1].Message)
|
||||
require.Equal(t, "message 7", messages[2].Message)
|
||||
require.Equal(t, "message 5", messages[3].Message) // Order!
|
||||
require.Equal(t, "message 3", messages[4].Message) // Order!
|
||||
|
||||
// Case 3: Since ID does not exist (-> Return all messages), include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID("doesntexist"), true)
|
||||
require.Equal(t, 7, len(messages))
|
||||
require.Equal(t, "message 1", messages[0].Message)
|
||||
require.Equal(t, "message 2", messages[1].Message)
|
||||
require.Equal(t, "message 4", messages[2].Message)
|
||||
require.Equal(t, "message 6", messages[3].Message)
|
||||
require.Equal(t, "message 7", messages[4].Message)
|
||||
require.Equal(t, "message 5", messages[5].Message) // Order!
|
||||
require.Equal(t, "message 3", messages[6].Message) // Order!
|
||||
|
||||
// Case 4: Since ID exists and is last message (-> Return no messages), exclude scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), false)
|
||||
require.Equal(t, 0, len(messages))
|
||||
|
||||
// Case 5: Since ID exists and is last message (-> Return no messages), include scheduled
|
||||
messages, _ = c.Messages("mytopic", newSinceID(m7.ID), true)
|
||||
require.Equal(t, 2, len(messages))
|
||||
require.Equal(t, "message 5", messages[0].Message)
|
||||
require.Equal(t, "message 3", messages[1].Message)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Prune(t *testing.T) {
|
||||
testCachePrune(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Prune(t *testing.T) {
|
||||
testCachePrune(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCachePrune(t *testing.T, c *messageCache) {
|
||||
m1 := newDefaultMessage("mytopic", "my message")
|
||||
m1.Time = 1
|
||||
|
||||
m2 := newDefaultMessage("mytopic", "my other message")
|
||||
m2.Time = 2
|
||||
|
||||
m3 := newDefaultMessage("another_topic", "and another one")
|
||||
m3.Time = 1
|
||||
|
||||
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")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
|
||||
count, err = c.MessageCount("another_topic")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_Attachments(t *testing.T) {
|
||||
testCacheAttachments(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
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)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 0" schema
|
||||
_, err = db.Exec(`
|
||||
BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(1024) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
COMMIT;
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename)
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
require.Equal(t, "some message 5", messages[5].Message)
|
||||
require.Equal(t, "", messages[5].Title)
|
||||
require.Nil(t, messages[5].Tags)
|
||||
require.Equal(t, 0, messages[5].Priority)
|
||||
}
|
||||
|
||||
func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Create "version 1" schema
|
||||
_, err = db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
time INT NOT NULL,
|
||||
topic VARCHAR(64) NOT NULL,
|
||||
message VARCHAR(512) NOT NULL,
|
||||
title VARCHAR(256) NOT NULL,
|
||||
priority INT NOT NULL,
|
||||
tags VARCHAR(256) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
version INT NOT NULL
|
||||
);
|
||||
INSERT INTO schemaVersion (id, version) VALUES (1, 1);
|
||||
`)
|
||||
require.Nil(t, err)
|
||||
|
||||
// Insert a bunch of messages
|
||||
for i := 0; i < 10; i++ {
|
||||
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i), "", 0, "")
|
||||
require.Nil(t, err)
|
||||
}
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename)
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
// Add delayed message
|
||||
delayedMessage := newDefaultMessage("mytopic", "some delayed message")
|
||||
delayedMessage.Time = time.Now().Add(time.Minute).Unix()
|
||||
require.Nil(t, c.AddMessage(delayedMessage))
|
||||
|
||||
// 10, not 11!
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 10, len(messages))
|
||||
|
||||
// 11!
|
||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 11, len(messages))
|
||||
}
|
||||
|
||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||
require.Nil(t, err)
|
||||
require.True(t, rows.Next())
|
||||
|
||||
var schemaVersion int
|
||||
require.Nil(t, rows.Scan(&schemaVersion))
|
||||
require.Equal(t, currentSchemaVersion, schemaVersion)
|
||||
require.Nil(t, rows.Close())
|
||||
}
|
||||
|
||||
func TestMemCache_NopCache(t *testing.T) {
|
||||
c, _ := newNopCache()
|
||||
assert.Nil(t, c.AddMessage(newDefaultMessage("mytopic", "my message")))
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
assert.Nil(t, err)
|
||||
assert.Empty(t, messages)
|
||||
|
||||
topics, err := c.Topics()
|
||||
assert.Nil(t, err)
|
||||
assert.Empty(t, topics)
|
||||
}
|
||||
|
||||
func newSqliteTestCache(t *testing.T) *messageCache {
|
||||
c, err := newSqliteCache(newSqliteTestCacheFile(t), false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFile(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "cache.db")
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFromFile(t *testing.T, filename string) *messageCache {
|
||||
c, err := newSqliteCache(filename, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newMemTestCache(t *testing.T) *messageCache {
|
||||
c, err := newMemCache()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
176
server/server.go
@@ -13,7 +13,6 @@ import (
|
||||
"golang.org/x/sync/errgroup"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
"html/template"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
@@ -45,51 +44,43 @@ type Server struct {
|
||||
mailer mailer
|
||||
messages int64
|
||||
auth auth.Auther
|
||||
cache cache
|
||||
messageCache *messageCache
|
||||
fileCache *fileCache
|
||||
closeChan chan bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type indexPage struct {
|
||||
Topic string
|
||||
CacheDuration time.Duration
|
||||
}
|
||||
|
||||
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
|
||||
type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
|
||||
|
||||
var (
|
||||
// If changed, don't forget to update Android App and auth_sqlite.go
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
|
||||
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
|
||||
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
|
||||
extTopicPathRegex = regexp.MustCompile(`^/[^/]+\.[^/]+/[-_A-Za-z0-9]{1,64}$`) // Extended topic path, for web-app, e.g. /example.com/mytopic
|
||||
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
|
||||
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
|
||||
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
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"} // If updated, also update in Android app
|
||||
disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
|
||||
attachURLRegex = regexp.MustCompile(`^https?://`)
|
||||
|
||||
templateFnMap = template.FuncMap{
|
||||
"durationToHuman": util.DurationToHuman,
|
||||
}
|
||||
|
||||
//go:embed "index.gohtml"
|
||||
indexSource string
|
||||
indexTemplate = template.Must(template.New("index").Funcs(templateFnMap).Parse(indexSource))
|
||||
|
||||
//go:embed "example.html"
|
||||
exampleSource string
|
||||
|
||||
//go:embed static
|
||||
webStaticFs embed.FS
|
||||
webStaticFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webStaticFs}
|
||||
//go:embed site
|
||||
webFs embed.FS
|
||||
webFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs}
|
||||
webSiteDir = "/site"
|
||||
webHomeIndex = "/home.html" // Landing page, only if "web-root: home"
|
||||
webAppIndex = "/app.html" // React app
|
||||
|
||||
//go:embed docs
|
||||
docsStaticFs embed.FS
|
||||
@@ -118,11 +109,11 @@ func New(conf *Config) (*Server, error) {
|
||||
if conf.SMTPSenderAddr != "" {
|
||||
mailer = &smtpSender{config: conf}
|
||||
}
|
||||
cache, err := createCache(conf)
|
||||
messageCache, err := createMessageCache(conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
topics, err := cache.Topics()
|
||||
topics, err := messageCache.Topics()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -149,24 +140,24 @@ func New(conf *Config) (*Server, error) {
|
||||
}
|
||||
}
|
||||
return &Server{
|
||||
config: conf,
|
||||
cache: cache,
|
||||
fileCache: fileCache,
|
||||
firebase: firebaseSubscriber,
|
||||
mailer: mailer,
|
||||
topics: topics,
|
||||
auth: auther,
|
||||
visitors: make(map[string]*visitor),
|
||||
config: conf,
|
||||
messageCache: messageCache,
|
||||
fileCache: fileCache,
|
||||
firebase: firebaseSubscriber,
|
||||
mailer: mailer,
|
||||
topics: topics,
|
||||
auth: auther,
|
||||
visitors: make(map[string]*visitor),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createCache(conf *Config) (cache, error) {
|
||||
func createMessageCache(conf *Config) (*messageCache, error) {
|
||||
if conf.CacheDuration == 0 {
|
||||
return newNopCache(), nil
|
||||
return newNopCache()
|
||||
} else if conf.CacheFile != "" {
|
||||
return newSqliteCache(conf.CacheFile)
|
||||
return newSqliteCache(conf.CacheFile, false)
|
||||
}
|
||||
return newMemCache(), nil
|
||||
return newMemCache()
|
||||
}
|
||||
|
||||
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
|
||||
@@ -276,6 +267,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.handleExample(w, r)
|
||||
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
||||
return s.handleEmpty(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
return s.handleWebConfig(w, r)
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.handleStatic(w, r)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
@@ -284,8 +277,6 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.limitRequests(s.handleFile)(w, r, v)
|
||||
} else if r.Method == http.MethodOptions {
|
||||
return s.handleOptions(w, r)
|
||||
} else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) {
|
||||
return s.handleTopic(w, r)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
@@ -300,15 +291,19 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && (topicPathRegex.MatchString(r.URL.Path) || extTopicPathRegex.MatchString(r.URL.Path)) {
|
||||
return s.handleTopic(w, r)
|
||||
}
|
||||
return errHTTPNotFound
|
||||
}
|
||||
|
||||
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
|
||||
return indexTemplate.Execute(w, &indexPage{
|
||||
Topic: r.URL.Path[1:],
|
||||
CacheDuration: s.config.CacheDuration,
|
||||
})
|
||||
if s.config.WebRootIsApp {
|
||||
r.URL.Path = webAppIndex
|
||||
} else {
|
||||
r.URL.Path = webHomeIndex
|
||||
}
|
||||
return s.handleStatic(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
|
||||
@@ -319,7 +314,8 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
|
||||
_, err := io.WriteString(w, `{"unifiedpush":{"version":1}}`+"\n")
|
||||
return err
|
||||
}
|
||||
return s.handleHome(w, r)
|
||||
r.URL.Path = webAppIndex
|
||||
return s.handleStatic(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
@@ -338,13 +334,29 @@ func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, r *http.Request) error {
|
||||
appRoot := "/"
|
||||
if !s.config.WebRootIsApp {
|
||||
appRoot = "/app"
|
||||
}
|
||||
disallowedTopicsStr := `"` + strings.Join(disallowedTopics, `", "`) + `"`
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
_, err := io.WriteString(w, fmt.Sprintf(`// Generated server configuration
|
||||
var config = {
|
||||
appRoot: "%s",
|
||||
disallowedTopics: [%s]
|
||||
};`, appRoot, disallowedTopicsStr))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
|
||||
http.FileServer(http.FS(webStaticFsCached)).ServeHTTP(w, r)
|
||||
r.URL.Path = webSiteDir + r.URL.Path
|
||||
util.Gzip(http.FileServer(http.FS(webFsCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleDocs(w http.ResponseWriter, r *http.Request) error {
|
||||
http.FileServer(http.FS(docsStaticCached)).ServeHTTP(w, r)
|
||||
util.Gzip(http.FileServer(http.FS(docsStaticCached))).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -416,7 +428,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
}()
|
||||
}
|
||||
if cache {
|
||||
if err := s.cache.AddMessage(m); err != nil {
|
||||
if err := s.messageCache.AddMessage(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -566,7 +578,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
} else if m.Time > time.Now().Add(s.config.AttachmentExpiryDuration).Unix() {
|
||||
return errHTTPBadRequestAttachmentsExpiryBeforeDelivery
|
||||
}
|
||||
visitorAttachmentsSize, err := s.cache.AttachmentsSize(v.ip)
|
||||
visitorAttachmentsSize, err := s.messageCache.AttachmentsSize(v.ip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -805,7 +817,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
|
||||
return err
|
||||
}
|
||||
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since sinceTime, scheduled bool, filters *queryFilter, err error) {
|
||||
func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, scheduled bool, filters *queryFilter, err error) {
|
||||
poll = readBoolParam(r, false, "x-poll", "poll", "po")
|
||||
scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
|
||||
since, err = parseSince(r, poll)
|
||||
@@ -819,12 +831,12 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceTime, schedule
|
||||
return
|
||||
}
|
||||
|
||||
func (s *Server) sendOldMessages(topics []*topic, since sinceTime, scheduled bool, sub subscriber) error {
|
||||
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, sub subscriber) error {
|
||||
if since.IsNone() {
|
||||
return nil
|
||||
}
|
||||
for _, t := range topics {
|
||||
messages, err := s.cache.Messages(t.ID, since, scheduled)
|
||||
messages, err := s.messageCache.Messages(t.ID, since, scheduled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -841,27 +853,36 @@ func (s *Server) sendOldMessages(topics []*topic, since sinceTime, scheduled boo
|
||||
//
|
||||
// Values in the "since=..." parameter can be either a unix timestamp or a duration (e.g. 12h), or
|
||||
// "all" for all messages.
|
||||
func parseSince(r *http.Request, poll bool) (sinceTime, error) {
|
||||
func parseSince(r *http.Request, poll bool) (sinceMarker, error) {
|
||||
since := readParam(r, "x-since", "since", "si")
|
||||
|
||||
// Easy cases (empty, all, none)
|
||||
if since == "" {
|
||||
if poll {
|
||||
return sinceAllMessages, nil
|
||||
}
|
||||
return sinceNoMessages, nil
|
||||
}
|
||||
if since == "all" {
|
||||
} else if since == "all" {
|
||||
return sinceAllMessages, nil
|
||||
} else if since == "none" {
|
||||
return sinceNoMessages, nil
|
||||
}
|
||||
|
||||
// ID, timestamp, duration
|
||||
if validMessageID(since) {
|
||||
return newSinceID(since), nil
|
||||
} else if s, err := strconv.ParseInt(since, 10, 64); err == nil {
|
||||
return sinceTime(time.Unix(s, 0)), nil
|
||||
return newSinceTime(s), nil
|
||||
} else if d, err := time.ParseDuration(since); err == nil {
|
||||
return sinceTime(time.Now().Add(-1 * d)), nil
|
||||
return newSinceTime(time.Now().Add(-1 * d).Unix()), nil
|
||||
}
|
||||
return sinceNoMessages, errHTTPBadRequestSinceInvalid
|
||||
}
|
||||
|
||||
func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request) error {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible?
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -922,7 +943,7 @@ func (s *Server) updateStatsAndPrune() {
|
||||
|
||||
// Delete expired attachments
|
||||
if s.fileCache != nil {
|
||||
ids, err := s.cache.AttachmentsExpired()
|
||||
ids, err := s.messageCache.AttachmentsExpired()
|
||||
if err == nil {
|
||||
if err := s.fileCache.Remove(ids...); err != nil {
|
||||
log.Printf("error while deleting attachments: %s", err.Error())
|
||||
@@ -934,7 +955,7 @@ func (s *Server) updateStatsAndPrune() {
|
||||
|
||||
// Prune message cache
|
||||
olderThan := time.Now().Add(-1 * s.config.CacheDuration)
|
||||
if err := s.cache.Prune(olderThan); err != nil {
|
||||
if err := s.messageCache.Prune(olderThan); err != nil {
|
||||
log.Printf("error pruning cache: %s", err.Error())
|
||||
}
|
||||
|
||||
@@ -942,7 +963,7 @@ func (s *Server) updateStatsAndPrune() {
|
||||
var subscribers, messages int
|
||||
for _, t := range s.topics {
|
||||
subs := t.Subscribers()
|
||||
msgs, err := s.cache.MessageCount(t.ID)
|
||||
msgs, err := s.messageCache.MessageCount(t.ID)
|
||||
if err != nil {
|
||||
log.Printf("cannot get stats for topic %s: %s", t.ID, err.Error())
|
||||
continue
|
||||
@@ -1038,7 +1059,7 @@ func (s *Server) runFirebaseKeepaliver() {
|
||||
func (s *Server) sendDelayedMessages() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
messages, err := s.cache.MessagesDue()
|
||||
messages, err := s.messageCache.MessagesDue()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1054,7 +1075,7 @@ func (s *Server) sendDelayedMessages() error {
|
||||
log.Printf("unable to publish to Firebase: %v", err.Error())
|
||||
}
|
||||
}
|
||||
if err := s.cache.MarkPublished(m); err != nil {
|
||||
if err := s.messageCache.MarkPublished(m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -1090,7 +1111,7 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
|
||||
return err
|
||||
}
|
||||
var user *auth.User // may stay nil if no auth header!
|
||||
username, password, ok := r.BasicAuth()
|
||||
username, password, ok := extractUserPass(r)
|
||||
if ok {
|
||||
if user, err = s.auth.Authenticate(username, password); err != nil {
|
||||
log.Printf("authentication failed: %s", err.Error())
|
||||
@@ -1107,6 +1128,27 @@ func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// extractUserPass reads the username/password from the basic auth header (Authorization: Basic ...),
|
||||
// or from the ?auth=... query param. The latter is required only to support the WebSocket JavaScript
|
||||
// class, which does not support passing headers during the initial request. The auth query param
|
||||
// is effectively double base64 encoded. Its format is base64(Basic base64(user:pass)).
|
||||
func extractUserPass(r *http.Request) (username string, password string, ok bool) {
|
||||
username, password, ok = r.BasicAuth()
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
authParam := readQueryParam(r, "authorization", "auth")
|
||||
if authParam != "" {
|
||||
a, err := base64.RawURLEncoding.DecodeString(authParam)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
r.Header.Set("Authorization", string(a))
|
||||
return r.BasicAuth()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// visitor creates or retrieves a rate.Limiter for the given visitor.
|
||||
// This function was taken from https://www.alexedwards.net/blog/how-to-rate-limit-http-requests (MIT).
|
||||
func (s *Server) visitor(r *http.Request) *visitor {
|
||||
|
||||
@@ -126,6 +126,11 @@
|
||||
#
|
||||
# manager-interval: "1m"
|
||||
|
||||
# Defines if the root route (/) is pointing to the landing page (as on ntfy.sh) or the
|
||||
# web app. If you self-host, you don't want to change this. Can be "app" (default) or "home".
|
||||
#
|
||||
# web-root: app
|
||||
|
||||
# Rate limiting: Total number of topics before the server rejects new topics.
|
||||
#
|
||||
# global-topic-limit: 15000
|
||||
|
||||
@@ -146,19 +146,16 @@ func TestServer_StaticSites(t *testing.T) {
|
||||
|
||||
rr = request(t, s, "GET", "/mytopic", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow" />`)
|
||||
require.Contains(t, rr.Body.String(), `<meta name="robots" content="noindex, nofollow"/>`)
|
||||
|
||||
rr = request(t, s, "GET", "/static/css/app.css", "", nil)
|
||||
rr = request(t, s, "GET", "/static/css/home.css", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), `html, body {`)
|
||||
|
||||
rr = request(t, s, "GET", "/docs", "", nil)
|
||||
require.Equal(t, 301, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/docs/", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), `Made with ❤️ by Philipp C. Heckel`)
|
||||
require.Contains(t, rr.Body.String(), `<script src=static/js/extra.js></script>`)
|
||||
// Docs test removed, it was failing annoyingly.
|
||||
|
||||
rr = request(t, s, "GET", "/example.html", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
@@ -657,6 +654,25 @@ func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
|
||||
require.Equal(t, 403, response.Code) // Anonymous read not allowed
|
||||
}
|
||||
|
||||
func TestServer_Auth_ViaQuery(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
|
||||
c.AuthDefaultRead = false
|
||||
c.AuthDefaultWrite = false
|
||||
s := newTestServer(t, c)
|
||||
|
||||
manager := s.auth.(auth.Manager)
|
||||
require.Nil(t, manager.AddUser("ben", "some pass", auth.RoleAdmin))
|
||||
|
||||
u := fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:some pass"))))
|
||||
response := request(t, s, "GET", u, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
u = fmt.Sprintf("/mytopic/json?poll=1&auth=%s", base64.RawURLEncoding.EncodeToString([]byte(basicAuth("ben:WRONNNGGGG"))))
|
||||
response = request(t, s, "GET", u, "", nil)
|
||||
require.Equal(t, 401, response.Code)
|
||||
}
|
||||
|
||||
/*
|
||||
func TestServer_Curl_Publish_Poll(t *testing.T) {
|
||||
s, port := test.StartServer(t)
|
||||
@@ -866,7 +882,7 @@ func TestServer_PublishAttachment(t *testing.T) {
|
||||
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()
|
||||
size, err := s.messageCache.AttachmentsSize("9.9.9.9") // See request()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(5000), size)
|
||||
}
|
||||
@@ -895,7 +911,7 @@ func TestServer_PublishAttachmentShortWithFilename(t *testing.T) {
|
||||
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")
|
||||
size, err := s.messageCache.AttachmentsSize("1.2.3.4")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(21), size)
|
||||
}
|
||||
@@ -915,7 +931,7 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
|
||||
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")
|
||||
size, err := s.messageCache.AttachmentsSize("127.0.0.1")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, int64(0), size)
|
||||
}
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
/* general styling */
|
||||
|
||||
html, body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.1em;
|
||||
color: #444;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* prevent scrollbar from repositioning website:
|
||||
* https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
color: #3a9784;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
color: #317f6f;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 35px;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
word-wrap: break-word; /* For very long topics */
|
||||
padding-right: 40px; /* For the X on the detail page */
|
||||
font-weight: 300;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.8em;
|
||||
font-weight: 300;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.3em;
|
||||
font-weight: 300;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
line-height: 160%;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p.smallMarginBottom {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
tt {
|
||||
background: #eee;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
background: #eee;
|
||||
font-family: monospace;
|
||||
padding: 20px;
|
||||
border-radius: 3px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Roboto font, embedded with the help of https://google-webfonts-helper.herokuapp.com/fonts/roboto?subsets=latin */
|
||||
|
||||
/* roboto-300 - latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: local(''),
|
||||
url('../font/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../font/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* roboto-regular - latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(''),
|
||||
url('../font/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../font/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* roboto-500 - latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local(''),
|
||||
url('../font/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../font/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* Main page */
|
||||
|
||||
#main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto 50px auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#error {
|
||||
color: darkred;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#ironicCenterTagDontFreakOut {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Anchors */
|
||||
|
||||
.anchor .anchorLink {
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
padding: 0 5px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.anchor:hover .anchorLink {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.anchor .anchorLink:hover {
|
||||
color: #3a9784;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Figures */
|
||||
|
||||
figure {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
figure img, figure video {
|
||||
filter: drop-shadow(3px 3px 3px #ccc);
|
||||
border-radius: 7px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure video {
|
||||
width: 100%;
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Screenshots */
|
||||
|
||||
#screenshots {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#screenshots img {
|
||||
height: 190px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(2px 2px 2px #ddd);
|
||||
}
|
||||
|
||||
#screenshots .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
|
||||
|
||||
.lightbox {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
left:0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
|
||||
.lightbox.show {
|
||||
background-color: rgba(0,0,0, 0.75);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.lightbox img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
filter: drop-shadow(5px 5px 10px #222);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.lightbox .close-lightbox {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.lightbox .close-lightbox::after,
|
||||
.lightbox .close-lightbox::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
background-color: #ddd;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.lightbox .close-lightbox::before {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.lightbox .close-lightbox:hover::after,
|
||||
.lightbox .close-lightbox:hover::before {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
|
||||
#header {
|
||||
background: #3a9784;
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
#header #headerBox {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#header #logo {
|
||||
margin-top: 23px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#header #name {
|
||||
float: left;
|
||||
color: white;
|
||||
font-size: 2.6em;
|
||||
font-weight: 300;
|
||||
margin: 35px 0 0 20px;
|
||||
}
|
||||
|
||||
#header ol {
|
||||
list-style-type: none;
|
||||
float: right;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
#header ol li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#header ol li a, nav ol li a:visited {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#header ol li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Subscribe box */
|
||||
|
||||
button {
|
||||
background: #3a9784;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 3px 5px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #317f6f;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1em;
|
||||
list-style-type: circle;
|
||||
padding-bottom: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 4px 0;
|
||||
margin: 4px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
|
||||
/* Hide top menu SMALL SCREEN */
|
||||
@media only screen and (max-width: 780px) {
|
||||
#header ol {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Subscribe box SMALL SCREEN */
|
||||
@media only screen and (max-width: 1599px) {
|
||||
#subscribeBox #subscribeForm {
|
||||
border-left: 4px solid #3a9784;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#subscribeBox #topicsHeader {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#subscribeBox input {
|
||||
height: 24px;
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #aaa;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
#subscribeBox input:focus {
|
||||
border-bottom: 2px solid #3a9784;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#subscribeBox ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#subscribeBox li {
|
||||
margin: 3px 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#subscribeBox li img {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
#subscribeBox li a {
|
||||
padding: 0 5px 0 0;
|
||||
}
|
||||
|
||||
#subscribeBox button {
|
||||
font-size: 0.8em;
|
||||
background: #3a9784;
|
||||
border-radius: 3px;
|
||||
padding: 5px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#subscribeBox button:hover {
|
||||
background: #317f6f;
|
||||
}
|
||||
}
|
||||
|
||||
/* Subscribe box BIG SCREEN */
|
||||
@media only screen and (min-width: 1600px) {
|
||||
#subscribeBox {
|
||||
position: fixed;
|
||||
top: 170px;
|
||||
right: 10px;
|
||||
width: 300px;
|
||||
border-left: 4px solid #3a9784;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#subscribeBox h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
#subscribeBox #topicsHeader {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#subscribeBox p {
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#subscribeBox ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#subscribeBox input {
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #aaa;
|
||||
}
|
||||
|
||||
#subscribeBox input:focus {
|
||||
border-bottom: 2px solid #3a9784;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#subscribeBox li {
|
||||
margin: 3px 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#subscribeBox li img {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
#subscribeBox li a {
|
||||
padding: 0 5px 0 0;
|
||||
}
|
||||
|
||||
#subscribeBox button {
|
||||
font-size: 0.7em;
|
||||
background: #3a9784;
|
||||
border-radius: 3px;
|
||||
padding: 5px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#subscribeBox button:hover {
|
||||
background: #317f6f;
|
||||
}
|
||||
}
|
||||
|
||||
/** Detail view */
|
||||
|
||||
#detail .detailEntry {
|
||||
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;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
#detail .detailTitle {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#detail #detailMain {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
position: relative; /* required for close button's "position: absolute" */
|
||||
padding: 0 10px 50px 10px; /* Chrome and Firefox behave differently regarding bottom margin */
|
||||
}
|
||||
|
||||
#detail #detailCloseButton {
|
||||
background: #eee;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
padding: 5px;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#detail #detailCloseButton:hover {
|
||||
padding: 5px;
|
||||
background: #ccc;
|
||||
}
|
||||
|
||||
#detail #detailCloseButton img {
|
||||
display: block; /* get rid of the weird bottom border */
|
||||
}
|
||||
|
||||
#detail #detailNotificationsDisallowed {
|
||||
display: none;
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
#detail #events {
|
||||
max-width: 900px;
|
||||
margin: 0 auto 50px auto;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 24 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
||||
|
Before Width: | Height: | Size: 268 B |
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
fill="#000000"
|
||||
version="1.1"
|
||||
id="svg1428"
|
||||
sodipodi:docname="priority_1_24dp.svg"
|
||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1432" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1430"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
inkscape:zoom="20.517358"
|
||||
inkscape:cx="22.834324"
|
||||
inkscape:cy="15.742768"
|
||||
inkscape:window-width="1863"
|
||||
inkscape:window-height="1025"
|
||||
inkscape:window-x="57"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1428" />
|
||||
<path
|
||||
style="color:#000000;fill:#999999;fill-opacity:1;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="m 12.195014,20.828316 a 1.2747098,1.2747098 0 0 0 0.661605,-0.185206 l 6.646593,-4.037178 a 1.2745823,1.2745823 0 0 0 0.427537,-1.751107 1.2745823,1.2745823 0 0 0 -1.750928,-0.427718 l -5.984807,3.635327 -5.9848086,-3.635327 a 1.2745823,1.2745823 0 0 0 -1.750927,0.427718 1.2745823,1.2745823 0 0 0 0.427536,1.751107 l 6.6464146,4.037178 a 1.2747098,1.2747098 0 0 0 0.661785,0.185206 z"
|
||||
id="rect3554" />
|
||||
<path
|
||||
style="color:#000000;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="m 12.195014,15.694014 a 1.2747098,1.2747098 0 0 0 0.661605,-0.185206 l 6.646593,-4.037176 A 1.2745823,1.2745823 0 0 0 19.930749,9.7205243 1.2745823,1.2745823 0 0 0 18.179821,9.2928073 L 12.195014,12.928134 6.2102054,9.2928073 a 1.2745823,1.2745823 0 0 0 -1.750927,0.427717 1.2745823,1.2745823 0 0 0 0.427536,1.7511077 l 6.6464146,4.037176 a 1.2747098,1.2747098 0 0 0 0.661785,0.185206 z"
|
||||
id="path9314" />
|
||||
<path
|
||||
style="color:#000000;fill:#cccccc;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="m 12.116784,10.426777 a 1.2747098,1.2747098 0 0 0 0.661606,-0.185205 l 6.646593,-4.0371767 a 1.2745823,1.2745823 0 0 0 0.427537,-1.751108 1.2745823,1.2745823 0 0 0 -1.750928,-0.427718 l -5.984808,3.635327 -5.9848066,-3.635327 a 1.2745823,1.2745823 0 0 0 -1.750928,0.427718 1.2745823,1.2745823 0 0 0 0.427537,1.751108 L 11.455,10.241572 a 1.2747098,1.2747098 0 0 0 0.661784,0.185205 z"
|
||||
id="path9316" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
fill="#000000"
|
||||
version="1.1"
|
||||
id="svg1428"
|
||||
sodipodi:docname="priority_2_24dp.svg"
|
||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1432" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1430"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
inkscape:zoom="20.517358"
|
||||
inkscape:cx="22.834324"
|
||||
inkscape:cy="15.742768"
|
||||
inkscape:window-width="1863"
|
||||
inkscape:window-height="1025"
|
||||
inkscape:window-x="57"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1428" />
|
||||
<path
|
||||
style="color:#000000;fill:#999999;fill-opacity:1;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="m 12.172712,17.774352 a 1.2747098,1.2747098 0 0 0 0.661605,-0.185206 l 6.646593,-4.037178 a 1.2745823,1.2745823 0 0 0 0.427537,-1.751107 1.2745823,1.2745823 0 0 0 -1.750928,-0.427718 L 12.172712,15.00847 6.1879033,11.373143 a 1.2745823,1.2745823 0 0 0 -1.750927,0.427718 1.2745823,1.2745823 0 0 0 0.427536,1.751107 l 6.6464147,4.037178 a 1.2747098,1.2747098 0 0 0 0.661785,0.185206 z"
|
||||
id="rect3554" />
|
||||
<path
|
||||
style="color:#000000;fill:#b3b3b3;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="m 12.172712,12.64005 a 1.2747098,1.2747098 0 0 0 0.661605,-0.185206 L 19.48091,8.4176679 A 1.2745823,1.2745823 0 0 0 19.908447,6.6665602 1.2745823,1.2745823 0 0 0 18.157519,6.2388432 L 12.172712,9.8741699 6.1879033,6.2388432 a 1.2745823,1.2745823 0 0 0 -1.750927,0.427717 1.2745823,1.2745823 0 0 0 0.427536,1.7511077 l 6.6464147,4.0371761 a 1.2747098,1.2747098 0 0 0 0.661785,0.185206 z"
|
||||
id="path9314" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
fill="#000000"
|
||||
version="1.1"
|
||||
id="svg1428"
|
||||
sodipodi:docname="priority_4_24dp.svg"
|
||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1432" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1430"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
inkscape:zoom="20.517358"
|
||||
inkscape:cx="22.834324"
|
||||
inkscape:cy="15.742768"
|
||||
inkscape:window-width="1863"
|
||||
inkscape:window-height="1025"
|
||||
inkscape:window-x="57"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1428" />
|
||||
<path
|
||||
style="color:#000000;fill:#c60000;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="M 12.116784,6.5394415 A 1.2747098,1.2747098 0 0 0 11.455179,6.724648 l -6.6465926,4.037176 a 1.2745823,1.2745823 0 0 0 -0.427537,1.751108 1.2745823,1.2745823 0 0 0 1.7509281,0.427717 l 5.9848065,-3.635327 5.984809,3.635327 A 1.2745823,1.2745823 0 0 0 19.85252,12.512932 1.2745823,1.2745823 0 0 0 19.424984,10.761824 L 12.778569,6.724648 A 1.2747098,1.2747098 0 0 0 12.116784,6.5394415 Z"
|
||||
id="path9314" />
|
||||
<path
|
||||
style="color:#000000;fill:#de0000;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="m 12.195014,11.806679 a 1.2747098,1.2747098 0 0 0 -0.661606,0.185205 l -6.6465924,4.037177 a 1.2745823,1.2745823 0 0 0 -0.427537,1.751108 1.2745823,1.2745823 0 0 0 1.750928,0.427718 l 5.9848074,-3.635327 5.984807,3.635327 a 1.2745823,1.2745823 0 0 0 1.750928,-0.427718 1.2745823,1.2745823 0 0 0 -0.427537,-1.751108 l -6.646414,-4.037177 a 1.2747098,1.2747098 0 0 0 -0.661784,-0.185205 z"
|
||||
id="path9316" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
height="24px"
|
||||
viewBox="0 0 24 24"
|
||||
width="24px"
|
||||
fill="#000000"
|
||||
version="1.1"
|
||||
id="svg1428"
|
||||
sodipodi:docname="priority_5_24dp.svg"
|
||||
inkscape:version="1.1.1 (3bf5ae0, 2021-09-20)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1432" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1430"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:pageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
showgrid="false"
|
||||
inkscape:zoom="20.517358"
|
||||
inkscape:cx="22.834323"
|
||||
inkscape:cy="15.742767"
|
||||
inkscape:window-width="1863"
|
||||
inkscape:window-height="1025"
|
||||
inkscape:window-x="57"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg1428" />
|
||||
<path
|
||||
style="color:#000000;fill:#aa0000;fill-opacity:1;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="M 12.116784,3.40514 A 1.2747098,1.2747098 0 0 0 11.455179,3.5903463 L 4.8085864,7.6275238 A 1.2745823,1.2745823 0 0 0 4.3810494,9.3786313 1.2745823,1.2745823 0 0 0 6.1319775,9.8063489 L 12.116784,6.1710217 18.101593,9.8063489 A 1.2745823,1.2745823 0 0 0 19.85252,9.3786313 1.2745823,1.2745823 0 0 0 19.424984,7.6275238 L 12.778569,3.5903463 A 1.2747098,1.2747098 0 0 0 12.116784,3.40514 Z"
|
||||
id="rect3554" />
|
||||
<path
|
||||
style="color:#000000;fill:#c60000;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="M 12.116784,8.5394415 A 1.2747098,1.2747098 0 0 0 11.455179,8.724648 l -6.6465926,4.037176 a 1.2745823,1.2745823 0 0 0 -0.427537,1.751108 1.2745823,1.2745823 0 0 0 1.7509281,0.427717 l 5.9848065,-3.635327 5.984809,3.635327 A 1.2745823,1.2745823 0 0 0 19.85252,14.512932 1.2745823,1.2745823 0 0 0 19.424984,12.761824 L 12.778569,8.724648 A 1.2747098,1.2747098 0 0 0 12.116784,8.5394415 Z"
|
||||
id="path9314" />
|
||||
<path
|
||||
style="color:#000000;fill:#de0000;fill-opacity:1;stroke:none;stroke-width:0.0919748;stroke-linecap:round;stroke-linejoin:round;-inkscape-stroke:none"
|
||||
d="m 12.195014,13.806679 a 1.2747098,1.2747098 0 0 0 -0.661606,0.185205 l -6.6465924,4.037177 a 1.2745823,1.2745823 0 0 0 -0.427537,1.751108 1.2745823,1.2745823 0 0 0 1.750928,0.427718 l 5.9848074,-3.635327 5.984807,3.635327 a 1.2745823,1.2745823 0 0 0 1.750928,-0.427718 1.2745823,1.2745823 0 0 0 -0.427537,-1.751108 l -6.646414,-4.037177 a 1.2747098,1.2747098 0 0 0 -0.661784,-0.185205 z"
|
||||
id="path9316" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 116 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#ffffff"><path d="M0 0h24v24H0z" fill="none"/><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
|
||||
|
Before Width: | Height: | Size: 195 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#FFFFFF"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/></svg>
|
||||
|
Before Width: | Height: | Size: 269 B |
@@ -1,435 +0,0 @@
|
||||
|
||||
/**
|
||||
* Hello, dear curious visitor. I am not a web-guy, so please don't judge my horrible JS code.
|
||||
* In fact, please do tell me about all the things I did wrong and that I could improve. I've been trying
|
||||
* to read up on modern JS, but it's just a little much.
|
||||
*
|
||||
* Feel free to open tickets at https://github.com/binwiederhier/ntfy/issues. Thank you!
|
||||
*/
|
||||
|
||||
/* All the things */
|
||||
|
||||
let topics = {};
|
||||
let currentTopic = "";
|
||||
let currentTopicUnsubscribeOnClose = false;
|
||||
let currentUrl = window.location.hostname;
|
||||
if (window.location.port) {
|
||||
currentUrl += ':' + window.location.port
|
||||
}
|
||||
|
||||
/* Main view */
|
||||
const main = document.getElementById("main");
|
||||
const topicsHeader = document.getElementById("topicsHeader");
|
||||
const topicsList = document.getElementById("topicsList");
|
||||
const topicField = document.getElementById("topicField");
|
||||
const notifySound = document.getElementById("notifySound");
|
||||
const subscribeButton = document.getElementById("subscribeButton");
|
||||
const errorField = document.getElementById("error");
|
||||
const originalTitle = document.title;
|
||||
|
||||
/* Detail view */
|
||||
const detailView = document.getElementById("detail");
|
||||
const detailTitle = document.getElementById("detailTitle");
|
||||
const detailEventsList = document.getElementById("detailEventsList");
|
||||
const detailTopicUrl = document.getElementById("detailTopicUrl");
|
||||
const detailNoNotifications = document.getElementById("detailNoNotifications");
|
||||
const detailCloseButton = document.getElementById("detailCloseButton");
|
||||
const detailNotificationsDisallowed = document.getElementById("detailNotificationsDisallowed");
|
||||
|
||||
/* Screenshots */
|
||||
const lightbox = document.getElementById("lightbox");
|
||||
|
||||
const subscribe = (topic) => {
|
||||
if (Notification.permission !== "granted") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
subscribeInternal(topic, true, 0);
|
||||
} else {
|
||||
showNotificationDeniedError();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
subscribeInternal(topic, true,0);
|
||||
}
|
||||
};
|
||||
|
||||
const subscribeInternal = (topic, persist, delaySec) => {
|
||||
setTimeout(() => {
|
||||
// Render list entry
|
||||
let topicEntry = document.getElementById(`topic-${topic}`);
|
||||
if (!topicEntry) {
|
||||
topicEntry = document.createElement('li');
|
||||
topicEntry.id = `topic-${topic}`;
|
||||
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/unsubscribe.svg"> Unsubscribe</button>`;
|
||||
topicsList.appendChild(topicEntry);
|
||||
}
|
||||
topicsHeader.style.display = '';
|
||||
|
||||
// Open event source
|
||||
let eventSource = new EventSource(`${topic}/sse`);
|
||||
eventSource.onopen = () => {
|
||||
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <button onclick="test('${topic}'); return false;"> <img src="static/img/send.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/unsubscribe.svg"> Unsubscribe</button>`;
|
||||
delaySec = 0; // Reset on successful connection
|
||||
};
|
||||
eventSource.onerror = (e) => {
|
||||
topicEntry.innerHTML = `<a href="/${topic}" onclick="return showDetail('${topic}')">${topic}</a> <i>(Reconnecting)</i> <button disabled="disabled">Test</button> <button onclick="unsubscribe('${topic}'); return false;">Unsubscribe</button>`;
|
||||
eventSource.close();
|
||||
const newDelaySec = (delaySec + 5 <= 15) ? delaySec + 5 : 15;
|
||||
subscribeInternal(topic, persist, newDelaySec);
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
const event = JSON.parse(e.data);
|
||||
topics[topic]['messages'].push(event);
|
||||
topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
|
||||
if (currentTopic === topic) {
|
||||
rerenderDetailView();
|
||||
}
|
||||
if (Notification.permission === "granted") {
|
||||
notifySound.play();
|
||||
const title = formatTitle(event);
|
||||
const message = formatMessage(event);
|
||||
const notification = new Notification(title, {
|
||||
body: message,
|
||||
icon: '/static/img/favicon.png'
|
||||
});
|
||||
notification.onclick = (e) => {
|
||||
showDetail(event.topic);
|
||||
};
|
||||
}
|
||||
};
|
||||
topics[topic] = {
|
||||
'eventSource': eventSource,
|
||||
'messages': [],
|
||||
'persist': persist
|
||||
};
|
||||
fetchCachedMessages(topic).then(() => {
|
||||
if (currentTopic === topic) {
|
||||
rerenderDetailView();
|
||||
}
|
||||
})
|
||||
let persistedTopicKeys = Object.keys(topics).filter(t => topics[t].persist);
|
||||
localStorage.setItem('topics', JSON.stringify(persistedTopicKeys));
|
||||
}, delaySec * 1000);
|
||||
};
|
||||
|
||||
const unsubscribe = (topic) => {
|
||||
topics[topic]['eventSource'].close();
|
||||
delete topics[topic];
|
||||
localStorage.setItem('topics', JSON.stringify(Object.keys(topics)));
|
||||
document.getElementById(`topic-${topic}`).remove();
|
||||
if (Object.keys(topics).length === 0) {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const test = (topic) => {
|
||||
fetch(`/${topic}`, {
|
||||
method: 'PUT',
|
||||
body: `This is a test notification sent by the ntfy.sh Web UI at ${new Date().toString()}.`
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCachedMessages = async (topic) => {
|
||||
const topicJsonUrl = `/${topic}/json?poll=1`; // Poll!
|
||||
for await (let line of makeTextFileLineIterator(topicJsonUrl)) {
|
||||
const message = JSON.parse(line);
|
||||
topics[topic]['messages'].push(message);
|
||||
}
|
||||
topics[topic]['messages'].sort((a, b) => { return a.time < b.time ? 1 : -1; }); // Newest first
|
||||
};
|
||||
|
||||
const showDetail = (topic) => {
|
||||
currentTopic = topic;
|
||||
history.replaceState(topic, `${currentUrl}/${topic}`, `/${topic}`);
|
||||
window.scrollTo(0, 0);
|
||||
rerenderDetailView();
|
||||
return false;
|
||||
};
|
||||
|
||||
const rerenderDetailView = () => {
|
||||
detailTitle.innerHTML = `${currentUrl}/${currentTopic}`; // document.location.replaceAll(..)
|
||||
detailTopicUrl.innerHTML = `${currentUrl}/${currentTopic}`;
|
||||
while (detailEventsList.firstChild) {
|
||||
detailEventsList.removeChild(detailEventsList.firstChild);
|
||||
}
|
||||
topics[currentTopic]['messages'].forEach(m => {
|
||||
const entryDiv = document.createElement('div');
|
||||
const dateDiv = document.createElement('div');
|
||||
const titleDiv = document.createElement('div');
|
||||
const messageDiv = document.createElement('div');
|
||||
const tagsDiv = document.createElement('div');
|
||||
|
||||
entryDiv.classList.add('detailEntry');
|
||||
dateDiv.classList.add('detailDate');
|
||||
titleDiv.classList.add('detailTitle');
|
||||
messageDiv.classList.add('detailMessage');
|
||||
tagsDiv.classList.add('detailTags');
|
||||
|
||||
const dateStr = new Date(m.time * 1000).toLocaleString();
|
||||
if (m.priority && [1,2,4,5].includes(m.priority)) {
|
||||
dateDiv.innerHTML = `${dateStr} <img src="static/img/priority-${m.priority}.svg"/>`;
|
||||
} else {
|
||||
dateDiv.innerHTML = `${dateStr}`;
|
||||
}
|
||||
messageDiv.innerText = formatMessage(m);
|
||||
entryDiv.appendChild(dateDiv);
|
||||
if (m.title) {
|
||||
titleDiv.innerText = formatTitleA(m);
|
||||
entryDiv.appendChild(titleDiv);
|
||||
}
|
||||
entryDiv.appendChild(messageDiv);
|
||||
const otherTags = unmatchedTags(m.tags);
|
||||
if (otherTags.length > 0) {
|
||||
tagsDiv.innerText = `Tags: ${otherTags.join(", ")}`;
|
||||
entryDiv.appendChild(tagsDiv);
|
||||
}
|
||||
detailEventsList.appendChild(entryDiv);
|
||||
})
|
||||
if (topics[currentTopic]['messages'].length === 0) {
|
||||
detailNoNotifications.style.display = '';
|
||||
} else {
|
||||
detailNoNotifications.style.display = 'none';
|
||||
}
|
||||
if (Notification.permission === "granted") {
|
||||
detailNotificationsDisallowed.style.display = 'none';
|
||||
} else {
|
||||
detailNotificationsDisallowed.style.display = 'block';
|
||||
}
|
||||
detailView.style.display = 'block';
|
||||
main.style.display = 'none';
|
||||
};
|
||||
|
||||
const hideDetailView = () => {
|
||||
if (currentTopicUnsubscribeOnClose) {
|
||||
unsubscribe(currentTopic);
|
||||
currentTopicUnsubscribeOnClose = false;
|
||||
}
|
||||
currentTopic = "";
|
||||
history.replaceState('', originalTitle, '/');
|
||||
detailView.style.display = 'none';
|
||||
main.style.display = 'block';
|
||||
return false;
|
||||
};
|
||||
|
||||
const requestPermission = () => {
|
||||
if (Notification.permission !== "granted") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
detailNotificationsDisallowed.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const showError = (msg) => {
|
||||
errorField.innerHTML = msg;
|
||||
topicField.disabled = true;
|
||||
subscribeButton.disabled = true;
|
||||
};
|
||||
|
||||
const showBrowserIncompatibleError = () => {
|
||||
showError("Your browser is not compatible to use the web-based desktop notifications.");
|
||||
};
|
||||
|
||||
const showNotificationDeniedError = () => {
|
||||
showError("You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications.");
|
||||
};
|
||||
|
||||
const showScreenshotOverlay = (e, el, index) => {
|
||||
lightbox.classList.add('show');
|
||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
||||
return showScreenshot(e, index);
|
||||
};
|
||||
|
||||
const showScreenshot = (e, index) => {
|
||||
const actualIndex = resolveScreenshotIndex(index);
|
||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
|
||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); };
|
||||
currentScreenshotIndex = actualIndex;
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextScreenshot = (e) => {
|
||||
return showScreenshot(e, currentScreenshotIndex+1);
|
||||
};
|
||||
|
||||
const previousScreenshot = (e) => {
|
||||
return showScreenshot(e, currentScreenshotIndex-1);
|
||||
};
|
||||
|
||||
const resolveScreenshotIndex = (index) => {
|
||||
if (index < 0) {
|
||||
return screenshots.length - 1;
|
||||
} else if (index > screenshots.length - 1) {
|
||||
return 0;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const hideScreenshotOverlay = (e) => {
|
||||
lightbox.classList.remove('show');
|
||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
||||
};
|
||||
|
||||
const nextScreenshotKeyboardListener = (e) => {
|
||||
switch (e.keyCode) {
|
||||
case 37:
|
||||
previousScreenshot(e);
|
||||
break;
|
||||
case 39:
|
||||
nextScreenshot(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTitle = (m) => {
|
||||
if (m.title) {
|
||||
return formatTitleA(m);
|
||||
} else {
|
||||
return `${location.host}/${m.topic}`;
|
||||
}
|
||||
};
|
||||
|
||||
const formatTitleA = (m) => {
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.title}`;
|
||||
} else {
|
||||
return m.title;
|
||||
}
|
||||
};
|
||||
|
||||
const formatMessage = (m) => {
|
||||
if (m.title) {
|
||||
return m.message;
|
||||
} else {
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.message}`;
|
||||
} else {
|
||||
return m.message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const toEmojis = (tags) => {
|
||||
if (!tags) return [];
|
||||
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
|
||||
}
|
||||
|
||||
const unmatchedTags = (tags) => {
|
||||
if (!tags) return [];
|
||||
else return tags.filter(tag => !(tag in emojis));
|
||||
}
|
||||
|
||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||
async function* makeTextFileLineIterator(fileURL) {
|
||||
const utf8Decoder = new TextDecoder('utf-8');
|
||||
const response = await fetch(fileURL);
|
||||
const reader = response.body.getReader();
|
||||
let { value: chunk, done: readerDone } = await reader.read();
|
||||
chunk = chunk ? utf8Decoder.decode(chunk) : '';
|
||||
|
||||
const re = /\n|\r|\r\n/gm;
|
||||
let startIndex = 0;
|
||||
let result;
|
||||
|
||||
for (;;) {
|
||||
let result = re.exec(chunk);
|
||||
if (!result) {
|
||||
if (readerDone) {
|
||||
break;
|
||||
}
|
||||
let remainder = chunk.substr(startIndex);
|
||||
({ value: chunk, done: readerDone } = await reader.read());
|
||||
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
|
||||
startIndex = re.lastIndex = 0;
|
||||
continue;
|
||||
}
|
||||
yield chunk.substring(startIndex, result.index);
|
||||
startIndex = re.lastIndex;
|
||||
}
|
||||
if (startIndex < chunk.length) {
|
||||
yield chunk.substr(startIndex); // last line didn't end in a newline char
|
||||
}
|
||||
}
|
||||
|
||||
subscribeButton.onclick = () => {
|
||||
if (!topicField.value) {
|
||||
return false;
|
||||
}
|
||||
subscribe(topicField.value);
|
||||
topicField.value = "";
|
||||
return false;
|
||||
};
|
||||
|
||||
detailCloseButton.onclick = () => {
|
||||
hideDetailView();
|
||||
};
|
||||
|
||||
let currentScreenshotIndex = 0;
|
||||
const screenshots = [...document.querySelectorAll("#screenshots a")];
|
||||
screenshots.forEach((el, index) => {
|
||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, index); };
|
||||
});
|
||||
|
||||
lightbox.onclick = hideScreenshotOverlay;
|
||||
|
||||
// Disable Web UI if notifications of EventSource are not available
|
||||
if (!window["Notification"] || !window["EventSource"]) {
|
||||
showBrowserIncompatibleError();
|
||||
} else if (Notification.permission === "denied") {
|
||||
showNotificationDeniedError();
|
||||
}
|
||||
|
||||
// Reset UI
|
||||
topicField.value = "";
|
||||
|
||||
// Restore topics
|
||||
const storedTopics = JSON.parse(localStorage.getItem('topics') || "[]");
|
||||
if (storedTopics) {
|
||||
storedTopics.forEach((topic) => { subscribeInternal(topic, true, 0); });
|
||||
if (storedTopics.length === 0) {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
} else {
|
||||
topicsHeader.style.display = 'none';
|
||||
}
|
||||
|
||||
// (Temporarily) subscribe topic if we navigated to /sometopic URL
|
||||
const match = location.pathname.match(/^\/([-_a-zA-Z0-9]{1,64})$/) // Regex must match Go & Android app!
|
||||
if (match) {
|
||||
currentTopic = match[1];
|
||||
if (!storedTopics.includes(currentTopic)) {
|
||||
subscribeInternal(currentTopic, false,0);
|
||||
currentTopicUnsubscribeOnClose = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Add anchor links
|
||||
document.querySelectorAll('.anchor').forEach((el) => {
|
||||
if (el.hasAttribute('id')) {
|
||||
const id = el.getAttribute('id');
|
||||
const anchor = document.createElement('a');
|
||||
anchor.innerHTML = `<a href="#${id}" class="anchorLink">#</a>`;
|
||||
el.appendChild(anchor);
|
||||
}
|
||||
});
|
||||
|
||||
// Change ntfy.sh url and protocol to match self-hosted one
|
||||
document.querySelectorAll('.ntfyUrl').forEach((el) => {
|
||||
el.innerHTML = currentUrl;
|
||||
});
|
||||
document.querySelectorAll('.ntfyProtocol').forEach((el) => {
|
||||
el.innerHTML = window.location.protocol + "//";
|
||||
});
|
||||
|
||||
// Format emojis (see emoji.js)
|
||||
const emojis = {};
|
||||
rawEmojis.forEach(emoji => {
|
||||
emoji.aliases.forEach(alias => {
|
||||
emojis[alias] = emoji.emoji;
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
messageIDLength = 10
|
||||
messageIDLength = 12
|
||||
)
|
||||
|
||||
// message represents a message published to a topic
|
||||
@@ -74,23 +74,46 @@ func newDefaultMessage(topic, msg string) *message {
|
||||
return newMessage(messageEvent, topic, msg)
|
||||
}
|
||||
|
||||
type sinceTime time.Time
|
||||
func validMessageID(s string) bool {
|
||||
return util.ValidRandomString(s, messageIDLength)
|
||||
}
|
||||
|
||||
func (t sinceTime) IsAll() bool {
|
||||
type sinceMarker struct {
|
||||
time time.Time
|
||||
id string
|
||||
}
|
||||
|
||||
func newSinceTime(timestamp int64) sinceMarker {
|
||||
return sinceMarker{time.Unix(timestamp, 0), ""}
|
||||
}
|
||||
|
||||
func newSinceID(id string) sinceMarker {
|
||||
return sinceMarker{time.Unix(0, 0), id}
|
||||
}
|
||||
|
||||
func (t sinceMarker) IsAll() bool {
|
||||
return t == sinceAllMessages
|
||||
}
|
||||
|
||||
func (t sinceTime) IsNone() bool {
|
||||
func (t sinceMarker) IsNone() bool {
|
||||
return t == sinceNoMessages
|
||||
}
|
||||
|
||||
func (t sinceTime) Time() time.Time {
|
||||
return time.Time(t)
|
||||
func (t sinceMarker) IsID() bool {
|
||||
return t.id != ""
|
||||
}
|
||||
|
||||
func (t sinceMarker) Time() time.Time {
|
||||
return t.time
|
||||
}
|
||||
|
||||
func (t sinceMarker) ID() string {
|
||||
return t.id
|
||||
}
|
||||
|
||||
var (
|
||||
sinceAllMessages = sinceTime(time.Unix(0, 0))
|
||||
sinceNoMessages = sinceTime(time.Unix(1, 0))
|
||||
sinceAllMessages = sinceMarker{time.Unix(0, 0), ""}
|
||||
sinceNoMessages = sinceMarker{time.Unix(1, 0), ""}
|
||||
)
|
||||
|
||||
type queryFilter struct {
|
||||
|
||||
@@ -14,12 +14,24 @@ func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
}
|
||||
|
||||
func readParam(r *http.Request, names ...string) string {
|
||||
value := readHeaderParam(r, names...)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return readQueryParam(r, names...)
|
||||
}
|
||||
|
||||
func readHeaderParam(r *http.Request, names ...string) string {
|
||||
for _, name := range names {
|
||||
value := r.Header.Get(name)
|
||||
if value != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readQueryParam(r *http.Request, names ...string) string {
|
||||
for _, name := range names {
|
||||
value := r.URL.Query().Get(strings.ToLower(name))
|
||||
if value != "" {
|
||||
|
||||
52
util/gzip_handler.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Gzip is a HTTP middleware to transparently compress responses using gzip.
|
||||
// Original code from https://gist.github.com/CJEnright/bc2d8b8dc0c1389a9feeddb110f822d7 (MIT)
|
||||
func Gzip(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
|
||||
gz := gzPool.Get().(*gzip.Writer)
|
||||
defer gzPool.Put(gz)
|
||||
|
||||
gz.Reset(w)
|
||||
defer gz.Close()
|
||||
|
||||
r.Header.Del("Accept-Encoding") // prevent double-gzipping
|
||||
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, Writer: gz}, r)
|
||||
})
|
||||
}
|
||||
|
||||
var gzPool = sync.Pool{
|
||||
New: func() interface{} {
|
||||
w := gzip.NewWriter(ioutil.Discard)
|
||||
return w
|
||||
},
|
||||
}
|
||||
|
||||
type gzipResponseWriter struct {
|
||||
io.Writer
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) WriteHeader(status int) {
|
||||
w.Header().Del("Content-Length")
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (w *gzipResponseWriter) Write(b []byte) (int, error) {
|
||||
return w.Writer.Write(b)
|
||||
}
|
||||
40
util/gzip_handler_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGzipHandler(t *testing.T) {
|
||||
s := Gzip(http.FileServer(http.FS(testFs)))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/embedfs/test.txt", nil)
|
||||
req.Header.Set("Accept-Encoding", "gzip, deflate")
|
||||
s.ServeHTTP(rr, req)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "gzip", rr.Header().Get("Content-Encoding"))
|
||||
require.Equal(t, "", rr.Header().Get("Content-Length"))
|
||||
|
||||
gz, _ := gzip.NewReader(rr.Body)
|
||||
b, _ := io.ReadAll(gz)
|
||||
require.Equal(t, "This is a test file for embedfs_test.go\n", string(b))
|
||||
}
|
||||
|
||||
func TestGzipHandler_NoGzip(t *testing.T) {
|
||||
s := Gzip(http.FileServer(http.FS(testFs)))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/embedfs/test.txt", nil)
|
||||
s.ServeHTTP(rr, req)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Equal(t, "", rr.Header().Get("Content-Encoding"))
|
||||
require.Equal(t, "40", rr.Header().Get("Content-Length"))
|
||||
|
||||
b, _ := io.ReadAll(rr.Body)
|
||||
require.Equal(t, "This is a test file for embedfs_test.go\n", string(b))
|
||||
}
|
||||
15
util/util.go
@@ -88,7 +88,20 @@ func RandomString(length int) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// DurationToHuman converts a duration to a human readable format
|
||||
// ValidRandomString returns true if the given string matches the format created by RandomString
|
||||
func ValidRandomString(s string, length int) bool {
|
||||
if len(s) != length {
|
||||
return false
|
||||
}
|
||||
for _, c := range strings.Split(s, "") {
|
||||
if !strings.Contains(randomStringCharset, c) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DurationToHuman converts a duration to a human-readable format
|
||||
func DurationToHuman(d time.Duration) (str string) {
|
||||
if d == 0 {
|
||||
return "0"
|
||||
|
||||
27861
web/package-lock.json
generated
Normal file
40
web/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "ntfy",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.8.2",
|
||||
"@emotion/styled": "^11.8.1",
|
||||
"@mui/icons-material": "^5.4.2",
|
||||
"@mui/material": "latest",
|
||||
"dexie": "^3.2.1",
|
||||
"dexie-react-hooks": "^1.1.1",
|
||||
"js-base64": "^3.7.2",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-infinite-scroll-component": "^6.1.0",
|
||||
"react-router-dom": "^6.2.2",
|
||||
"react-scripts": "^5.0.0",
|
||||
"stacktrace-gps": "^3.0.4",
|
||||
"stacktrace-js": "^2.0.2",
|
||||
"svgo": "^2.8.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
9
web/public/config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Configuration injected by the ntfy server.
|
||||
//
|
||||
// This file is just an example. It is removed during the build process.
|
||||
// The actual config is dynamically generated server-side.
|
||||
|
||||
var config = {
|
||||
appRoot: "/",
|
||||
disallowedTopics: ["docs", "static", "file", "app", "settings"]
|
||||
};
|
||||
@@ -1,11 +1,10 @@
|
||||
{{- /*gotype: heckel.io/ntfy/server.indexPage*/ -}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<title>ntfy.sh | Send push notifications to your phone via PUT/POST</title>
|
||||
<link rel="stylesheet" href="static/css/app.css" type="text/css">
|
||||
<link rel="stylesheet" href="static/css/home.css" type="text/css">
|
||||
|
||||
<!-- Mobile view -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
@@ -24,14 +23,13 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:site_name" content="ntfy.sh" />
|
||||
<meta property="og:title" content="ntfy.sh | Send push notifications to your phone or desktop via PUT/POST" />
|
||||
<meta property="og:title" content="ntfy.sh | Push notifications to your phone or desktop via PUT/POST" />
|
||||
<meta property="og:description" content="ntfy is a simple HTTP-based pub-sub notification service. It allows you to send desktop notifications via scripts from any computer, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
|
||||
<meta property="og:image" content="/static/img/ntfy.png" />
|
||||
<meta property="og:url" content="https://ntfy.sh" />
|
||||
{{if .Topic}}
|
||||
<!-- Never index topic page -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
{{end}}
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="stylesheet" href="static/css/fonts.css" type="text/css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -40,15 +38,15 @@
|
||||
<img id="logo" src="static/img/ntfy.png" alt="logo"/>
|
||||
<div id="name">ntfy</div>
|
||||
<ol>
|
||||
<li><a href="docs/">Getting started</a></li>
|
||||
<li><a href="app">Web app</a></li>
|
||||
<li><a href="docs/subscribe/phone/">Android/iOS</a></li>
|
||||
<li><a href="docs/">Docs</a></li>
|
||||
<li><a href="docs/publish/">API</a></li>
|
||||
<li><a href="docs/install/">Self-hosting</a></li>
|
||||
<li><a href="https://github.com/binwiederhier/ntfy">GitHub</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
</nav>
|
||||
<div id="main"{{if .Topic}} style="display: none"{{end}}>
|
||||
<div id="main">
|
||||
<h1>Send push notifications to your phone or desktop via PUT/POST</h1>
|
||||
<p>
|
||||
<b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based <a href="https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern">pub-sub</a> notification service.
|
||||
@@ -94,7 +92,7 @@
|
||||
Here's what that looks like in the <a href="docs/subscribe/phone/">Android app</a>:
|
||||
</p>
|
||||
<figure>
|
||||
<img src="static/img/priority-notification.png" style="max-height: 200px"/>
|
||||
<img src="static/img/screenshot-phone-popover.png" style="max-height: 200px"/>
|
||||
<figcaption>Urgent notification with pop-over</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -122,31 +120,23 @@
|
||||
<figcaption>Sending push notifications to your Android phone</figcaption>
|
||||
</figure>
|
||||
|
||||
<div id="subscribeBox">
|
||||
<h3 id="subscribe-web" class="anchor">Subscribe in this Web UI</h3>
|
||||
<p id="error"></p>
|
||||
<p>
|
||||
Subscribe to topics here and receive messages as <b>desktop notification</b>. Topics are not password-protected,
|
||||
so choose a name that's not easy to guess.
|
||||
</p>
|
||||
<form id="subscribeForm">
|
||||
<p>
|
||||
<b>Topic:</b><br/>
|
||||
<input type="text" id="topicField" autocomplete="off" placeholder="Topic name, e.g. phil_alerts" maxlength="64" pattern="[-_A-Za-z0-9]{1,64}" />
|
||||
<button id="subscribeButton">Subscribe</button>
|
||||
</p>
|
||||
<p id="topicsHeader"><b>Subscribed topics:</b></p>
|
||||
<ul id="topicsList"></ul>
|
||||
</form>
|
||||
<audio id="notifySound" src="static/sound/mixkit-message-pop-alert-2354.mp3"></audio>
|
||||
</div>
|
||||
<h3 id="subscribe-web" class="anchor">Subscribe via web app</h3>
|
||||
<p>
|
||||
Subscribe to topics in the <a href="app">web app</a> and receive messages as <b>desktop notification</b>.
|
||||
It is available at <b><a href="app"><span class="ntfyUrl">ntfy.sh</span>/app</a></b>.
|
||||
</p>
|
||||
<figure>
|
||||
<a href="app"><img src="static/img/screenshot-web-detail.png" width="100%"/></a>
|
||||
<figcaption>ntfy web app, available at <a href="app"><span class="ntfyUrl">ntfy.sh</span>/app</a></figcaption>
|
||||
</figure>
|
||||
|
||||
<h3 id="subscribe-api" class="anchor">Subscribe using the API</h3>
|
||||
<p>
|
||||
There's a super simple API that you can use to integrate your own app. You can consume
|
||||
a <a href="docs/subscribe/api/#subscribe-as-json-stream">JSON stream</a>,
|
||||
an <a href="docs/subscribe/api/#subscribe-as-sse-stream">SSE/EventSource stream</a> (useful for web apps),
|
||||
as well as a <a href="docs/subscribe/api/#subscribe-as-raw-stream">plain text stream</a>.
|
||||
an <a href="docs/subscribe/api/#subscribe-as-sse-stream">SSE/EventSource stream</a>,
|
||||
a <a href="docs/subscribe/api/#subscribe-as-raw-stream">plain text stream</a>,
|
||||
or <a href="docs/subscribe/api/#websockets">via WebSockets</a>.
|
||||
</p>
|
||||
<p class="smallMarginBottom">
|
||||
Here's an example for JSON. The <b>connection stays open</b>, so you can retrieve messages as they come in:
|
||||
@@ -186,33 +176,7 @@
|
||||
|
||||
<center id="ironicCenterTagDontFreakOut"><i>Made with ❤️ by <a href="https://heckel.io">Philipp C. Heckel</a></i></center>
|
||||
</div>
|
||||
<div id="detail"{{if not .Topic}} style="display: none"{{end}}>
|
||||
<div id="detailMain">
|
||||
<button id="detailCloseButton"><img src="static/img/close.svg"/></button>
|
||||
<h1><span id="detailTitle"></span></h1>
|
||||
<p class="smallMarginBottom">
|
||||
<b>ntfy</b> is a simple HTTP-based pub-sub notification service. This is a ntfy topic.
|
||||
To send notifications to it, simply PUT or POST to the topic URL. Here's an example using <tt>curl</tt>:
|
||||
</p>
|
||||
<code>
|
||||
curl -d "Backup failed" <span id="detailTopicUrl">ntfy.sh/topic</span>
|
||||
</code>
|
||||
<p id="detailNotificationsDisallowed">
|
||||
If you'd like to receive desktop notifications when new messages arrive on this topic, you have to
|
||||
<a href="#" onclick="return requestPermission()">grant the browser permission</a> to show notifications.
|
||||
Click the link to do so.
|
||||
</p>
|
||||
<p class="smallMarginBottom">
|
||||
<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>
|
||||
</p>
|
||||
<div id="detailEventsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lightbox" class="lightbox"></div>
|
||||
<script src="static/js/emoji.js"></script>
|
||||
<script src="static/js/app.js"></script>
|
||||
<script src="static/js/home.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
web/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ntfy web</title>
|
||||
|
||||
<!-- Mobile view -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="HandheldFriendly" content="true">
|
||||
|
||||
<!-- Mobile browsers, background color -->
|
||||
<meta name="theme-color" content="#317f6f">
|
||||
<meta name="msapplication-navbutton-color" content="#317f6f">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="#317f6f">
|
||||
|
||||
<!-- Favicon, see favicon.io -->
|
||||
<link rel="icon" type="image/png" href="%PUBLIC_URL%/static/img/favicon.png">
|
||||
|
||||
<!-- Previews in Google, Slack, WhatsApp, etc. -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:site_name" content="ntfy web" />
|
||||
<meta property="og:title" content="ntfy web" />
|
||||
<meta property="og:description" content="ntfy lets you send push notifications via scripts from any computer or phone, entirely without signup or cost. Made with ❤ by Philipp C. Heckel, Apache License 2.0, source at https://heckel.io/ntfy." />
|
||||
<meta property="og:image" content="%PUBLIC_URL%/static/img/ntfy.png" />
|
||||
<meta property="og:url" content="https://ntfy.sh" />
|
||||
|
||||
<!-- Never index -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="stylesheet" href="%PUBLIC_URL%/static/css/fonts.css" type="text/css">
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
ntfy web requires JavaScript, but you can also use the <a href="https://ntfy.sh/docs/subscribe/cli/">CLI</a>
|
||||
or <a href="https://ntfy.sh/docs/subscribe/phone/">Android/iOS app</a> to subscribe.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<script src="%PUBLIC_URL%/config.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
web/public/static/css/fonts.css
Normal file
@@ -0,0 +1,41 @@
|
||||
/* Roboto font, embedded with the help of https://google-webfonts-helper.herokuapp.com/fonts/roboto?subsets=latin */
|
||||
|
||||
/* roboto-300 - latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-300.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-300.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* roboto-regular - latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-regular.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-regular.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* roboto-500 - latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-500.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-500.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
|
||||
/* roboto-700 - latin */
|
||||
@font-face {
|
||||
font-family: 'Roboto';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: local(''),
|
||||
url('../fonts/roboto-v29-latin-700.woff2') format('woff2'), /* Chrome 26+, Opera 23+, Firefox 39+ */
|
||||
url('../fonts/roboto-v29-latin-700.woff') format('woff'); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
|
||||
}
|
||||
280
web/public/static/css/home.css
Normal file
@@ -0,0 +1,280 @@
|
||||
/* general styling */
|
||||
|
||||
html, body {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.1em;
|
||||
color: #444;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
/* prevent scrollbar from repositioning website:
|
||||
* https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
a, a:visited {
|
||||
color: #3a9784;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
color: #317f6f;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 35px;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
word-wrap: break-word; /* For very long topics */
|
||||
padding-right: 40px; /* For the X on the detail page */
|
||||
font-weight: 300;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 30px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.8em;
|
||||
font-weight: 300;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 1.3em;
|
||||
font-weight: 300;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
line-height: 160%;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
p.smallMarginBottom {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
tt {
|
||||
background: #eee;
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
code {
|
||||
display: block;
|
||||
background: #eee;
|
||||
font-family: monospace;
|
||||
padding: 20px;
|
||||
border-radius: 3px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Main page */
|
||||
|
||||
#main {
|
||||
max-width: 900px;
|
||||
margin: 0 auto 50px auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#error {
|
||||
color: darkred;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#ironicCenterTagDontFreakOut {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Anchors */
|
||||
|
||||
.anchor .anchorLink {
|
||||
color: #ccc;
|
||||
text-decoration: none;
|
||||
padding: 0 5px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.anchor:hover .anchorLink {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.anchor .anchorLink:hover {
|
||||
color: #3a9784;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Figures */
|
||||
|
||||
figure {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
figure img, figure video {
|
||||
filter: drop-shadow(3px 3px 3px #ccc);
|
||||
border-radius: 7px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
figure video {
|
||||
width: 100%;
|
||||
max-height: 450px;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
/* Screenshots */
|
||||
|
||||
#screenshots {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#screenshots img {
|
||||
height: 190px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(2px 2px 2px #ddd);
|
||||
}
|
||||
|
||||
#screenshots .nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Lightbox; thanks to https://yossiabramov.com/blog/vanilla-js-lightbox */
|
||||
|
||||
.lightbox {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
left:0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
|
||||
.lightbox.show {
|
||||
background-color: rgba(0,0,0, 0.75);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.lightbox img {
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
filter: drop-shadow(5px 5px 10px #222);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.lightbox .close-lightbox {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
top: 30px;
|
||||
right: 30px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.lightbox .close-lightbox::after,
|
||||
.lightbox .close-lightbox::before {
|
||||
content: '';
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
background-color: #ddd;
|
||||
position: absolute;
|
||||
border-radius: 5px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.lightbox .close-lightbox::before {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.lightbox .close-lightbox:hover::after,
|
||||
.lightbox .close-lightbox:hover::before {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
|
||||
#header {
|
||||
background: #3a9784;
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
#header #headerBox {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#header #logo {
|
||||
margin-top: 23px;
|
||||
float: left;
|
||||
}
|
||||
|
||||
#header #name {
|
||||
float: left;
|
||||
color: white;
|
||||
font-size: 2.6em;
|
||||
font-weight: 300;
|
||||
margin: 35px 0 0 20px;
|
||||
}
|
||||
|
||||
#header ol {
|
||||
list-style-type: none;
|
||||
float: right;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
#header ol li {
|
||||
display: inline-block;
|
||||
margin: 0 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#header ol li a, nav ol li a:visited {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#header ol li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 4px 0;
|
||||
margin: 4px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
|
||||
/* Hide top menu SMALL SCREEN */
|
||||
@media only screen and (max-width: 780px) {
|
||||
#header ol {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
BIN
web/public/static/fonts/roboto-v29-latin-700.woff
Normal file
BIN
web/public/static/fonts/roboto-v29-latin-700.woff2
Normal file
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 297 KiB |
BIN
web/public/static/img/screenshot-docs.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 225 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 270 KiB |
BIN
web/public/static/img/screenshot-web-detail.png
Normal file
|
After Width: | Height: | Size: 473 KiB |
84
web/public/static/js/home.js
Normal file
@@ -0,0 +1,84 @@
|
||||
|
||||
/* All the things */
|
||||
|
||||
let currentUrl = window.location.hostname;
|
||||
if (window.location.port) {
|
||||
currentUrl += ':' + window.location.port
|
||||
}
|
||||
|
||||
/* Screenshots */
|
||||
const lightbox = document.getElementById("lightbox");
|
||||
|
||||
const showScreenshotOverlay = (e, el, index) => {
|
||||
lightbox.classList.add('show');
|
||||
document.addEventListener('keydown', nextScreenshotKeyboardListener);
|
||||
return showScreenshot(e, index);
|
||||
};
|
||||
|
||||
const showScreenshot = (e, index) => {
|
||||
const actualIndex = resolveScreenshotIndex(index);
|
||||
lightbox.innerHTML = '<div class="close-lightbox"></div>' + screenshots[actualIndex].innerHTML;
|
||||
lightbox.querySelector('img').onclick = (e) => { return showScreenshot(e,actualIndex+1); };
|
||||
currentScreenshotIndex = actualIndex;
|
||||
e.stopPropagation();
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextScreenshot = (e) => {
|
||||
return showScreenshot(e, currentScreenshotIndex+1);
|
||||
};
|
||||
|
||||
const previousScreenshot = (e) => {
|
||||
return showScreenshot(e, currentScreenshotIndex-1);
|
||||
};
|
||||
|
||||
const resolveScreenshotIndex = (index) => {
|
||||
if (index < 0) {
|
||||
return screenshots.length - 1;
|
||||
} else if (index > screenshots.length - 1) {
|
||||
return 0;
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
const hideScreenshotOverlay = (e) => {
|
||||
lightbox.classList.remove('show');
|
||||
document.removeEventListener('keydown', nextScreenshotKeyboardListener);
|
||||
};
|
||||
|
||||
const nextScreenshotKeyboardListener = (e) => {
|
||||
switch (e.keyCode) {
|
||||
case 37:
|
||||
previousScreenshot(e);
|
||||
break;
|
||||
case 39:
|
||||
nextScreenshot(e);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let currentScreenshotIndex = 0;
|
||||
const screenshots = [...document.querySelectorAll("#screenshots a")];
|
||||
screenshots.forEach((el, index) => {
|
||||
el.onclick = (e) => { return showScreenshotOverlay(e, el, index); };
|
||||
});
|
||||
|
||||
lightbox.onclick = hideScreenshotOverlay;
|
||||
|
||||
// Add anchor links
|
||||
document.querySelectorAll('.anchor').forEach((el) => {
|
||||
if (el.hasAttribute('id')) {
|
||||
const id = el.getAttribute('id');
|
||||
const anchor = document.createElement('a');
|
||||
anchor.innerHTML = `<a href="#${id}" class="anchorLink">#</a>`;
|
||||
el.appendChild(anchor);
|
||||
}
|
||||
});
|
||||
|
||||
// Change ntfy.sh url and protocol to match self-hosted one
|
||||
document.querySelectorAll('.ntfyUrl').forEach((el) => {
|
||||
el.innerHTML = currentUrl;
|
||||
});
|
||||
document.querySelectorAll('.ntfyProtocol').forEach((el) => {
|
||||
el.innerHTML = window.location.protocol + "//";
|
||||
});
|
||||
68
web/src/app/Api.js
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
fetchLinesIterator,
|
||||
maybeWithBasicAuth,
|
||||
topicShortUrl,
|
||||
topicUrl,
|
||||
topicUrlAuth,
|
||||
topicUrlJsonPoll,
|
||||
topicUrlJsonPollWithSince
|
||||
} from "./utils";
|
||||
import userManager from "./UserManager";
|
||||
|
||||
class Api {
|
||||
async poll(baseUrl, topic, since) {
|
||||
const user = await userManager.get(baseUrl);
|
||||
const shortUrl = topicShortUrl(baseUrl, topic);
|
||||
const url = (since)
|
||||
? topicUrlJsonPollWithSince(baseUrl, topic, since)
|
||||
: topicUrlJsonPoll(baseUrl, topic);
|
||||
const messages = [];
|
||||
const headers = maybeWithBasicAuth({}, user);
|
||||
console.log(`[Api] Polling ${url}`);
|
||||
for await (let line of fetchLinesIterator(url, headers)) {
|
||||
console.log(`[Api, ${shortUrl}] Received message ${line}`);
|
||||
messages.push(JSON.parse(line));
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
async publish(baseUrl, topic, message, title, priority, tags) {
|
||||
const user = await userManager.get(baseUrl);
|
||||
const url = topicUrl(baseUrl, topic);
|
||||
console.log(`[Api] Publishing message to ${url}`);
|
||||
const headers = {};
|
||||
if (title) {
|
||||
headers["X-Title"] = title;
|
||||
}
|
||||
if (priority !== 3) {
|
||||
headers["X-Priority"] = `${priority}`;
|
||||
}
|
||||
if (tags.length > 0) {
|
||||
headers["X-Tags"] = tags.join(",");
|
||||
}
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: message,
|
||||
headers: maybeWithBasicAuth(headers, user)
|
||||
});
|
||||
}
|
||||
|
||||
async auth(baseUrl, topic, user) {
|
||||
const url = topicUrlAuth(baseUrl, topic);
|
||||
console.log(`[Api] Checking auth for ${url}`);
|
||||
const response = await fetch(url, {
|
||||
headers: maybeWithBasicAuth({}, user)
|
||||
});
|
||||
if (response.status >= 200 && response.status <= 299) {
|
||||
return true;
|
||||
} else if (!user && response.status === 404) {
|
||||
return true; // Special case: Anonymous login to old servers return 404 since /<topic>/auth doesn't exist
|
||||
} else if (response.status === 401 || response.status === 403) { // See server/server.go
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Unexpected server response ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const api = new Api();
|
||||
export default api;
|
||||
112
web/src/app/Connection.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import {basicAuth, encodeBase64Url, topicShortUrl, topicUrlWs} from "./utils";
|
||||
|
||||
const retryBackoffSeconds = [5, 10, 15, 20, 30];
|
||||
|
||||
/**
|
||||
* A connection contains a single WebSocket connection for one topic. It handles its connection
|
||||
* status itself, including reconnect attempts and backoff.
|
||||
*
|
||||
* Incoming messages and state changes are forwarded via listeners.
|
||||
*/
|
||||
class Connection {
|
||||
constructor(connectionId, subscriptionId, baseUrl, topic, user, since, onNotification, onStateChanged) {
|
||||
this.connectionId = connectionId;
|
||||
this.subscriptionId = subscriptionId;
|
||||
this.baseUrl = baseUrl;
|
||||
this.topic = topic;
|
||||
this.user = user;
|
||||
this.since = since;
|
||||
this.shortUrl = topicShortUrl(baseUrl, topic);
|
||||
this.onNotification = onNotification;
|
||||
this.onStateChanged = onStateChanged;
|
||||
this.ws = null;
|
||||
this.retryCount = 0;
|
||||
this.retryTimeout = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
// Don't fetch old messages; we do that as a poll() when adding a subscription;
|
||||
// we don't want to re-trigger the main view re-render potentially hundreds of times.
|
||||
|
||||
const wsUrl = this.wsUrl();
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Opening connection to ${wsUrl}`);
|
||||
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
this.ws.onopen = (event) => {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection established`, event);
|
||||
this.retryCount = 0;
|
||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connected);
|
||||
}
|
||||
this.ws.onmessage = (event) => {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'open') {
|
||||
return;
|
||||
}
|
||||
const relevantAndValid =
|
||||
data.event === 'message' &&
|
||||
'id' in data &&
|
||||
'time' in data &&
|
||||
'message' in data;
|
||||
if (!relevantAndValid) {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
|
||||
return;
|
||||
}
|
||||
this.since = data.id;
|
||||
this.onNotification(this.subscriptionId, data);
|
||||
} catch (e) {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error handling message: ${e}`);
|
||||
}
|
||||
};
|
||||
this.ws.onclose = (event) => {
|
||||
if (event.wasClean) {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
|
||||
this.ws = null;
|
||||
} else {
|
||||
const retrySeconds = retryBackoffSeconds[Math.min(this.retryCount, retryBackoffSeconds.length-1)];
|
||||
this.retryCount++;
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Connection died, retrying in ${retrySeconds} seconds`);
|
||||
this.retryTimeout = setTimeout(() => this.start(), retrySeconds * 1000);
|
||||
this.onStateChanged(this.subscriptionId, ConnectionState.Connecting);
|
||||
}
|
||||
};
|
||||
this.ws.onerror = (event) => {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Error occurred: ${event}`, event);
|
||||
};
|
||||
}
|
||||
|
||||
close() {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Closing connection`);
|
||||
const socket = this.ws;
|
||||
const retryTimeout = this.retryTimeout;
|
||||
if (socket !== null) {
|
||||
socket.close();
|
||||
}
|
||||
if (retryTimeout !== null) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
this.retryTimeout = null;
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
wsUrl() {
|
||||
const params = [];
|
||||
if (this.since) {
|
||||
params.push(`since=${this.since}`);
|
||||
}
|
||||
if (this.user) {
|
||||
const auth = encodeBase64Url(basicAuth(this.user.username, this.user.password));
|
||||
params.push(`auth=${auth}`);
|
||||
}
|
||||
const wsUrl = topicUrlWs(this.baseUrl, this.topic);
|
||||
return (params.length === 0) ? wsUrl : `${wsUrl}?${params.join('&')}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConnectionState {
|
||||
static Connected = "connected";
|
||||
static Connecting = "connecting";
|
||||
}
|
||||
|
||||
export default Connection;
|
||||
118
web/src/app/ConnectionManager.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import Connection from "./Connection";
|
||||
import {sha256} from "./utils";
|
||||
|
||||
/**
|
||||
* The connection manager keeps track of active connections (WebSocket connections, see Connection).
|
||||
*
|
||||
* Its refresh() method reconciles state changes with the target state by closing/opening connections
|
||||
* as required. This is done pretty much exactly the same way as in the Android app.
|
||||
*/
|
||||
class ConnectionManager {
|
||||
constructor() {
|
||||
this.connections = new Map(); // ConnectionId -> Connection (hash, see below)
|
||||
this.stateListener = null; // Fired when connection state changes
|
||||
this.notificationListener = null; // Fired when new notifications arrive
|
||||
}
|
||||
|
||||
registerStateListener(listener) {
|
||||
this.stateListener = listener;
|
||||
}
|
||||
|
||||
resetStateListener() {
|
||||
this.stateListener = null;
|
||||
}
|
||||
|
||||
registerNotificationListener(listener) {
|
||||
this.notificationListener = listener;
|
||||
}
|
||||
|
||||
resetNotificationListener() {
|
||||
this.notificationListener = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function figures out which websocket connections should be running by comparing the
|
||||
* current state of the world (connections) with the target state (targetIds).
|
||||
*
|
||||
* It uses a "connectionId", which is sha256($subscriptionId|$username|$password) to identify
|
||||
* connections. If any of them change, the connection is closed/replaced.
|
||||
*/
|
||||
async refresh(subscriptions, users) {
|
||||
if (!subscriptions || !users) {
|
||||
return;
|
||||
}
|
||||
console.log(`[ConnectionManager] Refreshing connections`);
|
||||
const subscriptionsWithUsersAndConnectionId = await Promise.all(subscriptions
|
||||
.map(async s => {
|
||||
const [user] = users.filter(u => u.baseUrl === s.baseUrl);
|
||||
const connectionId = await makeConnectionId(s, user);
|
||||
return {...s, user, connectionId};
|
||||
}));
|
||||
const targetIds = subscriptionsWithUsersAndConnectionId.map(s => s.connectionId);
|
||||
const deletedIds = Array.from(this.connections.keys()).filter(id => !targetIds.includes(id));
|
||||
|
||||
// Create and add new connections
|
||||
subscriptionsWithUsersAndConnectionId.forEach(subscription => {
|
||||
const subscriptionId = subscription.id;
|
||||
const connectionId = subscription.connectionId;
|
||||
const added = !this.connections.get(connectionId)
|
||||
if (added) {
|
||||
const baseUrl = subscription.baseUrl;
|
||||
const topic = subscription.topic;
|
||||
const user = subscription.user;
|
||||
const since = subscription.last;
|
||||
const connection = new Connection(
|
||||
connectionId,
|
||||
subscriptionId,
|
||||
baseUrl,
|
||||
topic,
|
||||
user,
|
||||
since,
|
||||
(subscriptionId, notification) => this.notificationReceived(subscriptionId, notification),
|
||||
(subscriptionId, state) => this.stateChanged(subscriptionId, state)
|
||||
);
|
||||
this.connections.set(connectionId, connection);
|
||||
console.log(`[ConnectionManager] Starting new connection ${connectionId} (subscription ${subscriptionId} with user ${user ? user.username : "anonymous"})`);
|
||||
connection.start();
|
||||
}
|
||||
});
|
||||
|
||||
// Delete old connections
|
||||
deletedIds.forEach(id => {
|
||||
console.log(`[ConnectionManager] Closing connection ${id}`);
|
||||
const connection = this.connections.get(id);
|
||||
this.connections.delete(id);
|
||||
connection.close();
|
||||
});
|
||||
}
|
||||
|
||||
stateChanged(subscriptionId, state) {
|
||||
if (this.stateListener) {
|
||||
try {
|
||||
this.stateListener(subscriptionId, state);
|
||||
} catch (e) {
|
||||
console.error(`[ConnectionManager] Error updating state of ${subscriptionId} to ${state}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notificationReceived(subscriptionId, notification) {
|
||||
if (this.notificationListener) {
|
||||
try {
|
||||
this.notificationListener(subscriptionId, notification);
|
||||
} catch (e) {
|
||||
console.error(`[ConnectionManager] Error handling notification for ${subscriptionId}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const makeConnectionId = async (subscription, user) => {
|
||||
const hash = (user)
|
||||
? await sha256(`${subscription.id}|${user.username}|${user.password}`)
|
||||
: await sha256(`${subscription.id}`);
|
||||
return hash.substring(0, 10);
|
||||
}
|
||||
|
||||
const connectionManager = new ConnectionManager();
|
||||
export default connectionManager;
|
||||
82
web/src/app/Notifier.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import {formatMessage, formatTitleWithDefault, openUrl, playSound, topicShortUrl} from "./utils";
|
||||
import prefs from "./Prefs";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
import logo from "../img/ntfy.png";
|
||||
|
||||
/**
|
||||
* The notifier is responsible for displaying desktop notifications. Note that not all modern browsers
|
||||
* support this; most importantly, all iOS browsers do not support window.Notification.
|
||||
*/
|
||||
class Notifier {
|
||||
async notify(subscriptionId, notification, onClickFallback) {
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
}
|
||||
const subscription = await subscriptionManager.get(subscriptionId);
|
||||
const shouldNotify = await this.shouldNotify(subscription, notification);
|
||||
if (!shouldNotify) {
|
||||
return;
|
||||
}
|
||||
const shortUrl = topicShortUrl(subscription.baseUrl, subscription.topic);
|
||||
const message = formatMessage(notification);
|
||||
const title = formatTitleWithDefault(notification, shortUrl);
|
||||
|
||||
// Show notification
|
||||
console.log(`[Notifier, ${shortUrl}] Displaying notification ${notification.id}: ${message}`);
|
||||
const n = new Notification(title, {
|
||||
body: message,
|
||||
icon: logo
|
||||
});
|
||||
if (notification.click) {
|
||||
n.onclick = (e) => openUrl(notification.click);
|
||||
} else {
|
||||
n.onclick = () => onClickFallback(subscription);
|
||||
}
|
||||
|
||||
// Play sound
|
||||
const sound = await prefs.sound();
|
||||
if (sound && sound !== "none") {
|
||||
try {
|
||||
await playSound(sound);
|
||||
} catch (e) {
|
||||
console.log(`[Notifier, ${shortUrl}] Error playing audio`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
granted() {
|
||||
return this.supported() && Notification.permission === 'granted';
|
||||
}
|
||||
|
||||
maybeRequestPermission(cb) {
|
||||
if (!this.supported()) {
|
||||
cb(false);
|
||||
return;
|
||||
}
|
||||
if (!this.granted()) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
const granted = permission === 'granted';
|
||||
cb(granted);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async shouldNotify(subscription, notification) {
|
||||
if (subscription.mutedUntil === 1) {
|
||||
return false;
|
||||
}
|
||||
const priority = (notification.priority) ? notification.priority : 3;
|
||||
const minPriority = await prefs.minPriority();
|
||||
if (priority < minPriority) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
supported() {
|
||||
return 'Notification' in window;
|
||||
}
|
||||
}
|
||||
|
||||
const notifier = new Notifier();
|
||||
export default notifier;
|
||||
61
web/src/app/Poller.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import api from "./Api";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
|
||||
const delayMillis = 8000; // 8 seconds
|
||||
const intervalMillis = 300000; // 5 minutes
|
||||
|
||||
class Poller {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
console.log(`[Poller] Starting worker`);
|
||||
this.timer = setInterval(() => this.pollAll(), intervalMillis);
|
||||
setTimeout(() => this.pollAll(), delayMillis);
|
||||
}
|
||||
|
||||
async pollAll() {
|
||||
console.log(`[Poller] Polling all subscriptions`);
|
||||
const subscriptions = await subscriptionManager.all();
|
||||
for (const s of subscriptions) {
|
||||
try {
|
||||
await this.poll(s);
|
||||
} catch (e) {
|
||||
console.log(`[Poller] Error polling ${s.id}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async poll(subscription) {
|
||||
console.log(`[Poller] Polling ${subscription.id}`);
|
||||
|
||||
const since = subscription.last;
|
||||
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
|
||||
if (!notifications || notifications.length === 0) {
|
||||
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
|
||||
await subscriptionManager.addNotifications(subscription.id, notifications);
|
||||
}
|
||||
|
||||
pollInBackground(subscription) {
|
||||
const fn = async () => {
|
||||
try {
|
||||
await this.poll(subscription);
|
||||
} catch (e) {
|
||||
console.error(`[App] Error polling subscription ${subscription.id}`, e);
|
||||
}
|
||||
};
|
||||
setTimeout(() => fn(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
const poller = new Poller();
|
||||
poller.startWorker();
|
||||
|
||||
export default poller;
|
||||
33
web/src/app/Prefs.js
Normal file
@@ -0,0 +1,33 @@
|
||||
import db from "./db";
|
||||
|
||||
class Prefs {
|
||||
async setSound(sound) {
|
||||
db.prefs.put({key: 'sound', value: sound.toString()});
|
||||
}
|
||||
|
||||
async sound() {
|
||||
const sound = await db.prefs.get('sound');
|
||||
return (sound) ? sound.value : "ding";
|
||||
}
|
||||
|
||||
async setMinPriority(minPriority) {
|
||||
db.prefs.put({key: 'minPriority', value: minPriority.toString()});
|
||||
}
|
||||
|
||||
async minPriority() {
|
||||
const minPriority = await db.prefs.get('minPriority');
|
||||
return (minPriority) ? Number(minPriority.value) : 1;
|
||||
}
|
||||
|
||||
async setDeleteAfter(deleteAfter) {
|
||||
db.prefs.put({key:'deleteAfter', value: deleteAfter.toString()});
|
||||
}
|
||||
|
||||
async deleteAfter() {
|
||||
const deleteAfter = await db.prefs.get('deleteAfter');
|
||||
return (deleteAfter) ? Number(deleteAfter.value) : 604800; // Default is one week
|
||||
}
|
||||
}
|
||||
|
||||
const prefs = new Prefs();
|
||||
export default prefs;
|
||||
40
web/src/app/Pruner.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import prefs from "./Prefs";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
|
||||
const delayMillis = 15000; // 15 seconds
|
||||
const intervalMillis = 1800000; // 30 minutes
|
||||
|
||||
class Pruner {
|
||||
constructor() {
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
if (this.timer !== null) {
|
||||
return;
|
||||
}
|
||||
console.log(`[Pruner] Starting worker`);
|
||||
this.timer = setInterval(() => this.prune(), intervalMillis);
|
||||
setTimeout(() => this.prune(), delayMillis);
|
||||
}
|
||||
|
||||
async prune() {
|
||||
const deleteAfterSeconds = await prefs.deleteAfter();
|
||||
const pruneThresholdTimestamp = Math.round(Date.now()/1000) - deleteAfterSeconds;
|
||||
if (deleteAfterSeconds === 0) {
|
||||
console.log(`[Pruner] Pruning is disabled. Skipping.`);
|
||||
return;
|
||||
}
|
||||
console.log(`[Pruner] Pruning notifications older than ${deleteAfterSeconds}s (timestamp ${pruneThresholdTimestamp})`);
|
||||
try {
|
||||
await subscriptionManager.pruneNotifications(pruneThresholdTimestamp);
|
||||
} catch (e) {
|
||||
console.log(`[Pruner] Error pruning old subscriptions`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pruner = new Pruner();
|
||||
pruner.startWorker();
|
||||
|
||||
export default pruner;
|
||||
125
web/src/app/SubscriptionManager.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import db from "./db";
|
||||
import {topicUrl} from "./utils";
|
||||
|
||||
class SubscriptionManager {
|
||||
/** All subscriptions, including "new count"; this is a JOIN, see https://dexie.org/docs/API-Reference#joining */
|
||||
async all() {
|
||||
const subscriptions = await db.subscriptions.toArray();
|
||||
await Promise.all(subscriptions.map(async s => {
|
||||
s.new = await db.notifications
|
||||
.where({ subscriptionId: s.id, new: 1 })
|
||||
.count();
|
||||
}));
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
async get(subscriptionId) {
|
||||
return await db.subscriptions.get(subscriptionId)
|
||||
}
|
||||
|
||||
async add(baseUrl, topic) {
|
||||
const subscription = {
|
||||
id: topicUrl(baseUrl, topic),
|
||||
baseUrl: baseUrl,
|
||||
topic: topic,
|
||||
mutedUntil: 0,
|
||||
last: null
|
||||
};
|
||||
await db.subscriptions.put(subscription);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
async updateState(subscriptionId, state) {
|
||||
db.subscriptions.update(subscriptionId, { state: state });
|
||||
}
|
||||
|
||||
async remove(subscriptionId) {
|
||||
await db.subscriptions.delete(subscriptionId);
|
||||
await db.notifications
|
||||
.where({subscriptionId: subscriptionId})
|
||||
.delete();
|
||||
}
|
||||
|
||||
async first() {
|
||||
return db.subscriptions.toCollection().first(); // May be undefined
|
||||
}
|
||||
|
||||
async getNotifications(subscriptionId) {
|
||||
// This is quite awkward, but it is the recommended approach as per the Dexie docs.
|
||||
// It's actually fine, because the reading and filtering is quite fast. The rendering is what's
|
||||
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
||||
|
||||
return db.notifications
|
||||
.orderBy("time") // Sort by time first
|
||||
.filter(n => n.subscriptionId === subscriptionId)
|
||||
.reverse()
|
||||
.toArray();
|
||||
}
|
||||
|
||||
async getAllNotifications() {
|
||||
return db.notifications
|
||||
.orderBy("time") // Efficient, see docs
|
||||
.reverse()
|
||||
.toArray();
|
||||
}
|
||||
|
||||
/** Adds notification, or returns false if it already exists */
|
||||
async addNotification(subscriptionId, notification) {
|
||||
const exists = await db.notifications.get(notification.id);
|
||||
if (exists) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
notification.new = 1; // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
await db.notifications.add({ ...notification, subscriptionId }); // FIXME consider put() for double tab
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: notification.id
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[SubscriptionManager] Error adding notification`, e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Adds/replaces notifications, will not throw if they exist */
|
||||
async addNotifications(subscriptionId, notifications) {
|
||||
const notificationsWithSubscriptionId = notifications
|
||||
.map(notification => ({ ...notification, subscriptionId }));
|
||||
const lastNotificationId = notifications.at(-1).id;
|
||||
await db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: lastNotificationId
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNotification(notificationId) {
|
||||
await db.notifications.delete(notificationId);
|
||||
}
|
||||
|
||||
async deleteNotifications(subscriptionId) {
|
||||
await db.notifications
|
||||
.where({subscriptionId: subscriptionId})
|
||||
.delete();
|
||||
}
|
||||
|
||||
async markNotificationsRead(subscriptionId) {
|
||||
await db.notifications
|
||||
.where({subscriptionId: subscriptionId, new: 1})
|
||||
.modify({new: 0});
|
||||
}
|
||||
|
||||
async setMutedUntil(subscriptionId, mutedUntil) {
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
mutedUntil: mutedUntil
|
||||
});
|
||||
}
|
||||
|
||||
async pruneNotifications(thresholdTimestamp) {
|
||||
await db.notifications
|
||||
.where("time").below(thresholdTimestamp)
|
||||
.delete();
|
||||
}
|
||||
}
|
||||
|
||||
const subscriptionManager = new SubscriptionManager();
|
||||
export default subscriptionManager;
|
||||
22
web/src/app/UserManager.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import db from "./db";
|
||||
|
||||
class UserManager {
|
||||
async all() {
|
||||
return db.users.toArray();
|
||||
}
|
||||
|
||||
async get(baseUrl) {
|
||||
return db.users.get(baseUrl);
|
||||
}
|
||||
|
||||
async save(user) {
|
||||
await db.users.put(user);
|
||||
}
|
||||
|
||||
async delete(baseUrl) {
|
||||
await db.users.delete(baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const userManager = new UserManager();
|
||||
export default userManager;
|
||||
2
web/src/app/config.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const config = window.config;
|
||||
export default config;
|
||||
18
web/src/app/db.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import Dexie from 'dexie';
|
||||
|
||||
// Uses Dexie.js
|
||||
// https://dexie.org/docs/API-Reference#quick-reference
|
||||
//
|
||||
// Notes:
|
||||
// - As per docs, we only declare the indexable columns, not all columns
|
||||
|
||||
const db = new Dexie('ntfy');
|
||||
|
||||
db.version(1).stores({
|
||||
subscriptions: '&id,baseUrl',
|
||||
notifications: '&id,subscriptionId,time,new,[subscriptionId+new]', // compound key for query performance
|
||||
users: '&baseUrl,username',
|
||||
prefs: '&key'
|
||||
});
|
||||
|
||||
export default db;
|
||||
3
web/src/app/emojis.js
Normal file
188
web/src/app/utils.js
Normal file
@@ -0,0 +1,188 @@
|
||||
import {rawEmojis} from "./emojis";
|
||||
import beep from "../sounds/beep.mp3";
|
||||
import juntos from "../sounds/juntos.mp3";
|
||||
import pristine from "../sounds/pristine.mp3";
|
||||
import ding from "../sounds/ding.mp3";
|
||||
import dadum from "../sounds/dadum.mp3";
|
||||
import pop from "../sounds/pop.mp3";
|
||||
import popSwoosh from "../sounds/pop-swoosh.mp3";
|
||||
import config from "./config";
|
||||
import {Base64} from 'js-base64';
|
||||
|
||||
export const topicUrl = (baseUrl, topic) => `${baseUrl}/${topic}`;
|
||||
export const topicUrlWs = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/ws`
|
||||
.replaceAll("https://", "wss://")
|
||||
.replaceAll("http://", "ws://");
|
||||
export const topicUrlJson = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/json`;
|
||||
export const topicUrlJsonPoll = (baseUrl, topic) => `${topicUrlJson(baseUrl, topic)}?poll=1`;
|
||||
export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJson(baseUrl, topic)}?poll=1&since=${since}`;
|
||||
export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
|
||||
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
|
||||
export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
|
||||
export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
|
||||
export const expandSecureUrl = (url) => `https://${url}`;
|
||||
|
||||
export const validUrl = (url) => {
|
||||
return url.match(/^https?:\/\//);
|
||||
}
|
||||
|
||||
export const validTopic = (topic) => {
|
||||
if (disallowedTopic(topic)) {
|
||||
return false;
|
||||
}
|
||||
return topic.match(/^([-_a-zA-Z0-9]{1,64})$/); // Regex must match Go & Android app!
|
||||
}
|
||||
|
||||
export const disallowedTopic = (topic) => {
|
||||
return config.disallowedTopics.includes(topic);
|
||||
}
|
||||
|
||||
// Format emojis (see emoji.js)
|
||||
const emojis = {};
|
||||
rawEmojis.forEach(emoji => {
|
||||
emoji.aliases.forEach(alias => {
|
||||
emojis[alias] = emoji.emoji;
|
||||
});
|
||||
});
|
||||
|
||||
const toEmojis = (tags) => {
|
||||
if (!tags) return [];
|
||||
else return tags.filter(tag => tag in emojis).map(tag => emojis[tag]);
|
||||
}
|
||||
|
||||
export const formatTitleWithDefault = (m, fallback) => {
|
||||
if (m.title) {
|
||||
return formatTitle(m);
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
export const formatTitle = (m) => {
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.title}`;
|
||||
} else {
|
||||
return m.title;
|
||||
}
|
||||
};
|
||||
|
||||
export const formatMessage = (m) => {
|
||||
if (m.title) {
|
||||
return m.message;
|
||||
} else {
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.message}`;
|
||||
} else {
|
||||
return m.message;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const unmatchedTags = (tags) => {
|
||||
if (!tags) return [];
|
||||
else return tags.filter(tag => !(tag in emojis));
|
||||
}
|
||||
|
||||
|
||||
export const maybeWithBasicAuth = (headers, user) => {
|
||||
if (user) {
|
||||
headers['Authorization'] = `Basic ${encodeBase64(`${user.username}:${user.password}`)}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export const basicAuth = (username, password) => {
|
||||
return `Basic ${encodeBase64(`${username}:${password}`)}`;
|
||||
}
|
||||
|
||||
export const encodeBase64 = (s) => {
|
||||
return Base64.encode(s);
|
||||
}
|
||||
|
||||
export const encodeBase64Url = (s) => {
|
||||
return Base64.encodeURI(s);
|
||||
}
|
||||
|
||||
export const shuffle = (arr) => {
|
||||
let j, x;
|
||||
for (let index = arr.length - 1; index > 0; index--) {
|
||||
j = Math.floor(Math.random() * (index + 1));
|
||||
x = arr[index];
|
||||
arr[index] = arr[j];
|
||||
arr[j] = x;
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
// https://jameshfisher.com/2017/10/30/web-cryptography-api-hello-world/
|
||||
export const sha256 = async (s) => {
|
||||
const buf = await crypto.subtle.digest("SHA-256", new TextEncoder("utf-8").encode(s));
|
||||
return Array.prototype.map.call(new Uint8Array(buf), x=>(('00'+x.toString(16)).slice(-2))).join('');
|
||||
}
|
||||
|
||||
export const formatShortDateTime = (timestamp) => {
|
||||
return new Intl.DateTimeFormat('default', {dateStyle: 'short', timeStyle: 'short'})
|
||||
.format(new Date(timestamp * 1000));
|
||||
}
|
||||
|
||||
export const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 bytes';
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
export const openUrl = (url) => {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
export const sounds = {
|
||||
"beep": beep,
|
||||
"juntos": juntos,
|
||||
"pristine": pristine,
|
||||
"ding": ding,
|
||||
"dadum": dadum,
|
||||
"pop": pop,
|
||||
"pop-swoosh": popSwoosh
|
||||
};
|
||||
|
||||
export const playSound = async (sound) => {
|
||||
const audio = new Audio(sounds[sound]);
|
||||
return audio.play();
|
||||
};
|
||||
|
||||
// From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
|
||||
export async function* fetchLinesIterator(fileURL, headers) {
|
||||
const utf8Decoder = new TextDecoder('utf-8');
|
||||
const response = await fetch(fileURL, {
|
||||
headers: headers
|
||||
});
|
||||
const reader = response.body.getReader();
|
||||
let { value: chunk, done: readerDone } = await reader.read();
|
||||
chunk = chunk ? utf8Decoder.decode(chunk) : '';
|
||||
|
||||
const re = /\n|\r|\r\n/gm;
|
||||
let startIndex = 0;
|
||||
|
||||
for (;;) {
|
||||
let result = re.exec(chunk);
|
||||
if (!result) {
|
||||
if (readerDone) {
|
||||
break;
|
||||
}
|
||||
let remainder = chunk.substr(startIndex);
|
||||
({ value: chunk, done: readerDone } = await reader.read());
|
||||
chunk = remainder + (chunk ? utf8Decoder.decode(chunk) : '');
|
||||
startIndex = re.lastIndex = 0;
|
||||
continue;
|
||||
}
|
||||
yield chunk.substring(startIndex, result.index);
|
||||
startIndex = re.lastIndex;
|
||||
}
|
||||
if (startIndex < chunk.length) {
|
||||
yield chunk.substr(startIndex); // last line didn't end in a newline char
|
||||
}
|
||||
}
|
||||
194
web/src/components/ActionBar.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import AppBar from "@mui/material/AppBar";
|
||||
import Navigation from "./Navigation";
|
||||
import Toolbar from "@mui/material/Toolbar";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import * as React from "react";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import {formatShortDateTime, shuffle, topicShortUrl} from "../app/utils";
|
||||
import {useLocation, useNavigate} from "react-router-dom";
|
||||
import ClickAwayListener from '@mui/material/ClickAwayListener';
|
||||
import Grow from '@mui/material/Grow';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Popper from '@mui/material/Popper';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import MenuList from '@mui/material/MenuList';
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import NotificationsOffIcon from '@mui/icons-material/NotificationsOff';
|
||||
import api from "../app/Api";
|
||||
import routes from "./routes";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import logo from "../img/ntfy.svg";
|
||||
|
||||
const ActionBar = (props) => {
|
||||
const location = useLocation();
|
||||
let title = "ntfy";
|
||||
if (props.selected) {
|
||||
title = topicShortUrl(props.selected.baseUrl, props.selected.topic);
|
||||
} else if (location.pathname === "/settings") {
|
||||
title = "Settings";
|
||||
}
|
||||
return (
|
||||
<AppBar position="fixed" sx={{
|
||||
width: '100%',
|
||||
zIndex: { sm: 1250 }, // > Navigation (1200), but < Dialog (1300)
|
||||
ml: { sm: `${Navigation.width}px` }
|
||||
}}>
|
||||
<Toolbar sx={{pr: '24px'}}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
edge="start"
|
||||
onClick={props.onMobileDrawerToggle}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Box component="img" src={logo} sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
marginRight: '10px',
|
||||
height: '28px'
|
||||
}}/>
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{props.selected &&
|
||||
<SettingsIcons
|
||||
subscription={props.selected}
|
||||
onUnsubscribe={props.onUnsubscribe}
|
||||
/>}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
);
|
||||
};
|
||||
|
||||
// Originally from https://mui.com/components/menus/#MenuListComposition.js
|
||||
const SettingsIcons = (props) => {
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef(null);
|
||||
const subscription = props.subscription;
|
||||
|
||||
const handleToggleOpen = () => {
|
||||
setOpen((prevOpen) => !prevOpen);
|
||||
};
|
||||
|
||||
const handleToggleMute = async () => {
|
||||
const mutedUntil = (subscription.mutedUntil) ? 0 : 1; // Make this a timestamp in the future
|
||||
await subscriptionManager.setMutedUntil(subscription.id, mutedUntil);
|
||||
}
|
||||
|
||||
const handleClose = (event) => {
|
||||
if (anchorRef.current && anchorRef.current.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleClearAll = async (event) => {
|
||||
handleClose(event);
|
||||
console.log(`[ActionBar] Deleting all notifications from ${props.subscription.id}`);
|
||||
await subscriptionManager.deleteNotifications(props.subscription.id);
|
||||
};
|
||||
|
||||
const handleUnsubscribe = async (event) => {
|
||||
console.log(`[ActionBar] Unsubscribing from ${props.subscription.id}`);
|
||||
handleClose(event);
|
||||
await subscriptionManager.remove(props.subscription.id);
|
||||
const newSelected = await subscriptionManager.first(); // May be undefined
|
||||
if (newSelected) {
|
||||
navigate(routes.forSubscription(newSelected));
|
||||
} else {
|
||||
navigate(routes.root);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTestMessage = () => {
|
||||
const baseUrl = props.subscription.baseUrl;
|
||||
const topic = props.subscription.topic;
|
||||
const tags = shuffle([
|
||||
"grinning", "octopus", "upside_down_face", "palm_tree", "maple_leaf", "apple", "skull", "warning", "jack_o_lantern",
|
||||
"de-server-1", "backups", "cron-script", "script-error", "phils-automation", "mouse", "go-rocks", "hi-ben"])
|
||||
.slice(0, Math.round(Math.random() * 4));
|
||||
const priority = shuffle([1, 2, 3, 4, 5])[0];
|
||||
const title = shuffle([
|
||||
"",
|
||||
"",
|
||||
"", // Higher chance of no title
|
||||
"Oh my, another test message?",
|
||||
"Titles are optional, did you know that?",
|
||||
"ntfy is open source, and will always be free. Cool, right?",
|
||||
"I don't really like apples",
|
||||
"My favorite TV show is The Wire. You should watch it!"
|
||||
])[0];
|
||||
const message = shuffle([
|
||||
`Hello friend, this is a test notification from ntfy web. It's ${formatShortDateTime(Date.now())} right now. Is that early or late?`,
|
||||
`So I heard you like ntfy? If that's true, go to GitHub and star it, or to the Play store and rate it. Thanks! Oh yeah, this is a test notification.`,
|
||||
`It's almost like you want to hear what I have to say. I'm not even a machine. I'm just a sentence that Phil typed on a random Thursday.`,
|
||||
`Alright then, it's ${formatShortDateTime(Date.now())} already. Boy oh boy, where did the time go? I hope you're alright, friend.`,
|
||||
`There are nine million bicycles in Beijing That's a fact; It's a thing we can't deny. I wonder if that's true ...`,
|
||||
`I'm really excited that you're trying out ntfy. Did you know that there are a few public topics, such as ntfy.sh/stats and ntfy.sh/annoucements.`,
|
||||
`It's interesting to hear what people use ntfy for. I've heard people talk about using it for so many cool things. What do you use it for?`
|
||||
])[0];
|
||||
api.publish(baseUrl, topic, message, title, priority, tags);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const handleListKeyDown = (event) => {
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
} else if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
// return focus to the button when we transitioned from !open -> open
|
||||
const prevOpen = useRef(open);
|
||||
useEffect(() => {
|
||||
if (prevOpen.current === true && open === false) {
|
||||
anchorRef.current.focus();
|
||||
}
|
||||
prevOpen.current = open;
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton color="inherit" size="large" edge="end" onClick={handleToggleMute} sx={{marginRight: 0}}>
|
||||
{subscription.mutedUntil ? <NotificationsOffIcon/> : <NotificationsIcon/>}
|
||||
</IconButton>
|
||||
<IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen}>
|
||||
<MoreVertIcon/>
|
||||
</IconButton>
|
||||
<Popper
|
||||
open={open}
|
||||
anchorEl={anchorRef.current}
|
||||
role={undefined}
|
||||
placement="bottom-start"
|
||||
transition
|
||||
disablePortal
|
||||
>
|
||||
{({TransitionProps, placement}) => (
|
||||
<Grow
|
||||
{...TransitionProps}
|
||||
style={{transformOrigin: placement === 'bottom-start' ? 'left top' : 'left bottom'}}
|
||||
>
|
||||
<Paper>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<MenuList autoFocusItem={open} onKeyDown={handleListKeyDown}>
|
||||
<MenuItem onClick={handleSendTestMessage}>Send test notification</MenuItem>
|
||||
<MenuItem onClick={handleClearAll}>Clear all notifications</MenuItem>
|
||||
<MenuItem onClick={handleUnsubscribe}>Unsubscribe</MenuItem>
|
||||
</MenuList>
|
||||
</ClickAwayListener>
|
||||
</Paper>
|
||||
</Grow>
|
||||
)}
|
||||
</Popper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionBar;
|
||||
121
web/src/components/App.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import * as React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import {ThemeProvider} from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import Toolbar from '@mui/material/Toolbar';
|
||||
import Notifications from "./Notifications";
|
||||
import theme from "./theme";
|
||||
import Navigation from "./Navigation";
|
||||
import ActionBar from "./ActionBar";
|
||||
import notifier from "../app/Notifier";
|
||||
import Preferences from "./Preferences";
|
||||
import {useLiveQuery} from "dexie-react-hooks";
|
||||
import subscriptionManager from "../app/SubscriptionManager";
|
||||
import userManager from "../app/UserManager";
|
||||
import {BrowserRouter, Outlet, Route, Routes, useOutletContext, useParams} from "react-router-dom";
|
||||
import {expandUrl} from "../app/utils";
|
||||
import ErrorBoundary from "./ErrorBoundary";
|
||||
import routes from "./routes";
|
||||
import {useAutoSubscribe, useConnectionListeners, useLocalStorageMigration} from "./hooks";
|
||||
|
||||
// TODO add drag and drop
|
||||
// TODO races when two tabs are open
|
||||
// TODO investigate service workers
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline/>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
<Route element={<Layout/>}>
|
||||
<Route path={routes.root} element={<AllSubscriptions/>}/>
|
||||
<Route path={routes.settings} element={<Preferences/>}/>
|
||||
<Route path={routes.subscription} element={<SingleSubscription/>}/>
|
||||
<Route path={routes.subscriptionExternal} element={<SingleSubscription/>}/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
const AllSubscriptions = () => {
|
||||
const { subscriptions } = useOutletContext();
|
||||
return <Notifications mode="all" subscriptions={subscriptions}/>;
|
||||
};
|
||||
|
||||
const SingleSubscription = () => {
|
||||
const { subscriptions, selected } = useOutletContext();
|
||||
useAutoSubscribe(subscriptions, selected);
|
||||
return <Notifications mode="one" subscription={selected}/>;
|
||||
};
|
||||
|
||||
const Layout = () => {
|
||||
const params = useParams();
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [notificationsGranted, setNotificationsGranted] = useState(notifier.granted());
|
||||
const users = useLiveQuery(() => userManager.all());
|
||||
const subscriptions = useLiveQuery(() => subscriptionManager.all());
|
||||
const newNotificationsCount = subscriptions?.reduce((prev, cur) => prev + cur.new, 0) || 0;
|
||||
const [selected] = (subscriptions || []).filter(s => {
|
||||
return (params.baseUrl && expandUrl(params.baseUrl).includes(s.baseUrl) && params.topic === s.topic)
|
||||
|| (window.location.origin === s.baseUrl && params.topic === s.topic)
|
||||
});
|
||||
|
||||
useConnectionListeners(subscriptions, users);
|
||||
useLocalStorageMigration();
|
||||
useEffect(() => updateTitle(newNotificationsCount), [newNotificationsCount]);
|
||||
|
||||
return (
|
||||
<Box sx={{display: 'flex'}}>
|
||||
<CssBaseline/>
|
||||
<ActionBar
|
||||
selected={selected}
|
||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||
/>
|
||||
<Navigation
|
||||
subscriptions={subscriptions}
|
||||
selectedSubscription={selected}
|
||||
notificationsGranted={notificationsGranted}
|
||||
mobileDrawerOpen={mobileDrawerOpen}
|
||||
onMobileDrawerToggle={() => setMobileDrawerOpen(!mobileDrawerOpen)}
|
||||
onNotificationGranted={setNotificationsGranted}
|
||||
/>
|
||||
<Main>
|
||||
<Toolbar/>
|
||||
<Outlet context={{ subscriptions, selected }}/>
|
||||
</Main>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const Main = (props) => {
|
||||
return (
|
||||
<Box
|
||||
id="main"
|
||||
component="main"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
flexDirection: 'column',
|
||||
padding: 3,
|
||||
width: {sm: `calc(100% - ${Navigation.width}px)`},
|
||||
height: '100vh',
|
||||
overflow: 'auto',
|
||||
backgroundColor: (theme) => theme.palette.mode === 'light' ? theme.palette.grey[100] : theme.palette.grey[900]
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const updateTitle = (newNotificationsCount) => {
|
||||
document.title = (newNotificationsCount > 0) ? `(${newNotificationsCount}) ntfy` : "ntfy";
|
||||
}
|
||||
|
||||
export default App;
|
||||
72
web/src/components/ErrorBoundary.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import * as React from "react";
|
||||
import StackTrace from "stacktrace-js";
|
||||
import {CircularProgress} from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
|
||||
class ErrorBoundary extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
error: false,
|
||||
originalStack: null,
|
||||
niceStack: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
console.error("[ErrorBoundary] Error caught", error, info);
|
||||
|
||||
// Immediately render original stack trace
|
||||
const prettierOriginalStack = info.componentStack
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(line => ` at ${line}`)
|
||||
.join("\n");
|
||||
this.setState({
|
||||
error: true,
|
||||
originalStack: `${error.toString()}\n${prettierOriginalStack}`
|
||||
});
|
||||
|
||||
// Fetch additional info and a better stack trace
|
||||
StackTrace.fromError(error).then(stack => {
|
||||
console.error("[ErrorBoundary] Stacktrace fetched", stack);
|
||||
const niceStack = `${error.toString()}\n` + stack.map( el => ` at ${el.functionName} (${el.fileName}:${el.columnNumber}:${el.lineNumber})`).join("\n");
|
||||
this.setState({ niceStack });
|
||||
});
|
||||
}
|
||||
|
||||
copyStack() {
|
||||
let stack = "";
|
||||
if (this.state.niceStack) {
|
||||
stack += `${this.state.niceStack}\n\n`;
|
||||
}
|
||||
stack += `${this.state.originalStack}\n`;
|
||||
navigator.clipboard.writeText(stack);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div style={{margin: '20px'}}>
|
||||
<h2>Oh no, ntfy crashed 😮</h2>
|
||||
<p>
|
||||
This should obviously not happen. Very sorry about this.<br/>
|
||||
If you have a minute, please <a href="https://github.com/binwiederhier/ntfy/issues">report this on GitHub</a>, or let us
|
||||
know via <a href="https://discord.gg/cT7ECsZj9w">Discord</a> or <a href="https://matrix.to/#/#ntfy:matrix.org">Matrix</a>.
|
||||
</p>
|
||||
<p>
|
||||
<Button variant="outlined" onClick={() => this.copyStack()}>Copy stack trace</Button>
|
||||
</p>
|
||||
<h3>Stack trace</h3>
|
||||
{this.state.niceStack
|
||||
? <pre>{this.state.niceStack}</pre>
|
||||
: <><CircularProgress size="20px" sx={{verticalAlign: "text-bottom"}}/> Gather more info ...</>}
|
||||
<pre>{this.state.originalStack}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||