Compare commits
12 commits
Author | SHA1 | Date | |
---|---|---|---|
|
a66731641c | ||
|
dafd62dc6b | ||
|
466c9874a8 | ||
|
09cb1482b4 | ||
|
9514e97219 | ||
|
ec3ba6331c | ||
|
cae06c5c61 | ||
|
78f9d4835e | ||
|
e5dc2242c4 | ||
|
67da1e4922 | ||
|
99e6c0ff97 | ||
|
febe45818c |
21 changed files with 4168 additions and 202 deletions
|
@ -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 {
|
||||
|
|
|
@ -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>]
|
||||
|
|
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))
|
||||
}
|
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
|
5
go.mod
5
go.mod
|
@ -25,7 +25,10 @@ require (
|
|||
|
||||
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
|
||||
|
|
2
go.sum
2
go.sum
|
@ -840,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=
|
||||
|
|
|
@ -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"}
|
||||
|
|
421
server/server.go
421
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,6 +1247,16 @@ 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()
|
||||
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
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 {
|
||||
|
|
12
util/util.go
12
util/util.go
|
@ -162,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()
|
||||
|
|
Loading…
Reference in a new issue