Compare commits

..

23 Commits
v1.28.0 ... e2e

Author SHA1 Message Date
Philipp Heckel
a66731641c Merge branch 'main' into e2e 2022-10-01 20:56:50 -04:00
Philipp Heckel
5014bba0b3 Replace interface{} 2022-10-01 16:31:48 -04:00
Philipp Heckel
eaf3e83e72 Bump release log 2022-10-01 15:58:39 -04:00
Philipp Heckel
bddde5c637 Bump Go version, Generics whoooo 2022-10-01 15:50:48 -04:00
Philipp Heckel
b15ecd785e Fix trailing slash issue for base-url 2022-10-01 15:23:14 -04:00
Philipp Heckel
f8c9945cc4 Korean 2022-10-01 14:54:16 -04:00
Philipp Heckel
0fc8dee9a9 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2022-10-01 14:51:52 -04:00
Philipp Heckel
f01576e40d Sponsors to README 2022-09-30 12:06:31 -04:00
Philipp Heckel
ea669c75a3 Add sponsors to release notes 2022-09-30 12:00:30 -04:00
Christian Meis
4abd0e290a Translated using Weblate (German)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/de/
2022-09-29 16:23:02 +02:00
Philipp Heckel
bcda08a01c Developer docs, closes #414 2022-09-28 09:22:36 -04:00
YJSoft
60043f14ea Translated using Weblate (Korean)
Currently translated at 100.0% (189 of 189 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ko/
2022-09-28 10:17:51 +02:00
Philipp Heckel
dafd62dc6b E2E save draft 2022-08-18 11:50:58 -04:00
Philipp Heckel
466c9874a8 WIP 2022-07-18 14:37:51 -04:00
Philipp Heckel
09cb1482b4 Tests 2022-07-15 20:35:23 -04:00
Philipp Heckel
9514e97219 Multipart encryption stuff 2022-07-15 16:52:37 -04:00
Philipp Heckel
ec3ba6331c Merge branch 'main' into e2e 2022-07-13 20:31:51 -04:00
Philipp Heckel
cae06c5c61 Continued 2022-07-13 20:31:17 -04:00
Philipp Heckel
78f9d4835e Merge branch 'main' into e2e 2022-07-08 15:42:35 -04:00
Philipp Heckel
e5dc2242c4 WIP WIP WIP crypto 2022-07-08 08:16:03 -04:00
Philipp Heckel
67da1e4922 E2E example in PHP and Python 2022-07-05 22:58:43 -04:00
Philipp Heckel
99e6c0ff97 JWE 2022-07-05 14:15:14 -04:00
Philipp Heckel
febe45818c WIP: Crypto stuff 2022-07-01 15:48:49 -04:00
41 changed files with 4501 additions and 292 deletions

View File

@@ -13,7 +13,7 @@ jobs:
name: Install node
uses: actions/setup-node@v2
with:
node-version: '16'
node-version: '17'
-
name: Checkout code
uses: actions/checkout@v2

View File

@@ -16,7 +16,7 @@ jobs:
name: Install node
uses: actions/setup-node@v2
with:
node-version: '16'
node-version: '17'
-
name: Checkout code
uses: actions/checkout@v2

View File

@@ -3,7 +3,7 @@ on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
steps:
-
name: Install Go
uses: actions/setup-go@v2
@@ -13,7 +13,7 @@ jobs:
name: Install node
uses: actions/setup-node@v2
with:
node-version: '16'
node-version: '17'
-
name: Checkout code
uses: actions/checkout@v2

View File

@@ -64,7 +64,16 @@ Or, if you'd like to help translate 🇩🇪 🇺🇸 🇧🇬, you can start im
## Donations
I have just very recently started accepting donations via [GitHub Sponsors](https://github.com/sponsors/binwiederhier).
I would be humbled if you helped me carry the server and developer account costs. Even small donations are very much
appreciated.
appreciated. A big fat Thank You to the folks already sponsoring ntfy:
<a href="https://github.com/aspyct"><img src="https://github.com/aspyct.png" width="40px" /></a>
<a href="https://github.com/codinghipster"><img src="https://github.com/codinghipster.png" width="40px" /></a>
<a href="https://github.com/HinFort"><img src="https://github.com/HinFort.png" width="40px" /></a>
<a href="https://github.com/mckay115"><img src="https://github.com/mckay115.png" width="40px" /></a>
<a href="https://github.com/neutralinsomniac"><img src="https://github.com/neutralinsomniac.png" width="40px" /></a>
<a href="https://github.com/nickexyz"><img src="https://github.com/nickexyz.png" width="40px" /></a>
<a href="https://github.com/qcasey"><img src="https://github.com/qcasey.png" width="40px" /></a>
<a href="https://github.com/Salamafet"><img src="https://github.com/Salamafet.png" width="40px" /></a>
## License
Made with ❤️ by [Philipp C. Heckel](https://heckel.io).

View File

@@ -3,14 +3,19 @@ package client
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/crypto"
"heckel.io/ntfy/log"
"heckel.io/ntfy/util"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync"
"time"
@@ -25,7 +30,8 @@ const (
)
const (
maxResponseBytes = 4096
maxResponseBytes = 4096
encryptedMessageBytesLimit = 100 * 1024 * 1024 // 100 MB
)
// Client is the ntfy client that can be used to publish and subscribe to ntfy topics
@@ -96,7 +102,7 @@ func (c *Client) Publish(topic, message string, options ...PublishOption) (*Mess
// To pass title, priority and tags, check out WithTitle, WithPriority, WithTagsList, WithDelay, WithNoCache,
// WithNoFirebase, and the generic WithHeader.
func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishOption) (*Message, error) {
topicURL := c.expandTopicURL(topic)
topicURL := util.ExpandTopicURL(topic, c.config.DefaultHost)
req, _ := http.NewRequest("POST", topicURL, body)
for _, option := range options {
if err := option(req); err != nil {
@@ -123,6 +129,59 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
return m, nil
}
func (c *Client) PublishEncryptedReader(topic string, body io.Reader, password string, options ...PublishOption) (*Message, error) {
topicURL := util.ExpandTopicURL(topic, c.config.DefaultHost)
key := crypto.DeriveKey(password, topicURL)
peaked, err := util.PeekLimit(io.NopCloser(body), encryptedMessageBytesLimit)
if err != nil {
return nil, err
}
ciphertext, err := crypto.Encrypt(peaked.PeekedBytes, key)
if err != nil {
return nil, err
}
var b bytes.Buffer
body = strings.NewReader(ciphertext)
w := multipart.NewWriter(&b)
for _, part := range parts {
mw, _ := w.CreateFormField(part.key)
_, err := io.Copy(mw, strings.NewReader(part.value))
require.Nil(t, err)
}
require.Nil(t, w.Close())
rr := httptest.NewRecorder()
req, err := http.NewRequest(method, url, &b)
if err != nil {
t.Fatal(err)
}
req, _ := http.NewRequest("POST", topicURL, body)
req.Header.Set("X-Encoding", "jwe")
for _, option := range options {
if err := option(req); err != nil {
return nil, err
}
}
log.Debug("%s Publishing message with headers %s", util.ShortTopicURL(topicURL), req.Header)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, errors.New(strings.TrimSpace(string(b)))
}
m, err := toMessage(string(b), topicURL, "")
if err != nil {
return nil, err
}
return m, nil
}
// Poll queries a topic for all (or a limited set) of messages. Unlike Subscribe, this method only polls for
// messages and does not subscribe to messages that arrive after this call.
//
@@ -137,7 +196,7 @@ func (c *Client) Poll(topic string, options ...SubscribeOption) ([]*Message, err
messages := make([]*Message, 0)
msgChan := make(chan *Message)
errChan := make(chan error)
topicURL := c.expandTopicURL(topic)
topicURL := util.ExpandTopicURL(topic, c.config.DefaultHost)
log.Debug("%s Polling from topic", util.ShortTopicURL(topicURL))
options = append(options, WithPoll())
go func() {
@@ -174,7 +233,7 @@ func (c *Client) Subscribe(topic string, options ...SubscribeOption) string {
c.mu.Lock()
defer c.mu.Unlock()
subscriptionID := util.RandomString(10)
topicURL := c.expandTopicURL(topic)
topicURL := util.ExpandTopicURL(topic, c.config.DefaultHost)
log.Debug("%s Subscribing to topic", util.ShortTopicURL(topicURL))
ctx, cancel := context.WithCancel(context.Background())
c.subscriptions[subscriptionID] = &subscription{
@@ -208,7 +267,7 @@ func (c *Client) Unsubscribe(subscriptionID string) {
func (c *Client) UnsubscribeAll(topic string) {
c.mu.Lock()
defer c.mu.Unlock()
topicURL := c.expandTopicURL(topic)
topicURL := util.ExpandTopicURL(topic, c.config.DefaultHost)
for _, sub := range c.subscriptions {
if sub.topicURL == topicURL {
delete(c.subscriptions, sub.ID)
@@ -217,15 +276,6 @@ func (c *Client) UnsubscribeAll(topic string) {
}
}
func (c *Client) expandTopicURL(topic string) string {
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
return topic
} else if strings.Contains(topic, "/") {
return fmt.Sprintf("https://%s", topic)
}
return fmt.Sprintf("%s/%s", c.config.DefaultHost, topic)
}
func handleSubscribeConnLoop(ctx context.Context, msgChan chan *Message, topicURL, subcriptionID string, options ...SubscribeOption) {
for {
// TODO The retry logic is crude and may lose messages. It should record the last message like the

View File

@@ -97,6 +97,11 @@ func WithNoFirebase() PublishOption {
return WithHeader("X-Firebase", "no")
}
// WithEncrypted sets the encoding header to "jwe"
func WithEncrypted() PublishOption {
return WithHeader("X-Encoding", "jwe")
}
// WithSince limits the number of messages returned from the server. The parameter since can be a Unix
// timestamp (see WithSinceUnixTime), a duration (WithSinceDuration) the word "all" (see WithSinceAll).
func WithSince(since string) SubscribeOption {

View File

@@ -97,11 +97,11 @@ func execUserAccess(c *cli.Context) error {
}
func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error {
if !util.InStringList([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) {
if !util.Contains([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) {
return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)")
}
read := util.InStringList([]string{"read-write", "rw", "read-only", "read", "ro"}, perms)
write := util.InStringList([]string{"read-write", "rw", "write-only", "write", "wo"}, perms)
read := util.Contains([]string{"read-write", "rw", "read-only", "read", "ro"}, perms)
write := util.Contains([]string{"read-write", "rw", "write-only", "write", "wo"}, perms)
user, err := manager.User(username)
if err == auth.ErrNotFound {
return fmt.Errorf("user %s does not exist", username)

View File

@@ -40,7 +40,7 @@ func initConfigFileInputSourceFunc(configFlag string, flags []cli.Flag, next cli
// This function also maps aliases, so a .yml file can contain short options, or options with underscores
// instead of dashes. See https://github.com/binwiederhier/ntfy/issues/255.
func newYamlSourceFromFile(file string, flags []cli.Flag) (altsrc.InputSourceContext, error) {
var rawConfig map[interface{}]interface{}
var rawConfig map[any]any
b, err := os.ReadFile(file)
if err != nil {
return nil, err

View File

@@ -6,6 +6,7 @@ import (
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"heckel.io/ntfy/log"
"heckel.io/ntfy/server"
"heckel.io/ntfy/util"
"io"
"os"
@@ -103,41 +104,35 @@ func execPublish(c *cli.Context) error {
noFirebase := c.Bool("no-firebase")
quiet := c.Bool("quiet")
pid := c.Int("wait-pid")
password := os.Getenv("NTFY_PASSWORD")
topic, message, command, err := parseTopicMessageCommand(c)
if err != nil {
return err
}
pm := &server.PublishMessage{
Topic: topic,
Title: title,
Message: message,
Tags: util.SplitNoEmpty(tags, ","),
Click: click,
Actions: nil,
Attach: attach,
Filename: filename,
Email: email,
Delay: delay,
}
var options []client.PublishOption
if title != "" {
options = append(options, client.WithTitle(title))
}
if priority != "" {
options = append(options, client.WithPriority(priority))
}
if tags != "" {
options = append(options, client.WithTagsList(tags))
}
if delay != "" {
options = append(options, client.WithDelay(delay))
}
if click != "" {
options = append(options, client.WithClick(click))
p, err := util.ParsePriority(priority)
if err != nil {
return err
}
pm.Priority = p
if icon != "" {
options = append(options, client.WithIcon(icon))
}
if actions != "" {
options = append(options, client.WithActions(strings.ReplaceAll(actions, "\n", " ")))
}
if attach != "" {
options = append(options, client.WithAttach(attach))
}
if filename != "" {
options = append(options, client.WithFilename(filename))
}
if email != "" {
options = append(options, client.WithEmail(email))
}
if noCache {
options = append(options, client.WithNoCache())
}
@@ -165,15 +160,15 @@ func execPublish(c *cli.Context) error {
newMessage, err := waitForProcess(pid)
if err != nil {
return err
} else if message == "" {
message = newMessage
} else if pm.Message == "" {
pm.Message = newMessage
}
} else if len(command) > 0 {
newMessage, err := runAndWaitForCommand(command)
if err != nil {
return err
} else if message == "" {
message = newMessage
} else if pm.Message == "" {
pm.Message = newMessage
}
}
var body io.Reader
@@ -198,10 +193,16 @@ func execPublish(c *cli.Context) error {
}
}
}
var m *client.Message
cl := client.New(conf)
m, err := cl.PublishReader(topic, body, options...)
if err != nil {
return err
if password != "" {
if m, err = cl.PublishEncryptedReader(topic, m, password, options...); err != nil {
return err
}
} else {
if m, err = cl.PublishReader(topic, m, options...); err != nil {
return err
}
}
if !quiet {
fmt.Fprintln(c.App.Writer, strings.TrimSpace(m.Raw))
@@ -210,7 +211,7 @@ func execPublish(c *cli.Context) error {
}
// parseTopicMessageCommand reads the topic and the remaining arguments from the context.
//
// There are a few cases to consider:
//
// ntfy publish <topic> [<message>]

View File

@@ -157,14 +157,18 @@ func execServe(c *cli.Context) error {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
return errors.New("if attachment-cache-dir is set, base-url must also be set")
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") && strings.HasSuffix(baseURL, "/") {
return errors.New("if set, base-url must start with http:// or https://, and must not end with a slash (/)")
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return errors.New("if set, base-url must start with http:// or https://")
} else if baseURL != "" && strings.HasSuffix(baseURL, "/") {
return errors.New("if set, base-url must not end with a slash (/)")
} else if !util.Contains([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
} else if !util.InStringList([]string{"app", "home", "disable"}, webRoot) {
} else if !util.Contains([]string{"app", "home", "disable"}, webRoot) {
return errors.New("if set, web-root must be 'home' or 'app'")
} else if upstreamBaseURL != "" && !strings.HasPrefix(upstreamBaseURL, "http://") && !strings.HasPrefix(upstreamBaseURL, "https://") {
return errors.New("if set, upstream-base-url must start with http:// or https://")
} else if upstreamBaseURL != "" && strings.HasSuffix(upstreamBaseURL, "/") {
return errors.New("if set, upstream-base-url must not end with a slash (/)")
} else if upstreamBaseURL != "" && baseURL == "" {
return errors.New("if upstream-base-url is set, base-url must also be set")
} else if upstreamBaseURL != "" && baseURL != "" && baseURL == upstreamBaseURL {

View File

@@ -273,7 +273,7 @@ func createAuthManager(c *cli.Context) (auth.Manager, error) {
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
} else if !util.FileExists(authFile) {
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
} else if !util.Contains([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'")
}
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"

43
crypto/crypto.go Normal file
View File

@@ -0,0 +1,43 @@
package crypto
import (
"crypto/sha256"
"golang.org/x/crypto/pbkdf2"
"gopkg.in/square/go-jose.v2"
)
const (
jweEncryption = jose.A256GCM
jweAlgorithm = jose.DIRECT
keyLenBytes = 32 // 256-bit for AES-256
keyDerivIter = 50000
)
func DeriveKey(password, topicURL string) []byte {
salt := sha256.Sum256([]byte(topicURL))
return pbkdf2.Key([]byte(password), salt[:], keyDerivIter, keyLenBytes, sha256.New)
}
func Encrypt(plaintext []byte, key []byte) (string, error) {
enc, err := jose.NewEncrypter(jweEncryption, jose.Recipient{Algorithm: jweAlgorithm, Key: key}, nil)
if err != nil {
return "", err
}
jwe, err := enc.Encrypt(plaintext)
if err != nil {
return "", err
}
return jwe.CompactSerialize()
}
func Decrypt(ciphertext string, key []byte) ([]byte, error) {
jwe, err := jose.ParseEncrypted(ciphertext)
if err != nil {
return nil, err
}
out, err := jwe.Decrypt(key)
if err != nil {
return nil, err
}
return out, nil
}

38
crypto/crypto_test.go Normal file
View File

@@ -0,0 +1,38 @@
package crypto
import (
"fmt"
"github.com/stretchr/testify/require"
"testing"
)
func TestDeriveKey(t *testing.T) {
key := DeriveKey("secr3t password", "https://ntfy.sh/mysecret")
require.Equal(t, "30b7e72f6273da6e59d2dec535466e548da3eafc98650c9664c06edab707fa25", fmt.Sprintf("%x", key))
}
func TestEncryptDecrypt(t *testing.T) {
message := "this is a message or is it?"
ciphertext, err := Encrypt([]byte(message), []byte("AES256Key-32Characters1234567890"))
require.Nil(t, err)
plaintext, err := Decrypt(ciphertext, []byte("AES256Key-32Characters1234567890"))
require.Nil(t, err)
require.Equal(t, message, string(plaintext))
}
func TestEncryptDecrypt_FromPHP(t *testing.T) {
ciphertext := "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..vbe1Qv_-mKYbUgce.EfmOUIUi7lxXZG_o4bqXZ9pmpr1Rzs4Y5QLE2XD2_aw_SQ.y2hadrN5b2LEw7_PJHhbcA"
key := DeriveKey("secr3t password", "https://ntfy.sh/mysecret")
fmt.Printf("%x", key)
plaintext, err := Decrypt(ciphertext, key)
require.Nil(t, err)
require.Equal(t, `{"message":"Secret!","priority":5}`, string(plaintext))
}
func TestEncryptDecrypt_FromPython(t *testing.T) {
ciphertext := "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q"
key := DeriveKey("secr3t password", "https://ntfy.sh/mysecret")
plaintext, err := Decrypt(ciphertext, key)
require.Nil(t, err)
require.Equal(t, `{"message":"Python says hi","tags":["secret"]}`, string(plaintext))
}

View File

@@ -58,8 +58,8 @@ These steps **assume Ubuntu**. Steps may vary on different Linux distributions.
First, install [Go](https://go.dev/) (see [official instructions](https://go.dev/doc/install)):
``` shell
wget https://go.dev/dl/go1.18.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.18.linux-amd64.tar.gz
wget https://go.dev/dl/go1.19.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.19.1.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
go version # verifies that it worked
```
@@ -72,7 +72,7 @@ goreleaser -v # verifies that it worked
Install [nodejs](https://nodejs.org/en/) (see [official instructions](https://nodejs.org/en/download/package-manager/)):
``` shell
curl -fsSL https://deb.nodesource.com/setup_17.x | sudo -E bash -
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
npm -v # verifies that it worked
```

View File

@@ -2,6 +2,39 @@
Binaries for all releases can be found on the GitHub releases pages for the [ntfy server](https://github.com/binwiederhier/ntfy/releases)
and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/releases).
## ntfy server v1.29.0 (UNRELEASED)
**Bug fixes + maintenance:**
* Subscriptions can now have a display name ([#370](https://github.com/binwiederhier/ntfy/issues/370), thanks to [@tfheen](https://github.com/tfheen) for reporting)
* Bump Go version to Go 18.x ([#422](https://github.com/binwiederhier/ntfy/issues/422))
**Documentation:**
* Updated developer docs, bump nodejs and go version ([#414](https://github.com/binwiederhier/ntfy/issues/414), thanks to [@YJSoft](https://github.com/YJSoft) for reporting)
**Additional translations:**
* Korean (thanks to [@YJSofta0f97461d82447ac](https://hosted.weblate.org/user/YJSofta0f97461d82447ac/))
**Sponsorships:**:
Thank you to the amazing folks who decided to [sponsor ntfy](https://github.com/sponsors/binwiederhier). Thank you for
helping carry the cost of the public server and developer licenses, and more importantly: Thank you for believing in ntfy!
You guys rock!
Sponsors (alphabetical order):
* [@aspyct](https://github.com/aspyct)
* [@codinghipster](https://github.com/codinghipster)
* [@HinFort](https://github.com/HinFort)
* [@mckay115](https://github.com/mckay115)
* [@neutralinsomniac](https://github.com/neutralinsomniac)
* [@nickexyz](https://github.com/nickexyz)
* [@qcasey](https://github.com/qcasey)
* [@Salamafet](https://github.com/Salamafet)
* +1 private sponsor
## ntfy Android app v1.14.0
Released September 27, 2022
@@ -18,7 +51,7 @@ languages. Hurray!
* Move action buttons in notification cards ([#236](https://github.com/binwiederhier/ntfy/issues/236), thanks to [@wunter8](https://github.com/wunter8))
* Icons can be set for each individual notification ([#126](https://github.com/binwiederhier/ntfy/issues/126), thanks to [@wunter8](https://github.com/wunter8))
**Bugs:**
**Bug fixes:**
* Long-click selecting of notifications doesn't scroll to the top anymore ([#235](https://github.com/binwiederhier/ntfy/issues/235), thanks to [@wunter8](https://github.com/wunter8))
* Add attachment and click URL extras to MESSAGE_RECEIVED broadcast ([#329](https://github.com/binwiederhier/ntfy/issues/329), thanks to [@wunter8](https://github.com/wunter8))
@@ -51,7 +84,7 @@ I would be very humbled if you consider donating.
* CLI: Allow default username/password in `client.yml` ([#372](https://github.com/binwiederhier/ntfy/pull/372), thanks to [@wunter8](https://github.com/wunter8))
* Build support for other Unix systems ([#393](https://github.com/binwiederhier/ntfy/pull/393), thanks to [@la-ninpre](https://github.com/la-ninpre))
**Bugs:**
**Bug fixes:**
* `ntfy user` commands don't work with `auth_file` but works with `auth-file` ([#344](https://github.com/binwiederhier/ntfy/issues/344), thanks to [@Histalek](https://github.com/Histalek) for reporting)
* Ignore new draft HTTP `Priority` header ([#351](https://github.com/binwiederhier/ntfy/issues/351), thanks to [@ksurl](https://github.com/ksurl) for reporting)
@@ -86,7 +119,7 @@ minute or so, due to competing stats gathering (personal installations will like
* Trace: Log entire HTTP request to simplify debugging (no ticket)
* Allow setting user password via `NTFY_PASSWORD` env variable ([#327](https://github.com/binwiederhier/ntfy/pull/327), thanks to [@Kenix3](https://github.com/Kenix3))
**Bugs:**
**Bug fixes:**
* Fix slow requests due to excessive locking ([#338](https://github.com/binwiederhier/ntfy/issues/338))
* Return HTTP 500 for `GET /_matrix/push/v1/notify` when `base-url` is not configured (no ticket)
@@ -111,7 +144,7 @@ CLI is now available via Scoop, and ntfy is now natively supported in Uptime Kum
* [Uptime Kuma](https://github.com/louislam/uptime-kuma) now allows publishing to ntfy ([uptime-kuma#1674](https://github.com/louislam/uptime-kuma/pull/1674), thanks to [@philippdormann](https://github.com/philippdormann))
* Display ntfy version in `ntfy serve` command ([#314](https://github.com/binwiederhier/ntfy/issues/314), thanks to [@poblabs](https://github.com/poblabs))
**Bugs:**
**Bug fixes:**
* Web app: Show "notifications not supported" alert on HTTP ([#323](https://github.com/binwiederhier/ntfy/issues/323), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
* Use last address in `X-Forwarded-For` header as visitor address ([#328](https://github.com/binwiederhier/ntfy/issues/328))
@@ -134,7 +167,7 @@ set your server as the default server for new topics.
* Support for auth and user management ([#277](https://github.com/binwiederhier/ntfy/issues/277))
* Ability to add default server ([#295](https://github.com/binwiederhier/ntfy/issues/295))
**Bugs:**
**Bug fixes:**
* Add validation for selfhosted server URL ([#290](https://github.com/binwiederhier/ntfy/issues/290))
@@ -197,7 +230,7 @@ for details).
* Cancel notifications when navigating to topic (no ticket)
* iOS 14.0 support (no ticket, [PR#1](https://github.com/binwiederhier/ntfy-ios/pull/1), thanks to [@callum-99](https://github.com/callum-99))
**Bugs:**
**Bug fixes:**
* iOS UI not always updating properly ([#267](https://github.com/binwiederhier/ntfy/issues/267))
@@ -214,7 +247,7 @@ Apple development environment.
* Add subscribe filter to query exact messages by ID (no ticket)
* Support for `poll_request` messages to support [iOS push notifications](https://ntfy.sh/docs/config/#ios-instant-notifications) for self-hosted servers (no ticket)
**Bugs:**
**Bug fixes:**
* Support emails without `Content-Type` ([#265](https://github.com/binwiederhier/ntfy/issues/265), thanks to [@dmbonsall](https://github.com/dmbonsall))
@@ -252,7 +285,7 @@ it adds support for APNs, the iOS messaging service. This is needed for the (soo
* Ability to disable the web app entirely ([#238](https://github.com/binwiederhier/ntfy/issues/238)/[#249](https://github.com/binwiederhier/ntfy/pull/249), thanks to [@Curid](https://github.com/Curid))
* Add APNs config to Firebase messages to support [iOS app](https://github.com/binwiederhier/ntfy/issues/4) ([#247](https://github.com/binwiederhier/ntfy/pull/247), thanks to [@Copephobia](https://github.com/Copephobia))
**Bugs:**
**Bug fixes:**
* Support underscores in server.yml config options ([#255](https://github.com/binwiederhier/ntfy/issues/255), thanks to [@ajdelgado](https://github.com/ajdelgado))
* Force MAKEFLAGS to --jobs=1 in `Makefile` ([#257](https://github.com/binwiederhier/ntfy/pull/257), thanks to [@oddlama](https://github.com/oddlama))
@@ -281,7 +314,7 @@ and custom icons. Aside from that, we've got tons of bug fixes as usual.
* Per-subscription settings, custom subscription icons ([#155](https://github.com/binwiederhier/ntfy/issues/155), thanks to [@mztiq](https://github.com/mztiq) for reporting)
* Cards in notification detail view ([#175](https://github.com/binwiederhier/ntfy/issues/175), thanks to [@cmeis](https://github.com/cmeis) for reporting)
**Bugs:**
**Bug fixes:**
* Accurate naming of "mute notifications" from "pause notifications" ([#224](https://github.com/binwiederhier/ntfy/issues/224), thanks to [@shadow00](https://github.com/shadow00) for reporting)
* Make messages with links selectable ([#226](https://github.com/binwiederhier/ntfy/issues/226), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
@@ -314,7 +347,7 @@ We've also improved the documentation a little and added translations for three
* Better parsing of the user actions, allowing quotes (no ticket)
* Add "mark as read" icon button to notification ([#243](https://github.com/binwiederhier/ntfy/pull/243), thanks to [@wunter8](https://github.com/wunter8))
**Bugs:**
**Bug fixes:**
* `Upgrade` header check is now case in-sensitive ([#228](https://github.com/binwiederhier/ntfy/issues/228), thanks to [@wunter8](https://github.com/wunter8) for finding it)
* Made web app sounds quieter ([#222](https://github.com/binwiederhier/ntfy/issues/222))
@@ -356,7 +389,7 @@ languages and fixed a ton of bugs.
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
* Channel settings option to configure DND override, sounds, etc. ([#91](https://github.com/binwiederhier/ntfy/issues/91))
**Bugs:**
**Bug fixes:**
* Validate URLs when changing default server and server in user management ([#193](https://github.com/binwiederhier/ntfy/issues/193),
thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov) for reporting)
@@ -397,7 +430,7 @@ Limited support is available in the web app.
* Added ARMv6 build ([#200](https://github.com/binwiederhier/ntfy/issues/200), thanks to [@jcrubioa](https://github.com/jcrubioa) for reporting)
* Web app internationalization support 🇧🇬 🇩🇪 🇺🇸 🌎 ([#189](https://github.com/binwiederhier/ntfy/issues/189))
**Bugs:**
**Bug fixes:**
* Web app: English language strings fixes, additional descriptions for settings ([#203](https://github.com/binwiederhier/ntfy/issues/203), thanks to [@StoyanDimitrov](https://github.com/StoyanDimitrov))
* Web app: Show error message snackbar when sending test notification fails ([#205](https://github.com/binwiederhier/ntfy/issues/205), thanks to [@cmeis](https://github.com/cmeis))
@@ -437,7 +470,7 @@ Released Apr 7, 2022
* Translations to different languages ([#188](https://github.com/binwiederhier/ntfy/issues/188), thanks to
[@StoyanDimitrov](https://github.com/StoyanDimitrov) for initiating things)
**Bugs:**
**Bug fixes:**
* IllegalStateException: Failed to build unique file ([#177](https://github.com/binwiederhier/ntfy/issues/177), thanks to [@Fallenbagel](https://github.com/Fallenbagel) for reporting)
* SQLiteConstraintException: Crash during UP registration ([#185](https://github.com/binwiederhier/ntfy/issues/185))
@@ -471,7 +504,7 @@ Released Apr 6, 2022
* Added message bar and publish dialog ([#196](https://github.com/binwiederhier/ntfy/issues/196))
**Bugs:**
**Bug fixes:**
* Added `EXPOSE 80/tcp` to Dockerfile to support auto-discovery in [Traefik](https://traefik.io/) ([#195](https://github.com/binwiederhier/ntfy/issues/195), thanks to [@s-h-a-r-d](https://github.com/s-h-a-r-d))
@@ -487,7 +520,7 @@ Released Apr 6, 2022
## ntfy server v1.19.0
Released Mar 30, 2022
**Bugs:**
**Bug fixes:**
* Do not pack binary with `upx` for armv7/arm64 due to `illegal instruction` errors ([#191](https://github.com/binwiederhier/ntfy/issues/191), thanks to [@iexos](https://github.com/iexos))
* Do not allow comma in topic name in publish via GET endpoint (no ticket)

3442
examples/publish-js/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"browserify": "^17.0.0",
"jose": "^4.8.3"
}
}

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Publish to ntfy.sh</title>
</head>
<body>
<input id="password" placeholder="Topic password"/>
<button onclick="publish()">Publish encrypted message</button>
</body>
<script async src="https://unpkg.com/jose@4.8.3/dist/browser/jwe/compact/encrypt.js" type="module"></script>
<script async src="publish-encrypted.js" type="module"></script>
</html>

View File

@@ -0,0 +1,7 @@
import * as jose from 'jose'
async function publish() {
const jwe = await new jose.CompactEncrypt(new TextEncoder().encode('Secret message from JS!'))
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.encrypt(publicKey)
}

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Publish to ntfy.sh</title>
</head>
<body>
<button onclick="publish()">Publish</button>
</body>
<script>
function publish() {
fetch('https://ntfy.sh/mytopic', {
method: 'POST', // PUT works too
body: 'Backup successful 😀'
});
}
</script>
</html>

View File

@@ -0,0 +1,46 @@
<?php
$message = [
"message" => "Secret!",
"priority" => 5
];
$plaintext = json_encode($message);
$key = deriveKey("secr3t password", "https://ntfy.sh/mysecret");
$ciphertext = encrypt($plaintext, $key);
file_get_contents('https://ntfy.sh/mysecret', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
"Content-Type: text/plain\r\n" .
"Encryption: jwe",
'content' => $ciphertext
]
]));
function deriveKey($password, $topicUrl)
{
$salt = hex2bin(hash("sha256", $topicUrl));
return openssl_pbkdf2($password, $salt, 32, 50000, "sha256");
}
function encrypt(string $plaintext, string $key): string
{
$encodedHeader = base64url_encode(json_encode(["alg" => "dir", "enc" => "A256GCM"]));
$iv = openssl_random_pseudo_bytes(12); // GCM is used with a 96-bit IV
$aad = $encodedHeader;
$tag = null;
$content = openssl_encrypt($plaintext, "aes-256-gcm", $key, OPENSSL_RAW_DATA, $iv, $tag, $aad);
return
$encodedHeader . "." .
"." . // No content encryption key (CEK) in "dir" mode
base64url_encode($iv) . "." .
base64url_encode($content) . "." .
base64url_encode($tag);
}
function base64url_encode($input)
{
return str_replace('=', '', strtr(base64_encode($input), '+/', '-_'));
}

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
import requests
from base64 import b64encode, urlsafe_b64encode, b64decode
from Crypto.Cipher import AES
from Crypto.Protocol.KDF import PBKDF2
from Crypto.Hash import SHA256
from Crypto.Random import get_random_bytes
def derive_key(password, topic_url):
salt = SHA256.new(data=topic_url.encode('utf-8')).digest()
return PBKDF2(password, salt, 32, count=50000, hmac_hash_module=SHA256)
def encrypt(plaintext, key):
encoded_header = b64urlencode('{"alg":"dir","enc":"A256GCM"}'.encode('utf-8'))
iv = get_random_bytes(12) # GCM is used with a 96-bit IV
aad = encoded_header
cipher = AES.new(key, AES.MODE_GCM, nonce=iv)
cipher.update(aad.encode('utf-8'))
ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode('utf-8'))
return "{header}..{iv}.{ciphertext}.{tag}".format(
header=encoded_header,
iv=b64urlencode(iv),
ciphertext=b64urlencode(ciphertext),
tag=b64urlencode(tag)
)
def b64urlencode(b):
return urlsafe_b64encode(b).decode('utf-8').replace("=", "")
key = derive_key("secr3t password", "https://ntfy.sh/mysecret")
ciphertext = encrypt('{"message":"Python says hi","tags":["secret"]}', key)
resp = requests.post("https://ntfy.sh/mysecret", data=ciphertext, headers={"Encryption": "jwe"})
resp.raise_for_status()

View File

@@ -0,0 +1,2 @@
requests
pycryptodome

23
go.mod
View File

@@ -1,6 +1,6 @@
module heckel.io/ntfy
go 1.17
go 1.18
require (
cloud.google.com/go/firestore v1.6.1 // indirect
@@ -13,24 +13,27 @@ require (
github.com/mattn/go-sqlite3 v1.14.15
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.16.3
github.com/urfave/cli/v2 v2.17.1
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 // indirect
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
golang.org/x/term v0.0.0-20220919170432-7a66f970e087
golang.org/x/time v0.0.0-20220922220347-f3bd1da661af
google.golang.org/api v0.97.0
google.golang.org/api v0.98.0
gopkg.in/yaml.v2 v2.4.0
)
require github.com/pkg/errors v0.9.1 // indirect
require firebase.google.com/go/v4 v4.8.0
require (
firebase.google.com/go/v4 v4.8.0
gopkg.in/square/go-jose.v2 v2.6.0
)
require (
cloud.google.com/go v0.104.0 // indirect
cloud.google.com/go/compute v1.10.0 // indirect
cloud.google.com/go/iam v0.4.0 // indirect
cloud.google.com/go/iam v0.5.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead // indirect
@@ -38,19 +41,19 @@ require (
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.0 // indirect
github.com/googleapis/gax-go/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20220927155233-aa73b2587036 // indirect
golang.org/x/sys v0.0.0-20220926163933-8cfa568d3c25 // indirect
golang.org/x/net v0.0.0-20220930213112-107f3e3c3b0b // indirect
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/appengine/v2 v2.0.2 // indirect
google.golang.org/genproto v0.0.0-20220927151529-dcaddaf36704 // indirect
google.golang.org/genproto v0.0.0-20220930163606-c98284e70a91 // indirect
google.golang.org/grpc v1.49.0 // indirect
google.golang.org/protobuf v1.28.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

18
go.sum
View File

@@ -83,6 +83,8 @@ cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
cloud.google.com/go/iam v0.4.0 h1:YBYU00SCDzZJdHqVc4I5d6lsklcYIjQZa1YmEz4jlSE=
cloud.google.com/go/iam v0.4.0/go.mod h1:cbaZxyScUhxl7ZAkNWiALgihfP75wS/fUsVNaa1r3vA=
cloud.google.com/go/iam v0.5.0 h1:fz9X5zyTWBmamZsqvqZqD7khbifcZF/q+Z1J8pfhIUg=
cloud.google.com/go/iam v0.5.0/go.mod h1:wPU9Vt0P4UmCux7mqtRu6jcpPAb74cP1fh50J3QpkUc=
cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic=
cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8=
cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4=
@@ -262,6 +264,8 @@ github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.1.0 h1:zO8WHNx/MYiAKJ3d5spxZXZE6KHmIQGQcAzwUzV7qQw=
github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.2.0 h1:y8Yozv7SZtlU//QXbezB6QkpuE6jMD2/gfzk4AftXjs=
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -311,6 +315,8 @@ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5Cc
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/urfave/cli/v2 v2.16.3 h1:gHoFIwpPjoyIMbJp/VFd+/vuD0dAgFK4B6DpEMFJfQk=
github.com/urfave/cli/v2 v2.16.3/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI=
github.com/urfave/cli/v2 v2.17.1 h1:UzjDEw2dJQUE3iRaiNQ1VrVFbyAtKGH3VdkMoHA58V0=
github.com/urfave/cli/v2 v2.17.1/go.mod h1:1CNUng3PtjQMtRzJO4FMXBQvkGtuYRxxiR9xMa7jMwI=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -417,6 +423,8 @@ golang.org/x/net v0.0.0-20220708220712-1185a9018129/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220927155233-aa73b2587036 h1:GDWXwjBkdo4XMin5T4iul98eH4BfGOR7TucJ057FxjY=
golang.org/x/net v0.0.0-20220927155233-aa73b2587036/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220930213112-107f3e3c3b0b h1:uKO3Js8lXGjpjdc4J3rqs0/Ex5yDKUGfk43tTYWVLas=
golang.org/x/net v0.0.0-20220930213112-107f3e3c3b0b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -456,6 +464,8 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 h1:ZrnxWX62AgTKOSagEqxvb3ffipvEDX2pl7E1TdqLqIc=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -522,6 +532,8 @@ golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220926163933-8cfa568d3c25 h1:nwzwVf0l2Y/lkov/+IYgMMbFyI+QypZDds9RxlSmsFQ=
golang.org/x/sys v0.0.0-20220926163933-8cfa568d3c25/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20220919170432-7a66f970e087 h1:tPwmk4vmvVCMdr98VgL4JH+qZxPL8fqlUOHnyOM8N3w=
@@ -653,6 +665,8 @@ google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaE
google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
google.golang.org/api v0.97.0 h1:x/vEL1XDF/2V4xzdNgFPaKHluRESo2aTsL7QzHnBtGQ=
google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
google.golang.org/api v0.98.0 h1:yxZrcxXESimy6r6mdL5Q6EnZwmewDJK2dVg3g75s5Dg=
google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -769,6 +783,8 @@ google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+S
google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw=
google.golang.org/genproto v0.0.0-20220927151529-dcaddaf36704 h1:H1AcWFV69NFCMeBJ8nVLtv8uHZZ5Ozcgoq012hHEFuU=
google.golang.org/genproto v0.0.0-20220927151529-dcaddaf36704/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI=
google.golang.org/genproto v0.0.0-20220930163606-c98284e70a91 h1:Ezh2cpcnP5Rq60sLensUsFnxh7P6513NLvNtCm9iyJ4=
google.golang.org/genproto v0.0.0-20220930163606-c98284e70a91/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -824,6 +840,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=

View File

@@ -40,32 +40,32 @@ var (
)
// Trace prints the given message, if the current log level is TRACE
func Trace(message string, v ...interface{}) {
func Trace(message string, v ...any) {
logIf(TraceLevel, message, v...)
}
// Debug prints the given message, if the current log level is DEBUG or lower
func Debug(message string, v ...interface{}) {
func Debug(message string, v ...any) {
logIf(DebugLevel, message, v...)
}
// Info prints the given message, if the current log level is INFO or lower
func Info(message string, v ...interface{}) {
func Info(message string, v ...any) {
logIf(InfoLevel, message, v...)
}
// Warn prints the given message, if the current log level is WARN or lower
func Warn(message string, v ...interface{}) {
func Warn(message string, v ...any) {
logIf(WarnLevel, message, v...)
}
// Error prints the given message, if the current log level is ERROR or lower
func Error(message string, v ...interface{}) {
func Error(message string, v ...any) {
logIf(ErrorLevel, message, v...)
}
// Fatal prints the given message, and exits the program
func Fatal(v ...interface{}) {
func Fatal(v ...any) {
log.Fatalln(v...)
}
@@ -122,7 +122,7 @@ func IsDebug() bool {
return Loggable(DebugLevel)
}
func logIf(l Level, message string, v ...interface{}) {
func logIf(l Level, message string, v ...any) {
if CurrentLevel() <= l {
log.Printf(l.String()+" "+message, v...)
}

View File

@@ -60,13 +60,13 @@ func parseActions(s string) (actions []*action, err error) {
return nil, fmt.Errorf("only %d actions allowed", actionsMax)
}
for _, action := range actions {
if !util.InStringList(actionsAll, action.Action) {
if !util.Contains(actionsAll, action.Action) {
return nil, fmt.Errorf("parameter 'action' cannot be '%s', valid values are 'view', 'broadcast' and 'http'", action.Action)
} else if action.Label == "" {
return nil, fmt.Errorf("parameter 'label' is required")
} else if util.InStringList(actionsWithURL, action.Action) && action.URL == "" {
} else if util.Contains(actionsWithURL, action.Action) && action.URL == "" {
return nil, fmt.Errorf("parameter 'url' is required for action '%s'", action.Action)
} else if action.Action == actionHTTP && util.InStringList([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
} else if action.Action == actionHTTP && util.Contains([]string{"GET", "HEAD"}, action.Method) && action.Body != "" {
return nil, fmt.Errorf("parameter 'body' cannot be set if method is %s", action.Method)
}
}
@@ -156,7 +156,7 @@ func populateAction(newAction *action, section int, key, value string) error {
key = "action"
} else if key == "" && section == 1 {
key = "label"
} else if key == "" && section == 2 && util.InStringList(actionsWithURL, newAction.Action) {
} else if key == "" && section == 2 && util.Contains(actionsWithURL, newAction.Action) {
key = "url"
}
@@ -178,7 +178,7 @@ func populateAction(newAction *action, section int, key, value string) error {
newAction.Label = value
case "clear":
lvalue := strings.ToLower(value)
if !util.InStringList([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
if !util.Contains([]string{"true", "yes", "1", "false", "no", "0"}, lvalue) {
return fmt.Errorf("parameter 'clear' cannot be '%s', only boolean values are allowed (true/yes/1/false/no/0)", value)
}
newAction.Clear = lvalue == "true" || lvalue == "yes" || lvalue == "1"

View File

@@ -23,7 +23,7 @@ func (e errHTTP) JSON() string {
return string(b)
}
func wrapErrHTTP(err *errHTTP, message string, args ...interface{}) *errHTTP {
func wrapErrHTTP(err *errHTTP, message string, args ...any) *errHTTP {
return &errHTTP{
Code: err.Code,
HTTPCode: err.HTTPCode,
@@ -52,12 +52,14 @@ var (
errHTTPBadRequestActionsInvalid = &errHTTP{40018, http.StatusBadRequest, "invalid request: actions invalid", "https://ntfy.sh/docs/publish/#action-buttons"}
errHTTPBadRequestMatrixMessageInvalid = &errHTTP{40019, http.StatusBadRequest, "invalid request: Matrix JSON invalid", "https://ntfy.sh/docs/publish/#matrix-gateway"}
errHTTPBadRequestMatrixPushkeyBaseURLMismatch = &errHTTP{40020, http.StatusBadRequest, "invalid request: push key must be prefixed with base URL", "https://ntfy.sh/docs/publish/#matrix-gateway"}
errHTTPBadRequestUnexpectedMultipartField = &errHTTP{40021, http.StatusBadRequest, "invalid request: unexpected multipart field", "https://ntfy.sh/docs/publish/#end-to-end-encryption"}
errHTTPBadRequestIconURLInvalid = &errHTTP{40021, http.StatusBadRequest, "invalid request: icon URL is invalid", "https://ntfy.sh/docs/publish/#icons"}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPEntityTooLargeAttachmentTooLarge = &errHTTP{41301, http.StatusRequestEntityTooLarge, "attachment too large, or bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPEntityTooLargeMatrixRequestTooLarge = &errHTTP{41302, http.StatusRequestEntityTooLarge, "Matrix request is larger than the max allowed length", ""}
errHTTPEntityTooLargeMessageTooLarge = &errHTTP{41303, http.StatusRequestEntityTooLarge, "message payload too large", "https://ntfy.sh/docs/publish/#limits"}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}

View File

@@ -7,6 +7,7 @@ import (
"embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
@@ -95,6 +96,9 @@ const (
newMessageBody = "New message" // Used in poll requests as generic message
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
encodingBase64 = "base64"
encodingJWE = "jwe"
multipartFieldMessage = "message"
multipartFieldAttachment = "attachment"
)
// WebSocket constants
@@ -309,12 +313,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.limitRequests(s.handleFile)(w, r, v)
} else if r.Method == http.MethodOptions {
return s.ensureWebEnabled(s.handleOptions)(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == "/" {
return s.limitRequests(s.transformBodyJSON(s.authWrite(s.handlePublish)))(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (r.URL.Path == "/" || topicPathRegex.MatchString(r.URL.Path)) {
return s.limitRequests(s.handlePublishAll)(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
return s.limitRequests(s.transformMatrixJSON(s.authWrite(s.handlePublishMatrix)))(w, r, v)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
@@ -414,7 +416,7 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
}
messageID := matches[1]
file := filepath.Join(s.config.AttachmentCacheDir, messageID)
stat, err := os.Stat(file)
stat, err := os.Stat(file) // TODO: Why is this here and not in fileCache?!
if err != nil {
return errHTTPNotFound
}
@@ -444,20 +446,27 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
return writeMatrixDiscoveryResponse(w)
}
func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) {
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, error) {
t, err := s.topicFromPath(r.URL.Path)
if err != nil {
return nil, err
}
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return nil, err
}
m := newDefaultMessage(t.ID, "")
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
if err != nil {
return nil, err
}
var body *util.PeekedReadCloser
if m.Encoding == encodingJWE {
m = newEncryptedMessage(t.ID, im.M)
if body, err = s.handlePublishEncrypted(r, m); err != nil {
return nil, err
}
} else {
if body, err = util.Peek(r.Body, s.config.MessageLimit); err != nil {
return nil, err
}
}
if m.PollID != "" {
m = newPollRequestMessage(t.ID, m.PollID)
}
@@ -500,8 +509,229 @@ func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*mes
return m, nil
}
type inputMessage struct {
PublishMessage
AttachmentBody io.ReadCloser
Cache bool
Firebase bool
UnifiedPush bool
PollID string
Encoding string
}
func (s *Server) handlePublishAll(w http.ResponseWriter, r *http.Request, v *visitor) error {
// TODO authWrite
im, err := s.parsePublishInputMessage(r, v)
if err != nil {
return err
}
t, err := s.topicsFromID(im.Topic)
if err != nil {
return err
}
m, err := s.checkAndConvertPublishMessage(v, im)
if err != nil {
return err
}
var body *util.PeekedReadCloser
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
return err
}
if m.Message == "" {
m.Message = emptyMessageBody
}
delayed := m.Time > time.Now().Unix()
log.Debug("%s Received message: event=%s, body=%d byte(s), delayed=%t, firebase=%t, cache=%t, up=%t, email=%s",
logMessagePrefix(v, m), m.Event, len(m.Message), delayed, im.Firebase, im.Cache, im.UnifiedPush, im.Email)
if log.IsTrace() {
log.Trace("%s Message body: %s", logMessagePrefix(v, m), util.MaybeMarshalJSON(m))
}
if !delayed {
if err := t.Publish(v, m); err != nil {
return err
}
if s.firebaseClient != nil && im.Firebase {
go s.sendToFirebase(v, m)
}
if s.smtpSender != nil && im.Email != "" {
go s.sendEmail(v, m, im.Email)
}
if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m)
}
} else {
log.Debug("%s Message delayed, will process later", logMessagePrefix(v, m))
}
if im.Cache {
if err := s.messageCache.AddMessage(m); err != nil {
return err
}
}
s.mu.Lock()
s.messages++
s.mu.Unlock()
return nil
}
func (s *Server) parsePublishInputMessage(r *http.Request, v *visitor) (im *inputMessage, err error) {
im = &inputMessage{}
encrypted := readParam(r, "x-encoding", "encoding") == encodingJWE
multipart := strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/")
isJSON := r.URL.Path == "/"
if err := s.parsePublishParams(r, im); err != nil {
return nil, err
}
parts := strings.Split(r.URL.Path, "/")
if len(parts) < 2 {
return nil, errHTTPBadRequestTopicInvalid
}
im.Topic = parts[1]
if multipart {
im.Message, im.AttachmentBody, err = s.readMultipart(r)
if err != nil {
return nil, err
}
if !encrypted && isJSON {
if err := json.NewDecoder(strings.NewReader(im.Message)).Decode(&im.PublishMessage); err != nil {
return nil, errHTTPBadRequestJSONInvalid
}
}
} else {
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return nil, err
}
if encrypted {
if body.LimitReached {
return nil, errHTTPEntityTooLargeMessageTooLarge
}
im.Message = string(body.PeekedBytes)
} else if body.LimitReached {
im.AttachmentBody = body
} else if isJSON {
if err := json.NewDecoder(strings.NewReader(im.Message)).Decode(&im.PublishMessage); err != nil {
return nil, errHTTPBadRequestJSONInvalid
}
} else {
im.Message = string(body.PeekedBytes)
}
}
return im, nil
}
func (s *Server) readMultipart(r *http.Request) (message string, attachment io.ReadCloser, err error) {
mp, err := r.MultipartReader()
if err != nil {
return "", nil, err
}
p, err := mp.NextPart()
if err != nil {
return "", nil, err
} else if p.FormName() != multipartFieldMessage {
return "", nil, wrapErrHTTP(errHTTPBadRequestUnexpectedMultipartField, "expected '%s', got '%s'", multipartFieldMessage, p.FormName())
}
messageBody, err := util.PeekLimit(p, s.config.MessageLimit)
if err == util.ErrLimitReached {
return "", nil, errHTTPEntityTooLargeMessageTooLarge
} else if err != nil {
return "", nil, err
}
message = string(messageBody.PeekedBytes)
attachment, err = mp.NextPart()
if err != nil {
return "", nil, err
} else if p.FormName() != multipartFieldAttachment {
return "", nil, wrapErrHTTP(errHTTPBadRequestUnexpectedMultipartField, "expected '%s', got '%s'", multipartFieldAttachment, p.FormName())
}
return message, attachment, nil
}
func (s *Server) checkAndConvertPublishMessage(v *visitor, im *inputMessage) (m *message, err error) {
if m.PollID != "" {
im.Cache = false
im.Email = ""
im.UnifiedPush = false
return newPollRequestMessage(im.Topic, m.PollID), nil
} else if im.Encoding == encodingJWE {
im.Email = ""
im.UnifiedPush = false
return newEncryptedMessage(im.Topic, im.Message), nil
}
m = newDefaultMessage(im.Topic, im.Message)
m.Title = im.Title
m.Priority = im.Priority
m.Tags = im.Tags
m.Click = im.Click
m.Actions = im.Actions
if im.Attach != "" || im.Filename != "" {
m.Attachment = &attachment{}
}
if im.Filename != "" {
m.Attachment.Name = im.Filename
}
if im.Attach != "" {
if !urlRegex.MatchString(im.Attach) {
return nil, errHTTPBadRequestAttachmentURLInvalid
}
if im.AttachmentBody != nil {
return nil, errors.New("cannot attach and send attachment body") // TODO test for this
}
m.Attachment.URL = im.Attach
if im.Filename == "" {
u, err := url.Parse(m.Attachment.URL)
if err == nil {
m.Attachment.Name = path.Base(u.Path)
if m.Attachment.Name == "." || m.Attachment.Name == "/" {
m.Attachment.Name = ""
}
}
}
if m.Attachment.Name == "" {
m.Attachment.Name = "attachment"
}
}
if im.Icon != "" {
if !urlRegex.MatchString(im.Icon) {
return nil, errHTTPBadRequestIconURLInvalid
}
m.Icon = im.Icon
}
if im.Email != "" {
if err := v.EmailAllowed(); err != nil {
return nil, errHTTPTooManyRequestsLimitEmails
}
}
if s.smtpSender == nil && im.Email != "" {
return nil, errHTTPBadRequestEmailDisabled
}
if im.Delay != "" {
if !im.Cache {
return nil, errHTTPBadRequestDelayNoCache
}
if im.Email != "" {
return nil, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
delay, err := util.ParseFutureTime(im.Delay, time.Now())
if err != nil {
return nil, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
return nil, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
return nil, errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
m.Sender = v.ip // Important for rate limiting
}
if im.UnifiedPush {
im.Firebase = false
im.UnifiedPush = true
}
return m, nil
}
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
m, err := s.handlePublishWithoutResponse(r, v)
m, err := s.handlePublishInternal(r, v)
if err != nil {
return err
}
@@ -514,7 +744,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
}
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
_, err := s.handlePublishWithoutResponse(r, v)
_, err := s.handlePublishInternal(r, v)
if err != nil {
return &errMatrix{pushKey: r.Header.Get(matrixPushKeyHeader), err: err}
}
@@ -563,60 +793,24 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
}
}
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
func (s *Server) parsePublishParams(r *http.Request, m *inputMessage) error {
m.Message = strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
m.Title = readParam(r, "x-title", "title", "t")
m.Click = readParam(r, "x-click", "click")
icon := readParam(r, "x-icon", "icon")
filename := readParam(r, "x-filename", "filename", "file", "f")
attach := readParam(r, "x-attach", "attach", "a")
if attach != "" || filename != "" {
m.Attachment = &attachment{}
}
if filename != "" {
m.Attachment.Name = filename
}
if attach != "" {
if !urlRegex.MatchString(attach) {
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
if m.Attachment.Name == "" {
u, err := url.Parse(m.Attachment.URL)
if err == nil {
m.Attachment.Name = path.Base(u.Path)
if m.Attachment.Name == "." || m.Attachment.Name == "/" {
m.Attachment.Name = ""
}
}
}
if m.Attachment.Name == "" {
m.Attachment.Name = "attachment"
}
}
if icon != "" {
if !urlRegex.MatchString(icon) {
return false, false, "", false, errHTTPBadRequestIconURLInvalid
}
m.Icon = icon
}
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if email != "" {
if err := v.EmailAllowed(); err != nil {
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
}
}
if s.smtpSender == nil && email != "" {
return false, false, "", false, errHTTPBadRequestEmailDisabled
}
messageStr := strings.ReplaceAll(readParam(r, "x-message", "message", "m"), "\\n", "\n")
if messageStr != "" {
m.Message = messageStr
}
m.Icon = readParam(r, "x-icon", "icon")
m.Filename = readParam(r, "x-filename", "filename", "file", "f")
m.Attach = readParam(r, "x-attach", "attach", "a")
m.Email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
m.Delay = readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
m.Encoding = readParam(r, "x-encoding", "encoding")
m.UnifiedPush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
m.Cache = readBoolParam(r, true, "x-cache", "cache")
m.Firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.PollID = readParam(r, "x-poll-id", "poll-id")
var err error
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
if err != nil {
return false, false, "", false, errHTTPBadRequestPriorityInvalid
return errHTTPBadRequestPriorityInvalid
}
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
if tagsStr != "" {
@@ -625,44 +819,14 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
m.Tags = append(m.Tags, strings.TrimSpace(s))
}
}
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" {
if !cache {
return false, false, "", false, errHTTPBadRequestDelayNoCache
}
if email != "" {
return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil {
return false, false, "", false, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
return false, false, "", false, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
return false, false, "", false, errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
m.Sender = v.ip // Important for rate limiting
}
actionsStr := readParam(r, "x-actions", "actions", "action")
if actionsStr != "" {
m.Actions, err = parseActions(actionsStr)
if err != nil {
return false, false, "", false, wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
return wrapErrHTTP(errHTTPBadRequestActionsInvalid, err.Error())
}
}
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
if unifiedpush {
firebase = false
unifiedpush = true
}
m.PollID = readParam(r, "x-poll-id", "poll-id")
if m.PollID != "" {
unifiedpush = false
cache = false
email = ""
}
return cache, firebase, email, unifiedpush, nil
return nil
}
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
@@ -1083,12 +1247,22 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
return topics, parts[1], nil
}
func (s *Server) topicsFromID(id string) (*topic, error) {
t, err := s.topicsFromIDs(id)
if err != nil {
return nil, err
} else if len(t) == 0 {
return nil, errHTTPBadRequestTopicDisallowed
}
return t[0], nil
}
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
s.mu.Lock()
defer s.mu.Unlock()
topics := make([]*topic, 0)
for _, id := range ids {
if util.InStringList(disallowedTopics, id) {
if util.Contains(disallowedTopics, id) {
return nil, errHTTPBadRequestTopicDisallowed
}
if _, ok := s.topics[id]; !ok {
@@ -1286,7 +1460,7 @@ func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
func (s *Server) limitRequests(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if util.InStringList(s.config.VisitorRequestExemptIPAddrs, v.ip) {
if util.Contains(s.config.VisitorRequestExemptIPAddrs, v.ip) {
return next(w, r, v)
} else if err := v.RequestAllowed(); err != nil {
return errHTTPTooManyRequestsLimitRequests
@@ -1304,65 +1478,6 @@ func (s *Server) ensureWebEnabled(next handleFunc) handleFunc {
}
}
// transformBodyJSON peeks the request body, reads the JSON, and converts it to headers
// before passing it on to the next handler. This is meant to be used in combination with handlePublish.
func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
body, err := util.Peek(r.Body, s.config.MessageLimit)
if err != nil {
return err
}
defer r.Body.Close()
var m publishMessage
if err := json.NewDecoder(body).Decode(&m); err != nil {
return errHTTPBadRequestJSONInvalid
}
if !topicRegex.MatchString(m.Topic) {
return errHTTPBadRequestTopicInvalid
}
if m.Message == "" {
m.Message = emptyMessageBody
}
r.URL.Path = "/" + m.Topic
r.Body = io.NopCloser(strings.NewReader(m.Message))
if m.Title != "" {
r.Header.Set("X-Title", m.Title)
}
if m.Priority != 0 {
r.Header.Set("X-Priority", fmt.Sprintf("%d", m.Priority))
}
if m.Tags != nil && len(m.Tags) > 0 {
r.Header.Set("X-Tags", strings.Join(m.Tags, ","))
}
if m.Attach != "" {
r.Header.Set("X-Attach", m.Attach)
}
if m.Filename != "" {
r.Header.Set("X-Filename", m.Filename)
}
if m.Click != "" {
r.Header.Set("X-Click", m.Click)
}
if m.Icon != "" {
r.Header.Set("X-Icon", m.Icon)
}
if len(m.Actions) > 0 {
actionsStr, err := json.Marshal(m.Actions)
if err != nil {
return errHTTPBadRequestJSONInvalid
}
r.Header.Set("X-Actions", string(actionsStr))
}
if m.Email != "" {
r.Header.Set("X-Email", m.Email)
}
if m.Delay != "" {
r.Header.Set("X-Delay", m.Delay)
}
return next(w, r, v)
}
}
func (s *Server) transformMatrixJSON(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
newRequest, err := newRequestFromMatrixJSON(r, s.config.BaseURL, s.config.MessageLimit)

View File

@@ -217,7 +217,7 @@ func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
// We must set the Alert struct ("alert"), and we need to set MutableContent ("mutable-content"), so the Notification Service
// Extension in iOS can modify the message.
func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSConfig {
apnsData := make(map[string]interface{})
apnsData := make(map[string]any)
for k, v := range data {
apnsData[k] = v
}
@@ -241,7 +241,7 @@ func createAPNSAlertConfig(m *message, data map[string]string) *messaging.APNSCo
//
// See https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/pushing_background_updates_to_your_app
func createAPNSBackgroundConfig(data map[string]string) *messaging.APNSConfig {
apnsData := make(map[string]interface{})
apnsData := make(map[string]any)
for k, v := range data {
apnsData[k] = v
}

View File

@@ -71,7 +71,7 @@ func TestToFirebaseMessage_Keepalive(t *testing.T) {
Aps: &messaging.Aps{
ContentAvailable: true,
},
CustomData: map[string]interface{}{
CustomData: map[string]any{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
@@ -102,7 +102,7 @@ func TestToFirebaseMessage_Open(t *testing.T) {
Aps: &messaging.Aps{
ContentAvailable: true,
},
CustomData: map[string]interface{}{
CustomData: map[string]any{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
@@ -166,7 +166,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
Body: "this is a message",
},
},
CustomData: map[string]interface{}{
CustomData: map[string]any{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
@@ -242,7 +242,7 @@ func TestToFirebaseMessage_PollRequest(t *testing.T) {
Body: "New message",
},
},
CustomData: map[string]interface{}{
CustomData: map[string]any{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": "poll_request",

View File

@@ -2,6 +2,7 @@ package server
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
@@ -10,6 +11,7 @@ import (
"io"
"log"
"math/rand"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -1461,6 +1463,82 @@ func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
log.Printf("Done: Waiting for all locks")
}
func TestServer_PublishEncrypted_Simple(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
ciphertext := "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q"
response := request(t, s, "PUT", "/mytopic", ciphertext, map[string]string{
"Encoding": "jwe",
"Title": "this will be stripped",
})
m := toMessage(t, response.Body.String())
require.Equal(t, "jwe", m.Encoding)
require.Equal(t, "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q", m.Message)
require.Equal(t, "", m.Title)
}
func TestServer_PublishEncrypted_Simple_TooLarge(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
ciphertext := util.RandomString(5001) // > 4096
response := request(t, s, "PUT", "/mytopic", ciphertext, map[string]string{
"Encoding": "jwe",
})
err := toHTTPError(t, response.Body.String())
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41303, err.Code)
}
func TestServer_PublishEncrypted_WithAttachment(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
parts := []mpart{
{"message", "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q"},
{"attachment", "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..vbe1Qv_-mKYbUgce.EfmOUIUi7lxXZG_o4bqXZ9pmpr1Rzs4Y5QLE2XD2_aw_SQ.y2hadrN5b2LEw7_PJHhbcA"},
}
response := requestMultipart(t, s, "PUT", "/mytopic", parts, map[string]string{
"Encoding": "jwe",
})
m := toMessage(t, response.Body.String())
require.Equal(t, "jwe", m.Encoding)
require.Equal(t, "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q", m.Message)
require.Equal(t, "attachment.jwe", m.Attachment.Name)
require.Equal(t, "application/jose", m.Attachment.Type)
require.Equal(t, int64(127), m.Attachment.Size)
file := filepath.Join(s.config.AttachmentCacheDir, m.ID)
require.FileExists(t, file)
require.Equal(t, "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..vbe1Qv_-mKYbUgce.EfmOUIUi7lxXZG_o4bqXZ9pmpr1Rzs4Y5QLE2XD2_aw_SQ.y2hadrN5b2LEw7_PJHhbcA", readFile(t, file))
}
func TestServer_PublishEncrypted_WithAttachment_TooLarge_Attachment(t *testing.T) {
c := newTestConfig(t)
c.AttachmentFileSizeLimit = 5000
s := newTestServer(t, c)
parts := []mpart{
{"message", "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q"},
{"attachment", strings.Repeat("a", 5001)}, // > 5000
}
response := requestMultipart(t, s, "PUT", "/mytopic", parts, map[string]string{
"Encoding": "jwe",
})
err := toHTTPError(t, response.Body.String())
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41301, err.Code)
}
func TestServer_PublishEncrypted_WithAttachment_TooLarge_Message(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
parts := []mpart{
{"message", strings.Repeat("a", 5000)},
{"attachment", "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..gSRYZeX6eBhlj13w.LOchcxFXwALXE2GqdoSwFJEXdMyEbLfLKV9geXr17WrAN-nH7ya1VQ_Y6ebT1w.2eyLaTUfc_rpKaZr4-5I1Q"},
}
response := requestMultipart(t, s, "PUT", "/mytopic", parts, map[string]string{
"Encoding": "jwe",
})
err := toHTTPError(t, response.Body.String())
log.Printf(err.Error())
require.Equal(t, 413, err.HTTPCode)
require.Equal(t, 41303, err.Code)
}
func newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"
@@ -1491,6 +1569,33 @@ func request(t *testing.T, s *Server, method, url, body string, headers map[stri
return rr
}
type mpart struct {
key, value string
}
func requestMultipart(t *testing.T, s *Server, method, url string, parts []mpart, headers map[string]string) *httptest.ResponseRecorder {
var b bytes.Buffer
w := multipart.NewWriter(&b)
for _, part := range parts {
mw, _ := w.CreateFormField(part.key)
_, err := io.Copy(mw, strings.NewReader(part.value))
require.Nil(t, err)
}
require.Nil(t, w.Close())
rr := httptest.NewRecorder()
req, err := http.NewRequest(method, url, &b)
if err != nil {
t.Fatal(err)
}
req.RemoteAddr = "9.9.9.9" // Used for tests
req.Header.Set("Content-Type", w.FormDataContentType())
for k, v := range headers {
req.Header.Set(k, v)
}
s.handle(rr, req)
return rr
}
func subscribe(t *testing.T, s *Server, url string, rr *httptest.ResponseRecorder) context.CancelFunc {
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)

View File

@@ -137,7 +137,7 @@ func toEmojis(tags []string) (emojisOut []string, tagsOut []string, err error) {
nextTag:
for _, t := range tags { // TODO Super inefficient; we should just create a .json file with a map
for _, e := range emojis {
if util.InStringList(e.Aliases, t) {
if util.Contains(e.Aliases, t) {
emojisOut = append(emojisOut, e.Emoji)
continue nextTag
}

View File

@@ -34,7 +34,7 @@ type message struct {
Attachment *attachment `json:"attachment,omitempty"`
PollID string `json:"poll_id,omitempty"`
Sender string `json:"-"` // IP address of uploader, used for rate limiting
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
Encoding string `json:"encoding,omitempty"` // empty for UTF-8, "base64", or "jwe" (encrypted)
}
type attachment struct {
@@ -65,8 +65,8 @@ func newAction() *action {
}
}
// publishMessage is used as input when publishing as JSON
type publishMessage struct {
// PublishMessage is used as input when publishing as JSON
type PublishMessage struct {
Topic string `json:"topic"`
Title string `json:"title"`
Message string `json:"message"`
@@ -117,6 +117,12 @@ func newPollRequestMessage(topic, pollID string) *message {
return m
}
func newEncryptedMessage(topic, message string) *message {
m := newMessage(messageEvent, topic, message)
m.Encoding = encodingJWE
return m
}
func validMessageID(s string) bool {
return util.ValidRandomString(s, messageIDLength)
}
@@ -203,10 +209,10 @@ func (q *queryFilter) Pass(msg *message) bool {
if messagePriority == 0 {
messagePriority = 3 // For query filters, default priority (3) is the same as "not set" (0)
}
if len(q.Priority) > 0 && !util.InIntList(q.Priority, messagePriority) {
if len(q.Priority) > 0 && !util.Contains(q.Priority, messagePriority) {
return false
}
if len(q.Tags) > 0 && !util.InStringListAll(msg.Tags, q.Tags) {
if len(q.Tags) > 0 && !util.ContainsAll(msg.Tags, q.Tags) {
return false
}
return true

View File

@@ -30,7 +30,7 @@ func Gzip(next http.Handler) http.Handler {
}
var gzPool = sync.Pool{
New: func() interface{} {
New: func() any {
w := gzip.NewWriter(io.Discard)
return w
},

View File

@@ -38,6 +38,16 @@ func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
}, nil
}
func PeekLimit(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
rc, err := Peek(underlying, limit)
if err != nil {
return nil, err
} else if rc.LimitReached {
return nil, ErrLimitReached
}
return rc, nil
}
// Read reads from the peeked bytes and then from the underlying stream
func (r *PeekedReadCloser) Read(p []byte) (n int, err error) {
if r.closed {

View File

@@ -35,8 +35,8 @@ func FileExists(filename string) bool {
return stat != nil
}
// InStringList returns true if needle is contained in haystack
func InStringList(haystack []string, needle string) bool {
// Contains returns true if needle is contained in haystack
func Contains[T comparable](haystack []T, needle T) bool {
for _, s := range haystack {
if s == needle {
return true
@@ -45,8 +45,8 @@ func InStringList(haystack []string, needle string) bool {
return false
}
// InStringListAll returns true if all needles are contained in haystack
func InStringListAll(haystack []string, needles []string) bool {
// ContainsAll returns true if all needles are contained in haystack
func ContainsAll[T comparable](haystack []T, needles []T) bool {
matches := 0
for _, s := range haystack {
for _, needle := range needles {
@@ -58,16 +58,6 @@ func InStringListAll(haystack []string, needles []string) bool {
return matches == len(needles)
}
// InIntList returns true if needle is contained in haystack
func InIntList(haystack []int, needle int) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
// SplitNoEmpty splits a string using strings.Split, but filters out empty strings
func SplitNoEmpty(s string, sep string) []string {
res := make([]string, 0)
@@ -172,11 +162,23 @@ func ShortTopicURL(s string) string {
return strings.TrimPrefix(strings.TrimPrefix(s, "https://"), "http://")
}
// ExpandTopicURL expands a topic to a fully qualified URL, e.g. "mytopic" -> "https://ntfy.sh/mytopic"
func ExpandTopicURL(topic, defaultHost string) string {
if strings.HasPrefix(topic, "http://") || strings.HasPrefix(topic, "https://") {
return topic
} else if strings.Contains(topic, "/") {
return fmt.Sprintf("https://%s", topic)
}
return fmt.Sprintf("%s/%s", defaultHost, topic)
}
// DetectContentType probes the byte array b and returns mime type and file extension.
// The filename is only used to override certain special cases.
func DetectContentType(b []byte, filename string) (mimeType string, ext string) {
if strings.HasSuffix(strings.ToLower(filename), ".apk") {
return "application/vnd.android.package-archive", ".apk"
} else if strings.HasSuffix(strings.ToLower(filename), ".jwe") {
return "application/jose", ".jwe"
}
m := mimetype.Detect(b)
mimeType, ext = m.String(), m.Extension()
@@ -251,7 +253,7 @@ func BasicAuth(user, pass string) string {
// MaybeMarshalJSON returns a JSON string of the given object, or "<cannot serialize>" if serialization failed.
// This is useful for logging purposes where a failure doesn't matter that much.
func MaybeMarshalJSON(v interface{}) string {
func MaybeMarshalJSON(v any) string {
jsonBytes, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "<cannot serialize>"

View File

@@ -26,20 +26,20 @@ func TestFileExists(t *testing.T) {
func TestInStringList(t *testing.T) {
s := []string{"one", "two"}
require.True(t, InStringList(s, "two"))
require.False(t, InStringList(s, "three"))
require.True(t, Contains(s, "two"))
require.False(t, Contains(s, "three"))
}
func TestInStringListAll(t *testing.T) {
s := []string{"one", "two", "three", "four"}
require.True(t, InStringListAll(s, []string{"two", "four"}))
require.False(t, InStringListAll(s, []string{"three", "five"}))
require.True(t, ContainsAll(s, []string{"two", "four"}))
require.False(t, ContainsAll(s, []string{"three", "five"}))
}
func TestInIntList(t *testing.T) {
func TestContains(t *testing.T) {
s := []int{1, 2}
require.True(t, InIntList(s, 2))
require.False(t, InIntList(s, 3))
require.True(t, Contains(s, 2))
require.False(t, Contains(s, 3))
}
func TestSplitNoEmpty(t *testing.T) {

View File

@@ -82,7 +82,7 @@
"publish_dialog_attach_placeholder": "Datei von URL anhängen, z.B. https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_placeholder": "Dateiname des Anhangs",
"publish_dialog_delay_label": "Verzögerung",
"publish_dialog_email_placeholder": "Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@beispiel.com",
"publish_dialog_email_placeholder": "E-Mail-Adresse, an die die Benachrichtigung gesendet werden soll, z.B. phil@example.com",
"publish_dialog_chip_click_label": "Klick-URL",
"publish_dialog_button_cancel_sending": "Senden abbrechen",
"publish_dialog_drop_file_here": "Datei hierher ziehen",

View File

@@ -1 +1,191 @@
{}
{
"action_bar_show_menu": "메뉴 표시",
"action_bar_logo_alt": "ntfy 로고",
"action_bar_settings": "설정",
"action_bar_send_test_notification": "시험용 알림 발송",
"action_bar_clear_notifications": "모든 알림 초기화",
"action_bar_unsubscribe": "구독 해제",
"action_bar_toggle_mute": "알림 음소거/해제",
"action_bar_toggle_action_menu": "액션 메뉴 열기/닫기",
"message_bar_type_message": "여기에 메세지를 입력하세요",
"message_bar_error_publishing": "메세지 발송 오류",
"message_bar_show_dialog": "발송 창 표시",
"message_bar_publish": "메세지 발송",
"nav_topics_title": "구독한 주제",
"nav_button_all_notifications": "모든 알림",
"nav_button_publish_message": "알림 보내기",
"nav_button_subscribe": "주제 구독하기",
"nav_button_muted": "알림 음소거됨",
"nav_button_connecting": "연결중",
"alert_grant_title": "알림이 비활성화되어 있습니다",
"alert_grant_description": "데스크톱 알림을 받기 위해서는 브라우저에서 권한을 부여해야 합니다.",
"alert_grant_button": "권한 부여하기",
"alert_not_supported_title": "알림이 지원되지 않습니다",
"notifications_list_item": "알림",
"notifications_mark_read": "읽음으로 표시",
"notifications_delete": "삭제",
"notifications_copied_to_clipboard": "클립보드에 복사됨",
"notifications_tags": "태그",
"notifications_priority_x": "우선순위 {{priority}}",
"notifications_new_indicator": "새 알림",
"notifications_attachment_image": "첨부 이미지",
"notifications_attachment_copy_url_title": "첨부 주소를 클립보드에 복사",
"notifications_attachment_copy_url_button": "URL 복사",
"notifications_attachment_open_title": "{{url}}로 가기",
"publish_dialog_attachment_limits_file_and_quota_reached": "첨부파일 크기 제한({{fileSizeLimit}}) 초과 및 할당량 초과({{remainingBytes}} 남음)",
"publish_dialog_attachment_limits_file_reached": "첨부파일 크기 제한({{fileSizeLimit}}) 초과",
"publish_dialog_attachment_limits_quota_reached": "할당량 초과({{remainingBytes}} 남음)",
"publish_dialog_emoji_picker_show": "이모지 선택",
"publish_dialog_priority_min": "우선순위 최소",
"publish_dialog_priority_low": "우선순위 낮음",
"publish_dialog_priority_default": "우선순위 기본",
"publish_dialog_priority_high": "우선순위 높음",
"publish_dialog_priority_max": "우선순위 최상",
"publish_dialog_base_url_label": "서비스 URL",
"publish_dialog_base_url_placeholder": "서비스 URL, 예를 들면 https://example.com",
"publish_dialog_topic_label": "주제 이름",
"publish_dialog_topic_placeholder": "주제 이름, 예를 들면 phil_alerts",
"publish_dialog_topic_reset": "주제 초기화",
"publish_dialog_title_label": "제목",
"publish_dialog_title_placeholder": "알림 제목, 예를 들면 디스크 공간 경고",
"publish_dialog_message_label": "메세지",
"publish_dialog_message_placeholder": "메세지를 여기에 입력하세요",
"publish_dialog_tags_label": "태그",
"publish_dialog_tags_placeholder": "반점으로 구분된 태그 목록, 예를 들면 warning, srv1-backup",
"publish_dialog_priority_label": "우선순위",
"publish_dialog_click_label": "클릭 URL",
"publish_dialog_click_placeholder": "알림이 클릭되었을때 이동할 URL",
"publish_dialog_click_reset": "클릭 URL 제거",
"publish_dialog_email_label": "이메일",
"publish_dialog_email_placeholder": "알림을 전달할 이메일 주소, 예를 들면 phil@example.com",
"publish_dialog_email_reset": "이메일 전달 삭제",
"publish_dialog_attach_label": "첨부 파일 URL",
"publish_dialog_attach_placeholder": "파일을 URL로 첨부하기, 예를 들면 https://f-droid.org/F-Droid.apk",
"publish_dialog_attach_reset": "첨부 파일 URL 삭제",
"publish_dialog_filename_label": "파일 이름",
"publish_dialog_filename_placeholder": "첨부 파일 이름",
"publish_dialog_delay_label": "지연",
"publish_dialog_chip_email_label": "이메일로 전달",
"publish_dialog_chip_attach_url_label": "URL로 파일 첨부",
"publish_dialog_chip_attach_file_label": "로컬 파일 첨부",
"publish_dialog_chip_delay_label": "발송 지연",
"publish_dialog_chip_topic_label": "주제 변경",
"publish_dialog_details_examples_description": "예제와 모든 전송 기능의 자세한 설명은 <docsLink>문서</docsLink>를 참고해주세요.",
"publish_dialog_button_cancel": "취소",
"publish_dialog_button_send": "보내기",
"publish_dialog_button_cancel_sending": "보내기 취소",
"publish_dialog_checkbox_publish_another": "다른 메세지 보내기",
"publish_dialog_attached_file_title": "첨부된 파일:",
"publish_dialog_attached_file_filename_placeholder": "첨부 파일 이름",
"publish_dialog_attached_file_remove": "첨부 파일 삭제",
"publish_dialog_drop_file_here": "여기에 파일을 끌어다 놓으세요",
"emoji_picker_search_placeholder": "이모지 검색",
"emoji_picker_search_clear": "검색 초기화",
"subscribe_dialog_subscribe_title": "주제 구독하기",
"subscribe_dialog_subscribe_description": "주제는 비밀번호로 보호되지 않을 수 있으니 추측하기 어려운 이름을 사용하십시오. 구독한 뒤 PUT/POST 알림을 보낼 수 있습니다.",
"subscribe_dialog_subscribe_topic_placeholder": "주제 이름, 예를 들면 phil_alerts",
"subscribe_dialog_subscribe_use_another_label": "다른 서버 사용",
"subscribe_dialog_subscribe_base_url_label": "서비스 URL",
"subscribe_dialog_subscribe_button_cancel": "취소",
"subscribe_dialog_subscribe_button_subscribe": "구독하기",
"subscribe_dialog_login_title": "로그인 필요함",
"subscribe_dialog_error_user_anonymous": "익명",
"subscribe_dialog_error_user_not_authorized": "사용자 {{username}} 은(는) 인증되지 않았습니다",
"subscribe_dialog_login_username_label": "사용자 이름, 예를 들면 phil",
"subscribe_dialog_login_password_label": "비밀번호",
"subscribe_dialog_login_button_back": "뒤로가기",
"subscribe_dialog_login_button_login": "로그인",
"prefs_notifications_title": "알림",
"prefs_notifications_sound_title": "알림 효과음",
"prefs_notifications_sound_description_none": "알림 도착시 효과음을 재생하지 않습니다",
"prefs_notifications_sound_description_some": "알림 도착시 {{sound}} 효과음이 재생됩니다",
"prefs_notifications_sound_no_sound": "효과음 없음",
"prefs_notifications_sound_play": "선택한 효과음 재생",
"prefs_notifications_min_priority_title": "우선순위 최소",
"prefs_notifications_min_priority_description_x_or_higher": "우선순위가 {{number}} ({{name}}) 이상인 알림만 보기",
"prefs_notifications_min_priority_description_max": "우선순위가 5 (최상)인 알림만 보기",
"prefs_notifications_min_priority_any": "아무 우선순위",
"prefs_notifications_min_priority_default_and_higher": "우선순위 기본 이상",
"prefs_notifications_min_priority_low_and_higher": "우선순위 낮음 이상",
"prefs_notifications_delete_after_three_hours": "3시간 뒤",
"prefs_notifications_delete_after_one_day": "1일 뒤",
"prefs_notifications_delete_after_one_week": "1주 뒤",
"prefs_notifications_delete_after_one_month": "1달 뒤",
"prefs_notifications_delete_after_never_description": "알림이 자동으로 삭제되지 않습니다",
"prefs_notifications_delete_after_three_hours_description": "알림이 3시간 뒤 자동으로 삭제됩니다",
"prefs_notifications_delete_after_one_day_description": "알림이 1일 뒤 자동으로 삭제됩니다",
"prefs_notifications_delete_after_one_week_description": "알림이 1주 뒤 자동으로 삭제됩니다",
"prefs_notifications_delete_after_one_month_description": "알림이 1달 뒤 자동으로 삭제됩니다",
"prefs_users_title": "사용자 관리",
"prefs_users_description": "이곳에서 보호된 주제를 위한 사용자를 추가하거나 삭제할 수 있습니다. 사용자 이름과 비밀번호는 브라우저의 로컬 저장소에 보관됩니다.",
"prefs_users_add_button": "사용자 추가",
"prefs_users_edit_button": "사용자 편집",
"prefs_users_delete_button": "사용자 삭제",
"prefs_users_table_user_header": "사용자",
"prefs_users_table_base_url_header": "서비스 URL",
"prefs_users_dialog_title_add": "사용자 추가",
"prefs_users_dialog_title_edit": "사용자 편집",
"prefs_users_dialog_base_url_label": "서비스 URL, 예를 들면 https://ntfy.sh",
"prefs_users_dialog_button_cancel": "취소",
"prefs_users_dialog_button_save": "저장",
"prefs_appearance_title": "표시 설정",
"prefs_users_dialog_button_add": "추가",
"prefs_appearance_language_title": "언어",
"priority_min": "최하",
"priority_low": "낮음",
"priority_default": "기본",
"priority_high": "높음",
"error_boundary_title": "이런, ntfy가 충돌했습니다",
"error_boundary_button_copy_stack_trace": "스택 트레이스 복사",
"error_boundary_stack_trace": "스택 트레이스",
"error_boundary_gathering_info": "더 많은 정보 모으기 …",
"error_boundary_unsupported_indexeddb_title": "시크릿 모드는 지원되지 않습니다",
"notifications_click_copy_url_button": "링크 복사",
"notifications_click_copy_url_title": "링크 URL을 클립보드에 복사",
"notifications_attachment_file_video": "동영상 파일",
"notifications_attachment_file_app": "안드로이드 앱 파일",
"notifications_attachment_file_document": "다른 문서",
"notifications_click_open_button": "링크 열기",
"notifications_actions_not_supported": "웹앱에서 지원되지 않는 동작입니다",
"publish_dialog_title_topic": "{{topic}}에 발송",
"alert_not_supported_description": "사용중인 브라우저에서 알림 기능을 지원하지 않습니다.",
"notifications_example": "예제",
"notifications_more_details": "더 많은 정보가 필요하시다면 <websiteLink>웹사이트</websiteLink>나 <docsLink>문서</docsLink>를 참고하세요.",
"notifications_list": "알림 목록",
"notifications_attachment_open_button": "첨부 파일 열기",
"notifications_no_subscriptions_title": "아직 아무런 구독을 추가하지 않으신 것 같습니다.",
"nav_button_settings": "설정",
"nav_button_documentation": "문서",
"notifications_attachment_link_expires": "링크가 {{date}}에 만료됨",
"notifications_attachment_link_expired": "다운로드 링크 만료됨",
"notifications_attachment_file_audio": "음성 파일",
"notifications_attachment_file_image": "사진 파일",
"notifications_actions_open_url_title": "{{url}]로 가기",
"notifications_actions_http_request_title": "HTTP {{method}}를 {{url}}에 보내기",
"notifications_none_for_topic_title": "아직 이 주제 관련 알림을 받지 않았습니다.",
"notifications_none_for_any_title": "아직 어떤 알림도 받지 않았습니다.",
"notifications_none_for_any_description": "알림을 받으려면 아래 주소로 PUT이나 POST 요청을 보내세요. 구독중이신 주제 중 하나로 예를 들자면 다음과 같습니다.",
"notifications_loading": "알림 불러오는중 …",
"publish_dialog_message_published": "알림 발송됨",
"notifications_none_for_topic_description": "알림을 받으려면 아래 주소로 PUT이나 POST 요청을 보내세요.",
"notifications_no_subscriptions_description": "\"{{linktext}}\" 링크를 눌러서 주제를 생성하거나 구독하세요. 그 다음, 메세지를 PUT이나 POST로 보내면 여기에서 알림을 받으실 수 있습니다.",
"publish_dialog_progress_uploading": "업로드중 …",
"publish_dialog_title_no_topic": "알림 발송",
"publish_dialog_progress_uploading_detail": "업로드중 {{loaded}}/{{total}} ({{percent}}%) …",
"publish_dialog_delay_placeholder": "알림 발송 지연, 예를 들면 {{unixTimestamp}}, {{relativeTime}} 또는 \"{{naturalLanguage}}\" (영어로 입력)",
"publish_dialog_delay_reset": "발송 지연 삭제",
"publish_dialog_chip_click_label": "클릭 URL",
"subscribe_dialog_login_description": "이 주제는 비밀번호로 보호되어 있습니다. 구독하시려면 사용자 이름과 비밀번호를 입력해주세요.",
"prefs_notifications_min_priority_max_only": "우선순위 최상만",
"publish_dialog_other_features": "다른 기능:",
"prefs_notifications_min_priority_description_any": "우선순위 무관 모든 알림 보기",
"prefs_notifications_min_priority_high_and_higher": "우선순위 높음 이상",
"error_boundary_unsupported_indexeddb_description": "ntfy 웹 앱은 동작하기 위해서 IndexedDB가 필요하지만 사용중이신 브라우저는 IndexedDB를 시크릿 모드에서 지원하지 않습니다.<br/><br/>안타깝지만 모든 정보는 브라우저에만 저장되므로 ntfy 웹앱을 시크릿 모드에서 사용할 이유는 존재하지 않습니다. <githubLink>이 깃허브 이슈</githubLink>를 참고해 보시거나, <discordLink>디스코드 서버</discordLink>나 <matrixLink>Matrix</matrixLink>에서 저희와 이야기를 나눌 수 있습니다.",
"prefs_notifications_delete_after_title": "알림 삭제",
"prefs_notifications_delete_after_never": "삭제하지 않음",
"prefs_users_table": "사용자 테이블",
"prefs_users_dialog_username_label": "사용자 이름, 예를 들면 phil",
"prefs_users_dialog_password_label": "비밀번호",
"priority_max": "최상",
"error_boundary_description": "이것은 당연히 발생되어서는 안됩니다. 굉장히 죄송합니다.<br/>가능하시다면 <githubLink>이 문제를 깃허브에 제보</githubLink>해 주시거나, <discordLink>디스코드 서버</discordLink>나 <matrixLink>Matrix</matrixLink>를 통해 알려주세요."
}

View File

@@ -458,6 +458,7 @@ const Language = () => {
<MenuItem value="fr">Français</MenuItem>
<MenuItem value="it">Italiano</MenuItem>
<MenuItem value="hu">Magyar</MenuItem>
<MenuItem value="ko">한국어</MenuItem>
<MenuItem value="ja">日本語</MenuItem>
<MenuItem value="nl">Nederlands</MenuItem>
<MenuItem value="nb_NO">Norsk bokmål</MenuItem>