From 6d7fec533701a75786f34d1b8b286d1ebaba1577 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Thu, 18 Nov 2021 09:22:33 -0500 Subject: [PATCH] Examples and anchors on website --- .../web-example-eventsource/example-ssh.html | 2 + server/example.html | 56 +++++++++ server/index.gohtml | 106 +++++++++++++++--- server/server.go | 10 ++ server/static/css/app.css | 4 +- server/static/img/badge-appstore.png | Bin 0 -> 5922 bytes server/static/img/badge-googleplay.png | Bin 0 -> 3812 bytes server/static/js/app.js | 1 + 8 files changed, 159 insertions(+), 20 deletions(-) create mode 100644 server/example.html create mode 100644 server/static/img/badge-appstore.png create mode 100644 server/static/img/badge-googleplay.png diff --git a/examples/web-example-eventsource/example-ssh.html b/examples/web-example-eventsource/example-ssh.html index db1acdae..e558ef12 100644 --- a/examples/web-example-eventsource/example-ssh.html +++ b/examples/web-example-eventsource/example-ssh.html @@ -3,6 +3,7 @@ ntfy.sh: EventSource Example + + + +

ntfy.sh: EventSource Example

+

+ This is an example showing how to use ntfy.sh with + EventSource.
+ This example doesn't need a server. You can just save the HTML page and run it from anywhere. +

+ +

Log:

+
+ + + + + diff --git a/server/index.gohtml b/server/index.gohtml index 1c4ad204..a98ab62e 100644 --- a/server/index.gohtml +++ b/server/index.gohtml @@ -38,7 +38,7 @@

ntfy
ntfy.sh | simple HTTP-based pub-sub

Ntfy (pronounce: notify) is a simple HTTP-based pub-sub notification service. - It allows you to send notifications to your phone or desktop via scripts from any computer, + It allows you to send notifications to your phone or desktop via scripts from any computer, entirely without signup or cost. It's also open source if you want to run your own.

@@ -53,9 +53,9 @@

- There are many ways to use Ntfy. You can send yourself messages for all sorts of things: When a long process finishes or fails (a backup, a long rsync job, ...), + There are many ways to use Ntfy. You can send yourself messages for all sorts of things: When a long process finishes or fails, or to notify yourself when somebody logs into your server(s). Or you may want to use it in your own app to distribute messages to subscribed clients. - Endless possibilities 😀. Be sure to check out the example on GitHub! + Endless possibilities 😀. Be sure to check out the examples below.

Publishing messages

@@ -104,16 +104,21 @@ -

Subscribe via Android App

+

Subscribe from your phone

You can use the Ntfy Android App to receive notifications directly on your phone. Just like the server, this app is also open source. + Since I don't have an iPhone or a Mac, I didn't make an iOS app yet. I'd be awesome if someone else could help out. +

+

+ +

Subscribe via your app, or via the CLI

Using EventSource in JS, you can consume - notifications like this (see full example): + notifications like this (see live example):

const eventSource = new EventSource('https://ntfy.sh/mytopic/sse');
@@ -149,19 +154,12 @@ $ curl -s ntfy.sh/mytopic/raw

- This is a notification -
-

- Here's an example of how to use this endpoint to send desktop notifications for every incoming message: -

- - while read msg; do
-   [ -n "$msg" ] && notify-send "$msg"
- done < <(stdbuf -i0 -o0 curl -s ntfy.sh/mytopic/raw) + This is a notification
+ And another one with a smiley face 😀

Other features

-

Fetching cached messages

+

Fetching cached messages (since=)

Messages are cached on disk for {{.CacheDuration}} to account for network interruptions of subscribers. You can read back what you missed by using the since= query parameter. It takes either a @@ -172,7 +170,7 @@ curl -s "ntfy.sh/mytopic/json?since=10m" -

Fetching cached messages

+

Polling (poll=1)

You can also just poll for messages if you don't like the long-standing connection using the poll=1 query parameter. The connection will end after all available messages have been read. This parameter can be @@ -182,7 +180,7 @@ curl -s "ntfy.sh/mytopic/json?poll=1" -

Subscribing to multiple topics

+

Subscribing to multiple topics (topic1,topic2,...)

It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics in the URL. This allows you to reduce the number of connections you have to maintain: @@ -194,6 +192,65 @@ {"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"} +

Examples

+

+ There are a million ways to use Ntfy, but here are some inspirations. I try to collect + examples on GitHub, so be sure to check + those out, too. +

+ +

Example: A long process is done: backups, copying data, pipelines, ...

+

