diff --git a/.gitignore b/.gitignore index 1062418c4b5dbd74d93b5bf6f6f5b2b2c42c0789..a88775f3c8b3b44f304c145a9583533ffc7fb5df 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ +dist/ .idea/ *.iml diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000000000000000000000000000000000000..a71e10425b7fabe715e40010ddd150159d4caa46 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,62 @@ +before: + hooks: + - go mod download +builds: + - binary: ntfy + env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 +nfpms: + - + package_name: ntfy + file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}" + homepage: https://heckel.io/ntfy + maintainer: Philipp C. Heckel <philipp.heckel@gmail.com> + description: Simple pub-sub notification service + license: Apache 2.0 + formats: + - deb + - rpm + bindir: /usr/bin + contents: + - src: config/config.yml + dst: /etc/ntfy/config.yml + type: config + - src: config/ntfy.service + dst: /lib/systemd/system/ntfy.service + scripts: + postremove: "scripts/postrm.sh" +archives: + - + wrap_in_directory: true + files: + - LICENSE + - README.md + - config/config.yml + - config/ntfy.service + replacements: + 386: i386 + amd64: x86_64 +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' +dockers: + - dockerfile: Dockerfile + ids: + - ntfy + image_templates: + - "binwiederhier/ntfy:latest" + - "binwiederhier/ntfy:{{ .Tag }}" + - "binwiederhier/ntfy:v{{ .Major }}.{{ .Minor }}" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..8e789a7b6f4147debe6d666186f51f9627bf61da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM alpine +MAINTAINER Philipp C. Heckel <philipp.heckel@gmail.com> + +COPY ntfy /usr/bin +ENTRYPOINT ["ntfy"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..109e6db62634c20a5231f19bc020b5ec0af91478 --- /dev/null +++ b/Makefile @@ -0,0 +1,124 @@ +GO=$(shell which go) +VERSION := $(shell git describe --tag) + +.PHONY: + +help: + @echo "Typical commands:" + @echo " make check - Run all tests, vetting/formatting checks and linters" + @echo " make fmt build-snapshot install - Build latest and install to local system" + @echo + @echo "Test/check:" + @echo " make test - Run tests" + @echo " make race - Run tests with -race flag" + @echo " make coverage - Run tests and show coverage" + @echo " make coverage-html - Run tests and show coverage (as HTML)" + @echo " make coverage-upload - Upload coverage results to codecov.io" + @echo + @echo "Lint/format:" + @echo " make fmt - Run 'go fmt'" + @echo " make fmt-check - Run 'go fmt', but don't change anything" + @echo " make vet - Run 'go vet'" + @echo " make lint - Run 'golint'" + @echo " make staticcheck - Run 'staticcheck'" + @echo + @echo "Build:" + @echo " make build - Build" + @echo " make build-snapshot - Build snapshot" + @echo " make build-simple - Build (using go build, without goreleaser)" + @echo " make clean - Clean build folder" + @echo + @echo "Releasing (requires goreleaser):" + @echo " make release - Create a release" + @echo " make release-snapshot - Create a test release" + @echo + @echo "Install locally (requires sudo):" + @echo " make install - Copy binary from dist/ to /usr/bin" + @echo " make install-deb - Install .deb from dist/" + @echo " make install-lint - Install golint" + + +# Test/check targets + +check: test fmt-check vet lint staticcheck + +test: .PHONY + $(GO) test ./... + +race: .PHONY + $(GO) test -race ./... + +coverage: + mkdir -p build/coverage + $(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./... + $(GO) tool cover -func build/coverage/coverage.txt + +coverage-html: + mkdir -p build/coverage + $(GO) test -race -coverprofile=build/coverage/coverage.txt -covermode=atomic ./... + $(GO) tool cover -html build/coverage/coverage.txt + +coverage-upload: + cd build/coverage && (curl -s https://codecov.io/bash | bash) + +# Lint/formatting targets + +fmt: + $(GO) fmt ./... + +fmt-check: + test -z $(shell gofmt -l .) + +vet: + $(GO) vet ./... + +lint: + which golint || $(GO) get -u golang.org/x/lint/golint + $(GO) list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status + +staticcheck: .PHONY + rm -rf build/staticcheck + which staticcheck || go get honnef.co/go/tools/cmd/staticcheck + mkdir -p build/staticcheck + ln -s "$(GO)" build/staticcheck/go + PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./... + rm -rf build/staticcheck + +# Building targets + +build: .PHONY + goreleaser build --rm-dist + +build-snapshot: + goreleaser build --snapshot --rm-dist + +build-simple: clean + mkdir -p dist/ntfy_linux_amd64 + $(GO) build \ + -o dist/ntfy_linux_amd64/ntfy \ + -ldflags \ + "-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 + + +# Releasing targets + +release: + goreleaser release --rm-dist + +release-snapshot: + goreleaser release --snapshot --skip-publish --rm-dist + + +# Installing targets + +install: + sudo rm -f /usr/bin/ntfy + sudo cp -a dist/ntfy_linux_amd64/ntfy /usr/bin/ntfy + +install-deb: + sudo systemctl stop ntfy || true + sudo apt-get purge ntfy || true + sudo dpkg -i dist/*.deb diff --git a/README.md b/README.md index af79562ed7b2cd079636b2b0386930a6807d3cea..45205a1326cf63b941527c9eb6848294bde6c17b 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,63 @@ # ntfy -ntfy is a super simple pub-sub notification service. It allows you to send desktop and (soon) phone notifications -via scripts. I run a free version of it on *[ntfy.sh](https://ntfy.sh)*. No signups or cost. +ntfy (pronounce: *notify*) is a super simple pub-sub notification service. It allows you to send desktop and (soon) phone notifications +via scripts. I run a free version of it on *[ntfy.sh](https://ntfy.sh)*. **No signups or cost.** ## Usage ### Subscribe to a topic +You can subscribe to a topic either in a web UI, or in your own app by subscribing to an +[SSE](https://en.wikipedia.org/wiki/Server-sent_events)/[EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), +or a JSON or raw feed. -You can subscribe to a topic either in a web UI, or in your own app by subscribing to an SSE/EventSource -or JSON feed. - -Here's how to do it via curl see the SSE stream in `curl`: +Here's how to see the raw/json/sse stream in `curl`. This will subscribe to the topic and wait for events. ``` -curl -s localhost:9997/mytopic/sse +# Subscribe to "mytopic" and output one message per line (\n are replaced with a space) +curl -s ntfy.sh/mytopic/raw + +# Subscribe to "mytopic" and output one JSON message per line +curl -s ntfy.sh/mytopic/json + +# Subscribe to "mytopic" and output an SSE stream (supported via JS/EventSource) +curl -s ntfy.sh/mytopic/sse ``` -You can easily script it to execute any command when a message arrives: +You can easily script it to execute any command when a message arrives. This sends desktop notifications (just like +the web UI, but without it): ``` -while read json; do - msg="$(echo "$json" | jq -r .message)" +while read msg; do notify-send "$msg" -done < <(stdbuf -i0 -o0 curl -s localhost:9997/mytopic/json) +done < <(stdbuf -i0 -o0 curl -s ntfy.sh/mytopic/raw) ``` ### Publish messages - Publishing messages can be done via PUT or POST using. Here's an example using `curl`: ``` curl -d "long process is done" ntfy.sh/mytopic ``` +Messages published to a non-existing topic or a topic without subscribers will not be delivered later. There is (currently) +no buffering of any kind. If you're not listening, the message won't be delivered. + +## FAQ + +### Isn't this like ...? +Probably. I didn't do a whole lot of research before making this. + +### Can I use this in my app? +Yes. As long as you don't abuse it, it'll be available and free of charge. + +### What are the uptime guarantees? +Best effort. + +### Why is the web UI so ugly? +I don't particularly like JS or dealing with CSS. I'll make it pretty after it's functional. + ## TODO -- /raw endpoint -- netcat usage - rate limiting / abuse protection - release/packaging +- add HTTPS ## Contributing I welcome any and all contributions. Just create a PR or an issue. diff --git a/cmd/app.go b/cmd/app.go new file mode 100644 index 0000000000000000000000000000000000000000..4d8c1fa1622f811a5f005320ec82d3429750480e --- /dev/null +++ b/cmd/app.go @@ -0,0 +1,73 @@ +// Package cmd provides the ntfy CLI application +package cmd + +import ( + "fmt" + "github.com/urfave/cli/v2" + "github.com/urfave/cli/v2/altsrc" + "heckel.io/ntfy/config" + "heckel.io/ntfy/server" + "log" + "os" +) + +// New creates a new CLI application +func New() *cli.App { + flags := []cli.Flag{ + &cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/config.yml", DefaultText: "/etc/ntfy/config.yml", Usage: "config file"}, + altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: config.DefaultListenHTTP, Usage: "ip:port used to as listen address"}), + } + return &cli.App{ + Name: "ntfy", + Usage: "Simple pub-sub notification service", + UsageText: "ntfy [OPTION..]", + HideHelp: true, + HideVersion: true, + EnableBashCompletion: true, + UseShortOptionHandling: true, + Reader: os.Stdin, + Writer: os.Stdout, + ErrWriter: os.Stderr, + Action: execRun, + Before: initConfigFileInputSource("config", flags), + Flags: flags, + } +} + +func execRun(c *cli.Context) error { + // Read all the options + listenHTTP := c.String("listen-http") + + // Run main bot, can be killed by signal + conf := config.New(listenHTTP) + s := server.New(conf) + if err := s.Run(); err != nil { + log.Fatalln(err) + } + + log.Printf("Exiting.") + return nil +} + +// initConfigFileInputSource is like altsrc.InitInputSourceWithContext and altsrc.NewYamlSourceFromFlagFunc, but checks +// if the config flag is exists and only loads it if it does. If the flag is set and the file exists, it fails. +func initConfigFileInputSource(configFlag string, flags []cli.Flag) cli.BeforeFunc { + return func(context *cli.Context) error { + configFile := context.String(configFlag) + if context.IsSet(configFlag) && !fileExists(configFile) { + return fmt.Errorf("config file %s does not exist", configFile) + } else if !context.IsSet(configFlag) && !fileExists(configFile) { + return nil + } + inputSource, err := altsrc.NewYamlSourceFromFile(configFile) + if err != nil { + return err + } + return altsrc.ApplyInputSourceValues(context, inputSource, flags) + } +} + +func fileExists(filename string) bool { + stat, _ := os.Stat(filename) + return stat != nil +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..85297910a7f39aadf07ebf4a8ea5eb2c5643b684 --- /dev/null +++ b/config/config.go @@ -0,0 +1,18 @@ +// Package config provides the main configuration +package config + +const ( + DefaultListenHTTP = ":80" +) + +// Config is the main config struct for the application. Use New to instantiate a default config struct. +type Config struct { + ListenHTTP string +} + +// New instantiates a default new config +func New(listenHTTP string) *Config { + return &Config{ + ListenHTTP: listenHTTP, + } +} diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..553ff00fae56607f808d9ecef9a0eae4d4ae2dbc --- /dev/null +++ b/config/config.yml @@ -0,0 +1,9 @@ +# ntfy config file + +# Listen address for the HTTP web server +# +# Format: <hostname>:<port> +# Default: :80 +# Required: No +# +# listen-http: ":80" diff --git a/config/ntfy.service b/config/ntfy.service new file mode 100644 index 0000000000000000000000000000000000000000..4a70cd023b25ab1e3bc0898a0ba3d891a946b723 --- /dev/null +++ b/config/ntfy.service @@ -0,0 +1,10 @@ +[Unit] +Description=ntfy server +After=network.target + +[Service] +ExecStart=/usr/bin/ntfy +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod index ee3af69f7b7a39644394e50b3ee7e6d2a0c98798..7c9ad4cdf8cd146fbb67c1cf860438797253a4f5 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,10 @@ -module heckel.io/notifyme +module heckel.io/ntfy go 1.16 -require github.com/gorilla/websocket v1.4.2 // indirect +require ( + github.com/BurntSushi/toml v0.4.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect + github.com/urfave/cli/v2 v2.3.0 + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum index 85efffd996ee16d6ec5e40659a9371074a0c5f60..2cd4b5df221ebbe0547f3f42c04810224aea67ba 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,23 @@ -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= +github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.3 h1:fvjTMHxHEw/mxHbtzPi3JCcKXQRAnQTBRo6YCJSVHKI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/main.go b/main.go index 43af89f2400a408efb909d4b41c0fede597f1cd2..cecae09d4c317ae98e0f9a76ec7089ad2243e8a5 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,32 @@ package main import ( - "heckel.io/notifyme/server" - "log" + "fmt" + "github.com/urfave/cli/v2" + "heckel.io/ntfy/cmd" + "os" + "runtime" +) + +var ( + version = "dev" + commit = "unknown" + date = "unknown" ) func main() { - s := server.New() - if err := s.Run(); err != nil { - log.Fatalln(err) + cli.AppHelpTemplate += fmt.Sprintf(` +Try 'ntfy COMMAND --help' for more information. + +ntfy %s (%s), runtime %s, built at %s +Copyright (C) 2021 Philipp C. Heckel, distributed under the Apache License 2.0 +`, version, commit[:7], runtime.Version(), date) + + app := cmd.New() + app.Version = version + + if err := app.Run(os.Args); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) } } diff --git a/scripts/postrm.sh b/scripts/postrm.sh new file mode 100644 index 0000000000000000000000000000000000000000..4588bc2770ab0713a24f69ff481081c11702a80d --- /dev/null +++ b/scripts/postrm.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -eu +systemctl stop ntfy >/dev/null 2>&1 || true +if [ "$1" = "purge" ]; then + rm -rf /etc/ntfy +fi diff --git a/server/index.html b/server/index.html index 50d999447bdfa93714ed6caa45b2b145e6c019e4..1e3c2318a681ebe16c509e13a7a1b4e64133eff1 100644 --- a/server/index.html +++ b/server/index.html @@ -3,37 +3,45 @@ <head> <title>ntfy.sh</title> <style> + body { font-size: 1.3em; line-height: 140%; } + #error { color: darkred; font-style: italic; } + #main { max-width: 800px; margin: 0 auto; } </style> </head> <body> -<h1>ntfy.sh</h1> +<div id="main"> + <h1>ntfy.sh</h1> -<p> - ntfy.sh is a super simple pub-sub notification service. It allows you to send desktop and (soon) phone notifications - via scripts, without signup or cost. It's entirely free and open source. You can find the source code <a href="https://github.com/binwiederhier/ntfy">on GitHub</a>. -</p> + <p> + <b>ntfy</b> (pronounce: <i>notify</i>) is a simple HTTP-based pub-sub notification service. It allows you to send desktop and (soon) phone notifications + via scripts, without signup or cost. It's entirely free and open source. You can find the source code <a href="https://github.com/binwiederhier/ntfy">on GitHub</a>. + </p> -<p> - You can subscribe to a topic either in this web UI, or in your own app by subscribing to an SSE/EventSource - or JSON feed. Once subscribed, you can publish messages via PUT or POST. -</p> + <p> + You can subscribe to a topic either in this web UI, or in your own app by subscribing to an SSE/EventSource + or JSON feed. Once subscribed, you can publish messages via PUT or POST. + </p> -<p id="error"></p> + <p id="error"></p> -<form id="subscribeForm"> - <input type="text" id="topicField" size="64" autofocus /> - <input type="submit" id="subscribeButton" value="Subscribe topic" /> -</form> + <form id="subscribeForm"> + <p> + <input type="text" id="topicField" size="64" placeholder="Topic ID (letters, numbers, _ and -)" pattern="[-_A-Za-z]{1,64}" autofocus /> + <input type="submit" id="subscribeButton" value="Subscribe topic" /> + </p> + </form> -<p>Topics:</p> -<ul id="topicsList"> -</ul> + <p id="topicsHeader"><b>Subscribed topics:</b></p> + <ul id="topicsList"></ul> + +</div> <script type="text/javascript"> let topics = {}; - const topicField = document.getElementById("topicField"); + const topicsHeader = document.getElementById("topicsHeader"); const topicsList = document.getElementById("topicsList"); + const topicField = document.getElementById("topicField"); const subscribeButton = document.getElementById("subscribeButton"); const subscribeForm = document.getElementById("subscribeForm"); const errorField = document.getElementById("error"); @@ -43,6 +51,8 @@ Notification.requestPermission().then((permission) => { if (permission === "granted") { subscribeInternal(topic, 0); + } else { + showNotificationDeniedError(); } }); } else { @@ -60,6 +70,7 @@ topicEntry.innerHTML = `${topic} <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; topicsList.appendChild(topicEntry); } + topicsHeader.style.display = ''; // Open event source let eventSource = new EventSource(`${topic}/sse`); @@ -68,7 +79,6 @@ delaySec = 0; // Reset on successful connection }; eventSource.onerror = (e) => { - console.log("onerror") const newDelaySec = (delaySec + 5 <= 30) ? delaySec + 5 : 30; topicEntry.innerHTML = `${topic} <i>(Reconnecting in ${newDelaySec}s ...)</i> <button onclick="unsubscribe('${topic}')">Unsubscribe</button>`; eventSource.close() @@ -88,6 +98,23 @@ 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 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."); }; subscribeForm.onsubmit = function () { @@ -101,13 +128,9 @@ // Disable Web UI if notifications of EventSource are not available if (!window["Notification"] || !window["EventSource"]) { - errorField.innerHTML = "Your browser is not compatible to use the web-based desktop notifications."; - topicField.disabled = true; - subscribeButton.disabled = true; + showBrowserIncompatibleError(); } else if (Notification.permission === "denied") { - errorField.innerHTML = "You have blocked desktop notifications for this website. Please unblock them and refresh to use the web-based desktop notifications."; - topicField.disabled = true; - subscribeButton.disabled = true; + showNotificationDeniedError(); } // Reset UI @@ -115,10 +138,14 @@ // Restore topics const storedTopics = localStorage.getItem('topics'); - if (storedTopics) { - JSON.parse(storedTopics).forEach((topic) => { - subscribeInternal(topic, 0); - }); + if (storedTopics && Notification.permission === "granted") { + const storedTopicsArray = JSON.parse(storedTopics) + storedTopicsArray.forEach((topic) => { subscribeInternal(topic, 0); }); + if (storedTopicsArray.length === 0) { + topicsHeader.style.display = 'none'; + } + } else { + topicsHeader.style.display = 'none'; } </script> diff --git a/server/server.go b/server/server.go index f620114e955958cd0304e16c3fd5c4e6547d3e0d..77b2e5ac3838c50af672c5ace33d9bc59aaa08c2 100644 --- a/server/server.go +++ b/server/server.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "heckel.io/ntfy/config" "io" "log" "net/http" @@ -16,6 +17,7 @@ import ( ) type Server struct { + config *config.Config topics map[string]*topic mu sync.Mutex } @@ -33,13 +35,17 @@ var ( topicRegex = regexp.MustCompile(`^/[^/]+$`) jsonRegex = regexp.MustCompile(`^/[^/]+/json$`) sseRegex = regexp.MustCompile(`^/[^/]+/sse$`) + rawRegex = regexp.MustCompile(`^/[^/]+/raw$`) //go:embed "index.html" indexSource string + + errTopicNotFound = errors.New("topic not found") ) -func New() *Server { +func New(conf *config.Config) *Server { return &Server{ + config: conf, topics: make(map[string]*topic), } } @@ -50,23 +56,22 @@ func (s *Server) Run() error { } func (s *Server) listenAndServe() error { - log.Printf("Listening on :9997") + log.Printf("Listening on %s", s.config.ListenHTTP) http.HandleFunc("/", s.handle) - return http.ListenAndServe(":9997", nil) + return http.ListenAndServe(s.config.ListenHTTP, nil) } func (s *Server) runMonitor() { for { - time.Sleep(5 * time.Second) + time.Sleep(30 * time.Second) s.mu.Lock() - log.Printf("topics: %d", len(s.topics)) + var subscribers, messages int for _, t := range s.topics { - t.mu.Lock() - log.Printf("- %s: %d subscriber(s), %d message(s) sent, last active = %s", - t.id, len(t.subscribers), t.messages, t.last.String()) - t.mu.Unlock() + subs, msgs := t.Stats() + subscribers += subs + messages += msgs } - // TODO kill dead topics + log.Printf("Stats: %d topic(s), %d subscriber(s), %d message(s) sent", len(s.topics), subscribers, messages) s.mu.Unlock() } } @@ -74,7 +79,7 @@ func (s *Server) runMonitor() { func (s *Server) handle(w http.ResponseWriter, r *http.Request) { if err := s.handleInternal(w, r); err != nil { w.WriteHeader(http.StatusInternalServerError) - _, _ = io.WriteString(w, err.Error()) + _, _ = io.WriteString(w, err.Error()+"\n") } } @@ -85,6 +90,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { return s.handleSubscribeJSON(w, r) } else if r.Method == http.MethodGet && sseRegex.MatchString(r.URL.Path) { return s.handleSubscribeSSE(w, r) + } else if r.Method == http.MethodGet && rawRegex.MatchString(r.URL.Path) { + return s.handleSubscribeRaw(w, r) } else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicRegex.MatchString(r.URL.Path) { return s.handlePublishHTTP(w, r) } @@ -125,7 +132,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request) err } return nil }) - defer t.Unsubscribe(subscriberID) + defer s.unsubscribe(t, subscriberID) select { case <-t.ctx.Done(): case <-r.Context().Done(): @@ -149,7 +156,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) erro } return nil }) - defer t.Unsubscribe(subscriberID) + defer s.unsubscribe(t, subscriberID) w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) if _, err := io.WriteString(w, "event: open\n\n"); err != nil { @@ -165,6 +172,26 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request) erro return nil } +func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request) error { + t := s.createTopic(strings.TrimSuffix(r.URL.Path[1:], "/raw")) // Hack + subscriberID := t.Subscribe(func(msg *message) error { + m := strings.ReplaceAll(msg.Message, "\n", " ") + "\n" + if _, err := io.WriteString(w, m); err != nil { + return err + } + if fl, ok := w.(http.Flusher); ok { + fl.Flush() + } + return nil + }) + defer s.unsubscribe(t, subscriberID) + select { + case <-t.ctx.Done(): + case <-r.Context().Done(): + } + return nil +} + func (s *Server) createTopic(id string) *topic { s.mu.Lock() defer s.mu.Unlock() @@ -179,7 +206,15 @@ func (s *Server) topic(topicID string) (*topic, error) { defer s.mu.Unlock() c, ok := s.topics[topicID] if !ok { - return nil, errors.New("topic does not exist") + return nil, errTopicNotFound } return c, nil } + +func (s *Server) unsubscribe(t *topic, subscriberID int) { + s.mu.Lock() + defer s.mu.Unlock() + if subscribers := t.Unsubscribe(subscriberID); subscribers == 0 { + delete(s.topics, t.id) + } +} diff --git a/server/topic.go b/server/topic.go index 283b6da49ef12c3d59958af49f55b069a48797d3..7c8f38bf458cf4d748d16ba170e2269f2de26c6c 100644 --- a/server/topic.go +++ b/server/topic.go @@ -41,10 +41,11 @@ func (t *topic) Subscribe(s subscriber) int { return subscriberID } -func (t *topic) Unsubscribe(id int) { +func (t *topic) Unsubscribe(id int) int { t.mu.Lock() defer t.mu.Unlock() delete(t.subscribers, id) + return len(t.subscribers) } func (t *topic) Publish(m *message) error { @@ -57,12 +58,18 @@ func (t *topic) Publish(m *message) error { t.messages++ for _, s := range t.subscribers { if err := s(m); err != nil { - log.Printf("error publishing message to subscriber x") + log.Printf("error publishing message to subscriber") } } return nil } +func (t *topic) Stats() (subscribers int, messages int) { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.subscribers), t.messages +} + func (t *topic) Close() { t.cancel() }