Compare commits

...

29 Commits

Author SHA1 Message Date
Philipp Heckel
e3debf4315 Count capitalization 2021-11-29 11:48:34 -05:00
Philipp Heckel
ccccae9aad Refine embedfs 2021-11-29 11:10:12 -05:00
Philipp Heckel
28f4e1e55e Reduce emoji.js size 2021-11-29 10:57:18 -05:00
Philipp Heckel
8616be12a2 Emojis in notifications; server caching 2021-11-29 09:34:43 -05:00
Philipp Heckel
052ab7d411 Emoji support in Web UI 2021-11-28 19:58:49 -05:00
Philipp Heckel
6ca63cc0e9 Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-11-28 19:03:53 -05:00
Philipp Heckel
f0edf0610e Add priorities and tags to web UI 2021-11-28 19:03:15 -05:00
Philipp C. Heckel
ee5b5c6edd Merge pull request #26 from Copephobia/css-fix
Fix topic div being hidden on iOS devices
2021-11-28 15:53:59 -05:00
Copephobia
4663c3b724 Fix topic div being hidden on iOS devices 2021-11-28 15:44:25 -05:00
Philipp Heckel
1193ddc65f Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-11-28 14:08:01 -05:00
Philipp Heckel
d4330e86ac Add title, priority, tags to cache; add schema migration 2021-11-28 14:07:29 -05:00
Philipp Heckel
1b8ebab5f3 Priorities, titles, tags 2021-11-27 16:12:08 -05:00
Philipp C. Heckel
b6af28de33 Update README.md 2021-11-26 17:09:41 -05:00
Philipp C. Heckel
e327e52766 Update README.md 2021-11-25 08:58:26 -05:00
Philipp Heckel
7b8185c2a7 Readme 2021-11-23 22:00:32 -05:00
Philipp Heckel
71af1af001 New logo and header 2021-11-23 20:22:09 -05:00
Philipp Heckel
9af64bf3dd Favicon 2021-11-23 12:02:16 -05:00
Philipp Heckel
093154fa6c Only build armv7, not armv6 anymore 2021-11-22 16:09:54 -05:00
Philipp Heckel
c247984ca9 Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-11-22 10:53:03 -05:00
Philipp Heckel
2a05715107 Multi-platform Docker images 2021-11-22 10:46:19 -05:00
Philipp C. Heckel
cb69f18c39 Merge pull request #12 from nathanaelhoun/custom-url-selfhosted
Use custom url for self-hosted
2021-11-22 08:42:56 -05:00
Nathanaël Houn
fde5fda635 Use custom url in case of self-hosted 2021-11-22 14:30:09 +01:00
Philipp Heckel
21990398c6 Bump readme 2021-11-20 20:31:04 -05:00
Philipp Heckel
bbbab8d2ef Properly statically compile, without warnings; netgo,osusergo 2021-11-20 20:27:17 -05:00
Philipp Heckel
ad057c12c0 Statically linking go-sqlite3 2021-11-20 20:18:40 -05:00
Philipp Heckel
e3bc92e158 Update readme 2021-11-20 16:02:05 -05:00
Philipp Heckel
45f94ead8b Merge branch 'main' of github.com:binwiederhier/ntfy into main 2021-11-20 15:58:31 -05:00
Philipp Heckel
a56e1bf36d This time really 2021-11-20 15:55:30 -05:00
Philipp Heckel
fa8a7ce43e ARM builds, hopefully working 2021-11-20 15:43:15 -05:00
24 changed files with 23546 additions and 138 deletions

View File

@@ -2,17 +2,42 @@ before:
hooks:
- go mod download
builds:
- binary: ntfy
-
id: ntfy
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
goos:
- linux
goarch:
- amd64
tags: [sqlite_omit_load_extension,osusergo,netgo]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [amd64]
-
id: ntfy_armv7
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=arm-linux-gnueabi-gcc # apt install gcc-arm-linux-gnueabi
tags: [sqlite_omit_load_extension,osusergo,netgo]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm]
goarm: [7]
-
id: ntfy_arm64
binary: ntfy
env:
- CGO_ENABLED=1 # required for go-sqlite3
- CC=aarch64-linux-gnu-gcc # apt install gcc-aarch64-linux-gnu
tags: [sqlite_omit_load_extension,osusergo,netgo]
ldflags:
- "-linkmode=external -extldflags=-static -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
goos: [linux]
goarch: [arm64]
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
@@ -51,10 +76,35 @@ changelog:
- '^docs:'
- '^test:'
dockers:
- dockerfile: Dockerfile
ids:
- ntfy
- image_templates:
- &amd64_image "binwiederhier/ntfy:{{ .Tag }}-amd64"
use: buildx
dockerfile: Dockerfile
goarch: amd64
build_flag_templates:
- "--platform=linux/amd64"
- image_templates:
- &arm64v8_image "binwiederhier/ntfy:{{ .Tag }}-arm64v8"
use: buildx
dockerfile: Dockerfile
build_flag_templates:
- "--platform=linux/arm64/v8"
- image_templates:
- &armv7_image "binwiederhier/ntfy:{{ .Tag }}-armv7"
use: buildx
dockerfile: Dockerfile
goarch: arm
goarm: 7
build_flag_templates:
- "--platform=linux/arm/v7"
docker_manifests:
- name_template: "binwiederhier/ntfy:latest"
image_templates:
- "binwiederhier/ntfy:latest"
- "binwiederhier/ntfy:{{ .Tag }}"
- "binwiederhier/ntfy:v{{ .Major }}.{{ .Minor }}"
- *amd64_image
- *arm64v8_image
- *armv7_image
- name_template: "binwiederhier/ntfy:{{ .Tag }}"
image_templates:
- *amd64_image
- *arm64v8_image
- *armv7_image

