Compare commits

...

14 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
19 changed files with 23358 additions and 64 deletions

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.
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>
@@ -196,6 +198,10 @@ To build releases, I use [GoReleaser](https://goreleaser.com/). If you have that
## 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).
@@ -207,5 +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

@@ -67,12 +67,6 @@
<code>
curl -d "Backup successful 😀" <span class="ntfyUrl">ntfy.sh</span>/mytopic
</code>
<p class="smallMarginBottom">
And another one using PUT (via <tt>curl -T</tt>):
</p>
<code>
echo -en "\u26A0\uFE0F Unauthorized login" | curl -T- <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>
@@ -82,6 +76,19 @@
&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>
@@ -196,6 +203,49 @@
{"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
@@ -213,7 +263,7 @@
rsync -a root@laptop /backups/laptop \<br/>
&nbsp;&nbsp;&& zfs snapshot ... \<br/>
&nbsp;&nbsp;&& curl -d "Laptop backup succeeded" <span class="ntfyUrl">ntfy.sh</span>/backups \<br/>
&nbsp;&nbsp;|| echo -en "\u26A0\uFE0F Laptop backup failed" | curl -sT- <span class="ntfyUrl">ntfy.sh</span>/backups
&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>
@@ -240,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- <span class="ntfyUrl">ntfy.sh</span>/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>
@@ -337,7 +387,7 @@
</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>
<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.
@@ -361,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

@@ -69,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,
@@ -372,17 +373,28 @@ li {
}
/** Detail view */
#detail {
display: none;
#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;
}

View File

Before

Width:  |  Height:  |  Size: 268 B

After

Width:  |  Height:  |  Size: 268 B

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

@@ -60,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 = '';
@@ -68,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) => {
@@ -86,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] = {
@@ -148,16 +153,37 @@ const rerenderDetailView = () => {
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 = '';
@@ -258,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');
@@ -359,3 +425,11 @@ document.querySelectorAll('.ntfyUrl').forEach((el) => {
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()
}