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](server/static/img/ntfy.png)
# ntfy.sh | simple HTTP-based pub-sub # 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 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. 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. too.
<p> <p>
@@ -196,6 +198,10 @@ To build releases, I use [GoReleaser](https://goreleaser.com/). If you have that
## Contributing ## Contributing
I welcome any and all contributions. Just create a PR or an issue. 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 ## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io). 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). 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 * [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 * [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 * [Firebase Admin SDK](https://github.com/firebase/firebase-admin-go) (Apache 2.0) is used to send FCM messages
* [github/gemoji](https://github.com/github/gemoji) (MIT) is used for emoji support (specifically the [emoji.json](https://raw.githubusercontent.com/github/gemoji/master/db/emoji.json) file)
* [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox) * [Lightbox with vanilla JS](https://yossiabramov.com/blog/vanilla-js-lightbox)
* [Statically linking go-sqlite3](https://www.arp242.net/static-go.html) * [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 ( import (
"database/sql" "database/sql"
"errors" "errors"
"fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"strings"
"time" "time"
) )
// Messages cache
const ( const (
createTableQuery = ` createMessagesTableQuery = `
BEGIN; BEGIN;
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(20) PRIMARY KEY, id VARCHAR(20) PRIMARY KEY,
time INT NOT NULL, time INT NOT NULL,
topic VARCHAR(64) 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); CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
COMMIT; 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 < ?` pruneMessagesQuery = `DELETE FROM messages WHERE time < ?`
selectMessagesSinceTimeQuery = ` selectMessagesSinceTimeQuery = `
SELECT id, time, message SELECT id, time, message, title, priority, tags
FROM messages FROM messages
WHERE topic = ? AND time >= ? WHERE topic = ? AND time >= ?
ORDER BY time ASC ORDER BY time ASC
` `
selectMessageCountQuery = `SELECT count(*) FROM messages WHERE topic = ?` selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
selectTopicsQuery = `SELECT topic, MAX(time) FROM messages GROUP BY TOPIC` 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 { type sqliteCache struct {
@@ -42,7 +71,7 @@ func newSqliteCache(filename string) (*sqliteCache, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if _, err := db.Exec(createTableQuery); err != nil { if err := setupDB(db); err != nil {
return nil, err return nil, err
} }
return &sqliteCache{ return &sqliteCache{
@@ -51,7 +80,7 @@ func newSqliteCache(filename string) (*sqliteCache, error) {
} }
func (c *sqliteCache) AddMessage(m *message) 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 return err
} }
@@ -64,19 +93,27 @@ func (c *sqliteCache) Messages(topic string, since sinceTime) ([]*message, error
messages := make([]*message, 0) messages := make([]*message, 0)
for rows.Next() { for rows.Next() {
var timestamp int64 var timestamp int64
var id, msg string var priority int
if err := rows.Scan(&id, &timestamp, &msg); err != nil { var id, msg, title, tagsStr string
if err := rows.Scan(&id, &timestamp, &msg, &title, &priority, &tagsStr); err != nil {
return nil, err return nil, err
} }
if msg == "" { if msg == "" {
msg = " " // Hack: never return empty messages; this should not happen msg = " " // Hack: never return empty messages; this should not happen
} }
var tags []string
if tagsStr != "" {
tags = strings.Split(tagsStr, ",")
}
messages = append(messages, &message{ messages = append(messages, &message{
ID: id, ID: id,
Time: timestamp, Time: timestamp,
Event: messageEvent, Event: messageEvent,
Topic: topic, Topic: topic,
Message: msg, Message: msg,
Title: title,
Priority: priority,
Tags: tags,
}) })
} }
if err := rows.Err(); err != nil { 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) { 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 { if err != nil {
return 0, err return 0, err
} }
@@ -124,7 +161,63 @@ func (s *sqliteCache) Topics() (map[string]*topic, error) {
return topics, nil return topics, nil
} }
func (c *sqliteCache) Prune(keep time.Duration) error { func (s *sqliteCache) Prune(keep time.Duration) error {
_, err := c.db.Exec(pruneMessagesQuery, time.Now().Add(-1*keep).Unix()) _, err := s.db.Exec(pruneMessagesQuery, time.Now().Add(-1*keep).Unix())
return err 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> <code>
curl -d "Backup successful 😀" <span class="ntfyUrl">ntfy.sh</span>/mytopic curl -d "Backup successful 😀" <span class="ntfyUrl">ntfy.sh</span>/mytopic
</code> </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"> <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>): 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> </p>
@@ -82,6 +76,19 @@
&nbsp;&nbsp;body: 'Hello from the other side.'<br/> &nbsp;&nbsp;body: 'Hello from the other side.'<br/>
}) })
</code> </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> <h2 id="subscribe" class="anchor">Subscribe to a topic</h2>
<p> <p>
@@ -196,6 +203,49 @@
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"} {"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
</code> </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> <h2 id="examples" class="anchor">Examples</h2>
<p> <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
@@ -213,7 +263,7 @@
rsync -a root@laptop /backups/laptop \<br/> rsync -a root@laptop /backups/laptop \<br/>
&nbsp;&nbsp;&& zfs snapshot ... \<br/> &nbsp;&nbsp;&& zfs snapshot ... \<br/>
&nbsp;&nbsp;&& curl -d "Laptop backup succeeded" <span class="ntfyUrl">ntfy.sh</span>/backups \<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> </code>
<h3 id="example-web" class="anchor">Example: Server-sent messages in your web app</h3> <h3 id="example-web" class="anchor">Example: Server-sent messages in your web app</h3>
@@ -240,7 +290,7 @@
<code> <code>
#!/bin/bash<br/> #!/bin/bash<br/>
if [ "${PAM_TYPE}" = "open_session" ]; then<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 fi
</code> </code>
@@ -337,7 +387,7 @@
</div> </div>
<div id="detail"{{if not .Topic}} style="display: none"{{end}}> <div id="detail"{{if not .Topic}} style="display: none"{{end}}>
<div id="detailMain"> <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> <h1><span id="detailTitle"></span></h1>
<p class="smallMarginBottom"> <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.
@@ -361,6 +411,7 @@
</div> </div>
</div> </div>
<div id="lightbox" class="lightbox"></div> <div id="lightbox" class="lightbox"></div>
<script src="static/js/emoji.js"></script>
<script src="static/js/app.js"></script> <script src="static/js/app.js"></script>
</body> </body>
</html> </html>

View File

@@ -18,11 +18,14 @@ const (
// message represents a message published to a topic // message represents a message published to a topic
type message struct { type message struct {
ID string `json:"id"` // Random message ID ID string `json:"id"` // Random message ID
Time int64 `json:"time"` // Unix time in seconds Time int64 `json:"time"` // Unix time in seconds
Event string `json:"event"` // One of the above Event string `json:"event"` // One of the above
Topic string `json:"topic"` Topic string `json:"topic"`
Message string `json:"message,omitempty"` 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 // 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 // newMessage creates a new message with the current timestamp
func newMessage(event, topic, msg string) *message { func newMessage(event, topic, msg string) *message {
return &message{ return &message{
ID: util.RandomString(messageIDLength), ID: util.RandomString(messageIDLength),
Time: time.Now().Unix(), Time: time.Now().Unix(),
Event: event, Event: event,
Topic: topic, Topic: topic,
Message: msg, Priority: 0,
Tags: nil,
Title: "",
Message: msg,
} }
} }

View File

@@ -89,10 +89,11 @@ var (
indexTemplate = template.Must(template.New("index").Parse(indexSource)) indexTemplate = template.Must(template.New("index").Parse(indexSource))
//go:embed "example.html" //go:embed "example.html"
exampleSource string exampleSource string
//go:embed static //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)} errHTTPBadRequest = &errHTTP{http.StatusBadRequest, http.StatusText(http.StatusBadRequest)}
errHTTPNotFound = &errHTTP{http.StatusNotFound, http.StatusText(http.StatusNotFound)} 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{ _, err := msg.Send(context.Background(), &messaging.Message{
Topic: m.Topic, Topic: m.Topic,
Data: map[string]string{ Data: map[string]string{
"id": m.ID, "id": m.ID,
"time": fmt.Sprintf("%d", m.Time), "time": fmt.Sprintf("%d", m.Time),
"event": m.Event, "event": m.Event,
"topic": m.Topic, "topic": m.Topic,
"message": m.Message, "priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"title": m.Title,
"message": m.Message,
}, },
}) })
return err 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 { 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 return nil
} }
@@ -246,6 +250,10 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
if m.Message == "" { if m.Message == "" {
return errHTTPBadRequest return errHTTPBadRequest
} }
title, priority, tags := parseHeaders(r.Header)
m.Title = title
m.Priority = priority
m.Tags = tags
if err := t.Publish(m); err != nil { if err := t.Publish(m); err != nil {
return err return err
} }
@@ -262,6 +270,40 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return nil 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 { func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
encoder := func(msg *message) (string, error) { encoder := func(msg *message) (string, error) {
var buf bytes.Buffer var buf bytes.Buffer
@@ -414,11 +456,11 @@ func (s *Server) topicFromID(id string) (*topic, error) {
return topics[0], nil return topics[0], nil
} }
func (s *Server) topicsFromIDs(ids... string) ([]*topic, error) { func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
topics := make([]*topic, 0) topics := make([]*topic, 0)
for _, id := range ids { for _, id := range ids {
if _, ok := s.topics[id]; !ok { if _, ok := s.topics[id]; !ok {
if len(s.topics) >= s.config.GlobalTopicLimit { if len(s.topics) >= s.config.GlobalTopicLimit {
return nil, errHTTPTooManyRequests return nil, errHTTPTooManyRequests

View File

@@ -69,6 +69,7 @@ code {
margin-top: 10px; margin-top: 10px;
margin-bottom: 20px; margin-bottom: 20px;
overflow-x: auto; overflow-x: auto;
white-space: nowrap;
} }
/* Lato font (OFL), https://fonts.google.com/specimen/Lato#about, /* Lato font (OFL), https://fonts.google.com/specimen/Lato#about,
@@ -372,17 +373,28 @@ li {
} }
/** Detail view */ /** Detail view */
#detail {
display: none; #detail .detailEntry {
margin-bottom: 20px;
} }
#detail .detailDate { #detail .detailDate, #detail .detailTags {
color: #888; color: #888;
font-size: 0.9em; 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 { #detail .detailMessage {
margin-bottom: 20px;
font-size: 1.1em; 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) { if (!topicEntry) {
topicEntry = document.createElement('li'); topicEntry = document.createElement('li');
topicEntry.id = `topic-${topic}`; 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); topicsList.appendChild(topicEntry);
} }
topicsHeader.style.display = ''; topicsHeader.style.display = '';
@@ -68,7 +68,7 @@ const subscribeInternal = (topic, persist, delaySec) => {
// Open event source // Open event source
let eventSource = new EventSource(`${topic}/sse`); let eventSource = new EventSource(`${topic}/sse`);
eventSource.onopen = () => { 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 delaySec = 0; // Reset on successful connection
}; };
eventSource.onerror = (e) => { eventSource.onerror = (e) => {
@@ -86,10 +86,15 @@ const subscribeInternal = (topic, persist, delaySec) => {
} }
if (Notification.permission === "granted") { if (Notification.permission === "granted") {
notifySound.play(); notifySound.play();
new Notification(`${location.host}/${topic}`, { const title = formatTitle(event);
body: event.message, const message = formatMessage(event);
const notification = new Notification(title, {
body: message,
icon: '/static/img/favicon.png' icon: '/static/img/favicon.png'
}); });
notification.onclick = (e) => {
showDetail(event.topic);
};
} }
}; };
topics[topic] = { topics[topic] = {
@@ -148,16 +153,37 @@ const rerenderDetailView = () => {
detailEventsList.removeChild(detailEventsList.firstChild); detailEventsList.removeChild(detailEventsList.firstChild);
} }
topics[currentTopic]['messages'].forEach(m => { topics[currentTopic]['messages'].forEach(m => {
let dateDiv = document.createElement('div'); const entryDiv = document.createElement('div');
let messageDiv = document.createElement('div'); const dateDiv = document.createElement('div');
let eventDiv = 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.classList.add('detailDate');
dateDiv.innerHTML = new Date(m.time * 1000).toLocaleString(); titleDiv.classList.add('detailTitle');
messageDiv.classList.add('detailMessage'); messageDiv.classList.add('detailMessage');
messageDiv.innerText = m.message; tagsDiv.classList.add('detailTags');
eventDiv.appendChild(dateDiv);
eventDiv.appendChild(messageDiv); const dateStr = new Date(m.time * 1000).toLocaleString();
detailEventsList.appendChild(eventDiv); 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) { if (topics[currentTopic]['messages'].length === 0) {
detailNoNotifications.style.display = ''; 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 // From: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch
async function* makeTextFileLineIterator(fileURL) { async function* makeTextFileLineIterator(fileURL) {
const utf8Decoder = new TextDecoder('utf-8'); const utf8Decoder = new TextDecoder('utf-8');
@@ -359,3 +425,11 @@ document.querySelectorAll('.ntfyUrl').forEach((el) => {
document.querySelectorAll('.ntfyProtocol').forEach((el) => { document.querySelectorAll('.ntfyProtocol').forEach((el) => {
el.innerHTML = window.location.protocol + "//"; 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()
}