View File

@@ -61,6 +61,7 @@ coverage-html:
coverage-upload:
cd build/coverage && (curl -s https://codecov.io/bash | bash)
# Lint/formatting targets
fmt:
@@ -84,21 +85,27 @@ staticcheck: .PHONY
PATH="$(PWD)/build/staticcheck:$(PATH)" staticcheck ./...
rm -rf build/staticcheck
# Building targets
build: .PHONY
goreleaser build --rm-dist
build-deps: .PHONY
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; }
build-snapshot:
goreleaser build --snapshot --rm-dist
build: build-deps
goreleaser build --rm-dist --debug
build-snapshot: build-deps
goreleaser build --snapshot --rm-dist --debug
build-simple: clean
mkdir -p dist/ntfy_linux_amd64
export CGO_ENABLED=1
$(GO) build \
-o dist/ntfy_linux_amd64/ntfy \
-tags sqlite_omit_load_extension,osusergo,netgo \
-ldflags \
"-s -w -X main.version=$(VERSION) -X main.commit=$(shell git rev-parse --short HEAD) -X main.date=$(shell date +%s)"
"-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
@@ -106,11 +113,11 @@ clean: .PHONY
# Releasing targets
release:
goreleaser release --rm-dist
release: build-deps
goreleaser release --rm-dist --debug
release-snapshot:
goreleaser release --snapshot --skip-publish --rm-dist
release-snapshot: build-deps
goreleaser release --snapshot --skip-publish --rm-dist --debug
# Installing targets

View File

