Compare commits

...

106 Commits

Author SHA1 Message Date
Philipp Heckel
4a6aca4c07 Fix packaging 2022-03-11 16:06:08 -05:00
Philipp Heckel
08f0d5fd1f Bump version 2022-03-11 16:01:33 -05:00
Philipp Heckel
750be7f07e Fix content type for config.js 2022-03-11 15:56:54 -05:00
Philipp Heckel
70538783d8 Fix one-off migration 2022-03-11 15:32:24 -05:00
Philipp Heckel
09336fa1e4 Comments 2022-03-11 15:17:12 -05:00
Philipp Heckel
c124434429 Migrate topics from old web ui; nicer stack traces 2022-03-11 14:43:54 -05:00
Philipp Heckel
0544a6f00d Feature complete 2022-03-11 11:46:19 -05:00
Philipp Heckel
7b186af765 Docs and screenshots 2022-03-11 10:43:18 -05:00
Philipp Heckel
3f978bc45f Better test messages 2022-03-10 22:58:24 -05:00
Philipp Heckel
488aeb119b Gzip static responses 2022-03-10 21:55:56 -05:00
Philipp Heckel
160c72997f Fix auth base64, fix iPhone things 2022-03-10 18:11:12 -05:00
Philipp Heckel
ccb9da9333 Add error boundary 2022-03-10 15:37:50 -05:00
Philipp Heckel
840cb5b182 Add server-generated /config.js; add error boundary 2022-03-09 23:28:55 -05:00
Philipp Heckel
04ee6b8be2 Embed resources 2022-03-09 15:58:21 -05:00
Philipp Heckel
8c8a1685b2 Fix it 2022-03-08 21:18:15 -05:00
Philipp Heckel
28e6f8a0f6 Autosubscribe (WIP) 2022-03-08 20:26:15 -05:00
Philipp Heckel
d9e5e08af5 No notifications page text 2022-03-08 18:56:28 -05:00
Philipp Heckel
60980df26b Mute button 2022-03-08 16:56:41 -05:00
Philipp Heckel
d3462d2905 Start work on ephemeral topics 2022-03-08 15:19:15 -05:00
Philipp Heckel
0aefcf29ef This is it 2022-03-08 14:29:03 -05:00
Philipp Heckel
55c021796e Attempt to use react router the way it was meant to 2022-03-08 14:13:32 -05:00
Philipp Heckel
4aad98256a Move things around a bit 2022-03-08 11:33:17 -05:00
Philipp Heckel
30b13cbdbc Working infinite scroll 2022-03-08 11:21:11 -05:00
Philipp Heckel
6d140d6a86 Working infinite scroll 2022-03-07 23:07:07 -05:00
Philipp Heckel
9757983046 Prep for infinite scroll 2022-03-07 20:11:58 -05:00
Philipp Heckel
5bed926323 Home page; "all notifications" 2022-03-07 16:36:49 -05:00
Philipp Heckel
1d2f3f72e4 Add "new" badge and title 2022-03-06 22:37:13 -05:00
Philipp Heckel
3a76e4733c Cleanup 2022-03-06 21:39:20 -05:00
Philipp Heckel
a4fbb1b4c5 Home button 2022-03-06 16:35:31 -05:00
Philipp Heckel
94296e7dd8 Licenses 2022-03-06 10:42:05 -05:00
Philipp Heckel
dc7ca6e405 Support sounds 2022-03-06 00:02:27 -05:00
Philipp Heckel
09b128f27a Move more stuff out of App.js 2022-03-05 22:33:34 -05:00
Philipp Heckel
acde2e5b6e Remove indexPage 2022-03-05 22:18:03 -05:00
Philipp Heckel
420e35c33c Use location.origin as default base URL 2022-03-05 22:11:32 -05:00
Philipp Heckel
c5ce51f242 Add --web-root switch 2022-03-05 21:28:25 -05:00
Philipp Heckel
2743c96694 Re-embed fonts 2022-03-05 21:15:40 -05:00
Philipp Heckel
36ccfac787 Fix tests 2022-03-05 20:48:27 -05:00
Philipp Heckel
e27d5719f0 Embed new web UI into server 2022-03-05 20:24:10 -05:00
Philipp Heckel
1a3816c1ff Strip down old web app 2022-03-05 14:48:42 -05:00
Philipp Heckel
52a55f71e6 Support external routes 2022-03-05 08:52:52 -05:00
Philipp Heckel
b5670d9a71 Routing 2022-03-04 16:10:04 -05:00
Philipp Heckel
e7bd3abadc SubscribeDialog use existing user 2022-03-04 12:10:11 -05:00
Philipp Heckel
5878d7e5a6 Conn state listener, click action button 2022-03-04 11:08:32 -05:00
Philipp Heckel
3bce0ad4ae Lightbox backdrop fixes 2022-03-03 20:28:16 -05:00
Philipp Heckel
695e029147 Make connections react on changes of users; this works wonderfully 2022-03-03 20:07:35 -05:00
Philipp Heckel
08846e4cc2 Refactor the db; move to *Manager classes 2022-03-03 16:52:07 -05:00
Philipp Heckel
f9219d2d96 Attachments 2022-03-03 14:51:56 -05:00
Philipp Heckel
7dfb2d50c7 Attachments, WIP 2022-03-02 20:22:53 -05:00
Philipp Heckel
349872bdb3 Switch everything to Dexie.js 2022-03-02 16:16:30 -05:00
Philipp Heckel
39f4613719 Do not store notifications in localStorage anymore 2022-03-01 22:41:49 -05:00
Philipp Heckel
effc1f42eb Switch prefs to dexie 2022-03-01 22:01:51 -05:00
Philipp Heckel
23d275acec Add Dexie for persistence; user management with dexie; this is the way 2022-03-01 21:23:12 -05:00
Philipp Heckel
8036aa2942 Remove mui/styles, Settings page, make minPriority functional, ahh so ugly 2022-03-01 16:22:47 -05:00
Philipp Heckel
f23c7a2dbf Use another server 2022-02-28 16:56:38 -05:00
Philipp Heckel
17e5af654b "No topics" and "No notifications" view 2022-02-28 11:52:50 -05:00
Philipp Heckel
0909354a6c Switch to since=ID 2022-02-27 19:29:17 -05:00
Philipp Heckel
cda9dfa9d0 Merge branch 'main' into ui 2022-02-27 16:10:21 -05:00
Philipp Heckel
018fa816e2 Update docs 2022-02-27 16:02:46 -05:00
Philipp Heckel
efa6d03ba5 Bump version 2022-02-27 15:49:31 -05:00
Philipp Heckel
1ed4ebaf03 Docs, release notes 2022-02-27 15:45:43 -05:00
Philipp Heckel
10c69a722f Merge branch 'main' of github.com:binwiederhier/ntfy into main 2022-02-27 14:58:18 -05:00
Philipp Heckel
324500d0b3 Deprecation notice 2022-02-27 14:57:44 -05:00
Philipp Heckel
4cd30c35ce Rename cache to messageCache 2022-02-27 14:47:28 -05:00
Philipp Heckel
e79dbf4d00 Docs 2022-02-27 14:40:44 -05:00
Philipp Heckel
e29a18a076 Add another scheduled message to since ID test 2022-02-27 14:31:22 -05:00
Philipp Heckel
f17df1e926 Combine entirely 2022-02-27 14:25:26 -05:00
Philipp Heckel
c21737d546 Combine tests and all that 2022-02-27 14:21:34 -05:00
Philipp Heckel
6dc4e441e4 Fix tests; remove memory implementation entirely 2022-02-27 14:05:13 -05:00
Philipp Heckel
7d93b0596b Almost there; Replace memCache with :memory: SQLite cache 2022-02-27 09:38:46 -05:00
Philipp Heckel
8b32cfaaff Implement since=ID logic in mem cache; add tests; still failing 2022-02-26 20:19:28 -05:00
Philipp Heckel
18b91cf250 Merge branch 'since-id' into ui 2022-02-26 16:01:31 -05:00
Philipp Heckel
4af9c07577 WIP: Since ID, works 2022-02-26 15:57:10 -05:00
Philipp Heckel
fb90ab480a Action bar fixes 2022-02-26 14:36:23 -05:00
Philipp Heckel
d705d3c3b1 Fix action bar 2022-02-26 14:22:21 -05:00
Philipp Heckel
ee743a7b01 TODOs 2022-02-26 11:51:45 -05:00
Philipp Heckel
e422c2c479 Poll on page refresh; validate subscribe dialog properly; avoid save-races 2022-02-26 11:45:39 -05:00
Philipp Heckel
aa79fe2861 Desktop notifications 2022-02-26 10:14:43 -05:00
Philipp Heckel
530f55c234 Fully support auth in Web UI; persist users in localStorage (for now); add ugly ?auth=... param 2022-02-25 23:25:04 -05:00
Philipp Heckel
6d343c0f1a Login page of "subscribe dialog", still WIP, but looking nice 2022-02-25 16:07:25 -05:00
Philipp Heckel
1599793de2 WIP: Auth 2022-02-25 13:40:03 -05:00
Philipp Heckel
42016f48ff Move things around 2022-02-25 12:46:22 -05:00
Philipp Heckel
f9e22dcaa9 Allow deleting individual notifications 2022-02-25 10:23:04 -05:00
Philipp Heckel
703f94a25f Refactor to responsive drawer 2022-02-24 20:18:46 -05:00
Philipp Heckel
0958c1d527 Re-add persistence 2022-02-24 15:17:47 -05:00
Philipp Heckel
fef46823eb Dedup without keeping deleted array 2022-02-24 14:53:45 -05:00
Philipp Heckel
48523a2269 Emojis, formatting, clear all 2022-02-24 12:26:07 -05:00
Philipp Heckel
202c4ac4b3 Do not fetch old messages on old connecting to avoid douple rendering 2022-02-24 10:30:58 -05:00
Philipp Heckel
1536201e9a Reconnect on failure, with backoff; Deduping notifications 2022-02-24 09:52:49 -05:00
Philipp Heckel
3fac1c3432 Refactor to make it more like the Android app 2022-02-23 20:30:12 -05:00
Philipp Heckel
415ab57749 Poll on subscribe; test message 2022-02-22 23:22:30 -05:00
Philipp Heckel
c57fac283e Unsubscribe 2022-02-22 22:10:50 -05:00
Philipp C. Heckel
2eff8d6b47 Merge pull request #150 from rogeliodh/patch-1
add watchtower/shoutrrr examples
2022-02-21 17:26:53 -05:00
Rogelio Domínguez Hernández
40be2a9153 add watchtower/shoutrrr examples 2022-02-21 16:21:42 -06:00
Philipp Heckel
4ba23390b5 Settings icon 2022-02-21 16:24:13 -05:00
Philipp Heckel
dd1a85e733 Awful use of localstorage 2022-02-20 20:04:03 -05:00
Philipp Heckel
c6c3caec39 Restructure 2022-02-20 16:55:55 -05:00
Philipp Heckel
8c0f3b2304 Add dialog 2022-02-19 22:26:58 -05:00
Philipp Heckel
c859f866b8 Move to dashboard theme 2022-02-19 19:48:33 -05:00
Philipp Heckel
b497063af4 Make topics clickable, show notifications 2022-02-18 15:47:25 -05:00
Philipp Heckel
1fe598a966 Split stuff 2022-02-18 14:41:01 -05:00
Philipp Heckel
31e7aa24bc Subscription form 2022-02-18 11:07:04 -05:00
Philipp Heckel
4c4e689af4 WIP: React 2022-02-18 09:49:51 -05:00
Philipp C. Heckel
43326be637 Merge pull request #148 from lrabane/cli-auth
Add authentification support for subscribing with CLI
2022-02-17 15:25:18 -05:00
lrabane
7e1a71b694 Add docs for auth support with CLI 2022-02-17 20:38:48 +01:00
lrabane
b89c18e83d Add support for auth in client config 2022-02-17 20:38:33 +01:00
lrabane
f4f5edb230 Add auth support for subscribing 2022-02-17 19:13:21 +01:00
128 changed files with 32412 additions and 2002 deletions

View File

@@ -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
View File

@@ -2,6 +2,8 @@ dist/
build/
.idea/
server/docs/
server/site/
tools/fbsend/fbsend
playground/
*.iml
node_modules/

View File

@@ -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"

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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]

View File

@@ -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

View File

@@ -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"
```

View File

@@ -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
View 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).

View File

@@ -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) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -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 |

View File

@@ -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
```

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}

View 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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -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

View File

@@ -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

View File

@@ -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;
});
});

File diff suppressed because one or more lines are too long

View File

@@ -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 {

View File

@@ -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
View 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
View 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))
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

40
web/package.json Normal file
View 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
View 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"]
};

View File

@@ -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
View 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>

View 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+ */
}

View 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;
}
}

Binary file not shown.

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 227 KiB

View File

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 225 KiB

View File

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 128 KiB

View File

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 224 KiB

View File

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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;

View 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;

View 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
View File

@@ -0,0 +1,2 @@
const config = window.config;
export default config;

18
web/src/app/db.js Normal file
View 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

File diff suppressed because one or more lines are too long

188
web/src/app/utils.js Normal file
View 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
}
}

View 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
View 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;

View 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;

Some files were not shown because too many files have changed in this diff Show More