mirror of
https://github.com/binwiederhier/ntfy.git
synced 2026-01-19 00:27:25 +01:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f2ce5e542 | ||
|
|
6acb921098 | ||
|
|
acf6d4370f | ||
|
|
297601d0f2 | ||
|
|
113900d3eb | ||
|
|
b4a824aa38 | ||
|
|
e8569c6008 | ||
|
|
b74defef14 | ||
|
|
ee38d76bc2 | ||
|
|
3334d84861 | ||
|
|
b1089e21f9 | ||
|
|
07b5d9a9df | ||
|
|
9cee8ab888 | ||
|
|
ed9d99fd57 | ||
|
|
edfc1b78a1 | ||
|
|
c1f7bed8d1 | ||
|
|
85f2252a77 | ||
|
|
4e29216b5f | ||
|
|
26fda847ca | ||
|
|
a160da3ad9 | ||
|
|
0080ea5a20 | ||
|
|
fec4864771 | ||
|
|
c40338c146 | ||
|
|
a7d8e69dfd | ||
|
|
5b68915fff | ||
|
|
f3e5961892 | ||
|
|
7de7e0de12 | ||
|
|
727c6268b9 | ||
|
|
50cd50cfdf | ||
|
|
1265e69eee | ||
|
|
d05211648d | ||
|
|
1226a7b70c | ||
|
|
30c2a67869 | ||
|
|
25a4b29ffc | ||
|
|
e578f01e5b | ||
|
|
16047ede61 | ||
|
|
affc79eab0 | ||
|
|
64590343f5 | ||
|
|
87cf765dcc | ||
|
|
b332e1aaea | ||
|
|
eef55c35a8 | ||
|
|
a2c661cbf6 | ||
|
|
9918f4965d | ||
|
|
1fae61e78f | ||
|
|
df2362e1a7 | ||
|
|
8a56b82813 | ||
|
|
6122cf20aa | ||
|
|
18bd3c0e55 | ||
|
|
0ff8e968ca | ||
|
|
ebbc2838ba | ||
|
|
91375b2e8e | ||
|
|
f1d134dfc2 | ||
|
|
cd536e6018 | ||
|
|
3dec7efadb | ||
|
|
27910772f0 | ||
|
|
632c21298f | ||
|
|
e9f3edb76b | ||
|
|
feef15c485 | ||
|
|
cf0f002bfa | ||
|
|
eb2262d06e | ||
|
|
41096ef1b0 | ||
|
|
3c47797bf3 | ||
|
|
a8c9927eab | ||
|
|
8565dc0ff3 | ||
|
|
2b42cea1a3 | ||
|
|
d7f7aa909c | ||
|
|
e5af7fe8d7 | ||
|
|
52fcfdccb2 | ||
|
|
9025e2a082 | ||
|
|
4667377649 | ||
|
|
f459a08f96 | ||
|
|
f542afb37f | ||
|
|
4baf6996c5 | ||
|
|
81da9a2756 | ||
|
|
fa98a16195 | ||
|
|
12b2636155 | ||
|
|
10c89b2e55 | ||
|
|
01d8ea0019 | ||
|
|
c7b790e070 | ||
|
|
b5eb3a40f4 | ||
|
|
ffb6de7d97 | ||
|
|
3ad5ed571d | ||
|
|
ad30c50418 | ||
|
|
f59c58b08f | ||
|
|
86c132f9cd | ||
|
|
0521f19ea4 | ||
|
|
17930caf21 | ||
|
|
d65ca9b10f | ||
|
|
ae3163c5b1 | ||
|
|
887a7c3288 | ||
|
|
f6dee345b7 | ||
|
|
2d7b986c9c |
21
Makefile
21
Makefile
@@ -96,7 +96,18 @@ build-deps-ubuntu:
|
||||
docs: docs-deps docs-build
|
||||
|
||||
docs-build: .PHONY
|
||||
mkdocs build
|
||||
@if ! /bin/echo -e "import sys\nif sys.version_info < (3,8):\n exit(1)" | python3; then \
|
||||
if which python3.8; then \
|
||||
echo "python3.8 $(shell which mkdocs) build"; \
|
||||
python3.8 $(shell which mkdocs) build; \
|
||||
else \
|
||||
echo "ERROR: Python version too low. mkdocs-material needs >= 3.8"; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
else \
|
||||
echo "mkdocs build"; \
|
||||
mkdocs build; \
|
||||
fi
|
||||
|
||||
docs-deps: .PHONY
|
||||
pip3 install -r requirements.txt
|
||||
@@ -289,16 +300,16 @@ release-checks:
|
||||
# Installing targets
|
||||
|
||||
install-linux-amd64: remove-binary
|
||||
sudo cp -a dist/ntfy_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
||||
sudo cp -a dist/ntfy_linux_amd64_linux_amd64_v1/ntfy /usr/bin/ntfy
|
||||
|
||||
install-linux-armv6: remove-binary
|
||||
sudo cp -a dist/ntfy_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
||||
sudo cp -a dist/ntfy_linux_armv6_linux_arm_6/ntfy /usr/bin/ntfy
|
||||
|
||||
install-linux-armv7: remove-binary
|
||||
sudo cp -a dist/ntfy_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
||||
sudo cp -a dist/ntfy_linux_armv7_linux_arm_7/ntfy /usr/bin/ntfy
|
||||
|
||||
install-linux-arm64: remove-binary
|
||||
sudo cp -a dist/ntfy_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo cp -a dist/ntfy_linux_arm64_linux_arm64/ntfy /usr/bin/ntfy
|
||||
|
||||
remove-binary:
|
||||
sudo rm -f /usr/bin/ntfy
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
[](https://codecov.io/gh/binwiederhier/ntfy)
|
||||
[](https://discord.gg/cT7ECsZj9w)
|
||||
[](https://matrix.to/#/#ntfy:matrix.org)
|
||||
[](https://matrix.to/#/#ntfy-space:matrix.org)
|
||||
[](https://ntfy.statuspage.io/)
|
||||
|
||||
**ntfy** (pronounce: *notify*) is a simple HTTP-based [pub-sub](https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern) notification service.
|
||||
It allows you to **send notifications to your phone or desktop via scripts** from any computer, entirely **without signup or cost**.
|
||||
It's also open source (as you can plainly see) if you want to run your own.
|
||||
|
||||
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**, and there's an [open source](https://github.com/binwiederhier/ntfy-android) [Android app](https://play.google.com/store/apps/details?id=io.heckel.ntfy)
|
||||
too.
|
||||
I run a free version of it at **[ntfy.sh](https://ntfy.sh)**. There's also an [open source Android app](https://github.com/binwiederhier/ntfy-android) (see [Google Play](https://play.google.com/store/apps/details?id=io.heckel.ntfy) or [F-Droid](https://f-droid.org/en/packages/io.heckel.ntfy/)), and an [open source iOS app](https://github.com/binwiederhier/ntfy-ios) (see [App Store](https://apps.apple.com/us/app/ntfy/id1625396347)).
|
||||
|
||||
<p>
|
||||
<img src="web/public/static/img/screenshot-curl.png" height="180">
|
||||
|
||||
150
cmd/publish.go
150
cmd/publish.go
@@ -5,11 +5,14 @@ import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/client"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -20,6 +23,7 @@ var flagsPublish = append(
|
||||
flagsDefault,
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
|
||||
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
|
||||
&cli.StringFlag{Name: "message", Aliases: []string{"m"}, EnvVars: []string{"NTFY_MESSAGE"}, Usage: "message body"},
|
||||
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
|
||||
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
|
||||
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
|
||||
@@ -30,6 +34,8 @@ var flagsPublish = append(
|
||||
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "file to upload as an attachment"},
|
||||
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
|
||||
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
|
||||
&cli.IntFlag{Name: "wait-pid", Aliases: []string{"pid"}, EnvVars: []string{"NTFY_WAIT_PID"}, Usage: "wait until PID exits before publishing"},
|
||||
&cli.BoolFlag{Name: "wait-cmd", Aliases: []string{"cmd", "done"}, EnvVars: []string{"NTFY_WAIT_CMD"}, Usage: "run command and wait until it finishes before publishing"},
|
||||
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
|
||||
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
|
||||
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
|
||||
@@ -37,14 +43,16 @@ var flagsPublish = append(
|
||||
)
|
||||
|
||||
var cmdPublish = &cli.Command{
|
||||
Name: "publish",
|
||||
Aliases: []string{"pub", "send", "trigger"},
|
||||
Usage: "Send message via a ntfy server",
|
||||
UsageText: "ntfy publish [OPTIONS..] TOPIC [MESSAGE]\nNTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE]",
|
||||
Action: execPublish,
|
||||
Category: categoryClient,
|
||||
Flags: flagsPublish,
|
||||
Before: initLogFunc,
|
||||
Name: "publish",
|
||||
Aliases: []string{"pub", "send", "trigger"},
|
||||
Usage: "Send message via a ntfy server",
|
||||
UsageText: `ntfy publish [OPTIONS..] TOPIC [MESSAGE...]
|
||||
ntfy publish [OPTIONS..] --wait-cmd COMMAND...
|
||||
NTFY_TOPIC=.. ntfy publish [OPTIONS..] -P [MESSAGE...]`,
|
||||
Action: execPublish,
|
||||
Category: categoryClient,
|
||||
Flags: flagsPublish,
|
||||
Before: initLogFunc,
|
||||
Description: `Publish a message to a ntfy server.
|
||||
|
||||
Examples:
|
||||
@@ -59,8 +67,10 @@ Examples:
|
||||
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
|
||||
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
|
||||
ntfy pub -u phil:mypass secret Psst # Publish with username/password
|
||||
ntfy pub --wait-pid 1234 mytopic # Wait for process 1234 to exit before publishing
|
||||
ntfy pub --wait-cmd mytopic rsync -av ./ /tmp/a # Run command and publish after it completes
|
||||
NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password
|
||||
NTFY_TOPIC=mytopic ntfy pub -P "some message"" # Use NTFY_TOPIC variable as topic
|
||||
NTFY_TOPIC=mytopic ntfy pub -P "some message" # Use NTFY_TOPIC variable as topic
|
||||
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
|
||||
ntfy trigger mywebhook # Sending without message, useful for webhooks
|
||||
|
||||
@@ -88,22 +98,11 @@ func execPublish(c *cli.Context) error {
|
||||
user := c.String("user")
|
||||
noCache := c.Bool("no-cache")
|
||||
noFirebase := c.Bool("no-firebase")
|
||||
envTopic := c.Bool("env-topic")
|
||||
quiet := c.Bool("quiet")
|
||||
var topic, message string
|
||||
if envTopic {
|
||||
topic = os.Getenv("NTFY_TOPIC")
|
||||
if c.NArg() > 0 {
|
||||
message = strings.Join(c.Args().Slice(), " ")
|
||||
}
|
||||
} else {
|
||||
if c.NArg() < 1 {
|
||||
return errors.New("must specify topic, type 'ntfy publish --help' for help")
|
||||
}
|
||||
topic = c.Args().Get(0)
|
||||
if c.NArg() > 1 {
|
||||
message = strings.Join(c.Args().Slice()[1:], " ")
|
||||
}
|
||||
pid := c.Int("wait-pid")
|
||||
topic, message, command, err := parseTopicMessageCommand(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var options []client.PublishOption
|
||||
if title != "" {
|
||||
@@ -156,6 +155,21 @@ func execPublish(c *cli.Context) error {
|
||||
}
|
||||
options = append(options, client.WithBasicAuth(user, pass))
|
||||
}
|
||||
if pid > 0 {
|
||||
newMessage, err := waitForProcess(pid)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if message == "" {
|
||||
message = newMessage
|
||||
}
|
||||
} else if len(command) > 0 {
|
||||
newMessage, err := runAndWaitForCommand(command)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if message == "" {
|
||||
message = newMessage
|
||||
}
|
||||
}
|
||||
var body io.Reader
|
||||
if file == "" {
|
||||
body = strings.NewReader(message)
|
||||
@@ -188,3 +202,91 @@ func execPublish(c *cli.Context) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseTopicMessageCommand reads the topic and the remaining arguments from the context.
|
||||
|
||||
// There are a few cases to consider:
|
||||
// ntfy publish <topic> [<message>]
|
||||
// ntfy publish --wait-cmd <topic> <command>
|
||||
// NTFY_TOPIC=.. ntfy publish [<message>]
|
||||
// NTFY_TOPIC=.. ntfy publish --wait-cmd <command>
|
||||
func parseTopicMessageCommand(c *cli.Context) (topic string, message string, command []string, err error) {
|
||||
var args []string
|
||||
topic, args, err = parseTopicAndArgs(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if c.Bool("wait-cmd") {
|
||||
if len(args) == 0 {
|
||||
err = errors.New("must specify command when --wait-cmd is passed, type 'ntfy publish --help' for help")
|
||||
return
|
||||
}
|
||||
command = args
|
||||
} else {
|
||||
message = strings.Join(args, " ")
|
||||
}
|
||||
if c.String("message") != "" {
|
||||
message = c.String("message")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func parseTopicAndArgs(c *cli.Context) (topic string, args []string, err error) {
|
||||
envTopic := c.Bool("env-topic")
|
||||
if envTopic {
|
||||
fmt.Fprintln(c.App.ErrWriter, "\x1b[1;33mDeprecation notice: The --env-topic/-P flag will be removed in July 2022, see https://ntfy.sh/docs/deprecations/ for details.\x1b[0m")
|
||||
topic = os.Getenv("NTFY_TOPIC")
|
||||
if topic == "" {
|
||||
return "", nil, errors.New("when --env-topic is passed, must define NTFY_TOPIC environment variable")
|
||||
}
|
||||
return topic, remainingArgs(c, 0), nil
|
||||
}
|
||||
if c.NArg() < 1 {
|
||||
return "", nil, errors.New("must specify topic, type 'ntfy publish --help' for help")
|
||||
}
|
||||
return c.Args().Get(0), remainingArgs(c, 1), nil
|
||||
}
|
||||
|
||||
func remainingArgs(c *cli.Context, fromIndex int) []string {
|
||||
if c.NArg() > fromIndex {
|
||||
return c.Args().Slice()[fromIndex:]
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func waitForProcess(pid int) (message string, err error) {
|
||||
if !processExists(pid) {
|
||||
return "", fmt.Errorf("process with PID %d not running", pid)
|
||||
}
|
||||
start := time.Now()
|
||||
log.Debug("Waiting for process with PID %d to exit", pid)
|
||||
for processExists(pid) {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
runtime := time.Since(start).Round(time.Millisecond)
|
||||
log.Debug("Process with PID %d exited after %s", pid, runtime)
|
||||
return fmt.Sprintf("Process with PID %d exited after %s", pid, runtime), nil
|
||||
}
|
||||
|
||||
func runAndWaitForCommand(command []string) (message string, err error) {
|
||||
prettyCmd := util.QuoteCommand(command)
|
||||
log.Debug("Running command: %s", prettyCmd)
|
||||
start := time.Now()
|
||||
cmd := exec.Command(command[0], command[1:]...)
|
||||
if log.IsTrace() {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
err = cmd.Run()
|
||||
runtime := time.Since(start).Round(time.Millisecond)
|
||||
if err != nil {
|
||||
if exitError, ok := err.(*exec.ExitError); ok {
|
||||
log.Debug("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd)
|
||||
return fmt.Sprintf("Command failed after %s (exit code %d): %s", runtime, exitError.ExitCode(), prettyCmd), nil
|
||||
}
|
||||
// Hard fail when command does not exist or could not be properly launched
|
||||
return "", fmt.Errorf("command failed: %s, error: %s", prettyCmd, err.Error())
|
||||
}
|
||||
log.Debug("Command succeeded after %s: %s", runtime, prettyCmd)
|
||||
return fmt.Sprintf("Command succeeded after %s: %s", runtime, prettyCmd), nil
|
||||
}
|
||||
|
||||
8
cmd/publish_darwin.go
Normal file
8
cmd/publish_darwin.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package cmd
|
||||
|
||||
import "syscall"
|
||||
|
||||
func processExists(pid int) bool {
|
||||
err := syscall.Kill(pid, syscall.Signal(0))
|
||||
return err == nil
|
||||
}
|
||||
8
cmd/publish_linux.go
Normal file
8
cmd/publish_linux.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package cmd
|
||||
|
||||
import "syscall"
|
||||
|
||||
func processExists(pid int) bool {
|
||||
err := syscall.Kill(pid, syscall.Signal(0))
|
||||
return err == nil
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/test"
|
||||
"heckel.io/ntfy/util"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
|
||||
@@ -70,3 +74,66 @@ func TestCLI_Publish_All_The_Things(t *testing.T) {
|
||||
require.Equal(t, int64(0), m.Attachment.Expires)
|
||||
require.Equal(t, "", m.Attachment.Type)
|
||||
}
|
||||
|
||||
func TestCLI_Publish_Wait_PID_And_Cmd(t *testing.T) {
|
||||
s, port := test.StartServer(t)
|
||||
defer test.StopServer(t, s, port)
|
||||
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
|
||||
|
||||
// Test: sleep 0.5
|
||||
sleep := exec.Command("sleep", "0.5")
|
||||
require.Nil(t, sleep.Start())
|
||||
go sleep.Wait() // Must be called to release resources
|
||||
start := time.Now()
|
||||
app, _, stdout, _ := newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-pid", strconv.Itoa(sleep.Process.Pid), topic}))
|
||||
m := toMessage(t, stdout.String())
|
||||
require.True(t, time.Since(start) >= 500*time.Millisecond)
|
||||
require.Regexp(t, `Process with PID \d+ exited after `, m.Message)
|
||||
|
||||
// Test: PID does not exist
|
||||
app, _, _, _ = newTestApp()
|
||||
err := app.Run([]string{"ntfy", "publish", "--wait-pid", "1234567", topic})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "process with PID 1234567 not running", err.Error())
|
||||
|
||||
// Test: Successful command (exit 0)
|
||||
start = time.Now()
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "sleep", "0.5"}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.True(t, time.Since(start) >= 500*time.Millisecond)
|
||||
require.Contains(t, m.Message, `Command succeeded after `)
|
||||
require.Contains(t, m.Message, `: sleep 0.5`)
|
||||
|
||||
// Test: Failing command (exit 1)
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "/bin/false", "false doesn't care about its args"}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Contains(t, m.Message, `Command failed after `)
|
||||
require.Contains(t, m.Message, `(exit code 1): /bin/false "false doesn't care about its args"`, m.Message)
|
||||
|
||||
// Test: Non-existing command (hard fail!)
|
||||
app, _, _, _ = newTestApp()
|
||||
err = app.Run([]string{"ntfy", "publish", "--wait-cmd", topic, "does-not-exist-no-really", "really though"})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, `command failed: does-not-exist-no-really "really though", error: exec: "does-not-exist-no-really": executable file not found in $PATH`, err.Error())
|
||||
|
||||
// Tests with NTFY_TOPIC set ////
|
||||
require.Nil(t, os.Setenv("NTFY_TOPIC", topic))
|
||||
|
||||
// Test: Successful command with NTFY_TOPIC
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--cmd", "echo", "hi there"}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
|
||||
// Test: Successful --wait-pid with NTFY_TOPIC
|
||||
sleep = exec.Command("sleep", "0.2")
|
||||
require.Nil(t, sleep.Start())
|
||||
go sleep.Wait() // Must be called to release resources
|
||||
app, _, stdout, _ = newTestApp()
|
||||
require.Nil(t, app.Run([]string{"ntfy", "publish", "--env-topic", "--wait-pid", strconv.Itoa(sleep.Process.Pid)}))
|
||||
m = toMessage(t, stdout.String())
|
||||
require.Regexp(t, `Process with PID \d+ exited after .+ms`, m.Message)
|
||||
}
|
||||
|
||||
10
cmd/publish_windows.go
Normal file
10
cmd/publish_windows.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
func processExists(pid int) bool {
|
||||
_, err := os.FindProcess(pid)
|
||||
return err == nil
|
||||
}
|
||||
10
cmd/serve.go
10
cmd/serve.go
@@ -40,6 +40,7 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"firebase_key_file", "F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"cache_file", "C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
|
||||
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"cache_duration", "b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-startup-queries", Aliases: []string{"cache_startup_queries"}, EnvVars: []string{"NTFY_CACHE_STARTUP_QUERIES"}, Usage: "queries run when the cache database is initialized"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file", "H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"auth_default_access", "p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
|
||||
@@ -103,6 +104,7 @@ func execServe(c *cli.Context) error {
|
||||
firebaseKeyFile := c.String("firebase-key-file")
|
||||
cacheFile := c.String("cache-file")
|
||||
cacheDuration := c.Duration("cache-duration")
|
||||
cacheStartupQueries := c.String("cache-startup-queries")
|
||||
authFile := c.String("auth-file")
|
||||
authDefaultAccess := c.String("auth-default-access")
|
||||
attachmentCacheDir := c.String("attachment-cache-dir")
|
||||
@@ -152,8 +154,8 @@ 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://") {
|
||||
return errors.New("if set, base-url must start with http:// or https://")
|
||||
} 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) {
|
||||
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) {
|
||||
@@ -162,6 +164,8 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("if set, upstream-base-url must start with http:// or https://")
|
||||
} 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 {
|
||||
return errors.New("base-url and upstream-base-url cannot be identical, you'll likely want to set upstream-base-url to https://ntfy.sh, see https://ntfy.sh/docs/config/#ios-instant-notifications")
|
||||
}
|
||||
|
||||
webRootIsApp := webRoot == "app"
|
||||
@@ -220,6 +224,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.FirebaseKeyFile = firebaseKeyFile
|
||||
conf.CacheFile = cacheFile
|
||||
conf.CacheDuration = cacheDuration
|
||||
conf.CacheStartupQueries = cacheStartupQueries
|
||||
conf.AuthFile = authFile
|
||||
conf.AuthDefaultRead = authDefaultRead
|
||||
conf.AuthDefaultWrite = authDefaultWrite
|
||||
@@ -249,6 +254,7 @@ func execServe(c *cli.Context) error {
|
||||
conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
|
||||
conf.BehindProxy = behindProxy
|
||||
conf.EnableWeb = enableWeb
|
||||
conf.Version = c.App.Version
|
||||
|
||||
// Set up hot-reloading of config
|
||||
go sigHandlerConfigReload(config)
|
||||
|
||||
60
cmd/user.go
60
cmd/user.go
@@ -6,11 +6,13 @@ import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/auth"
|
||||
"heckel.io/ntfy/util"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -36,7 +38,7 @@ var cmdUser = &cli.Command{
|
||||
Name: "add",
|
||||
Aliases: []string{"a"},
|
||||
Usage: "Adds a new user",
|
||||
UsageText: "ntfy user add [--role=admin|user] USERNAME",
|
||||
UsageText: "ntfy user add [--role=admin|user] USERNAME\nNTFY_PASSWORD=... ntfy user add [--role=admin|user] USERNAME",
|
||||
Action: execUserAdd,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
|
||||
@@ -48,8 +50,12 @@ granted otherwise by the auth-default-access setting). An admin user has read an
|
||||
topics.
|
||||
|
||||
Examples:
|
||||
ntfy user add phil # Add regular user phil
|
||||
ntfy user add --role=admin phil # Add admin user phil
|
||||
ntfy user add phil # Add regular user phil
|
||||
ntfy user add --role=admin phil # Add admin user phil
|
||||
NTFY_PASSWORD=... ntfy user add phil # Add user, using env variable to set password (for scripts)
|
||||
|
||||
You may set the NTFY_PASSWORD environment variable to pass the password. This is useful if
|
||||
you are creating users via scripts.
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -68,7 +74,7 @@ Example:
|
||||
Name: "change-pass",
|
||||
Aliases: []string{"chp"},
|
||||
Usage: "Changes a user's password",
|
||||
UsageText: "ntfy user change-pass USERNAME",
|
||||
UsageText: "ntfy user change-pass USERNAME\nNTFY_PASSWORD=... ntfy user change-pass USERNAME",
|
||||
Action: execUserChangePass,
|
||||
Description: `Change the password for the given user.
|
||||
|
||||
@@ -76,7 +82,12 @@ The new password will be read from STDIN, and it'll be confirmed by typing
|
||||
it twice.
|
||||
|
||||
Example:
|
||||
ntfy user change-pass phil
|
||||
ntfy user change-pass phil
|
||||
NTFY_PASSWORD=.. ntfy user change-pass phil
|
||||
|
||||
You may set the NTFY_PASSWORD environment variable to pass the new password. This is
|
||||
useful if you are updating users via scripts.
|
||||
|
||||
`,
|
||||
},
|
||||
{
|
||||
@@ -125,18 +136,24 @@ The command allows you to add/remove/change users in the ntfy user database, as
|
||||
passwords or roles.
|
||||
|
||||
Examples:
|
||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||
ntfy user add phil # Add regular user phil
|
||||
ntfy user add --role=admin phil # Add admin user phil
|
||||
ntfy user del phil # Delete user phil
|
||||
ntfy user change-pass phil # Change password for user phil
|
||||
ntfy user change-role phil admin # Make user phil an admin
|
||||
ntfy user list # Shows list of users (alias: 'ntfy access')
|
||||
ntfy user add phil # Add regular user phil
|
||||
NTFY_PASSWORD=... ntfy user add phil # As above, using env variable to set password (for scripts)
|
||||
ntfy user add --role=admin phil # Add admin user phil
|
||||
ntfy user del phil # Delete user phil
|
||||
ntfy user change-pass phil # Change password for user phil
|
||||
NTFY_PASSWORD=.. ntfy user change-pass phil # As above, using env variable to set password (for scripts)
|
||||
ntfy user change-role phil admin # Make user phil an admin
|
||||
|
||||
For the 'ntfy user add' and 'ntfy user change-pass' commands, you may set the NTFY_PASSWORD environment
|
||||
variable to pass the new password. This is useful if you are creating/updating users via scripts.
|
||||
`,
|
||||
}
|
||||
|
||||
func execUserAdd(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
role := auth.Role(c.String("role"))
|
||||
password := os.Getenv("NTFY_PASSWORD")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user add --help' for help")
|
||||
} else if username == userEveryone {
|
||||
@@ -151,9 +168,13 @@ func execUserAdd(c *cli.Context) error {
|
||||
if user, _ := manager.User(username); user != nil {
|
||||
return fmt.Errorf("user %s already exists", username)
|
||||
}
|
||||
password, err := readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
if password == "" {
|
||||
p, err := readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password = p
|
||||
}
|
||||
if err := manager.AddUser(username, password, role); err != nil {
|
||||
return err
|
||||
@@ -185,6 +206,7 @@ func execUserDel(c *cli.Context) error {
|
||||
|
||||
func execUserChangePass(c *cli.Context) error {
|
||||
username := c.Args().Get(0)
|
||||
password := os.Getenv("NTFY_PASSWORD")
|
||||
if username == "" {
|
||||
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
|
||||
} else if username == userEveryone {
|
||||
@@ -197,9 +219,11 @@ func execUserChangePass(c *cli.Context) error {
|
||||
if _, err := manager.User(username); err == auth.ErrNotFound {
|
||||
return fmt.Errorf("user %s does not exist", username)
|
||||
}
|
||||
password, err := readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
if password == "" {
|
||||
password, err = readPasswordAndConfirm(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := manager.ChangePassword(username, password); err != nil {
|
||||
return err
|
||||
|
||||
@@ -5,7 +5,9 @@ services:
|
||||
container_name: ntfy
|
||||
command:
|
||||
- serve
|
||||
user: UID:GID # optional. Set custom user/group or uid/gid
|
||||
environment:
|
||||
- TZ=UTC # optional: Change to your desired timezone
|
||||
user: UID:GID # optional: Set custom user/group or uid/gid
|
||||
volumes:
|
||||
- /var/cache/ntfy:/var/cache/ntfy
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
|
||||
@@ -733,6 +733,21 @@ out [this discussion on Reddit](https://www.reddit.com/r/golang/comments/r9u4ee/
|
||||
|
||||
Depending on *how you run it*, here are a few limits that are relevant:
|
||||
|
||||
### WAL for message cache
|
||||
By default, the [message cache](#message-cache) (defined by `cache-file`) uses the SQLite default settings, which means it
|
||||
syncs to disk on every write. For personal servers, this is perfectly adequate. For larger installations, such as ntfy.sh,
|
||||
the [write-ahead log (WAL)](https://sqlite.org/wal.html) should be enabled, and the sync mode should be adjusted.
|
||||
See [this article](https://phiresky.github.io/blog/2020/sqlite-performance-tuning/) for details.
|
||||
|
||||
Here's how ntfy.sh has been tuned in the `server.yml` file:
|
||||
|
||||
``` yaml
|
||||
cache-startup-queries: |
|
||||
pragma journal_mode = WAL;
|
||||
pragma synchronous = normal;
|
||||
pragma temp_store = memory;
|
||||
```
|
||||
|
||||
### For systemd services
|
||||
If you're running ntfy in a systemd service (e.g. for .deb/.rpm packages), the main limiting factor is the
|
||||
`LimitNOFILE` setting in the systemd unit. The default open files limit for `ntfy.service` is 10,000. You can override it
|
||||
@@ -865,6 +880,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
|
||||
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
|
||||
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
|
||||
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
|
||||
| `cache-startup-queries` | `NTFY_CACHE_STARTUP_QUERIES` | *string (SQL queries)* | - | SQL queries to run during database startup; this is useful for tuning and [enabling WAL mode](#wal-for-message-cache) |
|
||||
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
|
||||
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
|
||||
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
|
||||
@@ -929,6 +945,7 @@ OPTIONS:
|
||||
--behind-proxy, --behind_proxy, -P if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting) (default: false) [$NTFY_BEHIND_PROXY]
|
||||
--cache-duration since, --cache_duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
|
||||
--cache-file value, --cache_file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
|
||||
--cache-startup-queries value, --cache_startup_queries value queries run when the cache database is initialized [$NTFY_CACHE_STARTUP_QUERIES]
|
||||
--cert-file value, --cert_file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
|
||||
--config value, -c value config file (default: /etc/ntfy/server.yml) [$NTFY_CONFIG_FILE]
|
||||
--debug, -d enable debug logging (default: false) [$NTFY_DEBUG]
|
||||
|
||||
@@ -1,29 +1,43 @@
|
||||
# Deprecation notices
|
||||
This page is used to list deprecation notices for ntfy. Deprecated commands and options will be
|
||||
**removed after ~3 months** from the time they were deprecated.
|
||||
**removed after 1-3 months** from the time they were deprecated. How long the feature is deprecated
|
||||
before the behavior is changed depends on the severity of the change, and how prominent the feature is.
|
||||
|
||||
## Active deprecations
|
||||
|
||||
### Android app: WebSockets will become the default connection protocol
|
||||
> Active since 2022-03-13, behavior will change in **June 2022**
|
||||
### ntfy CLI: `ntfy publish --env-topic` will be removed
|
||||
> Active since 2022-06-20, behavior will change end of **July 2022**
|
||||
|
||||
In future versions of the Android app, instant delivery connections and connections to self-hosted servers will
|
||||
be using the WebSockets protocol. This potentially requires [configuration changes in your proxy](https://ntfy.sh/docs/config/#nginxapache2caddy).
|
||||
The `ntfy publish --env-topic` option will be removed. It'll still be possible to specify a topic via the
|
||||
`NTFY_TOPIC` environment variable, but it won't be necessary anymore to specify the `--env-topic` flag.
|
||||
|
||||
Due to [reports of varying battery consumption](https://github.com/binwiederhier/ntfy/issues/190) (which entirely
|
||||
seems to depend on the phone), JSON HTTP stream support will not be removed. Instead, I'll just flip the default to
|
||||
WebSocket in June.
|
||||
=== "Before"
|
||||
```
|
||||
$ NTFY_TOPIC=mytopic ntfy publish --env-topic "this is the message"
|
||||
```
|
||||
|
||||
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
||||
> Active since 2022-02-27, behavior will change in **May 2022**
|
||||
|
||||
In about 3 months, the Android app will start using `since=<id>` instead of `since=<timestamp>`, which means that it will
|
||||
not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
|
||||
|
||||
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
|
||||
=== "After"
|
||||
```
|
||||
$ NTFY_TOPIC=mytopic ntfy publish "this is the message"
|
||||
```
|
||||
|
||||
## Previous deprecations
|
||||
|
||||
### <del>Android app: WebSockets will become the default connection protocol</del>
|
||||
> Active since 2022-03-13, behavior will not change (deprecation removed 2022-06-20)
|
||||
|
||||
Instant delivery connections and connections to self-hosted servers in the Android app were going to switch
|
||||
to use the WebSockets protocol by default. It was decided to keep JSON stream as the most compatible default
|
||||
and add a notice banner in the Android app instead.
|
||||
|
||||
### Android app: Using `since=<timestamp>` instead of `since=<id>`
|
||||
> Active since 2022-02-27, behavior changed with v1.14.0
|
||||
|
||||
The Android app started using `since=<id>` instead of `since=<timestamp>`, which means as of Android app v1.14.0,
|
||||
it will not work with servers older than v1.16.0 anymore. This is to simplify handling of deduplication in the Android app.
|
||||
|
||||
The `since=<timestamp>` endpoint will continue to work. This is merely a notice that the Android app behavior will change.
|
||||
|
||||
### Running server via `ntfy` (instead of `ntfy serve`)
|
||||
> Deprecated 2021-12-17, behavior changed with v1.10.0
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ those out, too.
|
||||
[create a pull request](https://github.com/binwiederhier/ntfy/pulls), and I'll happily include it. Also note, that
|
||||
I cannot guarantee that all of these examples are functional. Many of them I have not tried myself.
|
||||
|
||||
## A long process is done: backups, copying data, pipelines, ...
|
||||
## Cronjobs
|
||||
ntfy is perfect for any kind of cronjobs or just when long processes are done (backups, pipelines, rsync copy commands, ...).
|
||||
|
||||
I started adding notifications pretty much all of my scripts. Typically, I just chain the <tt>curl</tt> call
|
||||
directly to the command I'm running. The following example will either send <i>Laptop backup succeeded</i>
|
||||
or ⚠️ <i>Laptop backup failed</i> directly to my phone:
|
||||
@@ -21,6 +23,15 @@ rsync -a root@laptop /backups/laptop \
|
||||
|| curl -H tags:warning -H prio:high -d "Laptop backup failed" ntfy.sh/backups
|
||||
```
|
||||
|
||||
Here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||
|
||||
``` cron
|
||||
# Check github/ntfy user
|
||||
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||
```
|
||||
|
||||
|
||||
## Low disk space alerts
|
||||
Here's a simple cronjob that I use to alert me when the disk space on the root disk is running low. It's simple, but
|
||||
effective.
|
||||
@@ -42,11 +53,7 @@ if [ -n "$avail" ]; then
|
||||
fi
|
||||
```
|
||||
|
||||
## Server-sent messages in your web app
|
||||
Just as you can [subscribe to topics in the Web UI](subscribe/web.md), you can use ntfy in your own
|
||||
web application. Check out the <a href="/example.html">live example</a>.
|
||||
|
||||
## Notify on SSH login
|
||||
## SSH login alerts
|
||||
Years ago my home server was broken into. That shook me hard, so every time someone logs into any machine that I
|
||||
own, I now message myself. Here's an example of how to use <a href="https://en.wikipedia.org/wiki/Linux_PAM">PAM</a>
|
||||
to notify yourself on SSH login.
|
||||
@@ -102,7 +109,7 @@ One of my co-workers uses the following Ansible task to let him know when things
|
||||
body: "{{ inventory_hostname }} reseeding complete"
|
||||
```
|
||||
|
||||
## Watchtower notifications (shoutrrr)
|
||||
## Watchtower (shoutrrr)
|
||||
You can use [shoutrrr](https://github.com/containrrr/shoutrrr) generic webhook support to send
|
||||
[Watchtower](https://github.com/containrrr/watchtower/) notifications to your ntfy topic.
|
||||
|
||||
@@ -121,16 +128,7 @@ Or, if you only want to send notifications using shoutrrr:
|
||||
shoutrrr send -u "generic+https://ntfy.sh/my_watchtower_topic?title=WatchtowerUpdates" -m "testMessage"
|
||||
```
|
||||
|
||||
## Random cronjobs
|
||||
Alright, here's one for the history books. I desperately want the `github.com/ntfy` organization, but all my tickets with
|
||||
GitHub have been hopeless. In case it ever becomes available, I want to know immediately.
|
||||
|
||||
``` cron
|
||||
# Check github/ntfy user
|
||||
*/6 * * * * if curl -s https://api.github.com/users/ntfy | grep "Not Found"; then curl -d "github.com/ntfy is available" -H "Tags: tada" -H "Prio: high" ntfy.sh/my-alerts; fi
|
||||
```
|
||||
|
||||
## Download notifications (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd)
|
||||
## Sonarr, Radarr, Lidarr, Readarr, Prowlarr, SABnzbd
|
||||
It's possible to use custom scripts for all the *arr services, plus SABnzbd. Notifications for downloads, warnings, grabs etc.
|
||||
Some simple bash scripts to achieve this are kindly provided in [nickexyz's repository](https://github.com/nickexyz/ntfy-shellscripts).
|
||||
|
||||
@@ -343,7 +341,7 @@ You can use the HTTP request node to send messages with [Node-RED](https://noder
|
||||
|
||||

|
||||
|
||||
## Gatus service health check
|
||||
## Gatus
|
||||
|
||||
An example for a custom alert with [Gatus](https://github.com/TwiN/gatus):
|
||||
``` yaml
|
||||
@@ -433,3 +431,39 @@ notify:
|
||||
message_param_name: message
|
||||
resource: https://ntfy.sh
|
||||
```
|
||||
|
||||
## Uptime Kuma
|
||||
Go to your [Uptime Kuma](https://github.com/louislam/uptime-kuma) Settings > Notifications, click on **Setup Notification**.
|
||||
Then set your desired **title** (e.g. "Uptime Kuma"), **ntfy topic**, **Server URL** and **priority (1-5)**:
|
||||
|
||||
<div id="uptimekuma-screenshots" class="screenshots">
|
||||
<a href="../static/img/uptimekuma-settings.png"><img src="../static/img/uptimekuma-settings.png"/></a>
|
||||
<a href="../static/img/uptimekuma-setup.png"><img src="../static/img/uptimekuma-setup.png"/></a>
|
||||
</div>
|
||||
|
||||
You can now test the notifications and apply them to monitors:
|
||||
|
||||
<div id="uptimekuma-monitor-screenshots" class="screenshots">
|
||||
<a href="../static/img/uptimekuma-ios-test.jpg"><img src="../static/img/uptimekuma-ios-test.jpg"/></a>
|
||||
<a href="../static/img/uptimekuma-ios-down.jpg"><img src="../static/img/uptimekuma-ios-down.jpg"/></a>
|
||||
<a href="../static/img/uptimekuma-ios-up.jpg"><img src="../static/img/uptimekuma-ios-up.jpg"/></a>
|
||||
</div>
|
||||
|
||||
## Apprise
|
||||
ntfy is integrated natively into [Apprise](https://github.com/caronc/apprise) (also check out the
|
||||
[Apprise/ntfy wiki page](https://github.com/caronc/apprise/wiki/Notify_ntfy)).
|
||||
|
||||
You can use it like this:
|
||||
|
||||
```
|
||||
apprise -vv -t "Test Message Title" -b "Test Message Body" \
|
||||
ntfy://mytopic
|
||||
```
|
||||
|
||||
Or with your own server like this:
|
||||
|
||||
```
|
||||
apprise -vv -t "Test Message Title" -b "Test Message Body" \
|
||||
ntfy://ntfy.example.com/mytopic
|
||||
```
|
||||
|
||||
|
||||
30
docs/faq.md
30
docs/faq.md
@@ -11,17 +11,16 @@ the service.
|
||||
Best effort.
|
||||
|
||||
## What happens if there are multiple subscribers to the same topic?
|
||||
As per usual with pub-sub, all subscribers receive notifications if they are
|
||||
subscribed to a topic.
|
||||
As per usual with pub-sub, all subscribers receive notifications if they are subscribed to a topic.
|
||||
|
||||
## Will you know what topics exist, can you spy on me?
|
||||
If you don't trust me or your messages are sensitive, run your own server. It's <a href="https://github.com/binwiederhier/ntfy">open source</a>.
|
||||
That said, the logs do not contain any topic names or other details about you.
|
||||
Messages are cached for the duration configured in `server.yml` (12h by default) to facilitate service restarts, message polling and to overcome
|
||||
client network disruptions.
|
||||
If you don't trust me or your messages are sensitive, run your own server. It's open source.
|
||||
That said, the logs do contain topic names and IP addresses, but I don't use them for anything other than
|
||||
troubleshooting and rate limiting. Messages are cached for the duration configured in `server.yml` (12h by default)
|
||||
to facilitate service restarts, message polling and to overcome client network disruptions.
|
||||
|
||||
## Can I self-host it?
|
||||
Yes. The server (including this Web UI) can be self-hosted, and the Android app supports adding topics from
|
||||
Yes. The server (including this Web UI) can be self-hosted, and the Android/iOS app supports adding topics from
|
||||
your own server as well. Check out the [install instructions](install.md).
|
||||
|
||||
## Why is Firebase used?
|
||||
@@ -34,16 +33,19 @@ of the app and [self-host your own ntfy server](install.md).
|
||||
|
||||
## How much battery does the Android app use?
|
||||
If you use the ntfy.sh server, and you don't use the [instant delivery](subscribe/phone.md#instant-delivery) feature,
|
||||
the Android app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
||||
or you use *instant delivery*, the app has to maintain a constant connection to the server, which consumes about 0-1% of
|
||||
battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
||||
the Android/iOS app uses no additional battery, since Firebase Cloud Messaging (FCM) is used. If you use your own server,
|
||||
or you use *instant delivery* (Android only), the app has to maintain a constant connection to the server, which consumes
|
||||
about 0-1% of battery in 17h of use (on my phone). There has been a ton of testing and improvement around this. I think it's pretty
|
||||
decent now.
|
||||
|
||||
## What is instant delivery?
|
||||
[Instant delivery](subscribe/phone.md#instant-delivery) is a feature in the Android app. If turned on, the app maintains a constant connection to the
|
||||
server and listens for incoming notifications. This consumes <a href="#battery-usage">additional battery</a>,
|
||||
server and listens for incoming notifications. This consumes additional battery (see above),
|
||||
but delivers notifications instantly.
|
||||
|
||||
## Why is there no iOS app (yet)?
|
||||
I don't have an iPhone or a Mac, so I didn't make an iOS app yet. It'd be awesome if
|
||||
<a href="https://github.com/binwiederhier/ntfy/issues/4">someone else could help out</a>.
|
||||
## Where can I donate?
|
||||
Many people have asked (thanks for that!), but I am currently not accepting any donations. The cost is manageable
|
||||
($25/month for hosting, and $99/year for the Apple cert) right now, and I don't want to have to feel obligated to
|
||||
anyone by accepting their money.
|
||||
|
||||
I may ask for donations in the future, though. After all, $400 per year isn't nothing...
|
||||
|
||||
@@ -26,37 +26,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.25.0_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.25.0_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_x86_64.tar.gz
|
||||
tar zxvf ntfy_1.27.1_linux_x86_64.tar.gz
|
||||
sudo cp -a ntfy_1.27.1_linux_x86_64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.27.1_linux_x86_64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.25.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.25.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_1.27.1_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_1.27.1_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.27.1_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.25.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.25.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_1.27.1_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_1.27.1_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.27.1_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.25.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.25.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.25.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_1.27.1_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_1.27.1_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_1.27.1_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -103,7 +103,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -111,7 +111,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -119,7 +119,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -127,7 +127,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -137,28 +137,28 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_1.25.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
@@ -184,33 +184,38 @@ nix-env -iA ntfy-sh
|
||||
|
||||
## macOS
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
|
||||
To install, please download the tarball, extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_macOS_all.tar.gz),
|
||||
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
|
||||
|
||||
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
|
||||
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
|
||||
|
||||
```bash
|
||||
curl https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_v1.25.0_macOS_all.tar.gz > ntfy_v1.25.0_macOS_all.tar.gz
|
||||
tar zxvf ntfy_v1.25.0_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_v1.25.0_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_macOS_all.tar.gz > ntfy_1.27.1_macOS_all.tar.gz
|
||||
tar zxvf ntfy_1.27.1_macOS_all.tar.gz
|
||||
sudo cp -a ntfy_1.27.1_macOS_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_v1.25.0_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_1.27.1_macOS_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
!!! info
|
||||
If there is a desire to install ntfy via [Homebrew](https://brew.sh/), please create a
|
||||
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know. Also, you can build and run the
|
||||
ntfy server on macOS as well, though I don't officially support that. Check out the [build instructions](develop.md)
|
||||
for details.
|
||||
There is a [GitHub issue](https://github.com/binwiederhier/ntfy/issues/286) about making ntfy installable via
|
||||
[Homebrew](https://brew.sh/). I'll eventually get to that, but I'd also love if somebody else stepped up to do it.
|
||||
Also, you can build and run the ntfy server on macOS as well, though I don't officially support that.
|
||||
Check out the [build instructions](develop.md) for details.
|
||||
|
||||
## Windows
|
||||
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on Windows as well.
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.25.0/ntfy_v1.25.0_windows_x86_64.zip),
|
||||
To install, please [download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v1.27.1/ntfy_1.27.1_windows_x86_64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
|
||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||
|
||||
Also available in [Scoop's](https://scoop.sh) Main repository:
|
||||
|
||||
`scoop install ntfy`
|
||||
|
||||
!!! info
|
||||
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
|
||||
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
|
||||
@@ -235,14 +240,15 @@ docker run \
|
||||
-p 80:80 \
|
||||
-it \
|
||||
binwiederhier/ntfy \
|
||||
--cache-file /var/cache/ntfy/cache.db \
|
||||
serve
|
||||
serve \
|
||||
--cache-file /var/cache/ntfy/cache.db
|
||||
```
|
||||
|
||||
With other config options and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
|
||||
With other config options, timezone, and non-root user (configured via `/etc/ntfy/server.yml`, see [configuration](config.md) for details):
|
||||
```bash
|
||||
docker run \
|
||||
-v /etc/ntfy:/etc/ntfy \
|
||||
-e TZ=UTC \
|
||||
-p 80:80 \
|
||||
-u UID:GID \
|
||||
-it \
|
||||
@@ -260,7 +266,9 @@ services:
|
||||
container_name: ntfy
|
||||
command:
|
||||
- serve
|
||||
user: UID:GID # optional. replace with your own user/group or uid/gid
|
||||
environment:
|
||||
- TZ=UTC # optional: set desired timezone
|
||||
user: UID:GID # optional: replace with your own user/group or uid/gid
|
||||
volumes:
|
||||
- /var/cache/ntfy:/var/cache/ntfy
|
||||
- /etc/ntfy:/etc/ntfy
|
||||
|
||||
@@ -2735,6 +2735,22 @@ parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Fireb
|
||||
option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally
|
||||
enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64.
|
||||
|
||||
### Matrix Gateway
|
||||
The ntfy server implements a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with
|
||||
[UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/)). This makes it easier to integrate
|
||||
with self-hosted [Matrix](https://matrix.org/) servers (such as [synapse](https://github.com/matrix-org/synapse)), since
|
||||
you don't have to set up a separate push proxy (such as [common-proxies](https://github.com/UnifiedPush/common-proxies)).
|
||||
|
||||
In short, ntfy accepts Matrix messages on the `/_matrix/push/v1/notify` endpoint (see [Push Gateway API](https://spec.matrix.org/v1.2/push-gateway-api/)),
|
||||
and forwards them to the ntfy topic defined in the `pushkey` of the message. The message will then be forwarded to the
|
||||
ntfy Android app, and passed on to the Matrix client there.
|
||||
|
||||
There is a nice diagram in the [Push Gateway docs](https://spec.matrix.org/v1.2/push-gateway-api/). In this diagram, the
|
||||
ntfy server plays the role of the Push Gateway, as well as the Push Provider. UnifiedPush is the Provider Push Protocol.
|
||||
|
||||
!!! info
|
||||
This is not a generic Matrix Push Gateway. It only works in combination with UnifiedPush and ntfy.
|
||||
|
||||
## Public topics
|
||||
Obviously all topics on ntfy.sh are public, but there are a few designated topics that are used in examples, and topics
|
||||
that you can use to try out what [authentication and access control](#authentication) looks like.
|
||||
|
||||
@@ -6,15 +6,98 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## ntfy Android app v1.14.0 (UNRELEASED)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Polling is now done with since=<id> API, which makes deduping easier ([#165](https://github.com/binwiederhier/ntfy/issues/165))
|
||||
* Turned JSON stream deprecation banner into "Use WebSockets" banner (no ticket)
|
||||
|
||||
**Bugs:**
|
||||
|
||||
* Long-click selecting of notifications doesn't scoll 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))
|
||||
* Accessibility: Clear/choose service URL button in base URL dropdown now has a label ([#292](https://github.com/binwiederhier/ntfy/issues/292), thanks to [@mhameed](https://github.com/mhameed) for reporting)
|
||||
|
||||
**Additional translations:**
|
||||
|
||||
* Italian (thanks to [@Genio2003](https://hosted.weblate.org/user/Genio2003/))
|
||||
* Dutch (thanks to [@SchoNie](https://hosted.weblate.org/user/SchoNie/))
|
||||
|
||||
Thank you to [@wunter8](https://github.com/wunter8) for proactively picking up some Android tickets, and fixing them! You rock!
|
||||
|
||||
-->
|
||||
|
||||
## ntfy server v1.25.0
|
||||
## ntfy server v1.27.1
|
||||
Released June 23, 2022
|
||||
|
||||
**Features:**
|
||||
|
||||
* Add `cache-startup-queries` option to allow custom [SQLite performance tuning](config.md#wal-for-message-cache) (no ticket)
|
||||
* ntfy CLI can now [wait for a command or PID](subscribe/cli.md#wait-for-pidcommand) before publishing ([#263](https://github.com/binwiederhier/ntfy/issues/263), thanks to the [original ntfy](https://github.com/dschep/ntfy) for the idea)
|
||||
* 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:**
|
||||
|
||||
* 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)
|
||||
* Disallow setting `upstream-base-url` to the same value as `base-url` ([#334](https://github.com/binwiederhier/ntfy/issues/334), thanks to [@oester](https://github.com/oester) for reporting)
|
||||
* Fix `since=<id>` implementation for multiple topics ([#336](https://github.com/binwiederhier/ntfy/issues/336), thanks to [@karmanyaahm](https://github.com/karmanyaahm) for reporting)
|
||||
* Simple parsing in `Actions` header now supports settings Android `intent=` key ([#341](https://github.com/binwiederhier/ntfy/pull/341), thanks to [@wunter8](https://github.com/wunter8))
|
||||
|
||||
**Deprecations:**
|
||||
|
||||
* The `ntfy publish --env-topic` option is deprecated as of now (see [deprecations](deprecations.md) for details)
|
||||
|
||||
## ntfy server v1.26.0
|
||||
Released June 16, 2022
|
||||
|
||||
This release adds a Matrix Push Gateway directly into ntfy, to make self-hosting a Matrix server easier. The Windows
|
||||
CLI is now available via Scoop, and ntfy is now natively supported in Uptime Kuma.
|
||||
|
||||
**Features:**
|
||||
|
||||
* ntfy now is a [Matrix Push Gateway](https://spec.matrix.org/v1.2/push-gateway-api/) (in combination with [UnifiedPush](https://unifiedpush.org) as the [Provider Push Protocol](https://unifiedpush.org/developers/gateway/), [#319](https://github.com/binwiederhier/ntfy/issues/319)/[#326](https://github.com/binwiederhier/ntfy/pull/326), thanks to [@MayeulC](https://github.com/MayeulC) for reporting)
|
||||
* Windows CLI is now available via [Scoop](https://scoop.sh) ([ScoopInstaller#3594](https://github.com/ScoopInstaller/Main/pull/3594), [#311](https://github.com/binwiederhier/ntfy/pull/311), [#269](https://github.com/binwiederhier/ntfy/issues/269), thanks to [@kzshantonu](https://github.com/kzshantonu))
|
||||
* [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:**
|
||||
|
||||
* 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))
|
||||
|
||||
**Documentation**
|
||||
|
||||
* Added [example](examples.md) for [Uptime Kuma](https://github.com/louislam/uptime-kuma) integration ([#315](https://github.com/binwiederhier/ntfy/pull/315), thanks to [@philippdormann](https://github.com/philippdormann))
|
||||
* Fix Docker install instructions ([#320](https://github.com/binwiederhier/ntfy/issues/320), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
|
||||
* Add clarifying comments to base-url ([#322](https://github.com/binwiederhier/ntfy/issues/322), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
|
||||
* Update FAQ for iOS app ([#321](https://github.com/binwiederhier/ntfy/issues/321), thanks to [@milksteakjellybeans](https://github.com/milksteakjellybeans) for reporting)
|
||||
|
||||
## ntfy iOS app v1.2
|
||||
Released June 16, 2022
|
||||
|
||||
This release adds support for authentication/authorization for self-hosted servers. It also allows you to
|
||||
set your server as the default server for new topics.
|
||||
|
||||
**Features:**
|
||||
|
||||
* 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:**
|
||||
|
||||
* Add validation for selfhosted server URL ([#290](https://github.com/binwiederhier/ntfy/issues/290))
|
||||
|
||||
## ntfy server v1.25.2
|
||||
Released June 2, 2022
|
||||
|
||||
This release adds the ability to set a log level to facilitate easier debugging of live systems. It also solves a
|
||||
production problem with a few over-users that resulted in Firebase quota problems (only applying to the over-users).
|
||||
We now block visitors from using Firebase if they trigger a quota exceeded response.
|
||||
|
||||
On top of that, we updated the Firebase SDK and are now building the release in GitHub Actions. We've also got two
|
||||
more translations: Chinese/Simplified and Dutch.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Advanced logging, with different log levels and hot reloading of the log level ([#284](https://github.com/binwiederhier/ntfy/pull/284))
|
||||
@@ -22,6 +105,7 @@ Released June 2, 2022
|
||||
**Bugs**:
|
||||
|
||||
* Respect Firebase "quota exceeded" response for topics, block Firebase publishing for user for 10min ([#289](https://github.com/binwiederhier/ntfy/issues/289))
|
||||
* Fix documentation header blue header due to mkdocs-material theme update (no ticket)
|
||||
|
||||
**Maintenance:**
|
||||
|
||||
|
||||
5
docs/static/css/extra.css
vendored
5
docs/static/css/extra.css
vendored
@@ -1,4 +1,4 @@
|
||||
:root {
|
||||
:root > * {
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color--light: #338574;
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
@@ -60,7 +60,8 @@ figure video {
|
||||
}
|
||||
|
||||
.screenshots img {
|
||||
height: 230px;
|
||||
max-height: 230px;
|
||||
max-width: 300px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(2px 2px 2px #ddd);
|
||||
|
||||
BIN
docs/static/img/uptimekuma-ios-down.jpg
vendored
Normal file
BIN
docs/static/img/uptimekuma-ios-down.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/static/img/uptimekuma-ios-test.jpg
vendored
Normal file
BIN
docs/static/img/uptimekuma-ios-test.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/static/img/uptimekuma-ios-up.jpg
vendored
Normal file
BIN
docs/static/img/uptimekuma-ios-up.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
docs/static/img/uptimekuma-settings.png
vendored
Normal file
BIN
docs/static/img/uptimekuma-settings.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/static/img/uptimekuma-setup.png
vendored
Normal file
BIN
docs/static/img/uptimekuma-setup.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
8
docs/static/js/extra.js
vendored
8
docs/static/js/extra.js
vendored
@@ -1,8 +1,8 @@
|
||||
// Link tabs, as per https://facelessuser.github.io/pymdown-extensions/extensions/tabbed/#linked-tabs
|
||||
|
||||
const savedTab = localStorage.getItem('savedTab')
|
||||
const tabs = document.querySelectorAll(".tabbed-set > input")
|
||||
for (const tab of tabs) {
|
||||
const savedCodeTab = localStorage.getItem('savedTab')
|
||||
const codeTabs = document.querySelectorAll(".tabbed-set > input")
|
||||
for (const tab of codeTabs) {
|
||||
tab.addEventListener("click", () => {
|
||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
||||
const pos = current.getBoundingClientRect().top
|
||||
@@ -25,7 +25,7 @@ for (const tab of tabs) {
|
||||
// Select saved tab
|
||||
const current = document.querySelector(`label[for=${tab.id}]`)
|
||||
const labelContent = current.innerHTML
|
||||
if (savedTab === labelContent) {
|
||||
if (savedCodeTab === labelContent) {
|
||||
tab.checked = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
|
||||
### Subscribe as SSE stream
|
||||
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
|
||||
notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly
|
||||
easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html).
|
||||
easy to use. Here's what it looks like. You may also want to check out the [full example on GitHub](https://github.com/binwiederhier/ntfy/tree/main/examples/web-example-eventsource).
|
||||
|
||||
=== "Command line (curl)"
|
||||
```
|
||||
|
||||
@@ -56,6 +56,71 @@ quick ones:
|
||||
ntfy pub mywebhook
|
||||
```
|
||||
|
||||
### Attaching a local file
|
||||
You can easily upload and attach a local file to a notification:
|
||||
|
||||
```
|
||||
$ ntfy pub --file README.md mytopic | jq .
|
||||
{
|
||||
"id": "meIlClVLABJQ",
|
||||
"time": 1655825460,
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"message": "You received a file: README.md",
|
||||
"attachment": {
|
||||
"name": "README.md",
|
||||
"type": "text/plain; charset=utf-8",
|
||||
"size": 2892,
|
||||
"expires": 1655836260,
|
||||
"url": "https://ntfy.sh/file/meIlClVLABJQ.txt"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Wait for PID/command
|
||||
If you have a long-running command and want to **publish a notification when the command completes**,
|
||||
you may wrap it with `ntfy publish --wait-cmd` (aliases: `--cmd`, `--done`). Or, if you forgot to wrap it, and the
|
||||
command is already running, you can wait for the process to complete with `ntfy publish --wait-pid` (alias: `--pid`).
|
||||
|
||||
Run a command and wait for it to complete (here: `rsync ...`):
|
||||
|
||||
```
|
||||
$ ntfy pub --wait-cmd mytopic rsync -av ./ root@example.com:/backups/ | jq .
|
||||
{
|
||||
"id": "Re0rWXZQM8WB",
|
||||
"time": 1655825624,
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"message": "Command succeeded after 56.553s: rsync -av ./ root@example.com:/backups/"
|
||||
}
|
||||
```
|
||||
|
||||
Or, if you already started the long-running process and want to wait for it using its process ID (PID), you can do this:
|
||||
|
||||
=== "Using a PID directly"
|
||||
```
|
||||
$ ntfy pub --wait-pid 8458 mytopic | jq .
|
||||
{
|
||||
"id": "orM6hJKNYkWb",
|
||||
"time": 1655825827,
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"message": "Process with PID 8458 exited after 2.003s"
|
||||
}
|
||||
```
|
||||
|
||||
=== "Using a `pidof`"
|
||||
```
|
||||
$ ntfy pub --wait-pid $(pidof rsync) mytopic | jq .
|
||||
{
|
||||
"id": "orM6hJKNYkWb",
|
||||
"time": 1655825827,
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"message": "Process with PID 8458 exited after 2.003s"
|
||||
}
|
||||
```
|
||||
|
||||
## Subscribe to topics
|
||||
You can subscribe to topics using `ntfy subscribe`. Depending on how it is called, this command
|
||||
will either print or execute a command for every arriving message. There are a few different ways
|
||||
|
||||
@@ -180,21 +180,27 @@ notification popups:
|
||||
|
||||
Here's a list of extras you can access. Most likely, you'll want to filter for `topic` and react on `message`:
|
||||
|
||||
| Extra name | Type | Example | Description |
|
||||
|-----------------|------------------------------|--------------------|------------------------------------------------------------------------------------|
|
||||
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
||||
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
||||
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
|
||||
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
||||
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
||||
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
||||
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| Extra name | Type | Example | Description |
|
||||
|----------------------|------------------------------|------------------------------------------|------------------------------------------------------------------------------------|
|
||||
| `id` | *String* | `bP8dMjO8ig` | Randomly chosen message identifier (likely not very useful for task automation) |
|
||||
| `base_url` | *String* | `https://ntfy.sh` | Root URL of the ntfy server this message came from |
|
||||
| `topic` ❤️ | *String* | `mytopic` | Topic name; **you'll likely want to filter for a specific topic** |
|
||||
| `muted` | *Boolean* | `true` | Indicates whether the subscription was muted in the app |
|
||||
| `muted_str` | *String (`true` or `false`)* | `true` | Same as `muted`, but as string `true` or `false` |
|
||||
| `time` | *Int* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `title` | *String* | `Some title` | Message [title](../publish.md#message-title); may be empty if not set |
|
||||
| `message` ❤️ | *String* | `Some message` | Message body; **this is likely what you're interested in** |
|
||||
| `message_bytes` | *ByteArray* | `(binary data)` | Message body as binary data |
|
||||
| `encoding`️ | *String* | - | Message encoding (empty or "base64") |
|
||||
| `tags` | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `tags_map` | *String* | `0=tag1,1=tag2,..` | Map of tags to make it easier to map first, second, ... tag |
|
||||
| `priority` | *Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| `click` | *String* | `https://google.com` | [Click action](../publish.md#click-action) URL, or empty if not set |
|
||||
| `attachment_name` | *String* | `attachment.jpg` | Filename of the attachment; may be empty if not set |
|
||||
| `attachment_type` | *String* | `image/jpeg` | Mime type of the attachment; may be empty if not set |
|
||||
| `attachment_size` | *Long* | `9923111` | Size in bytes of the attachment; may be zero if not set |
|
||||
| `attachment_expires` | *Long* | `1655514244` | Expiry date as Unix timestamp of the attachment URL; may be zero if not set |
|
||||
| `attachment_url` | *String* | `https://ntfy.sh/file/afUbjadfl7ErP.jpg` | URL of the attachment; may be empty if not set |
|
||||
|
||||
#### Send messages using intents
|
||||
To send messages from other apps (such as [MacroDroid](https://play.google.com/store/apps/details?id=com.arlosoft.macrodroid)
|
||||
|
||||
25
go.mod
25
go.mod
@@ -13,13 +13,13 @@ require (
|
||||
github.com/mattn/go-sqlite3 v1.14.13
|
||||
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/urfave/cli/v2 v2.8.1
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e
|
||||
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 // indirect
|
||||
github.com/urfave/cli/v2 v2.10.2
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d
|
||||
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2 // indirect
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
|
||||
golang.org/x/term v0.0.0-20220526004731-065cf7ba2467
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306
|
||||
google.golang.org/api v0.82.0
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858
|
||||
google.golang.org/api v0.85.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -28,8 +28,8 @@ require github.com/pkg/errors v0.9.1 // indirect
|
||||
require firebase.google.com/go/v4 v4.8.0
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.102.0 // indirect
|
||||
cloud.google.com/go/compute v1.6.1 // indirect
|
||||
cloud.google.com/go v0.102.1 // indirect
|
||||
cloud.google.com/go/compute v1.7.0 // indirect
|
||||
cloud.google.com/go/iam v0.3.0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
@@ -38,20 +38,21 @@ require (
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/google/go-cmp v0.5.8 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.1.0 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.4.0 // indirect
|
||||
github.com/googleapis/go-type-adapters v1.0.0 // 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-20220531201128-c960675eff93 // indirect
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect
|
||||
golang.org/x/net v0.0.0-20220622184535-263ec571b305 // indirect
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect
|
||||
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.1 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220623142657-077d458a5694 // indirect
|
||||
google.golang.org/grpc v1.47.0 // indirect
|
||||
google.golang.org/protobuf v1.28.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
59
go.sum
59
go.sum
@@ -28,8 +28,9 @@ cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Ud
|
||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
||||
cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
|
||||
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
|
||||
cloud.google.com/go v0.102.0 h1:DAq3r8y4mDgyB/ZPJ9v/5VJNqjgJAxTn6ZYLlUywOu8=
|
||||
cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
|
||||
cloud.google.com/go v0.102.1 h1:vpK6iQWv/2uUeFJth4/cBHsQAGjn1iIE6AAlxipRaA0=
|
||||
cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
@@ -41,8 +42,9 @@ cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC
|
||||
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
|
||||
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
|
||||
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
|
||||
cloud.google.com/go/compute v1.6.1 h1:2sMmt8prCn7DPaG4Pmh0N3Inmc8cT8ae5k1M6VJ9Wqc=
|
||||
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
|
||||
cloud.google.com/go/compute v1.7.0 h1:v/k9Eueb8aAJ0vZuxKMrgm6kPhCLZU9HxFU+AFDs9Uk=
|
||||
cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
|
||||
@@ -90,7 +92,6 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
|
||||
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -192,6 +193,9 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/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=
|
||||
@@ -239,8 +243,8 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4=
|
||||
github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY=
|
||||
github.com/urfave/cli/v2 v2.10.2 h1:x3p8awjp/2arX+Nl/G2040AZpOCHS/eMJJ1/a+mye4Y=
|
||||
github.com/urfave/cli/v2 v2.10.2/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo=
|
||||
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=
|
||||
@@ -262,8 +266,8 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
|
||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e h1:T8NU3HyQ8ClP4SEE+KbFlg6n0NhuTsN4MyznaarGsZM=
|
||||
golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -341,9 +345,10 @@ golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su
|
||||
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220526153639-5463443f8c37/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220531201128-c960675eff93 h1:MYimHLfoXEpOhqd/zgoA/uoXzHB86AEky4LAx5ij9xA=
|
||||
golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.0.0-20220622184535-263ec571b305 h1:dAgbJ2SP4jD6XYfMNLVj0BF21jo2PjChrtGaAvF5M3I=
|
||||
golang.org/x/net v0.0.0-20220622184535-263ec571b305/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
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=
|
||||
@@ -364,8 +369,9 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401 h1:zwrSfklXn0gxyLRX/aR+q6cgHbV/ItVyzbPlbA+dkAw=
|
||||
golang.org/x/oauth2 v0.0.0-20220524215830-622c5d57e401/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
|
||||
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2 h1:+jnHzr9VPj32ykQVai5DNahi9+NSp7yYuCsl5eAQtL0=
|
||||
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -377,7 +383,6 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f h1:Ax0t5p6N38Ga0dThY21weqDEyz2oklo4IvDkpigvkD8=
|
||||
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -439,8 +444,11 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664 h1:wEZYwx+kK+KlZ0hpvP2Ls1Xr4+RWnlzGFwPP0aiDjIU=
|
||||
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/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-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
|
||||
@@ -458,8 +466,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
|
||||
golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U=
|
||||
golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -516,8 +524,9 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3kFUOQGGLIzLIkbzUHp47618=
|
||||
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f h1:uF6paiQQebLeSXkrTqHqz0MXhXXS1KgF41eUdBNvxK0=
|
||||
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
@@ -561,8 +570,9 @@ google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRR
|
||||
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
|
||||
google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
|
||||
google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
|
||||
google.golang.org/api v0.82.0 h1:h6EGeZuzhoKSS7BUznzkW+2wHZ+4Ubd6rsVvvh3dRkw=
|
||||
google.golang.org/api v0.82.0/go.mod h1:Ld58BeTlL9DIYr2M2ajvoSqmGLei0BMn+kVBmkam1os=
|
||||
google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
|
||||
google.golang.org/api v0.85.0 h1:8rJoHuRxx+vCmZtAO/3k1dRLvYNVyTJtZ5oaFZvhgvc=
|
||||
google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
|
||||
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=
|
||||
@@ -657,9 +667,11 @@ google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX
|
||||
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20220527130721-00d5c0f3be58/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8 h1:qRu95HZ148xXw+XeZ3dvqe85PxH4X8+jIo0iRPKcEnM=
|
||||
google.golang.org/genproto v0.0.0-20220602131408-e326c6e8e9c8/go.mod h1:yKyY4AMRwFiC8yMMNaMi+RkCnjZJt9LoWuvhXjMs+To=
|
||||
google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
|
||||
google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
|
||||
google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
|
||||
google.golang.org/genproto v0.0.0-20220623142657-077d458a5694 h1:itnFmgk4Ls5nT+mYO2ZK6F0DpKsGZLhB5BB9y5ZL2HA=
|
||||
google.golang.org/genproto v0.0.0-20220623142657-077d458a5694/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
|
||||
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=
|
||||
@@ -716,8 +728,9 @@ 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=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
||||
@@ -186,6 +186,8 @@ func populateAction(newAction *action, section int, key, value string) error {
|
||||
newAction.Method = value
|
||||
case "body":
|
||||
newAction.Body = value
|
||||
case "intent":
|
||||
newAction.Intent = value
|
||||
default:
|
||||
return fmt.Errorf("key '%s' unknown", key)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,14 @@ func TestParseActions(t *testing.T) {
|
||||
require.Equal(t, "some command", actions[0].Extras["command"])
|
||||
require.Equal(t, "a parameter", actions[0].Extras["some_param"])
|
||||
|
||||
// Broadcast action with intent
|
||||
actions, err = parseActions("action=broadcast, label=Do a thing, intent=io.heckel.ntfy.TEST_INTENT")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(actions))
|
||||
require.Equal(t, "broadcast", actions[0].Action)
|
||||
require.Equal(t, "Do a thing", actions[0].Label)
|
||||
require.Equal(t, "io.heckel.ntfy.TEST_INTENT", actions[0].Intent)
|
||||
|
||||
// Headers with dashes
|
||||
actions, err = parseActions("action=http, label=Send request, url=http://example.com, method=GET, headers.Content-Type=application/json, headers.Authorization=Basic sdasffsf")
|
||||
require.Nil(t, err)
|
||||
|
||||
@@ -57,6 +57,7 @@ type Config struct {
|
||||
FirebaseKeyFile string
|
||||
CacheFile string
|
||||
CacheDuration time.Duration
|
||||
CacheStartupQueries string
|
||||
AuthFile string
|
||||
AuthDefaultRead bool
|
||||
AuthDefaultWrite bool
|
||||
@@ -94,6 +95,7 @@ type Config struct {
|
||||
VisitorEmailLimitReplenish time.Duration
|
||||
BehindProxy bool
|
||||
EnableWeb bool
|
||||
Version string // injected by App
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
@@ -135,5 +137,6 @@ func NewConfig() *Config {
|
||||
VisitorEmailLimitReplenish: DefaultVisitorEmailLimitReplenish,
|
||||
BehindProxy: false,
|
||||
EnableWeb: true,
|
||||
Version: "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +50,13 @@ var (
|
||||
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", "https://ntfy.sh/docs/subscribe/api/#websockets"}
|
||||
errHTTPBadRequestJSONInvalid = &errHTTP{40017, http.StatusBadRequest, "invalid request: request body must be message JSON", "https://ntfy.sh/docs/publish/#publish-as-json"}
|
||||
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"}
|
||||
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", ""}
|
||||
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"}
|
||||
@@ -61,4 +64,5 @@ var (
|
||||
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
|
||||
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
|
||||
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
|
||||
errHTTPInternalErrorMissingBaseURL = &errHTTP{50003, http.StatusInternalServerError, "internal server error: base-url must be be configured for this feature", "https://ntfy.sh/docs/config/"}
|
||||
)
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>ntfy.sh: EventSource Example</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<style>
|
||||
body { font-size: 1.2em; line-height: 130%; }
|
||||
#events { font-family: monospace; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ntfy.sh: EventSource Example</h1>
|
||||
<p>
|
||||
This is an example showing how to use <a href="https://ntfy.sh">ntfy.sh</a> with
|
||||
<a href="https://developer.mozilla.org/en-US/docs/Web/API/EventSource">EventSource</a>.<br/>
|
||||
This example doesn't need a server. You can just save the HTML page and run it from anywhere.
|
||||
</p>
|
||||
<button id="publishButton">Send test notification</button>
|
||||
<p><b>Log:</b></p>
|
||||
<div id="events"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
const publishURL = `https://ntfy.sh/example`;
|
||||
const subscribeURL = `https://ntfy.sh/example/sse`;
|
||||
const events = document.getElementById('events');
|
||||
const eventSource = new EventSource(subscribeURL);
|
||||
|
||||
// Publish button
|
||||
document.getElementById("publishButton").onclick = () => {
|
||||
fetch(publishURL, {
|
||||
method: 'POST', // works with PUT as well, though that sends an OPTIONS request too!
|
||||
body: `It is ${new Date().toString()}. This is a test.`
|
||||
})
|
||||
};
|
||||
|
||||
// Incoming events
|
||||
eventSource.onopen = () => {
|
||||
let event = document.createElement('div');
|
||||
event.innerHTML = `EventSource connected to ${subscribeURL}`;
|
||||
events.appendChild(event);
|
||||
};
|
||||
eventSource.onerror = (e) => {
|
||||
let event = document.createElement('div');
|
||||
event.innerHTML = `EventSource error: Failed to connect to ${subscribeURL}`;
|
||||
events.appendChild(event);
|
||||
};
|
||||
eventSource.onmessage = (e) => {
|
||||
let event = document.createElement('div');
|
||||
event.innerHTML = e.data;
|
||||
events.appendChild(event);
|
||||
};
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -49,7 +49,7 @@ const (
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE topic = ? AND mid = ?`
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT mid, time, topic, message, title, priority, tags, click, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, encoding
|
||||
FROM messages
|
||||
@@ -82,7 +82,7 @@ const (
|
||||
`
|
||||
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
|
||||
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
|
||||
selectMessageCountForTopicQuery = `SELECT COUNT(*) FROM messages WHERE topic = ?`
|
||||
selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
|
||||
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
|
||||
selectAttachmentsSizeQuery = `SELECT IFNULL(SUM(attachment_size), 0) FROM messages WHERE sender = ? AND attachment_expires >= ?`
|
||||
selectAttachmentsExpiredQuery = `SELECT mid FROM messages WHERE attachment_expires > 0 AND attachment_expires < ?`
|
||||
@@ -186,12 +186,12 @@ type messageCache struct {
|
||||
}
|
||||
|
||||
// newSqliteCache creates a SQLite file-backed cache
|
||||
func newSqliteCache(filename string, nop bool) (*messageCache, error) {
|
||||
func newSqliteCache(filename, startupQueries string, nop bool) (*messageCache, error) {
|
||||
db, err := sql.Open("sqlite3", filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setupCacheDB(db); err != nil {
|
||||
if err := setupCacheDB(db, startupQueries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &messageCache{
|
||||
@@ -202,13 +202,13 @@ func newSqliteCache(filename string, nop bool) (*messageCache, error) {
|
||||
|
||||
// newMemCache creates an in-memory cache
|
||||
func newMemCache() (*messageCache, error) {
|
||||
return newSqliteCache(createMemoryFilename(), false)
|
||||
return newSqliteCache(createMemoryFilename(), "", false)
|
||||
}
|
||||
|
||||
// newNopCache creates an in-memory cache that discards all messages;
|
||||
// it is always empty and can be used if caching is entirely disabled
|
||||
func newNopCache() (*messageCache, error) {
|
||||
return newSqliteCache(createMemoryFilename(), true)
|
||||
return newSqliteCache(createMemoryFilename(), "", true)
|
||||
}
|
||||
|
||||
// createMemoryFilename creates a unique memory filename to use for the SQLite backend.
|
||||
@@ -222,52 +222,66 @@ func createMemoryFilename() string {
|
||||
}
|
||||
|
||||
func (c *messageCache) AddMessage(m *message) error {
|
||||
if m.Event != messageEvent {
|
||||
return errUnexpectedMessageType
|
||||
}
|
||||
return c.addMessages([]*message{m})
|
||||
}
|
||||
|
||||
func (c *messageCache) addMessages(ms []*message) error {
|
||||
if c.nop {
|
||||
return nil
|
||||
}
|
||||
published := m.Time <= time.Now().Unix()
|
||||
tags := strings.Join(m.Tags, ",")
|
||||
var attachmentName, attachmentType, attachmentURL string
|
||||
var attachmentSize, attachmentExpires int64
|
||||
if m.Attachment != nil {
|
||||
attachmentName = m.Attachment.Name
|
||||
attachmentType = m.Attachment.Type
|
||||
attachmentSize = m.Attachment.Size
|
||||
attachmentExpires = m.Attachment.Expires
|
||||
attachmentURL = m.Attachment.URL
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var actionsStr string
|
||||
if len(m.Actions) > 0 {
|
||||
actionsBytes, err := json.Marshal(m.Actions)
|
||||
defer tx.Rollback()
|
||||
for _, m := range ms {
|
||||
if m.Event != messageEvent {
|
||||
return errUnexpectedMessageType
|
||||
}
|
||||
published := m.Time <= time.Now().Unix()
|
||||
tags := strings.Join(m.Tags, ",")
|
||||
var attachmentName, attachmentType, attachmentURL string
|
||||
var attachmentSize, attachmentExpires int64
|
||||
if m.Attachment != nil {
|
||||
attachmentName = m.Attachment.Name
|
||||
attachmentType = m.Attachment.Type
|
||||
attachmentSize = m.Attachment.Size
|
||||
attachmentExpires = m.Attachment.Expires
|
||||
attachmentURL = m.Attachment.URL
|
||||
}
|
||||
var actionsStr string
|
||||
if len(m.Actions) > 0 {
|
||||
actionsBytes, err := json.Marshal(m.Actions)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actionsStr = string(actionsBytes)
|
||||
}
|
||||
_, err := tx.Exec(
|
||||
insertMessageQuery,
|
||||
m.ID,
|
||||
m.Time,
|
||||
m.Topic,
|
||||
m.Message,
|
||||
m.Title,
|
||||
m.Priority,
|
||||
tags,
|
||||
m.Click,
|
||||
actionsStr,
|
||||
attachmentName,
|
||||
attachmentType,
|
||||
attachmentSize,
|
||||
attachmentExpires,
|
||||
attachmentURL,
|
||||
m.Sender,
|
||||
m.Encoding,
|
||||
published,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actionsStr = string(actionsBytes)
|
||||
}
|
||||
_, err := c.db.Exec(
|
||||
insertMessageQuery,
|
||||
m.ID,
|
||||
m.Time,
|
||||
m.Topic,
|
||||
m.Message,
|
||||
m.Title,
|
||||
m.Priority,
|
||||
tags,
|
||||
m.Click,
|
||||
actionsStr,
|
||||
attachmentName,
|
||||
attachmentType,
|
||||
attachmentSize,
|
||||
attachmentExpires,
|
||||
attachmentURL,
|
||||
m.Sender,
|
||||
m.Encoding,
|
||||
published,
|
||||
)
|
||||
return err
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (c *messageCache) Messages(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||
@@ -294,7 +308,7 @@ func (c *messageCache) messagesSinceTime(topic string, since sinceMarker, schedu
|
||||
}
|
||||
|
||||
func (c *messageCache) messagesSinceID(topic string, since sinceMarker, scheduled bool) ([]*message, error) {
|
||||
idrows, err := c.db.Query(selectRowIDFromMessageID, topic, since.ID())
|
||||
idrows, err := c.db.Query(selectRowIDFromMessageID, since.ID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -332,22 +346,24 @@ func (c *messageCache) MarkPublished(m *message) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *messageCache) MessageCount(topic string) (int, error) {
|
||||
rows, err := c.db.Query(selectMessageCountForTopicQuery, topic)
|
||||
func (c *messageCache) MessageCounts() (map[string]int, error) {
|
||||
rows, err := c.db.Query(selectMessageCountPerTopicQuery)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var topic string
|
||||
var count int
|
||||
if !rows.Next() {
|
||||
return 0, errors.New("no rows found")
|
||||
counts := make(map[string]int)
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(&topic, &count); err != nil {
|
||||
return nil, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts[topic] = count
|
||||
}
|
||||
if err := rows.Scan(&count); err != nil {
|
||||
return 0, err
|
||||
} else if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (c *messageCache) Topics() (map[string]*topic, error) {
|
||||
@@ -483,7 +499,14 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func setupCacheDB(db *sql.DB) error {
|
||||
func setupCacheDB(db *sql.DB, startupQueries string) error {
|
||||
// Run startup queries
|
||||
if startupQueries != "" {
|
||||
if _, err := db.Exec(startupQueries); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// If 'messages' table does not exist, this must be a new database
|
||||
rowsMC, err := db.Query(selectMessagesCountQuery)
|
||||
if err != nil {
|
||||
|
||||
@@ -34,9 +34,9 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
||||
require.Equal(t, errUnexpectedMessageType, c.AddMessage(newOpenMessage("example"))) // These should not be added!
|
||||
|
||||
// mytopic: count
|
||||
count, err := c.MessageCount("mytopic")
|
||||
counts, err := c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, count)
|
||||
require.Equal(t, 2, counts["mytopic"])
|
||||
|
||||
// mytopic: since all
|
||||
messages, _ := c.Messages("mytopic", sinceAllMessages, false)
|
||||
@@ -66,18 +66,18 @@ func testCacheMessages(t *testing.T, c *messageCache) {
|
||||
require.Equal(t, "my other message", messages[0].Message)
|
||||
|
||||
// example: count
|
||||
count, err = c.MessageCount("example")
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
require.Equal(t, 1, counts["example"])
|
||||
|
||||
// example: since all
|
||||
messages, _ = c.Messages("example", sinceAllMessages, false)
|
||||
require.Equal(t, "my example message", messages[0].Message)
|
||||
|
||||
// non-existing: count
|
||||
count, err = c.MessageCount("doesnotexist")
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
require.Equal(t, 0, counts["doesnotexist"])
|
||||
|
||||
// non-existing: since all
|
||||
messages, _ = c.Messages("doesnotexist", sinceAllMessages, false)
|
||||
@@ -255,13 +255,13 @@ func testCachePrune(t *testing.T, c *messageCache) {
|
||||
require.Nil(t, c.AddMessage(m3))
|
||||
require.Nil(t, c.Prune(time.Unix(2, 0)))
|
||||
|
||||
count, err := c.MessageCount("mytopic")
|
||||
counts, err := c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, count)
|
||||
require.Equal(t, 1, counts["mytopic"])
|
||||
|
||||
count, err = c.MessageCount("another_topic")
|
||||
counts, err = c.MessageCounts()
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
require.Equal(t, 0, counts["another_topic"])
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
require.Nil(t, err)
|
||||
@@ -378,7 +378,7 @@ func TestSqliteCache_Migration_From0(t *testing.T) {
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename)
|
||||
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, false)
|
||||
@@ -424,7 +424,7 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
require.Nil(t, db.Close())
|
||||
|
||||
// Create cache to trigger migration
|
||||
c := newSqliteTestCacheFromFile(t, filename)
|
||||
c := newSqliteTestCacheFromFile(t, filename, "")
|
||||
checkSchemaVersion(t, c.db)
|
||||
|
||||
// Add delayed message
|
||||
@@ -443,6 +443,37 @@ func TestSqliteCache_Migration_From1(t *testing.T) {
|
||||
require.Equal(t, 11, len(messages))
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_WAL(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := `pragma journal_mode = WAL;
|
||||
pragma synchronous = normal;
|
||||
pragma temp_store = memory;`
|
||||
db, err := newSqliteCache(filename, startupQueries, false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||
require.FileExists(t, filename)
|
||||
require.FileExists(t, filename+"-wal")
|
||||
require.FileExists(t, filename+"-shm")
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_None(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := ""
|
||||
db, err := newSqliteCache(filename, startupQueries, false)
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, db.AddMessage(newDefaultMessage("mytopic", "some message")))
|
||||
require.FileExists(t, filename)
|
||||
require.NoFileExists(t, filename+"-wal")
|
||||
require.NoFileExists(t, filename+"-shm")
|
||||
}
|
||||
|
||||
func TestSqliteCache_StartupQueries_Fail(t *testing.T) {
|
||||
filename := newSqliteTestCacheFile(t)
|
||||
startupQueries := `xx error`
|
||||
_, err := newSqliteCache(filename, startupQueries, false)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||
require.Nil(t, err)
|
||||
@@ -468,7 +499,7 @@ func TestMemCache_NopCache(t *testing.T) {
|
||||
}
|
||||
|
||||
func newSqliteTestCache(t *testing.T) *messageCache {
|
||||
c, err := newSqliteCache(newSqliteTestCacheFile(t), false)
|
||||
c, err := newSqliteCache(newSqliteTestCacheFile(t), "", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -479,8 +510,8 @@ func newSqliteTestCacheFile(t *testing.T) string {
|
||||
return filepath.Join(t.TempDir(), "cache.db")
|
||||
}
|
||||
|
||||
func newSqliteTestCacheFromFile(t *testing.T, filename string) *messageCache {
|
||||
c, err := newSqliteCache(filename, false)
|
||||
func newSqliteTestCacheFromFile(t *testing.T, filename, startupQueries string) *messageCache {
|
||||
c, err := newSqliteCache(filename, startupQueries, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
196
server/server.go
196
server/server.go
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -17,12 +16,15 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"heckel.io/ntfy/log"
|
||||
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.org/x/sync/errgroup"
|
||||
@@ -67,15 +69,13 @@ var (
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
userStatsPath = "/user/stats"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
disallowedTopics = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
|
||||
attachURLRegex = regexp.MustCompile(`^https?://`)
|
||||
|
||||
//go:embed "example.html"
|
||||
exampleSource string
|
||||
|
||||
//go:embed site
|
||||
webFs embed.FS
|
||||
webFsCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: webFs}
|
||||
@@ -158,7 +158,7 @@ func createMessageCache(conf *Config) (*messageCache, error) {
|
||||
if conf.CacheDuration == 0 {
|
||||
return newNopCache()
|
||||
} else if conf.CacheFile != "" {
|
||||
return newSqliteCache(conf.CacheFile, false)
|
||||
return newSqliteCache(conf.CacheFile, conf.CacheStartupQueries, false)
|
||||
}
|
||||
return newMemCache()
|
||||
}
|
||||
@@ -179,7 +179,7 @@ func (s *Server) Run() error {
|
||||
if s.config.SMTPServerListen != "" {
|
||||
listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
|
||||
}
|
||||
log.Info("Listening on%s, log level is %s", listenStr, log.CurrentLevel().String())
|
||||
log.Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", s.handle)
|
||||
errChan := make(chan error)
|
||||
@@ -247,6 +247,9 @@ func (s *Server) Stop() {
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
v := s.visitor(r)
|
||||
log.Debug("%s Dispatching request", logHTTPPrefix(v, r))
|
||||
if log.IsTrace() {
|
||||
log.Trace("%s Entire request (headers and body):\n%s", logHTTPPrefix(v, r), renderHTTPRequest(r))
|
||||
}
|
||||
if err := s.handleInternal(w, r, v); err != nil {
|
||||
if websocket.IsWebSocketUpgrade(r) {
|
||||
isNormalError := strings.Contains(err.Error(), "i/o timeout")
|
||||
@@ -257,11 +260,15 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return // Do not attempt to write to upgraded connection
|
||||
}
|
||||
if matrixErr, ok := err.(*errMatrix); ok {
|
||||
writeMatrixError(w, r, v, matrixErr)
|
||||
return
|
||||
}
|
||||
httpErr, ok := err.(*errHTTP)
|
||||
if !ok {
|
||||
httpErr = errHTTPInternalError
|
||||
}
|
||||
isNormalError := httpErr.HTTPCode == http.StatusNotFound
|
||||
isNormalError := httpErr.HTTPCode == http.StatusNotFound || httpErr.HTTPCode == http.StatusBadRequest
|
||||
if isNormalError {
|
||||
log.Debug("%s Connection closed with HTTP %d (ntfy error %d): %s", logHTTPPrefix(v, r), httpErr.HTTPCode, httpErr.Code, err.Error())
|
||||
} else {
|
||||
@@ -277,24 +284,26 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/" {
|
||||
return s.ensureWebEnabled(s.handleHome)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" {
|
||||
return s.ensureWebEnabled(s.handleExample)(w, r, v)
|
||||
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
|
||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
|
||||
return s.handleUserStats(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
|
||||
return s.handleMatrixDiscovery(w)
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
|
||||
} else if (r.Method == http.MethodGet || r.Method == http.MethodHead) && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
|
||||
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.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) {
|
||||
@@ -347,11 +356,6 @@ func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
_, err := io.WriteString(w, exampleSource)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
appRoot := "/"
|
||||
if !s.config.WebRootIsApp {
|
||||
@@ -405,39 +409,51 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
|
||||
if err != nil {
|
||||
return errHTTPNotFound
|
||||
}
|
||||
if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil {
|
||||
return errHTTPTooManyRequestsAttachmentBandwidthLimit
|
||||
if r.Method == http.MethodGet {
|
||||
if err := v.BandwidthLimiter().Allow(stat.Size()); err != nil {
|
||||
return errHTTPTooManyRequestsAttachmentBandwidthLimit
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", stat.Size()))
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
if r.Method == http.MethodGet {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f)
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(util.NewContentTypeWriter(w, r.URL.Path), f)
|
||||
return err
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
|
||||
if s.config.BaseURL == "" {
|
||||
return errHTTPInternalErrorMissingBaseURL
|
||||
}
|
||||
return writeMatrixDiscoveryResponse(w)
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishWithoutResponse(r *http.Request, v *visitor) (*message, error) {
|
||||
t, err := s.topicFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
body, err := util.Peek(r.Body, s.config.MessageLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
m := newDefaultMessage(t.ID, "")
|
||||
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if m.PollID != "" {
|
||||
m = newPollRequestMessage(t.ID, m.PollID)
|
||||
}
|
||||
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if m.Message == "" {
|
||||
m.Message = emptyMessageBody
|
||||
@@ -450,7 +466,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
}
|
||||
if !delayed {
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if s.firebaseClient != nil && firebase {
|
||||
go s.sendToFirebase(v, m)
|
||||
@@ -466,24 +482,44 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
}
|
||||
if cache {
|
||||
if err := s.messageCache.AddMessage(m); err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.messages++
|
||||
s.mu.Unlock()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
m, err := s.handlePublishWithoutResponse(r, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
|
||||
if err := json.NewEncoder(w).Encode(m); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.messages++
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
_, err := s.handlePublishWithoutResponse(r, v)
|
||||
if err != nil {
|
||||
return &errMatrix{pushKey: r.Header.Get(matrixPushKeyHeader), err: err}
|
||||
}
|
||||
return writeMatrixSuccess(w)
|
||||
}
|
||||
|
||||
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||
log.Debug("%s Publishing to Firebase", logMessagePrefix(v, m))
|
||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||
log.Warn("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error())
|
||||
if err == errFirebaseTemporarilyBanned {
|
||||
log.Debug("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error())
|
||||
} else {
|
||||
log.Warn("%s Unable to publish to Firebase: %v", logMessagePrefix(v, m), err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,6 +798,13 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
|
||||
return err
|
||||
}
|
||||
var wlock sync.Mutex
|
||||
defer func() {
|
||||
// Hack: This is the fix for a horrible data race that I have not been able to figure out in quite some time.
|
||||
// It appears to be happening when the Go HTTP code reads from the socket when closing the request (i.e. AFTER
|
||||
// this function returns), and causes a data race with the ResponseWriter. Locking wlock here silences the
|
||||
// data race detector. See https://github.com/binwiederhier/ntfy/issues/338#issuecomment-1163425889.
|
||||
wlock.TryLock()
|
||||
}()
|
||||
sub := func(v *visitor, msg *message) error {
|
||||
if !filters.Pass(msg) {
|
||||
return nil
|
||||
@@ -937,19 +980,26 @@ func parseSubscribeParams(r *http.Request) (poll bool, since sinceMarker, schedu
|
||||
return
|
||||
}
|
||||
|
||||
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
|
||||
// marker, returning only messages that are newer than the marker.
|
||||
func (s *Server) sendOldMessages(topics []*topic, since sinceMarker, scheduled bool, v *visitor, sub subscriber) error {
|
||||
if since.IsNone() {
|
||||
return nil
|
||||
}
|
||||
messages := make([]*message, 0)
|
||||
for _, t := range topics {
|
||||
messages, err := s.messageCache.Messages(t.ID, since, scheduled)
|
||||
topicMessages, err := s.messageCache.Messages(t.ID, since, scheduled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range messages {
|
||||
if err := sub(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
messages = append(messages, topicMessages...)
|
||||
}
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
return messages[i].Time < messages[j].Time
|
||||
})
|
||||
for _, m := range messages {
|
||||
if err := sub(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1037,18 +1087,23 @@ func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||
}
|
||||
|
||||
func (s *Server) updateStatsAndPrune() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
log.Debug("Manager: Starting")
|
||||
defer log.Debug("Manager: Finished")
|
||||
|
||||
// WARNING: Make sure to only selectively lock with the mutex, and be aware that this
|
||||
// there is no mutex for the entire function.
|
||||
|
||||
// Expire visitors from rate visitors map
|
||||
s.mu.Lock()
|
||||
staleVisitors := 0
|
||||
for ip, v := range s.visitors {
|
||||
if v.Stale() {
|
||||
log.Debug("Deleting stale visitor %s", v.ip)
|
||||
log.Trace("Deleting stale visitor %s", v.ip)
|
||||
delete(s.visitors, ip)
|
||||
staleVisitors++
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
log.Debug("Manager: Deleted %d stale visitor(s)", staleVisitors)
|
||||
|
||||
// Delete expired attachments
|
||||
@@ -1073,22 +1128,31 @@ func (s *Server) updateStatsAndPrune() {
|
||||
log.Warn("Manager: Error pruning cache: %s", err.Error())
|
||||
}
|
||||
|
||||
// Prune old topics, remove subscriptions without subscribers
|
||||
var subscribers, messages int
|
||||
// Message count per topic
|
||||
var messages int
|
||||
messageCounts, err := s.messageCache.MessageCounts()
|
||||
if err != nil {
|
||||
log.Warn("Manager: Cannot get message counts: %s", err.Error())
|
||||
messageCounts = make(map[string]int) // Empty, so we can continue
|
||||
}
|
||||
for _, count := range messageCounts {
|
||||
messages += count
|
||||
}
|
||||
|
||||
// Remove subscriptions without subscribers
|
||||
s.mu.Lock()
|
||||
var subscribers int
|
||||
for _, t := range s.topics {
|
||||
subs := t.Subscribers()
|
||||
msgs, err := s.messageCache.MessageCount(t.ID)
|
||||
if err != nil {
|
||||
log.Warn("Manager: Cannot get stats for topic %s: %s", t.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
if msgs == 0 && subs == 0 {
|
||||
subs := t.SubscribersCount()
|
||||
msgs, exists := messageCounts[t.ID]
|
||||
if subs == 0 && (!exists || msgs == 0) {
|
||||
log.Trace("Deleting empty topic %s", t.ID)
|
||||
delete(s.topics, t.ID)
|
||||
continue
|
||||
}
|
||||
subscribers += subs
|
||||
messages += msgs
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Mail stats
|
||||
var receivedMailTotal, receivedMailSuccess, receivedMailFailure int64
|
||||
@@ -1101,8 +1165,11 @@ func (s *Server) updateStatsAndPrune() {
|
||||
}
|
||||
|
||||
// Print stats
|
||||
s.mu.Lock()
|
||||
messagesCount, topicsCount, visitorsCount := s.messages, len(s.topics), len(s.visitors)
|
||||
s.mu.Unlock()
|
||||
log.Info("Stats: %d messages published, %d in cache, %d topic(s) active, %d subscriber(s), %d visitor(s), %d mails received (%d successful, %d failed), %d mails sent (%d successful, %d failed)",
|
||||
s.messages, messages, len(s.topics), subscribers, len(s.visitors),
|
||||
messagesCount, messages, topicsCount, subscribers, visitorsCount,
|
||||
receivedMailTotal, receivedMailSuccess, receivedMailFailure,
|
||||
sentMailTotal, sentMailSuccess, sentMailFailure)
|
||||
}
|
||||
@@ -1176,10 +1243,10 @@ func (s *Server) sendDelayedMessages() error {
|
||||
}
|
||||
|
||||
func (s *Server) sendDelayedMessage(v *visitor, m *message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
log.Debug("%s Sending delayed message", logMessagePrefix(v, m))
|
||||
s.mu.Lock()
|
||||
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
go func() {
|
||||
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
|
||||
@@ -1276,6 +1343,19 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := next(w, newRequest, v); err != nil {
|
||||
return &errMatrix{pushKey: newRequest.Header.Get(matrixPushKeyHeader), err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authWrite(next handleFunc) handleFunc {
|
||||
return s.withAuth(next, auth.PermissionWrite)
|
||||
}
|
||||
@@ -1340,8 +1420,12 @@ func (s *Server) visitor(r *http.Request) *visitor {
|
||||
if err != nil {
|
||||
ip = remoteAddr // This should not happen in real life; only in tests.
|
||||
}
|
||||
if s.config.BehindProxy && r.Header.Get("X-Forwarded-For") != "" {
|
||||
ip = r.Header.Get("X-Forwarded-For")
|
||||
if s.config.BehindProxy && strings.TrimSpace(r.Header.Get("X-Forwarded-For")) != "" {
|
||||
// X-Forwarded-For can contain multiple addresses (see #328). If we are behind a proxy,
|
||||
// only the right-most address can be trusted (as this is the one added by our proxy server).
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For for details.
|
||||
ips := util.SplitNoEmpty(r.Header.Get("X-Forwarded-For"), ",")
|
||||
ip = strings.TrimSpace(util.LastString(ips, remoteAddr))
|
||||
}
|
||||
return s.visitorFromIP(ip)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
# All options also support underscores (_) instead of dashes (-) to comply with the YAML spec.
|
||||
|
||||
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
|
||||
# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only).
|
||||
#
|
||||
# This setting is required for any of the following features:
|
||||
# - attachments (to return a download URL)
|
||||
# - e-mail sending (for the topic URL in the email footer)
|
||||
# - iOS push notifications for self-hosted servers (to calculate the Firebase poll_request topic)
|
||||
# - Matrix Push Gateway (to validate that the pushkey is correct)
|
||||
#
|
||||
# base-url:
|
||||
|
||||
@@ -32,14 +37,22 @@
|
||||
#
|
||||
# firebase-key-file: <filename>
|
||||
|
||||
# If set, messages are cached in a local SQLite database instead of only in-memory. This
|
||||
# allows for service restarts without losing messages in support of the since= parameter.
|
||||
# If "cache-file" is set, messages are cached in a local SQLite database instead of only in-memory.
|
||||
# This allows for service restarts without losing messages in support of the since= parameter.
|
||||
#
|
||||
# The "cache-duration" parameter defines the duration for which messages will be buffered
|
||||
# before they are deleted. This is required to support the "since=..." and "poll=1" parameter.
|
||||
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
|
||||
# The cache file is created automatically, provided that the correct permissions are set.
|
||||
#
|
||||
# The "cache-startup-queries" parameter allows you to run commands when the database is initialized,
|
||||
# e.g. to enable WAL mode (see https://phiresky.github.io/blog/2020/sqlite-performance-tuning/)).
|
||||
# Example:
|
||||
# cache-startup-queries: |
|
||||
# pragma journal_mode = WAL;
|
||||
# pragma synchronous = normal;
|
||||
# pragma temp_store = memory;
|
||||
#
|
||||
# Debian/RPM package users:
|
||||
# Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package
|
||||
# creates this folder for you.
|
||||
@@ -50,6 +63,7 @@
|
||||
#
|
||||
# cache-file: <filename>
|
||||
# cache-duration: "12h"
|
||||
# cache-startup-queries:
|
||||
|
||||
# If set, access to the ntfy server and API can be controlled on a granular level using
|
||||
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
|
||||
|
||||
@@ -20,7 +20,8 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
errFirebaseQuotaExceeded = errors.New("quota exceeded for Firebase messages to topic")
|
||||
errFirebaseQuotaExceeded = errors.New("quota exceeded for Firebase messages to topic")
|
||||
errFirebaseTemporarilyBanned = errors.New("visitor temporarily banned from using Firebase")
|
||||
)
|
||||
|
||||
// firebaseClient is a generic client that formats and sends messages to Firebase.
|
||||
@@ -39,7 +40,7 @@ func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClien
|
||||
|
||||
func (c *firebaseClient) Send(v *visitor, m *message) error {
|
||||
if err := v.FirebaseAllowed(); err != nil {
|
||||
return errFirebaseQuotaExceeded
|
||||
return errFirebaseTemporarilyBanned
|
||||
}
|
||||
fbm, err := toFirebaseMessage(m, c.auther)
|
||||
if err != nil {
|
||||
|
||||
@@ -331,6 +331,6 @@ func TestToFirebaseSender_Abuse(t *testing.T) {
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
|
||||
sender.messages = make([]*messaging.Message, 0) // Reset to test that time limit is working
|
||||
require.Equal(t, errFirebaseQuotaExceeded, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, errFirebaseTemporarilyBanned, client.Send(visitor, &message{Topic: "mytopic"}))
|
||||
require.Equal(t, 0, len(sender.Messages()))
|
||||
}
|
||||
|
||||
175
server/server_matrix.go
Normal file
175
server/server_matrix.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/log"
|
||||
"heckel.io/ntfy/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Matrix Push Gateway / UnifiedPush / ntfy integration:
|
||||
//
|
||||
// ntfy implements a Matrix Push Gateway (as defined in https://spec.matrix.org/v1.2/push-gateway-api/),
|
||||
// in combination with UnifiedPush as the Provider Push Protocol (as defined in https://unifiedpush.org/developers/gateway/).
|
||||
//
|
||||
// In the picture below, ntfy is the Push Gateway (mostly in this file), as well as the Push Provider (ntfy's
|
||||
// main functionality). UnifiedPush is the Provider Push Protocol, as implemented by the ntfy server and the
|
||||
// ntfy Android app.
|
||||
//
|
||||
// +--------------------+ +-------------------+
|
||||
// Matrix HTTP | | | |
|
||||
// Notification Protocol | App Developer | | Device Vendor |
|
||||
// | | | |
|
||||
// +-------------------+ | +----------------+ | | +---------------+ |
|
||||
// | | | | | | | | | |
|
||||
// | Matrix homeserver +-----> Push Gateway +------> Push Provider | |
|
||||
// | | | | | | | | | |
|
||||
// +-^-----------------+ | +----------------+ | | +----+----------+ |
|
||||
// | | | | | |
|
||||
// Matrix | | | | | |
|
||||
// Client/Server API + | | | | |
|
||||
// | | +--------------------+ +-------------------+
|
||||
// | +--+-+ |
|
||||
// | | <-------------------------------------------+
|
||||
// +---+ |
|
||||
// | | Provider Push Protocol
|
||||
// +----+
|
||||
//
|
||||
// Mobile Device or Client
|
||||
//
|
||||
|
||||
// matrixRequest represents a Matrix message, as it is sent to a Push Gateway (as per
|
||||
// this spec: https://spec.matrix.org/v1.2/push-gateway-api/).
|
||||
//
|
||||
// From the message, we only require the "pushkey", as it represents our target topic URL.
|
||||
// A message may look like this (excerpt):
|
||||
// {
|
||||
// "notification": {
|
||||
// "devices": [
|
||||
// {
|
||||
// "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1",
|
||||
// ...
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
//
|
||||
type matrixRequest struct {
|
||||
Notification *struct {
|
||||
Devices []*struct {
|
||||
PushKey string `json:"pushkey"`
|
||||
} `json:"devices"`
|
||||
} `json:"notification"`
|
||||
}
|
||||
|
||||
// matrixResponse represents the response to a Matrix push gateway message, as defined
|
||||
// in the spec (https://spec.matrix.org/v1.2/push-gateway-api/).
|
||||
type matrixResponse struct {
|
||||
Rejected []string `json:"rejected"`
|
||||
}
|
||||
|
||||
// errMatrix represents an error when handing Matrix gateway messages
|
||||
type errMatrix struct {
|
||||
pushKey string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e errMatrix) Error() string {
|
||||
if e.err != nil {
|
||||
return fmt.Sprintf("message with push key %s rejected: %s", e.pushKey, e.err.Error())
|
||||
}
|
||||
return fmt.Sprintf("message with push key %s rejected", e.pushKey)
|
||||
}
|
||||
|
||||
const (
|
||||
// matrixPushKeyHeader is a header that's used internally to pass the Matrix push key (from the matrixRequest)
|
||||
// along with the request. The push key is only used if an error occurs down the line.
|
||||
matrixPushKeyHeader = "X-Matrix-Pushkey"
|
||||
)
|
||||
|
||||
// newRequestFromMatrixJSON reads the request body as a Matrix JSON message, parses the "pushkey", and creates a new
|
||||
// HTTP request that looks like a normal ntfy request from it.
|
||||
//
|
||||
// It basically converts a Matrix push gatewqy request:
|
||||
//
|
||||
// POST /_matrix/push/v1/notify HTTP/1.1
|
||||
// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } }
|
||||
//
|
||||
// to a ntfy request, looking like this:
|
||||
//
|
||||
// POST /upDAHJKFFDFD?up=1 HTTP/1.1
|
||||
// { "notification": { "devices": [ { "pushkey": "https://ntfy.sh/upDAHJKFFDFD?up=1", ... } ] } }
|
||||
//
|
||||
func newRequestFromMatrixJSON(r *http.Request, baseURL string, messageLimit int) (*http.Request, error) {
|
||||
if baseURL == "" {
|
||||
return nil, errHTTPInternalErrorMissingBaseURL
|
||||
}
|
||||
body, err := util.Peek(r.Body, messageLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer r.Body.Close()
|
||||
if body.LimitReached {
|
||||
return nil, errHTTPEntityTooLargeMatrixRequestTooLarge
|
||||
}
|
||||
var m matrixRequest
|
||||
if err := json.Unmarshal(body.PeekedBytes, &m); err != nil {
|
||||
return nil, errHTTPBadRequestMatrixMessageInvalid
|
||||
} else if m.Notification == nil || len(m.Notification.Devices) == 0 || m.Notification.Devices[0].PushKey == "" {
|
||||
return nil, errHTTPBadRequestMatrixMessageInvalid
|
||||
}
|
||||
pushKey := m.Notification.Devices[0].PushKey // We ignore other devices for now, see discussion in #316
|
||||
if !strings.HasPrefix(pushKey, baseURL+"/") {
|
||||
return nil, &errMatrix{pushKey: pushKey, err: errHTTPBadRequestMatrixPushkeyBaseURLMismatch}
|
||||
}
|
||||
newRequest, err := http.NewRequest(http.MethodPost, pushKey, io.NopCloser(bytes.NewReader(body.PeekedBytes)))
|
||||
if err != nil {
|
||||
return nil, &errMatrix{pushKey: pushKey, err: err}
|
||||
}
|
||||
newRequest.RemoteAddr = r.RemoteAddr // Not strictly necessary, since visitor was already extracted
|
||||
if r.Header.Get("X-Forwarded-For") != "" {
|
||||
newRequest.Header.Set("X-Forwarded-For", r.Header.Get("X-Forwarded-For"))
|
||||
}
|
||||
newRequest.Header.Set(matrixPushKeyHeader, pushKey)
|
||||
return newRequest, nil
|
||||
}
|
||||
|
||||
// writeMatrixDiscoveryResponse writes the UnifiedPush Matrix Gateway Discovery response to the given http.ResponseWriter,
|
||||
// as per the spec (https://unifiedpush.org/developers/gateway/).
|
||||
func writeMatrixDiscoveryResponse(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, err := io.WriteString(w, `{"unifiedpush":{"gateway":"matrix"}}`+"\n")
|
||||
return err
|
||||
}
|
||||
|
||||
// writeMatrixError logs and writes the errMatrix to the given http.ResponseWriter as a matrixResponse
|
||||
func writeMatrixError(w http.ResponseWriter, r *http.Request, v *visitor, err *errMatrix) error {
|
||||
log.Debug("%s Matrix gateway error: %s", logHTTPPrefix(v, r), err.Error())
|
||||
return writeMatrixResponse(w, err.pushKey)
|
||||
}
|
||||
|
||||
// writeMatrixSuccess writes a successful matrixResponse (no rejected push key) to the given http.ResponseWriter
|
||||
func writeMatrixSuccess(w http.ResponseWriter) error {
|
||||
return writeMatrixResponse(w, "")
|
||||
}
|
||||
|
||||
// writeMatrixResponse writes a matrixResponse to the given http.ResponseWriter, as defined in
|
||||
// the spec (https://spec.matrix.org/v1.2/push-gateway-api/)
|
||||
func writeMatrixResponse(w http.ResponseWriter, rejectedPushKey string) error {
|
||||
rejected := make([]string, 0)
|
||||
if rejectedPushKey != "" {
|
||||
rejected = append(rejected, rejectedPushKey)
|
||||
}
|
||||
response := &matrixResponse{
|
||||
Rejected: rejected,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(response); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
84
server/server_matrix_test.go
Normal file
84
server/server_matrix_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMatrix_NewRequestFromMatrixJSON_Success(t *testing.T) {
|
||||
baseURL := "https://ntfy.sh"
|
||||
maxLength := 4096
|
||||
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||
newRequest, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "POST", newRequest.Method)
|
||||
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.URL.String())
|
||||
require.Equal(t, "https://ntfy.sh/upABCDEFGHI?up=1", newRequest.Header.Get("X-Matrix-Pushkey"))
|
||||
require.Equal(t, body, readAll(t, newRequest.Body))
|
||||
}
|
||||
|
||||
func TestMatrix_NewRequestFromMatrixJSON_TooLarge(t *testing.T) {
|
||||
baseURL := "https://ntfy.sh"
|
||||
maxLength := 10 // Small
|
||||
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.sh/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||
require.Equal(t, errHTTPEntityTooLargeMatrixRequestTooLarge, err)
|
||||
}
|
||||
|
||||
func TestMatrix_NewRequestFromMatrixJSON_InvalidJSON(t *testing.T) {
|
||||
baseURL := "https://ntfy.sh"
|
||||
maxLength := 4096
|
||||
body := `this is not json`
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||
require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err)
|
||||
}
|
||||
|
||||
func TestMatrix_NewRequestFromMatrixJSON_NotAMatrixMessage(t *testing.T) {
|
||||
baseURL := "https://ntfy.sh"
|
||||
maxLength := 4096
|
||||
body := `{"message":"this is not a matrix message, but valid json"}`
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||
require.Equal(t, errHTTPBadRequestMatrixMessageInvalid, err)
|
||||
}
|
||||
|
||||
func TestMatrix_NewRequestFromMatrixJSON_MismatchingPushKey(t *testing.T) {
|
||||
baseURL := "https://ntfy.sh" // Mismatch!
|
||||
maxLength := 4096
|
||||
body := `{"notification":{"content":{"body":"I'm floating in a most peculiar way.","msgtype":"m.text"},"counts":{"missed_calls":1,"unread":2},"devices":[{"app_id":"org.matrix.matrixConsole.ios","data":{},"pushkey":"https://ntfy.example.com/upABCDEFGHI?up=1","pushkey_ts":12345678,"tweaks":{"sound":"bing"}}],"event_id":"$3957tyerfgewrf384","prio":"high","room_alias":"#exampleroom:matrix.org","room_id":"!slw48wfj34rtnrf:example.com","room_name":"Mission Control","sender":"@exampleuser:matrix.org","sender_display_name":"Major Tom","type":"m.room.message"}}`
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", strings.NewReader(body))
|
||||
_, err := newRequestFromMatrixJSON(r, baseURL, maxLength)
|
||||
matrixErr, ok := err.(*errMatrix)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, errHTTPBadRequestMatrixPushkeyBaseURLMismatch, matrixErr.err)
|
||||
require.Equal(t, "https://ntfy.example.com/upABCDEFGHI?up=1", matrixErr.pushKey)
|
||||
}
|
||||
|
||||
func TestMatrix_WriteMatrixDiscoveryResponse(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
require.Nil(t, writeMatrixDiscoveryResponse(w))
|
||||
require.Equal(t, 200, w.Result().StatusCode)
|
||||
require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", w.Body.String())
|
||||
}
|
||||
|
||||
func TestMatrix_WriteMatrixError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.example.com/_matrix/push/v1/notify", nil)
|
||||
v := newVisitor(newTestConfig(t), nil, "1.2.3.4")
|
||||
require.Nil(t, writeMatrixError(w, r, v, &errMatrix{"https://ntfy.example.com/upABCDEFGHI?up=1", errHTTPBadRequestMatrixPushkeyBaseURLMismatch}))
|
||||
require.Equal(t, 200, w.Result().StatusCode)
|
||||
require.Equal(t, `{"rejected":["https://ntfy.example.com/upABCDEFGHI?up=1"]}`+"\n", w.Body.String())
|
||||
}
|
||||
|
||||
func TestMatrix_WriteMatrixSuccess(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
require.Nil(t, writeMatrixSuccess(w))
|
||||
require.Equal(t, 200, w.Result().StatusCode)
|
||||
require.Equal(t, `{"rejected":[]}`+"\n", w.Body.String())
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -171,10 +174,6 @@ func TestServer_StaticSites(t *testing.T) {
|
||||
require.Equal(t, 301, rr.Code)
|
||||
|
||||
// Docs test removed, it was failing annoyingly.
|
||||
|
||||
rr = request(t, s, "GET", "/example.html", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
require.Contains(t, rr.Body.String(), "</html>")
|
||||
}
|
||||
|
||||
func TestServer_WebEnabled(t *testing.T) {
|
||||
@@ -185,9 +184,6 @@ func TestServer_WebEnabled(t *testing.T) {
|
||||
rr := request(t, s, "GET", "/", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/example.html", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
rr = request(t, s, "GET", "/config.js", "", nil)
|
||||
require.Equal(t, 404, rr.Code)
|
||||
|
||||
@@ -201,9 +197,6 @@ func TestServer_WebEnabled(t *testing.T) {
|
||||
rr = request(t, s2, "GET", "/", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s2, "GET", "/example.html", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
rr = request(t, s2, "GET", "/config.js", "", nil)
|
||||
require.Equal(t, 200, rr.Code)
|
||||
|
||||
@@ -446,6 +439,53 @@ func TestServer_PublishAndPollSince(t *testing.T) {
|
||||
require.Equal(t, 40008, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func newMessageWithTimestamp(topic, message string, timestamp int64) *message {
|
||||
m := newDefaultMessage(topic, message)
|
||||
m.Time = timestamp
|
||||
return m
|
||||
}
|
||||
|
||||
func TestServer_PollSinceID_MultipleTopics(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 1", 1655740277)))
|
||||
markerMessage := newMessageWithTimestamp("mytopic2", "test 2", 1655740283)
|
||||
require.Nil(t, s.messageCache.AddMessage(markerMessage))
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289)))
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293)))
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297)))
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303)))
|
||||
|
||||
response := request(t, s, "GET", fmt.Sprintf("/mytopic1,mytopic2/json?poll=1&since=%s", markerMessage.ID), "", nil)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 4, len(messages))
|
||||
require.Equal(t, "test 3", messages[0].Message)
|
||||
require.Equal(t, "mytopic1", messages[0].Topic)
|
||||
require.Equal(t, "test 4", messages[1].Message)
|
||||
require.Equal(t, "mytopic2", messages[1].Topic)
|
||||
require.Equal(t, "test 5", messages[2].Message)
|
||||
require.Equal(t, "mytopic1", messages[2].Topic)
|
||||
require.Equal(t, "test 6", messages[3].Message)
|
||||
require.Equal(t, "mytopic2", messages[3].Topic)
|
||||
}
|
||||
|
||||
func TestServer_PollSinceID_MultipleTopics_IDDoesNotMatch(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 3", 1655740289)))
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 4", 1655740293)))
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic1", "test 5", 1655740297)))
|
||||
require.Nil(t, s.messageCache.AddMessage(newMessageWithTimestamp("mytopic2", "test 6", 1655740303)))
|
||||
|
||||
response := request(t, s, "GET", "/mytopic1,mytopic2/json?poll=1&since=NoMatchForID", "", nil)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 4, len(messages))
|
||||
require.Equal(t, "test 3", messages[0].Message)
|
||||
require.Equal(t, "test 4", messages[1].Message)
|
||||
require.Equal(t, "test 5", messages[2].Message)
|
||||
require.Equal(t, "test 6", messages[3].Message)
|
||||
}
|
||||
|
||||
func TestServer_PublishViaGET(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
@@ -916,6 +956,70 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
|
||||
require.Equal(t, "this is a unifiedpush text message", m.Message)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Discovery_Success(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"unifiedpush":{"gateway":"matrix"}}`+"\n", response.Body.String())
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Discovery_Failure_Unconfigured(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.BaseURL = ""
|
||||
s := newTestServer(t, c)
|
||||
response := request(t, s, "GET", "/_matrix/push/v1/notify", "", nil)
|
||||
require.Equal(t, 500, response.Code)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 50003, err.Code)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Success(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"rejected":[]}`+"\n", response.Body.String())
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, notification, m.Message)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_InvalidPushkey(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://wrong-base-url.com/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, `{"rejected":["http://wrong-base-url.com/mytopic?up=1"]}`+"\n", response.Body.String())
|
||||
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "", response.Body.String()) // Empty!
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_EverythingIsWrong(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
notification := `{"message":"this is not really a Matrix message"}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 400, response.Code)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 40019, err.Code)
|
||||
require.Equal(t, 400, err.HTTPCode)
|
||||
}
|
||||
|
||||
func TestServer_MatrixGateway_Push_Failure_Unconfigured(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.BaseURL = ""
|
||||
s := newTestServer(t, c)
|
||||
notification := `{"notification":{"devices":[{"pushkey":"http://127.0.0.1:12345/mytopic?up=1"}]}}`
|
||||
response := request(t, s, "POST", "/_matrix/push/v1/notify", notification, nil)
|
||||
require.Equal(t, 500, response.Code)
|
||||
err := toHTTPError(t, response.Body.String())
|
||||
require.Equal(t, 50003, err.Code)
|
||||
require.Equal(t, 500, err.HTTPCode)
|
||||
}
|
||||
|
||||
func TestServer_PublishActions_AndPoll(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
response := request(t, s, "PUT", "/mytopic", "my message", map[string]string{
|
||||
@@ -964,6 +1068,7 @@ func TestServer_PublishAsJSON_WithEmail(t *testing.T) {
|
||||
body := `{"topic":"mytopic","message":"A message","email":"phil@example.com"}`
|
||||
response := request(t, s, "PUT", "/", body, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
time.Sleep(100 * time.Millisecond) // E-Mail publishing happens in a Go routine
|
||||
|
||||
m := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "mytopic", m.Topic)
|
||||
@@ -1026,12 +1131,19 @@ func TestServer_PublishAttachment(t *testing.T) {
|
||||
require.Equal(t, "", msg.Sender) // Should never be returned
|
||||
require.FileExists(t, filepath.Join(s.config.AttachmentCacheDir, msg.ID))
|
||||
|
||||
// GET
|
||||
path := strings.TrimPrefix(msg.Attachment.URL, "http://127.0.0.1:12345")
|
||||
response = request(t, s, "GET", path, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "5000", response.Header().Get("Content-Length"))
|
||||
require.Equal(t, content, response.Body.String())
|
||||
|
||||
// HEAD
|
||||
response = request(t, s, "HEAD", path, "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
require.Equal(t, "5000", response.Header().Get("Content-Length"))
|
||||
require.Equal(t, "", response.Body.String())
|
||||
|
||||
// Slightly unrelated cross-test: make sure we add an owner for internal attachments
|
||||
size, err := s.messageCache.AttachmentBytesUsed("9.9.9.9") // See request()
|
||||
require.Nil(t, err)
|
||||
@@ -1267,6 +1379,84 @@ func TestServer_PublishAttachmentUserStats(t *testing.T) {
|
||||
require.Equal(t, int64(1001), stats.VisitorAttachmentBytesRemaining)
|
||||
}
|
||||
|
||||
func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.BehindProxy = true
|
||||
s := newTestServer(t, c)
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11"
|
||||
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
|
||||
v := s.visitor(r)
|
||||
require.Equal(t, "8.9.10.11", v.ip)
|
||||
}
|
||||
|
||||
func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.BehindProxy = true
|
||||
s := newTestServer(t, c)
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11"
|
||||
r.Header.Set("X-Forwarded-For", "1.1.1.1")
|
||||
v := s.visitor(r)
|
||||
require.Equal(t, "1.1.1.1", v.ip)
|
||||
}
|
||||
|
||||
func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
|
||||
c := newTestConfig(t)
|
||||
c.BehindProxy = true
|
||||
s := newTestServer(t, c)
|
||||
r, _ := http.NewRequest("GET", "/bla", nil)
|
||||
r.RemoteAddr = "8.9.10.11"
|
||||
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
|
||||
v := s.visitor(r)
|
||||
require.Equal(t, "234.5.2.1", v.ip)
|
||||
}
|
||||
|
||||
func TestServer_PublishWhileUpdatingStatsWithLotsOfMessages(t *testing.T) {
|
||||
count := 50000
|
||||
c := newTestConfig(t)
|
||||
c.TotalTopicLimit = 50001
|
||||
c.CacheStartupQueries = "pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory;"
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add lots of messages
|
||||
log.Printf("Adding %d messages", count)
|
||||
start := time.Now()
|
||||
messages := make([]*message, 0)
|
||||
for i := 0; i < count; i++ {
|
||||
topicID := fmt.Sprintf("topic%d", i)
|
||||
_, err := s.topicsFromIDs(topicID) // Add topic to internal s.topics array
|
||||
require.Nil(t, err)
|
||||
messages = append(messages, newDefaultMessage(topicID, "some message"))
|
||||
}
|
||||
require.Nil(t, s.messageCache.addMessages(messages))
|
||||
log.Printf("Done: Adding %d messages; took %s", count, time.Since(start).Round(time.Millisecond))
|
||||
|
||||
// Update stats
|
||||
statsChan := make(chan bool)
|
||||
go func() {
|
||||
log.Printf("Updating stats")
|
||||
start := time.Now()
|
||||
s.updateStatsAndPrune()
|
||||
log.Printf("Done: Updating stats; took %s", time.Since(start).Round(time.Millisecond))
|
||||
statsChan <- true
|
||||
}()
|
||||
time.Sleep(50 * time.Millisecond) // Make sure it starts first
|
||||
|
||||
// Publish message (during stats update)
|
||||
log.Printf("Publishing message")
|
||||
start = time.Now()
|
||||
response := request(t, s, "PUT", "/mytopic", "some body", nil)
|
||||
m := toMessage(t, response.Body.String())
|
||||
assert.Equal(t, "some body", m.Message)
|
||||
assert.True(t, time.Since(start) < 100*time.Millisecond)
|
||||
log.Printf("Done: Publishing message; took %s", time.Since(start).Round(time.Millisecond))
|
||||
|
||||
// Wait for all goroutines
|
||||
<-statsChan
|
||||
log.Printf("Done: Waiting for all locks")
|
||||
}
|
||||
|
||||
func newTestConfig(t *testing.T) *Config {
|
||||
conf := NewConfig()
|
||||
conf.BaseURL = "http://127.0.0.1:12345"
|
||||
@@ -1341,3 +1531,11 @@ func toHTTPError(t *testing.T, s string) *errHTTP {
|
||||
func basicAuth(s string) string {
|
||||
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||
b, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package server
|
||||
import (
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/stretchr/testify/require"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -304,14 +303,6 @@ func newTestBackend(t *testing.T, handler func(http.ResponseWriter, *http.Reques
|
||||
return conf, backend
|
||||
}
|
||||
|
||||
func readAll(t *testing.T, rc io.ReadCloser) string {
|
||||
b, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func fakeConnState(t *testing.T, remoteAddr string) *smtp.ConnectionState {
|
||||
ip, err := net.ResolveIPAddr("ip", remoteAddr)
|
||||
if err != nil {
|
||||
|
||||
@@ -44,14 +44,19 @@ func (t *topic) Unsubscribe(id int) {
|
||||
// Publish asynchronously publishes to all subscribers
|
||||
func (t *topic) Publish(v *visitor, m *message) error {
|
||||
go func() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if len(t.subscribers) > 0 {
|
||||
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(t.subscribers))
|
||||
for _, s := range t.subscribers {
|
||||
if err := s(v, m); err != nil {
|
||||
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
|
||||
}
|
||||
// We want to lock the topic as short as possible, so we make a shallow copy of the
|
||||
// subscribers map here. Actually sending out the messages then doesn't have to lock.
|
||||
subscribers := t.subscribersCopy()
|
||||
if len(subscribers) > 0 {
|
||||
log.Debug("%s Forwarding to %d subscriber(s)", logMessagePrefix(v, m), len(subscribers))
|
||||
for _, s := range subscribers {
|
||||
// We call the subscriber functions in their own Go routines because they are blocking, and
|
||||
// we don't want individual slow subscribers to be able to block others.
|
||||
go func(s subscriber) {
|
||||
if err := s(v, m); err != nil {
|
||||
log.Warn("%s Error forwarding to subscriber", logMessagePrefix(v, m))
|
||||
}
|
||||
}(s)
|
||||
}
|
||||
} else {
|
||||
log.Trace("%s No stream or WebSocket subscribers, not forwarding", logMessagePrefix(v, m))
|
||||
@@ -60,9 +65,20 @@ func (t *topic) Publish(v *visitor, m *message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Subscribers returns the number of subscribers to this topic
|
||||
func (t *topic) Subscribers() int {
|
||||
// SubscribersCount returns the number of subscribers to this topic
|
||||
func (t *topic) SubscribersCount() int {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.subscribers)
|
||||
}
|
||||
|
||||
// subscribersCopy returns a shallow copy of the subscribers map
|
||||
func (t *topic) subscribersCopy() map[int]subscriber {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
subscribers := make(map[int]subscriber)
|
||||
for k, v := range t.subscribers {
|
||||
subscribers[k] = v
|
||||
}
|
||||
return subscribers
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package server
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/emersion/go-smtp"
|
||||
"heckel.io/ntfy/util"
|
||||
"net/http"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
|
||||
@@ -58,3 +60,32 @@ func logHTTPPrefix(v *visitor, r *http.Request) string {
|
||||
func logSMTPPrefix(state *smtp.ConnectionState) string {
|
||||
return fmt.Sprintf("%s/%s SMTP", state.Hostname, state.RemoteAddr.String())
|
||||
}
|
||||
|
||||
func renderHTTPRequest(r *http.Request) string {
|
||||
peekLimit := 4096
|
||||
lines := fmt.Sprintf("%s %s %s\n", r.Method, r.URL.RequestURI(), r.Proto)
|
||||
for key, values := range r.Header {
|
||||
for _, value := range values {
|
||||
lines += fmt.Sprintf("%s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
lines += "\n"
|
||||
body, err := util.Peek(r.Body, peekLimit)
|
||||
if err != nil {
|
||||
lines = fmt.Sprintf("(could not read body: %s)\n", err.Error())
|
||||
} else if utf8.Valid(body.PeekedBytes) {
|
||||
lines += string(body.PeekedBytes)
|
||||
if body.LimitReached {
|
||||
lines += fmt.Sprintf(" ... (peeked %d bytes)", peekLimit)
|
||||
}
|
||||
lines += "\n"
|
||||
} else {
|
||||
if body.LimitReached {
|
||||
lines += fmt.Sprintf("(peeked bytes not UTF-8, peek limit of %d bytes reached, hex: %x ...)\n", peekLimit, body.PeekedBytes)
|
||||
} else {
|
||||
lines += fmt.Sprintf("(peeked bytes not UTF-8, %d bytes, hex: %x)\n", len(body.PeekedBytes), body.PeekedBytes)
|
||||
}
|
||||
}
|
||||
r.Body = body // Important: Reset body, so it can be re-read
|
||||
return strings.TrimSpace(lines)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -27,3 +31,47 @@ func TestReadBoolParam(t *testing.T) {
|
||||
require.Equal(t, false, up)
|
||||
require.Equal(t, true, firebase)
|
||||
}
|
||||
|
||||
func TestRenderHTTPRequest_ValidShort(t *testing.T) {
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.sh/mytopic?p=2", strings.NewReader("some message"))
|
||||
r.Header.Set("Title", "A title")
|
||||
expected := `POST /mytopic?p=2 HTTP/1.1
|
||||
Title: A title
|
||||
|
||||
some message`
|
||||
require.Equal(t, expected, renderHTTPRequest(r))
|
||||
}
|
||||
|
||||
func TestRenderHTTPRequest_ValidLong(t *testing.T) {
|
||||
body := strings.Repeat("a", 5000)
|
||||
r, _ := http.NewRequest("POST", "http://ntfy.sh/mytopic?p=2", strings.NewReader(body))
|
||||
r.Header.Set("Accept", "*/*")
|
||||
expected := `POST /mytopic?p=2 HTTP/1.1
|
||||
Accept: */*
|
||||
|
||||
` + strings.Repeat("a", 4096) + " ... (peeked 4096 bytes)"
|
||||
require.Equal(t, expected, renderHTTPRequest(r))
|
||||
}
|
||||
|
||||
func TestRenderHTTPRequest_InvalidShort(t *testing.T) {
|
||||
body := []byte{0xc3, 0x28}
|
||||
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", bytes.NewReader(body))
|
||||
r.Header.Set("Accept", "*/*")
|
||||
expected := `GET /mytopic/json?since=all HTTP/1.1
|
||||
Accept: */*
|
||||
|
||||
(peeked bytes not UTF-8, 2 bytes, hex: c328)`
|
||||
require.Equal(t, expected, renderHTTPRequest(r))
|
||||
}
|
||||
|
||||
func TestRenderHTTPRequest_InvalidLong(t *testing.T) {
|
||||
body := make([]byte, 5000)
|
||||
rand.Read(body)
|
||||
r, _ := http.NewRequest("GET", "http://ntfy.sh/mytopic/json?since=all", bytes.NewReader(body))
|
||||
r.Header.Set("Accept", "*/*")
|
||||
expected := `GET /mytopic/json?since=all HTTP/1.1
|
||||
Accept: */*
|
||||
|
||||
(peeked bytes not UTF-8, peek limit of 4096 bytes reached, hex: ` + fmt.Sprintf("%x", body[:4096]) + ` ...)`
|
||||
require.Equal(t, expected, renderHTTPRequest(r))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ type PeekedReadCloser struct {
|
||||
closed bool
|
||||
}
|
||||
|
||||
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser
|
||||
// Peek reads the underlying ReadCloser into memory up until the limit and returns a PeekedReadCloser.
|
||||
// It does not return an error if limit is reached. Instead, LimitReached will be set to true.
|
||||
func Peek(underlying io.ReadCloser, limit int) (*PeekedReadCloser, error) {
|
||||
if underlying == nil {
|
||||
underlying = io.NopCloser(strings.NewReader(""))
|
||||
|
||||
60
util/util.go
60
util/util.go
@@ -26,6 +26,7 @@ var (
|
||||
randomMutex = sync.Mutex{}
|
||||
sizeStrRegex = regexp.MustCompile(`(?i)^(\d+)([gmkb])?$`)
|
||||
errInvalidPriority = errors.New("invalid priority")
|
||||
noQuotesRegex = regexp.MustCompile(`^[-_./:@a-zA-Z0-9]+$`)
|
||||
)
|
||||
|
||||
// FileExists checks if a file exists, and returns true if it does
|
||||
@@ -88,6 +89,14 @@ func SplitKV(s string, sep string) (key string, value string) {
|
||||
return "", strings.TrimSpace(kv[0])
|
||||
}
|
||||
|
||||
// LastString returns the last string in a slice, or def if s is empty
|
||||
func LastString(s []string, def string) string {
|
||||
if len(s) == 0 {
|
||||
return def
|
||||
}
|
||||
return s[len(s)-1]
|
||||
}
|
||||
|
||||
// RandomString returns a random string with a given length
|
||||
func RandomString(length int) string {
|
||||
randomMutex.Lock() // Who would have thought that random.Intn() is not thread-safe?!
|
||||
@@ -112,38 +121,6 @@ func ValidRandomString(s string, length int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// DurationToHuman converts a duration to a human-readable format
|
||||
func DurationToHuman(d time.Duration) (str string) {
|
||||
if d == 0 {
|
||||
return "0"
|
||||
}
|
||||
|
||||
d = d.Round(time.Second)
|
||||
days := d / time.Hour / 24
|
||||
if days > 0 {
|
||||
str += fmt.Sprintf("%dd", days)
|
||||
}
|
||||
d -= days * time.Hour * 24
|
||||
|
||||
hours := d / time.Hour
|
||||
if hours > 0 {
|
||||
str += fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
d -= hours * time.Hour
|
||||
|
||||
minutes := d / time.Minute
|
||||
if minutes > 0 {
|
||||
str += fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
d -= minutes * time.Minute
|
||||
|
||||
seconds := d / time.Second
|
||||
if seconds > 0 {
|
||||
str += fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ParsePriority parses a priority string into its equivalent integer value
|
||||
func ParsePriority(priority string) (int, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(priority)) {
|
||||
@@ -278,3 +255,22 @@ func MaybeMarshalJSON(v interface{}) string {
|
||||
}
|
||||
return string(jsonBytes)
|
||||
}
|
||||
|
||||
// QuoteCommand combines a command array to a string, quoting arguments that need quoting.
|
||||
// This function is naive, and sometimes wrong. It is only meant for lo pretty-printing a command.
|
||||
//
|
||||
// Warning: Never use this function with the intent to run the resulting command.
|
||||
//
|
||||
// Example:
|
||||
// []string{"ls", "-al", "Document Folder"} -> ls -al "Document Folder"
|
||||
func QuoteCommand(command []string) string {
|
||||
var quoted []string
|
||||
for _, c := range command {
|
||||
if noQuotesRegex.MatchString(c) {
|
||||
quoted = append(quoted, c)
|
||||
} else {
|
||||
quoted = append(quoted, fmt.Sprintf(`"%s"`, c))
|
||||
}
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
|
||||
@@ -5,33 +5,8 @@ import (
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDurationToHuman_SevenDays(t *testing.T) {
|
||||
d := 7 * 24 * time.Hour
|
||||
require.Equal(t, "7d", DurationToHuman(d))
|
||||
}
|
||||
|
||||
func TestDurationToHuman_MoreThanOneDay(t *testing.T) {
|
||||
d := 49 * time.Hour
|
||||
require.Equal(t, "2d1h", DurationToHuman(d))
|
||||
}
|
||||
|
||||
func TestDurationToHuman_LessThanOneDay(t *testing.T) {
|
||||
d := 17*time.Hour + 15*time.Minute
|
||||
require.Equal(t, "17h15m", DurationToHuman(d))
|
||||
}
|
||||
|
||||
func TestDurationToHuman_TenOfThings(t *testing.T) {
|
||||
d := 10*time.Hour + 10*time.Minute + 10*time.Second
|
||||
require.Equal(t, "10h10m10s", DurationToHuman(d))
|
||||
}
|
||||
|
||||
func TestDurationToHuman_Zero(t *testing.T) {
|
||||
require.Equal(t, "0", DurationToHuman(0))
|
||||
}
|
||||
|
||||
func TestRandomString(t *testing.T) {
|
||||
s1 := RandomString(10)
|
||||
s2 := RandomString(10)
|
||||
@@ -157,3 +132,14 @@ func TestSplitKV(t *testing.T) {
|
||||
require.Equal(t, "mykey", key)
|
||||
require.Equal(t, "value=with=separator", value)
|
||||
}
|
||||
|
||||
func TestLastString(t *testing.T) {
|
||||
require.Equal(t, "last", LastString([]string{"first", "second", "last"}, "default"))
|
||||
require.Equal(t, "default", LastString([]string{}, "default"))
|
||||
}
|
||||
|
||||
func TestQuoteCommand(t *testing.T) {
|
||||
require.Equal(t, `ls -al "Document Folder"`, QuoteCommand([]string{"ls", "-al", "Document Folder"}))
|
||||
require.Equal(t, `rsync -av /home/phil/ root@example.com:/home/phil/`, QuoteCommand([]string{"rsync", "-av", "/home/phil/", "root@example.com:/home/phil/"}))
|
||||
require.Equal(t, `/home/sweet/home "Äöü this is a test" "\a\b"`, QuoteCommand([]string{"/home/sweet/home", "Äöü this is a test", "\\a\\b"}))
|
||||
}
|
||||
|
||||
2026
web/package-lock.json
generated
2026
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
||||
"alert_grant_button": "Grant now",
|
||||
"alert_not_supported_title": "Notifications not supported",
|
||||
"alert_not_supported_description": "Notifications are not supported in your browser.",
|
||||
"alert_not_supported_context_description": "Notifications are only supported over HTTPS. This is a limitation of the <mdnLink>Notifications API</mdnLink>.",
|
||||
"notifications_list": "Notifications list",
|
||||
"notifications_list_item": "Notification",
|
||||
"notifications_mark_read": "Mark as read",
|
||||
|
||||
@@ -59,15 +59,15 @@
|
||||
"notifications_more_details": "Pour plus d'information, visitez <websiteLink>le site web</websiteLink> ou <docsLink>la documentation</docsLink>.",
|
||||
"publish_dialog_title_placeholder": "Titre de la notification, par ex. Alerte d'espace disque",
|
||||
"publish_dialog_topic_placeholder": "Nom du sujet, par ex. phil_alerts",
|
||||
"publish_dialog_delay_placeholder": "Délai de la délivrance, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)",
|
||||
"publish_dialog_delay_placeholder": "Délai de réception, par ex. {{unixTimestamp}}, {{relativeTime}}, ou « {{naturalLanguage}} » (en anglais seulement)",
|
||||
"publish_dialog_other_features": "Autres fonctionnalités :",
|
||||
"notifications_actions_not_supported": "Cette action n'est pas supportée dans l'application web",
|
||||
"notifications_actions_http_request_title": "Envoyer une requête HTTP {{method}} à {{url}}",
|
||||
"publish_dialog_attachment_limits_quota_reached": "quota dépassé, {{remainingBytes}} restants",
|
||||
"publish_dialog_tags_placeholder": "Liste séparée par des virgules d'étiquettes, par ex. avertissement,backup-srv1",
|
||||
"publish_dialog_priority_label": "Priorité",
|
||||
"publish_dialog_click_label": "Cliquer sur l'URL",
|
||||
"publish_dialog_click_placeholder": "URL ouverte quand la notification est cliquée",
|
||||
"publish_dialog_click_label": "URL du clic",
|
||||
"publish_dialog_click_placeholder": "URL ouverte lors d'un clic sur la notification",
|
||||
"publish_dialog_attach_label": "URL de la pièce jointe",
|
||||
"publish_dialog_attach_placeholder": "Attachez un fichier par une URL, par ex. https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_filename_label": "Nom du fichier",
|
||||
@@ -131,7 +131,7 @@
|
||||
"prefs_users_dialog_button_add": "Ajouter",
|
||||
"error_boundary_description": "Ceci ne devrait évidemment pas arriver. Désolé pour ça.<br/>Si vous avez une minute, merci de <githubLink>signaler ceci sur GitHub</githubLink>, ou faites-le nous savoir par <discordLink>Discord</discordLink> ou <matrixLink>Matric</matrixLink>.",
|
||||
"prefs_users_dialog_title_add": "Ajouter un utilisateur",
|
||||
"error_boundary_stack_trace": "Stack trace",
|
||||
"error_boundary_stack_trace": "Trace de pile d'appels",
|
||||
"error_boundary_gathering_info": "Récupérer plus d'information…",
|
||||
"prefs_notifications_delete_after_one_week": "Après une semaine",
|
||||
"prefs_notifications_delete_after_one_month": "Après un mois",
|
||||
@@ -152,5 +152,40 @@
|
||||
"publish_dialog_chip_topic_label": "Changer de sujet",
|
||||
"publish_dialog_details_examples_description": "Pour des exemples et une description détaillée des fonctionnalités d'envoi, voir la <docsLink>documentation</docsLink>.",
|
||||
"publish_dialog_button_cancel_sending": "Annuler l'envoi",
|
||||
"prefs_users_dialog_button_save": "Enregistrer"
|
||||
"prefs_users_dialog_button_save": "Enregistrer",
|
||||
"notifications_new_indicator": "Nouvelle notification",
|
||||
"publish_dialog_delay_reset": "Retirer le délai de réception",
|
||||
"notifications_list_item": "Notification",
|
||||
"notifications_priority_x": "Priorité {{priority}}",
|
||||
"notifications_mark_read": "Marquer comme lu",
|
||||
"notifications_attachment_image": "Image jointe",
|
||||
"notifications_delete": "Supprimer",
|
||||
"notifications_attachment_file_video": "fichier vidéo",
|
||||
"notifications_attachment_file_audio": "fichier audio",
|
||||
"prefs_users_table": "Liste des utilisateurs",
|
||||
"notifications_attachment_file_image": "fichier image",
|
||||
"notifications_attachment_file_app": "fichier d'application Android",
|
||||
"notifications_attachment_file_document": "autre document",
|
||||
"prefs_notifications_sound_play": "Jouer le son sélectionné",
|
||||
"error_boundary_unsupported_indexeddb_description": "L'application web ntfy a besoin d'IndexedDB pour fonctionner, mais votre navigateur ne supporte pas IndexedDB en navigation privée.<br/><br/>Bien que cela soit regrettable, il serait peu utile d'utiliser l'application web ntfy en navigation privée, car tout est stocké par votre navigateur. Vous pouvez vous renseigner plus amplement à ce propos <githubLink>dans ce ticket GitHub</githubLink>, ou en parler avec nous sur <discordLink>Discord</discordLink> ou <matrixLink>Matrix</matrixLink>.",
|
||||
"action_bar_show_menu": "Montrer le menu",
|
||||
"action_bar_toggle_mute": "Mettre en sourdine/réactiver les notifications",
|
||||
"action_bar_toggle_action_menu": "Ouvrir/fermer le menu d'actions",
|
||||
"publish_dialog_emoji_picker_show": "Choisir un emoji",
|
||||
"publish_dialog_topic_reset": "Réinitialiser le sujet",
|
||||
"message_bar_publish": "Publier le message",
|
||||
"nav_button_muted": "Notifications en sourdine",
|
||||
"nav_button_connecting": "connexion en cours",
|
||||
"notifications_list": "Liste des notifications",
|
||||
"message_bar_show_dialog": "Montrer le formulaire de publication",
|
||||
"action_bar_logo_alt": "Logo de ntfy",
|
||||
"publish_dialog_click_reset": "Retirer l'URL du clic",
|
||||
"publish_dialog_email_reset": "Retirer le transfert par courriel",
|
||||
"publish_dialog_attach_reset": "Retirer l'URL de la pièce jointe",
|
||||
"emoji_picker_search_clear": "Effacer la recherche",
|
||||
"subscribe_dialog_subscribe_base_url_label": "URL du service",
|
||||
"prefs_users_edit_button": "Éditer l'utilisateur",
|
||||
"prefs_users_delete_button": "Supprimer l'utilisateur",
|
||||
"error_boundary_unsupported_indexeddb_title": "Navigation privée non prise en charge",
|
||||
"publish_dialog_attached_file_remove": "Retirer le fichier joint"
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@
|
||||
"notifications_click_copy_url_title": "URL naar klembord kopiëren",
|
||||
"notifications_click_copy_url_button": "Link kopiëren",
|
||||
"notifications_click_open_button": "Link openen",
|
||||
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL.",
|
||||
"notifications_none_for_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar het onderwerp URL. Hier is een voorbeeld met één van je onderwerpen.",
|
||||
"notifications_none_for_topic_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp.",
|
||||
"notifications_none_for_any_description": "Om notificaties naar dit onderwerp te sturen, doe een PUT of POST naar de URL van het onderwerp. Hier is een voorbeeld met één van je onderwerpen.",
|
||||
"notifications_no_subscriptions_title": "Het lijkt erop dat je nog op geen onderwerpen geabonneerd bent.",
|
||||
"notifications_no_subscriptions_description": "Klik op de \"{{linktext}}\" link om een onderwerp te maken of erop te abonneren. Daarna kan je berichten sturen via PUT of POST and ontvang je hier notificaties.",
|
||||
"notifications_example": "Voorbeeld",
|
||||
@@ -148,12 +148,12 @@
|
||||
"prefs_notifications_sound_title": "Meldingsgeluid",
|
||||
"prefs_notifications_sound_description_none": "Notificaties zullen geen geluid geven",
|
||||
"prefs_notifications_sound_play": "Geselecteerd geluid afspelen",
|
||||
"prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} afspelen",
|
||||
"prefs_notifications_sound_description_some": "Inkomende notificaties zullen het {{sound}} geluid afspelen",
|
||||
"prefs_notifications_sound_no_sound": "Geen geluid",
|
||||
"prefs_notifications_min_priority_title": "Minimale prioriteit",
|
||||
"prefs_notifications_min_priority_description_any": "Toon alle notificaties, ongeacht prioriteit",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit is {{number}} ({{name}}) of hoger",
|
||||
"prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit is 5 (maximaal)",
|
||||
"prefs_notifications_min_priority_description_x_or_higher": "Toon notificaties als prioriteit {{number}} ({{name}}) is of hoger",
|
||||
"prefs_notifications_min_priority_description_max": "Toon notificaties als prioriteit 5 (maximaal) is",
|
||||
"prefs_notifications_min_priority_any": "Elke prioriteit",
|
||||
"prefs_notifications_min_priority_low_and_higher": "Lage prioriteit en hoger",
|
||||
"prefs_notifications_min_priority_default_and_higher": "Standaard prioriteit en hoger",
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"publish_dialog_delay_label": "延期",
|
||||
"publish_dialog_other_features": "其它功能:",
|
||||
"publish_dialog_attach_placeholder": "使用链接地址附加文件,例如 https://f-droid.org/F-Droid.apk",
|
||||
"publish_dialog_delay_reset": "删除延迟交付",
|
||||
"publish_dialog_delay_reset": "删除延期投递",
|
||||
"publish_dialog_attach_reset": "移除附件链接地址",
|
||||
"publish_dialog_chip_click_label": "点击链接地址",
|
||||
"publish_dialog_chip_email_label": "转发邮件",
|
||||
@@ -95,7 +95,7 @@
|
||||
"emoji_picker_search_placeholder": "查找表情符号",
|
||||
"emoji_picker_search_clear": "清除搜索",
|
||||
"subscribe_dialog_subscribe_title": "订阅主题",
|
||||
"publish_dialog_chip_delay_label": "延迟交付",
|
||||
"publish_dialog_chip_delay_label": "延期投递",
|
||||
"publish_dialog_chip_attach_url_label": "链接附件地址",
|
||||
"subscribe_dialog_subscribe_use_another_label": "使用其他服务器",
|
||||
"subscribe_dialog_subscribe_button_subscribe": "订阅",
|
||||
@@ -187,5 +187,5 @@
|
||||
"publish_dialog_tags_placeholder": "英文逗号分隔标记列表,例如 warning, srv1-backup",
|
||||
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
|
||||
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易猜测的名字。订阅后,您可以使用 PUT/POST 通知。",
|
||||
"publish_dialog_delay_placeholder": "延迟交付,例如{{unixTimestamp}}、{{relativeTime}}或“{{naturalLanguage}}”(仅限英语)"
|
||||
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)"
|
||||
}
|
||||
|
||||
@@ -74,8 +74,22 @@ class Notifier {
|
||||
}
|
||||
|
||||
supported() {
|
||||
return this.browserSupported() && this.contextSupported();
|
||||
}
|
||||
|
||||
browserSupported() {
|
||||
return 'Notification' in window;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this is a HTTPS site, or served over localhost. Otherwise the Notification API
|
||||
* is not supported, see https://developer.mozilla.org/en-US/docs/Web/API/notification
|
||||
*/
|
||||
contextSupported() {
|
||||
return location.protocol === 'https:'
|
||||
|| location.hostname.match('^127.')
|
||||
|| location.hostname === 'localhost';
|
||||
}
|
||||
}
|
||||
|
||||
const notifier = new Notifier();
|
||||
|
||||
@@ -11,7 +11,7 @@ import List from "@mui/material/List";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import SubscribeDialog from "./SubscribeDialog";
|
||||
import {Alert, AlertTitle, Badge, CircularProgress, ListSubheader} from "@mui/material";
|
||||
import {Alert, AlertTitle, Badge, CircularProgress, Link, ListSubheader} from "@mui/material";
|
||||
import Button from "@mui/material/Button";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {openUrl, topicShortUrl, topicUrl} from "../app/utils";
|
||||
@@ -24,7 +24,7 @@ import Box from "@mui/material/Box";
|
||||
import notifier from "../app/Notifier";
|
||||
import config from "../app/config";
|
||||
import ArticleIcon from '@mui/icons-material/Article';
|
||||
import {useTranslation} from "react-i18next";
|
||||
import {Trans, useTranslation} from "react-i18next";
|
||||
|
||||
const navWidth = 280;
|
||||
|
||||
@@ -91,14 +91,17 @@ const NavList = (props) => {
|
||||
};
|
||||
|
||||
const showSubscriptionsList = props.subscriptions?.length > 0;
|
||||
const showNotificationNotSupportedBox = !notifier.supported();
|
||||
const showNotificationBrowserNotSupportedBox = !notifier.browserSupported();
|
||||
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
||||
const showNotificationGrantBox = notifier.supported() && props.subscriptions?.length > 0 && !props.notificationsGranted;
|
||||
const navListPadding = (showNotificationGrantBox || showNotificationBrowserNotSupportedBox || showNotificationContextNotSupportedBox) ? '0' : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar sx={{ display: { xs: 'none', sm: 'block' } }}/>
|
||||
<List component="nav" sx={{ paddingTop: (showNotificationGrantBox || showNotificationNotSupportedBox) ? '0' : '' }}>
|
||||
{showNotificationNotSupportedBox && <NotificationNotSupportedAlert/>}
|
||||
<List component="nav" sx={{ paddingTop: navListPadding }}>
|
||||
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert/>}
|
||||
{showNotificationContextNotSupportedBox && <NotificationContextNotSupportedAlert/>}
|
||||
{showNotificationGrantBox && <NotificationGrantAlert onRequestPermissionClick={handleRequestNotificationPermission}/>}
|
||||
{!showSubscriptionsList &&
|
||||
<ListItemButton onClick={() => navigate(routes.root)} selected={location.pathname === config.appRoot}>
|
||||
@@ -211,7 +214,7 @@ const NotificationGrantAlert = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationNotSupportedAlert = () => {
|
||||
const NotificationBrowserNotSupportedAlert = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
@@ -224,4 +227,24 @@ const NotificationNotSupportedAlert = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const NotificationContextNotSupportedAlert = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<Alert severity="warning" sx={{paddingTop: 2}}>
|
||||
<AlertTitle>{t("alert_not_supported_title")}</AlertTitle>
|
||||
<Typography gutterBottom>
|
||||
<Trans
|
||||
i18nKey="alert_not_supported_context_description"
|
||||
components={{
|
||||
mdnLink: <Link href="https://developer.mozilla.org/en-US/docs/Web/API/notification" target="_blank" rel="noopener"/>
|
||||
}}
|
||||
/>
|
||||
</Typography>
|
||||
</Alert>
|
||||
<Divider/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
|
||||
Reference in New Issue
Block a user