Compare commits

...

41 Commits

Author SHA1 Message Date
Philipp Heckel
5f2ce5e542 Fix intermittent test failure; bump version 2022-06-23 15:01:35 -04:00
Philipp Heckel
6acb921098 tidy 2022-06-23 14:53:42 -04:00
Philipp Heckel
acf6d4370f Update deps 2022-06-23 14:46:01 -04:00
Philipp Heckel
297601d0f2 Bump version 2022-06-23 14:33:51 -04:00
Philipp Heckel
113900d3eb Cache startup queries 2022-06-23 11:02:45 -04:00
Philipp Heckel
b4a824aa38 Format actions PR, changelog 2022-06-22 20:23:15 -04:00
Philipp C. Heckel
e8569c6008 Merge pull request #341 from wunter8/custom-intent-broadcast-action
allow custom intent in broadcast action without JSON
2022-06-22 20:21:11 -04:00
Philipp Heckel
b74defef14 Enable WAL mode, add changelog 2022-06-22 20:17:47 -04:00
Hunter Kehoe
ee38d76bc2 allow custom intent in broadcast action without JSON 2022-06-22 15:29:16 -06:00
Philipp Heckel
3334d84861 Fix another race, add test 2022-06-22 15:11:50 -04:00
Philipp Heckel
b1089e21f9 Changelog 2022-06-22 14:56:26 -04:00
Philipp C. Heckel
07b5d9a9df Merge pull request #340 from binwiederhier/shorter-lock
WIP: Shorter lock, for #338
2022-06-22 14:48:07 -04:00
Philipp Heckel
9cee8ab888 Call subscriber funtions in individual goroutines 2022-06-22 13:52:49 -04:00
Philipp Heckel
ed9d99fd57 "Fix" data race 2022-06-22 13:47:54 -04:00
Philipp Heckel
edfc1b78a1 Delayed message lock shorter 2022-06-21 20:07:08 -04:00
Philipp Heckel
c1f7bed8d1 Fix tests, lock topic as short as possible 2022-06-21 19:45:23 -04:00
Philipp Heckel
85f2252a77 WIP: Shorter lock, for #338 2022-06-21 19:07:27 -04:00
Philipp C. Heckel
4e29216b5f Merge pull request #335 from binwiederhier/done
WIP: ntfy publish --pid $PID ...
2022-06-21 13:20:34 -04:00
Philipp Heckel
26fda847ca Docs 2022-06-21 11:43:26 -04:00
Philipp Heckel
a160da3ad9 Tests 2022-06-21 11:18:35 -04:00
Philipp Heckel
0080ea5a20 All --wait-cmd 2022-06-20 23:03:16 -04:00
Philipp Heckel
fec4864771 done command 2022-06-20 21:57:54 -04:00
Philipp Heckel
c40338c146 Merge branch 'main' into done 2022-06-20 20:34:00 -04:00
Philipp Heckel
a7d8e69dfd Refine NTFY_PASSWORD logic 2022-06-20 16:03:39 -04:00
Philipp C. Heckel
5b68915fff Merge pull request #327 from Kenix3/add_user_supports_envvar
Add user now supports reading password from an env var.
2022-06-20 15:40:23 -04:00
Kenix3
f3e5961892 Fixes envvar fetch in ntfy user add for password 2022-06-20 14:21:30 -04:00
Kenix
7de7e0de12 Adds missing colon assignment for username variable in ntfy user add command. 2022-06-20 13:26:13 -04:00
Kenix
727c6268b9 Updating order of variables ntfy user add command. 2022-06-20 13:25:31 -04:00
Kenix
50cd50cfdf Moves password stdin down to the original location. 2022-06-20 13:24:42 -04:00
Kenix
1265e69eee Changes user add to use a NTFY_PASSWORD env var rather than NTFY_USER. 2022-06-20 13:19:54 -04:00
Philipp Heckel
d05211648d Fix since=<id> implementation for multiple topics, closes #336 2022-06-20 12:11:52 -04:00
Philipp Heckel
1226a7b70c ntfy publish --pid $PID ... 2022-06-20 10:56:45 -04:00
Philipp Heckel
30c2a67869 Disallow setting upstream-base-url to the same value as base-url 2022-06-19 21:33:17 -04:00
Philipp Heckel
25a4b29ffc Return HTTP 500 on Matrix discovery GET if base-url not configured; log entire HTTP request when TRACE enabled 2022-06-19 21:25:35 -04:00
Philipp Heckel
e578f01e5b Changelog 2022-06-18 21:04:48 -04:00
Philipp Heckel
16047ede61 Changelog 2022-06-18 20:10:28 -04:00
Philipp Heckel
affc79eab0 Changelog 2022-06-17 21:07:43 -04:00
Philipp Heckel
64590343f5 Docs for #329 2022-06-17 21:05:31 -04:00
Philipp C. Heckel
87cf765dcc Merge pull request #330 from wunter8/329-attachment-url-broadcast-intent
update docs to explain attachment name and URL in broadcast intent
2022-06-17 20:59:37 -04:00
Hunter Kehoe
b332e1aaea update docs to explain attachment name and URL in broadcast intent 2022-06-17 07:19:35 -06:00
Kenix
3dec7efadb Add user now supports reading password from an env var. 2022-06-15 11:42:22 -04:00
29 changed files with 1480 additions and 647 deletions

View File

@@ -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
View 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
View File