@@ -1,12 +1,14 @@
![ntfy](server/static/img/ntfy.png)
# ntfy.sh | simple HTTP-based pub-sub
[![Release](https://img.shields.io/github/release/binwiederhier/ntfy.svg?color=success&style=flat-square)](https://github.com/binwiederhier/ntfy/releases/latest)
[![Slack channel](https://img.shields.io/badge/slack-@gophers/binwiederhier-success.svg?logo=slack)](https://gophers.slack.com/archives/C01JMTPGF2Q)
**Ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
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 (as you can plainly see) if you want to run your own.
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [open source](https://github.com/binwiederhier/ntfy-android) [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
too.
<p>
@@ -136,19 +138,29 @@ sudo apt install ntfy
**Debian/Ubuntu** (*manual install*)**:**
```bash
sudo apt install tmux
wget https://github.com/binwiederhier/ntfy/releases/download/v1.4.3/ntfy_1.3.0_amd64.deb
dpkg -i ntfy_1.4.3_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.4.8/ntfy_1.4.8_amd64.deb
dpkg -i ntfy_1.4.8_amd64.deb
```
**Fedora/RHEL/CentOS:**
```bash
rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.4.3/ntfy_1.3.0_amd64.rpm
rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.4.8/ntfy_1.4.8_amd64.rpm
```
**Docker:**
Without cache:
```
docker run -p 80:80 -it binwiederhier/ntfy
```
With cache:
```bash
docker run --rm -it binwiederhier/ntfy
docker run \
-v /var/cache/ntfy:/var/cache/ntfy \
-p 80:80 \
-it \
binwiederhier/ntfy \
--cache-file /var/cache/ntfy/cache.db
```
**Go:**
@@ -156,15 +168,24 @@ docker run --rm -it binwiederhier/ntfy
go get -u heckel.io/ntfy
```
**Manual install** (*any x86_64-based Linux*)**:**
**Manual install:**
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.4.3/ntfy_1.3.0_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_1.4.3_linux_x86_64.tar.gz ntfy
# x86_64/amd64
wget https://github.com/binwiederhier/ntfy/releases/download/v1.4.8/ntfy_1.4.8_linux_x86_64.tar.gz
# armv7
wget https://github.com/binwiederhier/ntfy/releases/download/v1.4.8/ntfy_1.4.8_linux_armv7.tar.gz
# arm64/v8
wget https://github.com/binwiederhier/ntfy/releases/download/v1.4.8/ntfy_1.4.8_linux_arm64.tar.gz
# Extract and run
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
./ntfy
```
## Building
Building ntfy is simple. Here's how you do it:
Building `ntfy` is simple. Here's how you do it:
```
make build-simple
@@ -174,12 +195,13 @@ make build-simple
To build releases, I use [GoReleaser](https://goreleaser.com/). If you have that installed, you can run `make build` or
`make build-snapshot`.
## TODO
- add HTTPS
## Contributing
I welcome any and all contributions. Just create a PR or an issue.
## Contact me
You can directly contact me [on Slack](https://gophers.slack.com/archives/C01JMTPGF2Q), or via the [GitHub issues](https://github.com/binwiederhier/ntfy/issues),
or find more contact information [on my website](https://heckel.io/about).
## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).
The project is dual licensed under the [Apache License 2.0](LICENSE) and the [GPLv2 License](LICENSE.GPLv2).
@@ -191,4 +213,6 @@ Third party libraries and resources:
* [GoReleaser](https://goreleaser.com/) (MIT) is used to create releases
* [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)
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html)

24
scripts/emoji-convert.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
# This script reduces the size and converts the emoji.json file from https://github.com/github/gemoji/blob/master/db/emoji.json
# to be used in the Android app (app/src/main/resources/emoji.json) and the Web UI (server/static/js/emoji.js).
SCRIPTDIR="$(cd "$(dirname "$0")" && pwd)"
ROOTDIR="$(cd "$(dirname "$0")/.." && pwd)"
if [ -z "$1" ]; then
echo "Syntax: $0 FILE.(js|json)"
echo "Example:"
echo " $0 emoji-converted.json"
echo " $0 $ROOTDIR/server/static/js/emoji.js"
exit 1
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"
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' >> "$1"
else
cat "$SCRIPTDIR/emoji.json" | jq -rc 'map({emoji: .emoji,aliases: .aliases})' > "$1"
fi

22747
scripts/emoji.json Normal file

File diff suppressed because it is too large Load Diff

0
scripts/postrm.sh Normal file → Executable file
View File

View File

@@ -3,32 +3,61 @@ package server
import (
"database/sql"
"errors"
"fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"strings"
"time"
)
// Messages cache
const (
createTableQuery = `
createMessagesTableQuery = `
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
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);
COMMIT;
`
insertMessageQuery = `INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`
insertMessageQuery = `INSERT INTO messages (id, time, topic, message, title, priority, tags) VALUES (?, ?, ?, ?, ?, ?, ?)`
pruneMessagesQuery = `DELETE FROM messages WHERE time < ?`
selectMessagesSinceTimeQuery = `
SELECT id, time, message
SELECT id, time, message, title, priority, tags
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time ASC
`
selectMessageCountQuery = `SELECT count(*) FROM messages WHERE topic = ?`
selectTopicsQuery = `SELECT topic, MAX(time) FROM messages GROUP BY TOPIC`
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
selectTopicsQuery = `SELECT topic, MAX(time) FROM messages GROUP BY topic`
)
// Schema management queries
const (
currentSchemaVersion = 1
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
`
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
// 0 -> 1
migrate0To1AlterMessagesTableQuery = `
BEGIN;
ALTER TABLE messages ADD COLUMN title VARCHAR(256) NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
ALTER TABLE messages ADD COLUMN tags VARCHAR(256) NOT NULL DEFAULT('');
COMMIT;
`
)
type sqliteCache struct {
@@ -42,7 +71,7 @@ func newSqliteCache(filename string) (*sqliteCache, error) {
if err != nil {
return nil, err
}
if _, err := db.Exec(createTableQuery); err != nil {
if err := setupDB(db); err != nil {
return nil, err
}
return &sqliteCache{
@@ -51,7 +80,7 @@ func newSqliteCache(filename string) (*sqliteCache, error) {
}
func (c *sqliteCache) AddMessage(m *message) error {
_, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message)
_, err := c.db.Exec(insertMessageQuery, m.ID, m.Time, m.Topic, m.Message, m.Title, m.Priority, strings.Join(m.Tags, ","))
return err
}
@@ -64,19 +93,27 @@ func (c *sqliteCache) Messages(topic string, since sinceTime) ([]*message, error
messages := make([]*message, 0)
for rows.Next() {
var timestamp int64
var id, msg string
if err := rows.Scan(&id, &timestamp, &msg); err != nil {
var priority int
var id, msg, title, tagsStr string
if err := rows.Scan(&id, &timestamp, &msg, &title, &priority, &tagsStr); err != nil {
return nil, err
}
if msg == "" {
msg = " " // Hack: never return empty messages; this should not happen
}
var tags []string
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
messages = append(messages, &message{
ID: id,
Time: timestamp,
Event: messageEvent,
Topic: topic,
Message: msg,
ID: id,
Time: timestamp,
Event: messageEvent,
Topic: topic,
Message: msg,
Title: title,
Priority: priority,
Tags: tags,
})
}
if err := rows.Err(); err != nil {
@@ -86,7 +123,7 @@ func (c *sqliteCache) Messages(topic string, since sinceTime) ([]*message, error
}
func (c *sqliteCache) MessageCount(topic string) (int, error) {
rows, err := c.db.Query(selectMessageCountQuery, topic)
rows, err := c.db.Query(selectMessageCountForTopicQuery, topic)
if err != nil {
return 0, err
}
@@ -124,7 +161,63 @@ func (s *sqliteCache) Topics() (map[string]*topic, error) {
return topics, nil
}
func (c *sqliteCache) Prune(keep time.Duration) error {
_, err := c.db.Exec(pruneMessagesQuery, time.Now().Add(-1*keep).Unix())
func (s *sqliteCache) Prune(keep time.Duration) error {
_, err := s.db.Exec(pruneMessagesQuery, time.Now().Add(-1*keep).Unix())
return err
}
func setupDB(db *sql.DB) error {
// If 'messages' table does not exist, this must be a new database
rowsMC, err := db.Query(selectMessagesCountQuery)
if err != nil {
return setupNewDB(db)
}
defer rowsMC.Close()
// If 'messages' table exists, check 'schemaVersion' table
schemaVersion := 0
rowsSV, err := db.Query(selectSchemaVersionQuery)
if err == nil {
defer rowsSV.Close()
if !rowsSV.Next() {
return errors.New("cannot determine schema version: cache file may be corrupt")
}
if err := rowsSV.Scan(&schemaVersion); err != nil {
return err
}
}
// Do migrations
if schemaVersion == currentSchemaVersion {
return nil
} else if schemaVersion == 0 {
return migrateFrom0To1(db)
}
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
}
func setupNewDB(db *sql.DB) error {
if _, err := db.Exec(createMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
return err
}
return nil
}
func migrateFrom0To1(db *sql.DB) error {
if _, err := db.Exec(migrate0To1AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(createSchemaVersionTableQuery); err != nil {
return err
}
if _, err := db.Exec(insertSchemaVersion, 1); err != nil {
return err
}
return nil
}

View File

@@ -34,14 +34,14 @@
{{end}}
</head>
<body>
<div id="header"><div id="headerBox"><img src="static/img/ntfy.png" alt="ntfy"/></div></div>
<div id="main"{{if .Topic}} style="display: none"{{end}}>
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/>ntfy.sh | simple HTTP-based pub-sub</h1>
<h1>ntfy.sh | simple HTTP-based pub-sub</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.
It allows you to send notifications <a href="#subscribe-phone">to your phone</a> or desktop via scripts from any computer,
entirely <b>without signup or cost</b>. It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
<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.
It allows you to send notifications to your phone or desktop via scripts from any computer,
entirely <b>without signup, cost or setup</b>. It's also <a href="https://github.com/binwiederhier/ntfy">open source</a> if you want to run your own.
</p>
<div id="screenshots">
<a href="static/img/screenshot-curl.png"><img src="static/img/screenshot-curl.png"/></a>
<a href="static/img/screenshot-web-detail.png"><img src="static/img/screenshot-web-detail.png"/></a>
@@ -51,11 +51,9 @@
<a href="static/img/screenshot-phone-notification.jpg"><img src="static/img/screenshot-phone-notification.jpg"/></a>
</span>
</div>
<p>
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 <a href="#examples">examples below</a>.
There are many ways to use it: Notify yourself when a build finishes, when an rsync is done or a backup fails,
or know when somebody logs into your server. There are <a href="#examples">many more examples</a>, endless possibilities 😀.
</p>
<h2 id="publish" class="anchor">Publishing messages</h2>
@@ -64,26 +62,33 @@
Because there is no sign-up, <b>the topic is essentially a password</b>, so pick something that's not easily guessable.
</p>
<p class="smallMarginBottom">
Here's an example showing how to publish a message using <tt>curl</tt> (via POST):
Here's an example showing how to publish a message using a POST request (via <tt>curl -d</tt>):
</p>
<code>
curl -d "Backup successful 😀" ntfy.sh/mytopic
</code>
<p class="smallMarginBottom">
And another one using PUT:
</p>
<code>
echo -en "\u26A0\uFE0F Unauthorized login" | curl -sT- ntfy.sh/mytopic
curl -d "Backup successful 😀" <span class="ntfyUrl">ntfy.sh</span>/mytopic
</code>
<p class="smallMarginBottom">
Here's an example in JS with <tt>fetch()</tt> (see <a href="https://github.com/binwiederhier/ntfy/tree/main/examples">full example</a>):
</p>
<code>
fetch('https://ntfy.sh/mytopic', {<br/>
fetch('https://<span class="ntfyUrl">ntfy.sh</span>/mytopic', {<br/>
&nbsp;&nbsp;method: 'POST', // PUT works too<br/>
&nbsp;&nbsp;body: 'Hello from the other side.'<br/>
})
</code>
<p class="smallMarginBottom">
There are <a href="#other-features">more features</a> related to publishing messages: You can set a
<a href="#priority">notification priority</a>, a <a href="#title">title</a>, and <a href="#tags">tag messages</a>.
Here's an example using all of them:
</p>
<code>
curl \<br/>
&nbsp;&nbsp;-H "Title: Unauthorized access detected" \<br/>
&nbsp;&nbsp;-H "Priority: urgent" \<br/>
&nbsp;&nbsp;-H "Tags: warning,skull" \<br/>
&nbsp;&nbsp;-d "Remote access to $(hostname) detected. Act right away." \<br/>
&nbsp;&nbsp;<span class="ntfyUrl">ntfy.sh</span>/mytopic
</code>
<h2 id="subscribe" class="anchor">Subscribe to a topic</h2>
<p>
@@ -127,7 +132,7 @@
notifications like this (see <a href="example.html">live example</a>):
</p>
<code>
const eventSource = new EventSource('https://ntfy.sh/mytopic/sse');<br/>
const eventSource = new EventSource('<span class="ntfyProtocol">https://</span><span class="ntfyUrl">ntfy.sh</span>/mytopic/sse');<br/>
eventSource.onmessage = (e) => {<br/>
&nbsp;&nbsp;// Do something with e.data<br/>
};
@@ -136,7 +141,7 @@
You can also use the same <tt>/sse</tt> endpoint via <tt>curl</tt> or any other HTTP library:
</p>
<code>
$ curl -s ntfy.sh/mytopic/sse<br/>
$ curl -s <span class="ntfyUrl">ntfy.sh</span>/mytopic/sse<br/>
event: open<br/>
data: {"id":"weSj9RtNkj","time":1635528898,"event":"open","topic":"mytopic"}<br/><br/>
@@ -149,7 +154,7 @@
To consume JSON instead, use the <tt>/json</tt> endpoint, which prints one message per line:
</p>
<code>
$ curl -s ntfy.sh/mytopic/json<br/>
$ curl -s <span class="ntfyUrl">ntfy.sh</span>/mytopic/json<br/>
{"id":"SLiKI64DOt","time":1635528757,"event":"open","topic":"mytopic"}<br/>
{"id":"hwQ2YpKdmg","time":1635528741,"event":"message","topic":"mytopic","message":"Hi!"}<br/>
{"id":"DGUDShMCsc","time":1635528787,"event":"keepalive","topic":"mytopic"}
@@ -158,7 +163,7 @@
Or use the <tt>/raw</tt> endpoint if you need something super simple (empty lines are keepalive messages):
</p>
<code>
$ curl -s ntfy.sh/mytopic/raw<br/>
$ curl -s <span class="ntfyUrl">ntfy.sh</span>/mytopic/raw<br/>
<br/>
This is a notification<br/>
And another one with a smiley face 😀
@@ -173,7 +178,7 @@
cached messages).
</p>
<code>
curl -s "ntfy.sh/mytopic/json?since=10m"
curl -s "<span class="ntfyUrl">ntfy.sh</span>/mytopic/json?since=10m"
</code>
<h3 id="polling" class="anchor">Polling (<tt>poll=1</tt>)</h3>
@@ -183,7 +188,7 @@
combined with <tt>since=</tt> (defaults to <tt>since=all</tt>).
</p>
<code>
curl -s "ntfy.sh/mytopic/json?poll=1"
curl -s "<span class="ntfyUrl">ntfy.sh</span>/mytopic/json?poll=1"
</code>
<h3 id="multiple-topics" class="anchor">Subscribing to multiple topics (<tt>topic1,topic2,...</tt>)</h3>
@@ -192,15 +197,58 @@
comma-separated list of topics in the URL. This allows you to reduce the number of connections you have to maintain:
</p>
<code>
$ curl -s ntfy.sh/mytopic1,mytopic2/json<br/>
$ curl -s <span class="ntfyUrl">ntfy.sh</span>/mytopic1,mytopic2/json<br/>
{"id":"0OkXIryH3H","time":1637182619,"event":"open","topic":"mytopic1,mytopic2,mytopic3"}<br/>
{"id":"dzJJm7BCWs","time":1637182634,"event":"message","topic":"mytopic1","message":"for topic 1"}<br/>
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
</code>
<h3 id="priority" class="anchor">Message priority (<tt>X-Priority</tt>, <tt>Priority</tt>, <tt>prio</tt>, or <tt>p</tt>)</h3>
<p>
All messages have a priority, which defines how your urgently your phone notifies you. You can set custom
notification sounds and vibration patterns on your phone to map to these priorities.
</p>
<p class="smallMarginBottom">
The following priorities exist: <tt>1</tt> (<tt>min</tt>), <tt>2</tt> (<tt>low</tt>), <tt>3</tt> (<tt>default</tt>),
<tt>4</tt> (<tt>high</tt>), and <tt>5</tt> (<tt>max</tt>/<tt>urgent</tt>). You can set the priority with the
header <tt>X-Priority</tt> (or any of its aliases: <tt>Priority</tt>, <tt>prio</tt>, or <tt>p</tt>). Here are a few examples:
</p>
<code>
curl -H "X-Priority: urgent" -d "An urgent message" <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
curl -H "Priority: 2" -d "Low priority message" <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
curl -H p:4 -d "A high priority message" <span class="ntfyUrl">ntfy.sh</span>/mytopic
</code>
<h3 id="title" class="anchor">Notification title (<tt>X-Title</tt>, <tt>Title</tt>, <tt>ti</tt>, or <tt>t</tt>)</h3>
<p class="smallMarginBottom">
The notification title is typically set to the topic short URL (e.g. <tt><span class="ntfyUrl">ntfy.sh</span>/mytopic</tt>.
To override it, you can set the <tt>X-Title</tt> header (or any of its aliases: <tt>Title</tt>, <tt>ti</tt>, or <tt>t</tt>).
</p>
<code>
curl -H "Title: Dogs are better than cats" -d "Oh my ..." <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
</code>
<h3 id="tags" class="anchor">Tags &amp; emojis 🥳 🎉 (<tt>X-Tags</tt>, <tt>Tags</tt>, or <tt>ta</tt>)</h3>
<p>
You can tag messages with emojis (or other relevant strings). If a tag matches a <a href="https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json">known emoji short code</a>,
it will be converted to an emoji. If it doesn't match, it will be listed below the notification. This is useful
for things like warnings and such (⚠️, ️🚨, or 🚩), but also to simply tag messages otherwise (e.g. which script the
message came from, ...).
</p>
<p class="smallMarginBottom">
You can set tags with the <tt>X-Tags</tt> header (or any of its aliases: <tt>Tags</tt>, or <tt>ta</tt>).
Use <a href="https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json">this reference</a>
to figure out what tags can be converted to emojis. In the example below, the tag "warning" matches the emoji ⚠️,
the tag "ssh-login" doesn't match and will be displayed below the message.
</p>
<code>
$ curl -H "Tags: warning,ssh-login" -d "Unauthorized SSH access" <span class="ntfyUrl">ntfy.sh</span>/mytopic<br/>
{"id":"ZEIwjfHlSS",...,"tags":["warning","ssh-login"],"message":"Unauthorized SSH access"}
</code>
<h2 id="examples" class="anchor">Examples</h2>
<p>
There are a million ways to use Ntfy, but here are some inspirations. I try to collect
There are a million ways to use ntfy, but here are some inspirations. I try to collect
<a href="https://github.com/binwiederhier/ntfy/tree/main/examples">examples on GitHub</a>, so be sure to check
those out, too.
</p>
@@ -214,13 +262,13 @@
<code>
rsync -a root@laptop /backups/laptop \<br/>
&nbsp;&nbsp;&& zfs snapshot ... \<br/>
&nbsp;&nbsp;&& curl -d "Laptop backup succeeded" ntfy.sh/backups \<br/>
&nbsp;&nbsp;|| echo -en "\u26A0\uFE0F Laptop backup failed" | curl -sT- ntfy.sh/backups
&nbsp;&nbsp;&& curl -d "Laptop backup succeeded" <span class="ntfyUrl">ntfy.sh</span>/backups \<br/>
&nbsp;&nbsp;|| curl -H tags:warning -H prio:high -d "Laptop backup failed" <span class="ntfyUrl">ntfy.sh</span>/backups
</code>
<h3 id="example-web" class="anchor">Example: Server-sent messages in your web app</h3>
<p>
Just as you can <a href="#subscribe-web">subscribe to topics in this Web UI</a>, you can use Ntfy in your own
Just as you can <a href="#subscribe-web">subscribe to topics in this Web UI</a>, you can use ntfy in your own
web application. Check out the <a href="example.html">live example</a> or just look the source of this page.
</p>
@@ -242,7 +290,7 @@
<code>
#!/bin/bash<br/>
if [ "${PAM_TYPE}" = "open_session" ]; then<br/>
&nbsp;&nbsp;echo -en "\u26A0\uFE0F SSH login: ${PAM_USER} from ${PAM_RHOST}" | curl -T- ntfy.sh/alerts<br/>
&nbsp;&nbsp;curl -H tags:warning -d "SSH login: ${PAM_USER} from ${PAM_RHOST}" <span class="ntfyUrl">ntfy.sh</span>/alerts<br/>
fi
</code>
@@ -254,7 +302,7 @@
<code>
while read result; do<br/>
&nbsp;&nbsp;[ -n "$result" ] && echo "$result" >> results.csv<br/>
done < <(stdbuf -i0 -o0 curl -s ntfy.sh/results/raw)
done < <(stdbuf -i0 -o0 curl -s <span class="ntfyUrl">ntfy.sh</span>/results/raw)
</code>
<h2 id="faq" class="anchor">FAQ</h2>
@@ -302,9 +350,24 @@
is to facilitate instant notifications on Android.
</p>
<p>
<b id="battery-usage" class="anchor">How much battery does the Android app use?</b><br/>
If you use the ntfy.sh server and you don't use the <i>instant delivery</i> feature, the Android app uses no
additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server, or you use
<i>instant delivery</i>, the app has to maintain a constant connection to the server, which consumes about 4% of
battery in 17h of use (on my phone). I use it and it makes no difference to me.
</p>
<p>
<b id="instant-delivery" class="anchor">What is instant delivery?</b><br/>
Instant delivery is a feature in the Android app. If turned on, the app maintains a constant connection to the
server and listens for incoming notifications. This consumes <a href="#battery-usage">additional battery</a>,
but delivers notifications instantly.
</p>
<p>
<b id="why-no-ios" class="anchor">Why is there no iOS app (yet)?</b><br/>
I don't have an iPhone or a Mac, so I didn't make an iOS app yet. I'd be awesome if
I don't have an iPhone or a Mac, so I didn't make an iOS app yet. It'd be awesome if
<a href="https://github.com/binwiederhier/ntfy/issues/4">someone else could help out</a>.
</p>
@@ -324,14 +387,14 @@
</div>
<div id="detail"{{if not .Topic}} style="display: none"{{end}}>
<div id="detailMain">
<button id="detailCloseButton"><img src="static/img/close_black_24dp.svg"/></button>
<h1><img src="static/img/ntfy.png" alt="ntfy"/><br/><span id="detailTitle"></span></h1>
<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.
<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"></span>
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
@@ -348,6 +411,7 @@
</div>
</div>
<div id="lightbox" class="lightbox"></div>
<script src="static/js/emoji.js"></script>
<script src="static/js/app.js"></script>
</body>
</html>

View File

@@ -18,11 +18,14 @@ const (
// message represents a message published to a topic
type message struct {
ID string `json:"id"` // Random message ID
Time int64 `json:"time"` // Unix time in seconds
Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Message string `json:"message,omitempty"`
ID string `json:"id"` // Random message ID
Time int64 `json:"time"` // Unix time in seconds
Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
}
// messageEncoder is a function that knows how to encode a message
@@ -31,11 +34,14 @@ type messageEncoder func(msg *message) (string, error)
// newMessage creates a new message with the current timestamp
func newMessage(event, topic, msg string) *message {
return &message{
ID: util.RandomString(messageIDLength),
Time: time.Now().Unix(),
Event: event,
Topic: topic,
Message: msg,
ID: util.RandomString(messageIDLength),
Time: time.Now().Unix(),
Event: event,
Topic: topic,
Priority: 0,
Tags: nil,
Title: "",
Message: msg,
}
}

View File

@@ -89,10 +89,11 @@ var (
indexTemplate = template.Must(template.New("index").Parse(indexSource))
//go:embed "example.html"
exampleSource string
exampleSource string
//go:embed static
webStaticFs embed.FS
webStaticFs embed.FS
webStaticFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webStaticFs}
errHTTPBadRequest = &errHTTP{http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)}
@@ -150,11 +151,14 @@ func createFirebaseSubscriber(conf *config.Config) (subscriber, error) {
_, err := msg.Send(context.Background(), &messaging.Message{
Topic: m.Topic,
Data: map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"message": m.Message,
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"title": m.Title,
"message": m.Message,
},
})
return err
@@ -228,7 +232,7 @@ func (s *Server) handleExample(w http.ResponseWriter, r *http.Request) error {
}
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) error {
http.FileServer(http.FS(webStaticFs)).ServeHTTP(w, r)
http.FileServer(http.FS(webStaticFsCached)).ServeHTTP(w, r)
return nil
}
@@ -246,6 +250,10 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
if m.Message == "" {
return errHTTPBadRequest
}
title, priority, tags := parseHeaders(r.Header)
m.Title = title
m.Priority = priority
m.Tags = tags
if err := t.Publish(m); err != nil {
return err
}
@@ -262,6 +270,40 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return nil
}
func parseHeaders(header http.Header) (title string, priority int, tags []string) {
title = readHeader(header, "x-title", "title", "ti", "t")
priorityStr := readHeader(header, "x-priority", "priority", "prio", "p")
if priorityStr != "" {
switch strings.ToLower(priorityStr) {
case "1", "min":
priority = 1
case "2", "low":
priority = 2
case "4", "high":
priority = 4
case "5", "max", "urgent":
priority = 5
default:
priority = 3
}
}
tagsStr := readHeader(header, "x-tags", "tags", "ta")
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
return title, priority, tags
}
func readHeader(header http.Header, names ...string) string {
for _, name := range names {
value := header.Get(name)
if value != "" {
return value
}
}
return ""
}
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) {
var buf bytes.Buffer
@@ -414,11 +456,11 @@ func (s *Server) topicFromID(id string) (*topic, error) {
return topics[0], nil
}
func (s *Server) topicsFromIDs(ids... string) ([]*topic, error) {
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
s.mu.Lock()
defer s.mu.Unlock()
topics := make([]*topic, 0)
for _, id := range ids {
for _, id := range ids {
if _, ok := s.topics[id]; !ok {
if len(s.topics) >= s.config.GlobalTopicLimit {
return nil, errHTTPTooManyRequests

View File

@@ -4,6 +4,8 @@ html, body {
font-family: 'Lato', sans-serif;
color: #333;
font-size: 1.1em;
margin: 0;
padding: 0;
}
html {
@@ -25,6 +27,8 @@ h1 {
margin-top: 25px;
margin-bottom: 18px;
font-size: 2.5em;
word-wrap: break-word; /* For very long topics */
padding-right: 40px; /* For the X on the detail page */
}
h2 {
@@ -65,6 +69,7 @@ code {
margin-top: 10px;
margin-bottom: 20px;
overflow-x: auto;
white-space: nowrap;
}
/* Lato font (OFL), https://fonts.google.com/specimen/Lato#about,
@@ -84,6 +89,7 @@ code {
#main {
max-width: 900px;
margin: 0 auto 50px auto;
padding: 0 10px;
}
#error {
@@ -190,6 +196,23 @@ code {
background-color: #fff;
}
/* Header */
#header {
background: #3a9784;
height: 130px;
}
#header #headerBox {
max-width: 900px;
margin: 0 auto;
padding: 0 10px;
}
#header img {
margin-top: 23px;
}
/* Subscribe box */
button {
@@ -350,24 +373,28 @@ li {
}
/** Detail view */
#detail {
display: none;
position: absolute;
z-index: 1;
left: 8px;
right: 8px;
top: 0;
bottom: 0;
background: white;
#detail .detailEntry {
margin-bottom: 20px;
}
#detail .detailDate {
#detail .detailDate, #detail .detailTags {
color: #888;
font-size: 0.9em;
}
#detail .detailDate img {
width: 20px;
height: 20px;
vertical-align: bottom;
}
#detail .detailTitle {
font-weight: bold;
font-size: 1.1em;
}
#detail .detailMessage {
margin-bottom: 20px;
font-size: 1.1em;
}
@@ -375,7 +402,7 @@ li {
max-width: 900px;
margin: 0 auto;
position: relative; /* required for close button's "position: absolute" */
padding-bottom: 50px; /* Chrome and Firefox behave differently regarding bottom margin */
padding: 0 10px 50px 10px; /* Chrome and Firefox behave differently regarding bottom margin */
}
#detail #detailCloseButton {
@@ -384,7 +411,7 @@ li {
border: none;
padding: 5px;
position: absolute;
right: 0;
right: 10px;
top: 10px;
display: block;
}

View File

Before

Width:  |  Height:  |  Size: 268 B

After

Width:  |  Height:  |  Size: 268 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1,47 @@
<?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>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -0,0 +1,43 @@
<?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>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,43 @@
<?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>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,47 @@
<?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>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 195 B

View File

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 269 B

View File

@@ -12,6 +12,10 @@
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");
@@ -56,7 +60,7 @@ const subscribeInternal = (topic, persist, delaySec) => {
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_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
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 = '';
@@ -64,7 +68,7 @@ const subscribeInternal = (topic, persist, delaySec) => {
// 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_black_24dp.svg"> Test</button> <button onclick="unsubscribe('${topic}'); return false;"> <img src="static/img/clear_black_24dp.svg"> Unsubscribe</button>`;
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) => {
@@ -82,10 +86,15 @@ const subscribeInternal = (topic, persist, delaySec) => {
}
if (Notification.permission === "granted") {
notifySound.play();
new Notification(`${location.host}/${topic}`, {
body: event.message,
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] = {
@@ -131,29 +140,50 @@ const fetchCachedMessages = async (topic) => {
const showDetail = (topic) => {
currentTopic = topic;
history.replaceState(topic, `ntfy.sh/${topic}`, `/${topic}`);
history.replaceState(topic, `${currentUrl}/${topic}`, `/${topic}`);
window.scrollTo(0, 0);
rerenderDetailView();
return false;
};
const rerenderDetailView = () => {
detailTitle.innerHTML = `ntfy.sh/${currentTopic}`; // document.location.replaceAll(..)
detailTopicUrl.innerHTML = `ntfy.sh/${currentTopic}`;
detailTitle.innerHTML = `${currentUrl}/${currentTopic}`; // document.location.replaceAll(..)
detailTopicUrl.innerHTML = `${currentUrl}/${currentTopic}`;
while (detailEventsList.firstChild) {
detailEventsList.removeChild(detailEventsList.firstChild);
}
topics[currentTopic]['messages'].forEach(m => {
let dateDiv = document.createElement('div');
let messageDiv = document.createElement('div');
let eventDiv = document.createElement('div');
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');
dateDiv.innerHTML = new Date(m.time * 1000).toLocaleString();
titleDiv.classList.add('detailTitle');
messageDiv.classList.add('detailMessage');
messageDiv.innerText = m.message;
eventDiv.appendChild(dateDiv);
eventDiv.appendChild(messageDiv);
detailEventsList.appendChild(eventDiv);
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 = '';
@@ -177,7 +207,7 @@ const hideDetailView = () => {
currentTopic = "";
history.replaceState('', originalTitle, '/');
detailView.style.display = 'none';
main.style.display = '';
main.style.display = 'block';
return false;
};
@@ -254,6 +284,46 @@ const nextScreenshotKeyboardListener = (e) => {
}
};
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) {
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) {
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');
@@ -347,3 +417,19 @@ document.querySelectorAll('.anchor').forEach((el) => {
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

55
util/embedfs.go Normal file
View File

@@ -0,0 +1,55 @@
package util
import (
"embed"
"errors"
"io"
"io/fs"
"time"
)
type CachingEmbedFS struct {
ModTime time.Time
FS embed.FS
}
func (f CachingEmbedFS) Open(name string) (fs.File, error) {
file, err := f.FS.Open(name)
if err != nil {
return nil, err
}
stat, err := file.Stat()
if err != nil {
return nil, err
}
return &cachingEmbedFile{file, f.ModTime, stat}, nil
}
type cachingEmbedFile struct {
file fs.File
modTime time.Time
fs.FileInfo
}
func (f cachingEmbedFile) Stat() (fs.FileInfo, error) {
return f, nil
}
func (f cachingEmbedFile) Read(bytes []byte) (int, error) {
return f.file.Read(bytes)
}
func (f *cachingEmbedFile) Seek(offset int64, whence int) (int64, error) {
if seeker, ok := f.file.(io.Seeker); ok {
return seeker.Seek(offset, whence)
}
return 0, errors.New("io.Seeker not implemented")
}
func (f cachingEmbedFile) ModTime() time.Time {
return f.modTime // We override this!
}
func (f cachingEmbedFile) Close() error {
return f.file.Close()
}