mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-19 00:27:25 +01:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66731641c | ||
|
|
5014bba0b3 | ||
|
|
eaf3e83e72 | ||
|
|
bddde5c637 | ||
|
|
b15ecd785e | ||
|
|
f8c9945cc4 | ||
|
|
0fc8dee9a9 | ||
|
|
f01576e40d | ||
|
|
ea669c75a3 | ||
|
|
4abd0e290a | ||
|
|
bcda08a01c | ||
|
|
60043f14ea | ||
|
|
dafd62dc6b | ||
|
|
466c9874a8 | ||
|
|
09cb1482b4 | ||
|
|
9514e97219 | ||
|
|
ec3ba6331c | ||
|
|
cae06c5c61 | ||
|
|
78f9d4835e | ||
|
|
e5dc2242c4 | ||
|
|
67da1e4922 | ||
|
|
99e6c0ff97 | ||
|
|
febe45818c |
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/test.yaml
vendored
4
.github/workflows/test.yaml
vendored
@@ -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
|
||||
|
||||
11
README.md
11
README.md
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>]
|
||||
|
||||
12
cmd/serve.go
12
cmd/serve.go
@@ -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 {
|
||||
|
||||
@@ -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
43
crypto/crypto.go
Normal 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
38
crypto/crypto_test.go
Normal 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))
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
3442
examples/publish-js/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
examples/publish-js/package.json
Normal file
6
examples/publish-js/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"browserify": "^17.0.0",
|
||||
"jose": "^4.8.3"
|
||||
}
|
||||
}
|
||||
13
examples/publish-js/publish-encrypted.html
Normal file
13
examples/publish-js/publish-encrypted.html
Normal 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>
|
||||
7
examples/publish-js/publish-encrypted.js
Normal file
7
examples/publish-js/publish-encrypted.js
Normal 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)
|
||||
}
|
||||
18
examples/publish-js/publish.html
Normal file
18
examples/publish-js/publish.html
Normal 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>
|
||||
46
examples/publish-php/publish-encrypted.php
Normal file
46
examples/publish-php/publish-encrypted.php
Normal 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), '+/', '-_'));
|
||||
}
|
||||
|
||||
40
examples/publish-python/publish-encrypted.py
Executable file
40
examples/publish-python/publish-encrypted.py
Executable 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()
|
||||
2
examples/publish-python/requirements.txt
Normal file
2
examples/publish-python/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
requests
|
||||
pycryptodome
|
||||
23
go.mod
23
go.mod
@@ -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
18
go.sum
@@ -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=
|
||||
|
||||
14
log/log.go
14
log/log.go
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"}
|
||||
|
||||
425
server/server.go
425
server/server.go
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
10
util/peek.go
10
util/peek.go
@@ -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 {
|
||||
|
||||
32
util/util.go
32
util/util.go
@@ -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>"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>를 통해 알려주세요."
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user