@@ -0,0 +1,8 @@
package cmd
import "syscall"
func processExists(pid int) bool {
err := syscall.Kill(pid, syscall.Signal(0))
return err == nil
}

View File

@@ -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
View File

@@ -0,0 +1,10 @@
package cmd
import (
"os"
)
func processExists(pid int) bool {
_, err := os.FindProcess(pid)
return err == nil
}

View File

@@ -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")
@@ -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

View File

@@ -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

View File

@@ -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]

View File

@@ -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

View File

@@ -26,37 +26,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.26.0/ntfy_1.26.0_linux_x86_64.tar.gz
tar zxvf ntfy_1.26.0_linux_x86_64.tar.gz
sudo cp -a ntfy_1.26.0_linux_x86_64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.26.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.26.0/ntfy_1.26.0_linux_armv6.tar.gz
tar zxvf ntfy_1.26.0_linux_armv6.tar.gz
sudo cp -a ntfy_1.26.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.26.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.26.0/ntfy_1.26.0_linux_armv7.tar.gz
tar zxvf ntfy_1.26.0_linux_armv7.tar.gz
sudo cp -a ntfy_1.26.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.26.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.26.0/ntfy_1.26.0_linux_arm64.tar.gz
tar zxvf ntfy_1.26.0_linux_arm64.tar.gz
sudo cp -a ntfy_1.26.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_1.26.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.26.0/ntfy_1.26.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.26.0/ntfy_1.26.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.26.0/ntfy_1.26.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.26.0/ntfy_1.26.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.26.0/ntfy_1.26.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.26.0/ntfy_1.26.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.26.0/ntfy_1.26.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.26.0/ntfy_1.26.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,29 +184,30 @@ 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 -L https://github.com/binwiederhier/ntfy/releases/download/v1.26.0/ntfy_1.26.0_macOS_all.tar.gz > ntfy_1.26.0_macOS_all.tar.gz
tar zxvf ntfy_1.26.0_macOS_all.tar.gz
sudo cp -a ntfy_1.26.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_1.26.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.26.0/ntfy_v1.26.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).

View File

@@ -6,12 +6,48 @@ 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.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
@@ -37,7 +73,6 @@ CLI is now available via Scoop, and ntfy is now natively supported in Uptime Kum
* 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

View File

@@ -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

View File

@@ -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)

20
go.mod
View File

@@ -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-20220608161450-d0670ef3b1eb // 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-20220609170525-579cf78fd858
google.golang.org/api v0.84.0
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
@@ -45,14 +45,14 @@ require (
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-20220615171555-694bf12d69de // indirect
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c // 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-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-20220616135557-88e70c0c3a90 // 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
)

36
go.sum
View File

@@ -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=
@@ -242,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=
@@ -265,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=
@@ -345,8 +346,9 @@ golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su
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-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220615171555-694bf12d69de h1:ogOG2+P6LjO2j55AkRScrkB2BFpd+Z8TY2wcM0Z3MGo=
golang.org/x/net v0.0.0-20220615171555-694bf12d69de/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=
@@ -367,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-20220608161450-d0670ef3b1eb h1:8tDJ3aechhddbdPAxpycgXHJRMLpk/Ab+aa4OgdN5/g=
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=
@@ -443,8 +446,9 @@ golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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 h1:aFV+BgZ4svzjfabn8ERpuB4JI4N6/rdy1iusx77G3oU=
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=
@@ -566,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.84.0 h1:NMB9J4cCxs9xEm+1Z9QiO3eFvn7EnQj3Eo3hN6ugVlg=
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=
@@ -663,8 +668,10 @@ google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP
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-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90 h1:4SPz2GL2CXJt28MTF8V6Ap/9ZiVbQlJeGSd9qtA7DLs=
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=
@@ -721,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=

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -57,6 +57,7 @@ type Config struct {
FirebaseKeyFile string
CacheFile string
CacheDuration time.Duration
CacheStartupQueries string
AuthFile string
AuthDefaultRead bool
AuthDefaultWrite bool

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -16,6 +16,7 @@ import (
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
@@ -157,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()
}
@@ -246,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")
@@ -425,6 +429,9 @@ func (s *Server) handleFile(w http.ResponseWriter, r *http.Request, v *visitor)
}
func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
if s.config.BaseURL == "" {
return errHTTPInternalErrorMissingBaseURL
}
return writeMatrixDiscoveryResponse(w)
}
@@ -791,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
@@ -966,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
@@ -1066,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
@@ -1102,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
@@ -1130,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)
}
@@ -1205,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

View File

@@ -37,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.
@@ -55,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.

View File

@@ -6,7 +6,9 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"github.com/stretchr/testify/assert"
"io"
"log"
"math/rand"
"net/http"
"net/http/httptest"
@@ -437,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))
@@ -907,13 +956,23 @@ func TestServer_PublishUnifiedPushText(t *testing.T) {
require.Equal(t, "this is a unifiedpush text message", m.Message)
}
func TestServer_MatrixGateway_Discovery(t *testing.T) {
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"}]}}`
@@ -1009,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)
@@ -1352,6 +1412,51 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
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"

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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))
}

View File

@@ -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
@@ -120,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)) {
@@ -286,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, " ")
}

View File

@@ -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)
@@ -162,3 +137,9 @@ 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"}))
}

886
web/package-lock.json generated

File diff suppressed because it is too large Load Diff