+ I started adding notifications pretty much all of my scripts. Typically, I just chain the curl call + directly to the command I'm running. The following example will either send Laptop backup succeeded + or ⚠️ Laptop backup failed directly to my phone: +

+ + rsync -a root@laptop /backups/laptop \
+   && zfs snapshot ... \
+   && curl -d "Laptop backup succeeded" ntfy.sh/backups \
+   || echo -en "\u26A0\uFE0F Laptop backup failed" | curl -sT- ntfy.sh/backups +
+ +

Example: Server-sent messages in your web app

+

+ Just as you can subscribe to topics in this Web UI, you can use Ntfy in your own + web application. Check out the live example or just look the source of this page. +

+ +

Example: Notify on SSH login

+

+ Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I + own, I now message myself. Here's an example of how to use PAM + to notify yourself on SSH login. +

+

+ /etc/pam.d/sshd (at the end of the file): +

+ + session optional pam_exec.so /usr/local/bin/ntfy-ssh-login.sh + +

+ /usr/local/bin/ntfy-ssh-login.sh: +

+ + #!/bin/bash
+ if [ "${PAM_TYPE}" = "open_session" ]; then
+   echo -en "\u26A0\uFE0F SSH login: ${PAM_USER} from ${PAM_RHOST}" | curl -T- ntfy.sh/alerts
+ fi +
+ +

Example: Collect data from multiple machines

+

+ The other day I was running tasks on 20 servers and I wanted to collect the interim results + as a CSV in one place. Here's the script I wrote: +

+ + while read result; do
+   [ -n "$result" ] && echo "result" >> results.csv
+ done < <(stdbuf -i0 -o0 curl -s ntfy.sh/results/raw) +
+

FAQ

Isn't this like ...?
@@ -225,6 +282,13 @@ client network disruptions.

+

+ Can I self-host it?
+ Yes. The server (including this Web UI) can be self-hosted, and the Android app supports adding topics from + your own server as well. There are install instructions + on GitHub. +

+

Why is Firebase used?
In addition to caching messages locally and delivering them to long-polling subscribers, all messages are also @@ -232,7 +296,13 @@ is to facilitate instant notifications on Android.

-

Privacy policy

+

+ Why is there no iOS app (yet)?
+ I don't have an iPhone or a Mac, so I didn't make an iOS app yet. I'd be awesome if + someone else could help out. +

+ +

Privacy policy

Neither the server nor the app record any personal information, or share any of the messages and topics with any outside service. All data is exclusively used to make the service function properly. The one exception diff --git a/server/server.go b/server/server.go index 71ab024c..07b9973c 100644 --- a/server/server.go +++ b/server/server.go @@ -88,6 +88,9 @@ var ( indexSource string indexTemplate = template.Must(template.New("index").Parse(indexSource)) + //go:embed "example.html" + exampleSource string + //go:embed static webStaticFs embed.FS @@ -188,6 +191,8 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error { if r.Method == http.MethodGet && (r.URL.Path == "/" || topicRegex.MatchString(r.URL.Path)) { return s.handleHome(w, r) + } else if r.Method == http.MethodGet && r.URL.Path == "/example.html" { + return s.handleExample(w, r) } else if r.Method == http.MethodHead && r.URL.Path == "/" { return s.handleEmpty(w, r) } else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) { @@ -217,6 +222,11 @@ func (s *Server) handleEmpty(w http.ResponseWriter, r *http.Request) error { return nil } +func (s *Server) handleExample(w http.ResponseWriter, r *http.Request) error { + _, err := io.WriteString(w, exampleSource) + return err +} + func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error { http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r) return nil diff --git a/server/static/css/app.css b/server/static/css/app.css index 272263fc..d7ce0407 100644 --- a/server/static/css/app.css +++ b/server/static/css/app.css @@ -28,13 +28,13 @@ h1 { } h2 { - margin-top: 20px; + margin-top: 30px; margin-bottom: 5px; font-size: 1.8em; } h3 { - margin-top: 20px; + margin-top: 25px; margin-bottom: 5px; font-size: 1.3em; } diff --git a/server/static/img/badge-appstore.png b/server/static/img/badge-appstore.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4ce1c06dd4973b32c2ada5a2d6db3b044a29cd GIT binary patch literal 5922 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 Ef;P&bCjbBd literal 0 HcmV?d00001 diff --git a/server/static/img/badge-googleplay.png b/server/static/img/badge-googleplay.png new file mode 100644 index 0000000000000000000000000000000000000000..36036d8bdc64bdc8adf32905719fef19362fef67 GIT binary patch literal 3812 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 ab}JoSS9!w3tD~s^0000 { if (el.hasAttribute('id')) { const id = el.getAttribute('id');