Merge branch 'main' of github.com:binwiederhier/ntfy into gitglubber/main
@@ -48,13 +48,15 @@ builds:
|
||||
- id: ntfy_windows_amd64
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
tags: [ noserver ] # don't include server files
|
||||
- CGO_ENABLED=1 # required for go-sqlite3
|
||||
- CC=x86_64-w64-mingw32-gcc # apt install gcc-mingw-w64-x86-64
|
||||
tags: [ sqlite_omit_load_extension,osusergo,netgo ]
|
||||
ldflags:
|
||||
- "-X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
- "-s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}"
|
||||
goos: [ windows ]
|
||||
goarch: [ amd64 ]
|
||||
- id: ntfy_darwin_all
|
||||
goarch: [amd64 ]
|
||||
-
|
||||
id: ntfy_darwin_all
|
||||
binary: ntfy
|
||||
env:
|
||||
- CGO_ENABLED=0 # explicitly disable, since we don't need go-sqlite3
|
||||
@@ -201,4 +203,4 @@ docker_manifests:
|
||||
- *amd64_image
|
||||
- *arm64v8_image
|
||||
- *armv7_image
|
||||
- *armv6_image
|
||||
- *armv6_image
|
||||
|
||||
17
Makefile
@@ -31,6 +31,7 @@ help:
|
||||
@echo "Build server & client (without GoReleaser):"
|
||||
@echo " make cli-linux-server - Build client & server (no GoReleaser, current arch, Linux)"
|
||||
@echo " make cli-darwin-server - Build client & server (no GoReleaser, current arch, macOS)"
|
||||
@echo " make cli-windows-server - Build client & server (no GoReleaser, amd64 only, Windows)"
|
||||
@echo " make cli-client - Build client only (no GoReleaser, current arch, Linux/macOS/Windows)"
|
||||
@echo
|
||||
@echo "Build dev Docker:"
|
||||
@@ -106,6 +107,7 @@ build-deps-ubuntu:
|
||||
curl \
|
||||
gcc-aarch64-linux-gnu \
|
||||
gcc-arm-linux-gnueabi \
|
||||
gcc-mingw-w64-x86-64 \
|
||||
python3 \
|
||||
python3-venv \
|
||||
jq
|
||||
@@ -201,6 +203,16 @@ cli-darwin-server: cli-deps-static-sites
|
||||
-ldflags \
|
||||
"-linkmode=external -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-windows-server: cli-deps-static-sites
|
||||
# This is a target to build the CLI (including the server) for Windows.
|
||||
# Use this for Windows development, if you really don't want to install GoReleaser ...
|
||||
mkdir -p dist/ntfy_windows_server server/docs
|
||||
CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 CGO_ENABLED=1 go build \
|
||||
-o dist/ntfy_windows_server/ntfy.exe \
|
||||
-tags sqlite_omit_load_extension,osusergo,netgo \
|
||||
-ldflags \
|
||||
"-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(shell date +%s)"
|
||||
|
||||
cli-client: cli-deps-static-sites
|
||||
# This is a target to build the CLI (excluding the server) manually. This should work on Linux/macOS/Windows.
|
||||
# Use this for development, if you really don't want to install GoReleaser ...
|
||||
@@ -213,7 +225,7 @@ cli-client: cli-deps-static-sites
|
||||
|
||||
cli-deps: cli-deps-static-sites cli-deps-all cli-deps-gcc
|
||||
|
||||
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64
|
||||
cli-deps-gcc: cli-deps-gcc-armv6-armv7 cli-deps-gcc-arm64 cli-deps-gcc-windows
|
||||
|
||||
cli-deps-static-sites:
|
||||
mkdir -p server/docs server/site
|
||||
@@ -228,6 +240,9 @@ cli-deps-gcc-armv6-armv7:
|
||||
cli-deps-gcc-arm64:
|
||||
which aarch64-linux-gnu-gcc || { echo "ERROR: ARM64 cross compiler not installed. On Ubuntu, run: apt install gcc-aarch64-linux-gnu"; exit 1; }
|
||||
|
||||
cli-deps-gcc-windows:
|
||||
which x86_64-w64-mingw32-gcc || { echo "ERROR: Windows cross compiler not installed. On Ubuntu, run: apt install gcc-mingw-w64-x86-64"; exit 1; }
|
||||
|
||||
cli-deps-update:
|
||||
go get -u
|
||||
go install honnef.co/go/tools/cmd/staticcheck@latest
|
||||
|
||||
@@ -11,6 +11,9 @@ const (
|
||||
DefaultBaseURL = "https://ntfy.sh"
|
||||
)
|
||||
|
||||
// DefaultConfigFile is the default path to the client config file (set in config_*.go)
|
||||
var DefaultConfigFile string
|
||||
|
||||
// Config is the config struct for a Client
|
||||
type Config struct {
|
||||
DefaultHost string `yaml:"default-host"`
|
||||
|
||||
18
client/config_darwin.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build darwin
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
u, err := user.Current()
|
||||
if err == nil && u.Uid == "0" {
|
||||
DefaultConfigFile = "/etc/ntfy/client.yml"
|
||||
} else if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
18
client/config_unix.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build linux || dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
u, err := user.Current()
|
||||
if err == nil && u.Uid == "0" {
|
||||
DefaultConfigFile = "/etc/ntfy/client.yml"
|
||||
} else if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
14
client/config_windows.go
Normal file
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if configDir, err := os.UserConfigDir(); err == nil {
|
||||
DefaultConfigFile = filepath.Join(configDir, "ntfy", "client.yml")
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,11 @@ func WithFilename(filename string) PublishOption {
|
||||
return WithHeader("X-Filename", filename)
|
||||
}
|
||||
|
||||
// WithSequenceID sets a sequence ID for the message, allowing updates to existing notifications
|
||||
func WithSequenceID(sequenceID string) PublishOption {
|
||||
return WithHeader("X-Sequence-ID", sequenceID)
|
||||
}
|
||||
|
||||
// WithEmail instructs the server to also send the message to the given e-mail address
|
||||
func WithEmail(email string) PublishOption {
|
||||
return WithHeader("X-Email", email)
|
||||
|
||||
11
cmd/app.go
@@ -3,11 +3,12 @@ package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"os"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -15,6 +16,12 @@ const (
|
||||
categoryServer = "Server commands"
|
||||
)
|
||||
|
||||
// Build metadata keys for app.Metadata
|
||||
const (
|
||||
MetadataKeyCommit = "commit"
|
||||
MetadataKeyDate = "date"
|
||||
)
|
||||
|
||||
var commands = make([]*cli.Command, 0)
|
||||
|
||||
var flagsDefault = []cli.Flag{
|
||||
|
||||
@@ -34,6 +34,7 @@ var flagsPublish = append(
|
||||
&cli.BoolFlag{Name: "markdown", Aliases: []string{"md"}, EnvVars: []string{"NTFY_MARKDOWN"}, Usage: "Message is formatted as Markdown"},
|
||||
&cli.StringFlag{Name: "template", Aliases: []string{"tpl"}, EnvVars: []string{"NTFY_TEMPLATE"}, Usage: "use templates to transform JSON message body"},
|
||||
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "filename for the attachment"},
|
||||
&cli.StringFlag{Name: "sequence-id", Aliases: []string{"sequence_id", "sid", "S"}, EnvVars: []string{"NTFY_SEQUENCE_ID"}, Usage: "sequence ID for updating notifications"},
|
||||
&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"},
|
||||
@@ -70,6 +71,7 @@ Examples:
|
||||
ntfy pub --icon="http://some.tld/icon.png" 'Icon!' # Send notification with custom icon
|
||||
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 -S my-id mytopic 'Update me' # Send with sequence ID for updates
|
||||
echo 'message' | ntfy publish mytopic # Send message from stdin
|
||||
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
|
||||
@@ -101,6 +103,7 @@ func execPublish(c *cli.Context) error {
|
||||
markdown := c.Bool("markdown")
|
||||
template := c.String("template")
|
||||
filename := c.String("filename")
|
||||
sequenceID := c.String("sequence-id")
|
||||
file := c.String("file")
|
||||
email := c.String("email")
|
||||
user := c.String("user")
|
||||
@@ -154,6 +157,9 @@ func execPublish(c *cli.Context) error {
|
||||
if filename != "" {
|
||||
options = append(options, client.WithFilename(filename))
|
||||
}
|
||||
if sequenceID != "" {
|
||||
options = append(options, client.WithSequenceID(sequenceID))
|
||||
}
|
||||
if email != "" {
|
||||
options = append(options, client.WithEmail(email))
|
||||
}
|
||||
|
||||
71
cmd/serve.go
@@ -10,10 +10,9 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
@@ -77,6 +76,7 @@ var flagsServe = append(
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-auth-token", Aliases: []string{"twilio_auth_token"}, EnvVars: []string{"NTFY_TWILIO_AUTH_TOKEN"}, Usage: "Twilio auth token"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-phone-number", Aliases: []string{"twilio_phone_number"}, EnvVars: []string{"NTFY_TWILIO_PHONE_NUMBER"}, Usage: "Twilio number to use for outgoing calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-verify-service", Aliases: []string{"twilio_verify_service"}, EnvVars: []string{"NTFY_TWILIO_VERIFY_SERVICE"}, Usage: "Twilio Verify service ID, used for phone number verification"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "twilio-call-format", Aliases: []string{"twilio_call_format"}, EnvVars: []string{"NTFY_TWILIO_CALL_FORMAT"}, Usage: "Twilio/TwiML format string for phone calls"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-size-limit", Aliases: []string{"message_size_limit"}, EnvVars: []string{"NTFY_MESSAGE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultMessageSizeLimit), Usage: "size limit for the message (see docs for limitations)"}),
|
||||
altsrc.NewStringFlag(&cli.StringFlag{Name: "message-delay-limit", Aliases: []string{"message_delay_limit"}, EnvVars: []string{"NTFY_MESSAGE_DELAY_LIMIT"}, Value: util.FormatDuration(server.DefaultMessageDelayMax), Usage: "max duration a message can be scheduled into the future"}),
|
||||
altsrc.NewIntFlag(&cli.IntFlag{Name: "global-topic-limit", Aliases: []string{"global_topic_limit", "T"}, EnvVars: []string{"NTFY_GLOBAL_TOPIC_LIMIT"}, Value: server.DefaultTotalTopicLimit, Usage: "total number of topics allowed"}),
|
||||
@@ -187,6 +187,7 @@ func execServe(c *cli.Context) error {
|
||||
twilioAuthToken := c.String("twilio-auth-token")
|
||||
twilioPhoneNumber := c.String("twilio-phone-number")
|
||||
twilioVerifyService := c.String("twilio-verify-service")
|
||||
twilioCallFormat := c.String("twilio-call-format")
|
||||
messageSizeLimitStr := c.String("message-size-limit")
|
||||
messageDelayLimitStr := c.String("message-delay-limit")
|
||||
totalTopicLimit := c.Int("global-topic-limit")
|
||||
@@ -347,6 +348,8 @@ func execServe(c *cli.Context) error {
|
||||
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
|
||||
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
|
||||
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
|
||||
} else if runtime.GOOS == "windows" && listenUnix != "" {
|
||||
return errors.New("listen-unix is not supported on Windows")
|
||||
}
|
||||
|
||||
// Backwards compatibility
|
||||
@@ -456,6 +459,13 @@ func execServe(c *cli.Context) error {
|
||||
conf.TwilioAuthToken = twilioAuthToken
|
||||
conf.TwilioPhoneNumber = twilioPhoneNumber
|
||||
conf.TwilioVerifyService = twilioVerifyService
|
||||
if twilioCallFormat != "" {
|
||||
tmpl, err := template.New("twiml").Parse(twilioCallFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse twilio-call-format template: %w", err)
|
||||
}
|
||||
conf.TwilioCallFormat = tmpl
|
||||
}
|
||||
conf.MessageSizeLimit = int(messageSizeLimit)
|
||||
conf.MessageDelayMax = messageDelayLimit
|
||||
conf.TotalTopicLimit = totalTopicLimit
|
||||
@@ -491,7 +501,17 @@ func execServe(c *cli.Context) error {
|
||||
conf.WebPushStartupQueries = webPushStartupQueries
|
||||
conf.WebPushExpiryDuration = webPushExpiryDuration
|
||||
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
|
||||
conf.Version = c.App.Version
|
||||
conf.BuildVersion = c.App.Version
|
||||
conf.BuildDate = maybeFromMetadata(c.App.Metadata, MetadataKeyDate)
|
||||
conf.BuildCommit = maybeFromMetadata(c.App.Metadata, MetadataKeyCommit)
|
||||
|
||||
// Check if we should run as a Windows service
|
||||
if ranAsService, err := maybeRunAsService(conf); err != nil {
|
||||
log.Fatal("%s", err.Error())
|
||||
} else if ranAsService {
|
||||
log.Info("Exiting.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Set up hot-reloading of config
|
||||
go sigHandlerConfigReload(config)
|
||||
@@ -507,22 +527,6 @@ func execServe(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func sigHandlerConfigReload(config string) {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGHUP)
|
||||
for range sigs {
|
||||
log.Info("Partially hot reloading configuration ...")
|
||||
inputSource, err := newYamlSourceFromFile(config, flagsServe)
|
||||
if err != nil {
|
||||
log.Warn("Hot reload failed: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
if err := reloadLogLevel(inputSource); err != nil {
|
||||
log.Warn("Reloading log level failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseIPHostPrefix(host string) (prefixes []netip.Prefix, err error) {
|
||||
// Try parsing as prefix, e.g. 10.0.1.0/24 or 2001:db8::/32
|
||||
prefix, err := netip.ParsePrefix(host)
|
||||
@@ -654,24 +658,17 @@ func parseTokens(users []*user.User, tokensRaw []string) (map[string][]*user.Tok
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load log level: %s", err.Error())
|
||||
func maybeFromMetadata(m map[string]any, key string) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
|
||||
v, exists := m[key]
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
log.ResetLevelOverrides()
|
||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
log.SetLevel(log.ToLevel(newLevelStr))
|
||||
if len(overrides) > 0 {
|
||||
log.Info("Log level is %v, %d override(s) in place", strings.ToUpper(newLevelStr), len(overrides))
|
||||
} else {
|
||||
log.Info("Log level is %v", strings.ToUpper(newLevelStr))
|
||||
}
|
||||
return nil
|
||||
return s
|
||||
}
|
||||
|
||||
55
cmd/serve_unix.go
Normal file
@@ -0,0 +1,55 @@
|
||||
//go:build linux || dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/urfave/cli/v2/altsrc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
)
|
||||
|
||||
func sigHandlerConfigReload(config string) {
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, syscall.SIGHUP)
|
||||
for range sigs {
|
||||
log.Info("Partially hot reloading configuration ...")
|
||||
inputSource, err := newYamlSourceFromFile(config, flagsServe)
|
||||
if err != nil {
|
||||
log.Warn("Hot reload failed: %s", err.Error())
|
||||
continue
|
||||
}
|
||||
if err := reloadLogLevel(inputSource); err != nil {
|
||||
log.Warn("Reloading log level failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reloadLogLevel(inputSource altsrc.InputSourceContext) error {
|
||||
newLevelStr, err := inputSource.String("log-level")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
overrides, err := inputSource.StringSlice("log-level-overrides")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.ResetLevelOverrides()
|
||||
if err := applyLogLevelOverrides(overrides); err != nil {
|
||||
return err
|
||||
}
|
||||
log.SetLevel(log.ToLevel(newLevelStr))
|
||||
if len(overrides) > 0 {
|
||||
log.Info("Log level is %v, %d override(s) in place", newLevelStr, len(overrides))
|
||||
} else {
|
||||
log.Info("Log level is %v", newLevelStr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maybeRunAsService(conf *server.Config) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
100
cmd/serve_windows.go
Normal file
@@ -0,0 +1,100 @@
|
||||
//go:build windows && !noserver
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/server"
|
||||
)
|
||||
|
||||
const serviceName = "ntfy"
|
||||
|
||||
// sigHandlerConfigReload is a no-op on Windows since SIGHUP is not available.
|
||||
// Windows users can restart the service to reload configuration.
|
||||
func sigHandlerConfigReload(config string) {
|
||||
log.Debug("Config hot-reload via SIGHUP is not supported on Windows")
|
||||
}
|
||||
|
||||
// runAsWindowsService runs the ntfy server as a Windows service
|
||||
func runAsWindowsService(conf *server.Config) error {
|
||||
return svc.Run(serviceName, &windowsService{conf: conf})
|
||||
}
|
||||
|
||||
// windowsService implements the svc.Handler interface
|
||||
type windowsService struct {
|
||||
conf *server.Config
|
||||
server *server.Server
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Execute is the main entry point for the Windows service
|
||||
func (s *windowsService) Execute(args []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) {
|
||||
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
|
||||
status <- svc.Status{State: svc.StartPending}
|
||||
|
||||
// Create and start the server
|
||||
var err error
|
||||
s.mu.Lock()
|
||||
s.server, err = server.New(s.conf)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Error("Failed to create server: %s", err.Error())
|
||||
return true, 1
|
||||
}
|
||||
|
||||
// Start server in a goroutine
|
||||
serverErrChan := make(chan error, 1)
|
||||
go func() {
|
||||
serverErrChan <- s.server.Run()
|
||||
}()
|
||||
|
||||
status <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}
|
||||
log.Info("Windows service started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case err := <-serverErrChan:
|
||||
if err != nil {
|
||||
log.Error("Server error: %s", err.Error())
|
||||
return true, 1
|
||||
}
|
||||
return false, 0
|
||||
case req := <-requests:
|
||||
switch req.Cmd {
|
||||
case svc.Interrogate:
|
||||
status <- req.CurrentStatus
|
||||
case svc.Stop, svc.Shutdown:
|
||||
log.Info("Windows service stopping...")
|
||||
status <- svc.Status{State: svc.StopPending}
|
||||
s.mu.Lock()
|
||||
if s.server != nil {
|
||||
s.server.Stop()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return false, 0
|
||||
default:
|
||||
log.Warn("Unexpected service control request: %d", req.Cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// maybeRunAsService checks if the process is running as a Windows service,
|
||||
// and if so, runs the server as a service. Returns true if it ran as a service.
|
||||
func maybeRunAsService(conf *server.Config) (bool, error) {
|
||||
isService, err := svc.IsWindowsService()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to detect Windows service mode: %w", err)
|
||||
} else if !isService {
|
||||
return false, nil
|
||||
}
|
||||
log.Info("Running as Windows service")
|
||||
if err := runAsWindowsService(conf); err != nil {
|
||||
return true, fmt.Errorf("failed to run as Windows service: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -3,28 +3,21 @@ package cmd
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/client"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
commands = append(commands, cmdSubscribe)
|
||||
}
|
||||
|
||||
const (
|
||||
clientRootConfigFileUnixAbsolute = "/etc/ntfy/client.yml"
|
||||
clientUserConfigFileUnixRelative = "ntfy/client.yml"
|
||||
clientUserConfigFileWindowsRelative = "ntfy\\client.yml"
|
||||
)
|
||||
|
||||
var flagsSubscribe = append(
|
||||
append([]cli.Flag{}, flagsDefault...),
|
||||
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
|
||||
@@ -310,45 +303,16 @@ func loadConfig(c *cli.Context) (*client.Config, error) {
|
||||
if filename != "" {
|
||||
return client.LoadConfig(filename)
|
||||
}
|
||||
configFile, err := defaultClientConfigFile()
|
||||
if err != nil {
|
||||
log.Warn("Could not determine default client config file: %s", err.Error())
|
||||
} else {
|
||||
if s, _ := os.Stat(configFile); s != nil {
|
||||
return client.LoadConfig(configFile)
|
||||
if client.DefaultConfigFile != "" {
|
||||
if s, _ := os.Stat(client.DefaultConfigFile); s != nil {
|
||||
return client.LoadConfig(client.DefaultConfigFile)
|
||||
}
|
||||
log.Debug("Config file %s not found", configFile)
|
||||
log.Debug("Config file %s not found", client.DefaultConfigFile)
|
||||
}
|
||||
log.Debug("Loading default config")
|
||||
return client.NewConfig(), nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileUnix() (string, error) {
|
||||
u, err := user.Current()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine current user: %w", err)
|
||||
}
|
||||
configFile := clientRootConfigFileUnixAbsolute
|
||||
if u.Uid != "0" {
|
||||
homeDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, clientUserConfigFileUnixRelative), nil
|
||||
}
|
||||
return configFile, nil
|
||||
}
|
||||
|
||||
//lint:ignore U1000 Conditionally used in different builds
|
||||
func defaultClientConfigFileWindows() (string, error) {
|
||||
homeDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine user config dir: %w", err)
|
||||
}
|
||||
return filepath.Join(homeDir, clientUserConfigFileWindowsRelative), nil
|
||||
}
|
||||
|
||||
func logMessagePrefix(m *client.Message) string {
|
||||
return fmt.Sprintf("%s/%s", util.ShortTopicURL(m.TopicURL), m.ID)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build darwin
|
||||
|
||||
package cmd
|
||||
|
||||
const (
|
||||
@@ -10,7 +12,3 @@ or "~/Library/Application Support/ntfy/client.yml" for all other users.`
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.`
|
||||
var (
|
||||
scriptLauncher = []string{"sh", "-c"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileUnix()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
const (
|
||||
@@ -9,7 +11,3 @@ const (
|
||||
var (
|
||||
scriptLauncher = []string{"cmd.exe", "/Q", "/C"}
|
||||
)
|
||||
|
||||
func defaultClientConfigFile() (string, error) {
|
||||
return defaultClientConfigFileWindows()
|
||||
}
|
||||
|
||||
@@ -1261,10 +1261,85 @@ are the easiest), and then configure the following options:
|
||||
* `twilio-auth-token` is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||
* `twilio-phone-number` is the outgoing phone number you purchased, e.g. +18775132586
|
||||
* `twilio-verify-service` is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||
* `twilio-call-format` is the custom Twilio markup ([TwiML](https://www.twilio.com/docs/voice/twiml)) to use for phone calls (optional)
|
||||
|
||||
After you have configured phone calls, create a [tier](#tiers) with a call limit (e.g. `ntfy tier create --call-limit=10 ...`),
|
||||
and then assign it to a user. Users may then use the `X-Call` header to receive a phone call when publishing a message.
|
||||
|
||||
To customize the message that is spoken out loud, set the `twilio-call-format` option with [TwiML](https://www.twilio.com/docs/voice/twiml). The format is
|
||||
rendered as a [Go template](https://pkg.go.dev/text/template), so you can use the following fields from the message:
|
||||
|
||||
* `{{.Topic}}` is the topic name
|
||||
* `{{.Message}}` is the message body
|
||||
* `{{.Title}}` is the message title
|
||||
* `{{.Tags}}` is a list of tags
|
||||
* `{{.Priority}}` is the message priority
|
||||
* `{{.Sender}}` is the IP address or username of the sender
|
||||
|
||||
Here's an example:
|
||||
|
||||
=== "Custom TwiML (English)"
|
||||
``` yaml
|
||||
twilio-account: "AC12345beefbeef67890beefbeef122586"
|
||||
twilio-auth-token: "affebeef258625862586258625862586"
|
||||
twilio-phone-number: "+18775132586"
|
||||
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
|
||||
twilio-call-format: |
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
Yo yo yo, you should totally check out this message for {{.Topic}}.
|
||||
{{ if eq .Priority 5 }}
|
||||
It's really really important, dude. So listen up!
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{ if neq .Title "" }}
|
||||
Bro, it's titled: {{.Title}}.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
That is all.
|
||||
<break time="1s"/>
|
||||
You know who this message is from? It is from {{.Sender}}.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>See ya!</Say>
|
||||
</Response>
|
||||
```
|
||||
|
||||
=== "Custom TwiML (German)"
|
||||
``` yaml
|
||||
twilio-account: "AC12345beefbeef67890beefbeef122586"
|
||||
twilio-auth-token: "affebeef258625862586258625862586"
|
||||
twilio-phone-number: "+18775132586"
|
||||
twilio-verify-service: "VA12345beefbeef67890beefbeef122586"
|
||||
twilio-call-format: |
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3" voice="alice" language="de-DE">
|
||||
Du hast eine Nachricht zum Thema {{.Topic}}.
|
||||
{{ if eq .Priority 5 }}
|
||||
Achtung. Die Nachricht ist sehr wichtig.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
{{ if neq .Title "" }}
|
||||
Titel der Nachricht: {{.Title}}.
|
||||
{{ end }}
|
||||
<break time="1s"/>
|
||||
Nachricht:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
Ende der Nachricht.
|
||||
<break time="1s"/>
|
||||
Diese Nachricht wurde vom Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say voice="alice" language="de-DE">Alla mol!</Say>
|
||||
</Response>
|
||||
```
|
||||
|
||||
## Message limits
|
||||
There are a few message limits that you can configure:
|
||||
|
||||
|
||||
@@ -441,6 +441,6 @@ To have instant notifications/better notification delivery when using firebase,
|
||||
1. In XCode, find the NTFY app target. **Not** the NSE app target.
|
||||
1. Find the Asset/ folder in the project navigator
|
||||
1. Drag the `GoogleService-Info.plist` file into the Asset/ folder that you get from the firebase console. It can be
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labled "GoogleService-Info.plist"
|
||||
found in the "Project settings" > "General" > "Your apps" with a button labeled "GoogleService-Info.plist"
|
||||
|
||||
After that, you should be all set!
|
||||
|
||||
@@ -96,8 +96,8 @@ appreciated.
|
||||
## Can I email you? Can I DM you on Discord/Matrix?
|
||||
For community support, please use the public channels listed on the [contact page](contact.md). I generally
|
||||
**do not respond to direct messages** about ntfy, unless you are paying for a [ntfy Pro](https://ntfy.sh/#pricing)
|
||||
plan (see [paid support](contact.md#paid-support-ntfy-pro-subscribers)), or you are inquiring about business
|
||||
opportunities (see [general inquiries](contact.md#general-inquiries)).
|
||||
plan (see [paid support](contact.md#paid-support)), or you are inquiring about business
|
||||
opportunities (see [other inquiries](contact.md#other-inquiries)).
|
||||
|
||||
I am sorry, but answering individual questions about ntfy on a 1-on-1 basis is not scalable. Answering your questions
|
||||
in public forums benefits others, since I can link to the discussion at a later point in time, or other users
|
||||
|
||||
100
docs/install.md
@@ -30,37 +30,37 @@ deb/rpm packages.
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.tar.gz
|
||||
tar zxvf ntfy_2.16.0_linux_amd64.tar.gz
|
||||
sudo cp -a ntfy_2.16.0_linux_amd64/ntfy /usr/local/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_amd64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.tar.gz
|
||||
tar zxvf ntfy_2.16.0_linux_armv6.tar.gz
|
||||
sudo cp -a ntfy_2.16.0_linux_armv6/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv6/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.tar.gz
|
||||
tar zxvf ntfy_2.16.0_linux_armv7.tar.gz
|
||||
sudo cp -a ntfy_2.16.0_linux_armv7/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_armv7/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.15.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.15.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.tar.gz
|
||||
tar zxvf ntfy_2.16.0_linux_arm64.tar.gz
|
||||
sudo cp -a ntfy_2.16.0_linux_arm64/ntfy /usr/bin/ntfy
|
||||
sudo mkdir /etc/ntfy && sudo cp ntfy_2.16.0_linux_arm64/{client,server}/*.yml /etc/ntfy
|
||||
sudo ntfy serve
|
||||
```
|
||||
|
||||
@@ -116,7 +116,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -124,7 +124,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -132,7 +132,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -140,7 +140,7 @@ Manually installing the .deb file:
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.deb
|
||||
wget https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.deb
|
||||
sudo dpkg -i ntfy_*.deb
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
@@ -150,33 +150,35 @@ Manually installing the .deb file:
|
||||
|
||||
=== "x86_64/amd64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_amd64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_amd64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv6"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv6.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv6.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "armv7/armhf"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_armv7.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_armv7.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
=== "arm64"
|
||||
```bash
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_linux_arm64.rpm
|
||||
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_linux_arm64.rpm
|
||||
sudo systemctl enable ntfy
|
||||
sudo systemctl start ntfy
|
||||
```
|
||||
|
||||
## Arch Linux
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
ntfy can be installed using an [AUR package](https://aur.archlinux.org/packages/ntfysh-bin/).
|
||||
You can use an [AUR helper](https://wiki.archlinux.org/title/AUR_helpers) like `paru`, `yay` or others to download,
|
||||
build and install ntfy and keep it up to date.
|
||||
@@ -191,7 +193,9 @@ cd ntfysh-bin
|
||||
makepkg -si
|
||||
```
|
||||
|
||||
## NixOS / Nix
|
||||
## NixOS / Nix
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
ntfy is packaged in nixpkgs as `ntfy-sh`. It can be installed by adding the package name to the configuration file and calling `nixos-rebuild`. Alternatively, the following command can be used to install ntfy in the current user environment:
|
||||
```
|
||||
nix-env -iA ntfy-sh
|
||||
@@ -199,20 +203,28 @@ nix-env -iA ntfy-sh
|
||||
|
||||
NixOS also supports [declarative setup of the ntfy server](https://search.nixos.org/options?channel=unstable&show=services.ntfy-sh.enable&from=0&size=50&sort=relevance&type=packages&query=ntfy).
|
||||
|
||||
## FreeBSD
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
ntfy is ported to FreeBSD and available via the ports collection as [sysutils/go-ntfy](https://www.freshports.org/sysutils/go-ntfy/). You can install it via `pkg`:
|
||||
```
|
||||
pkg install go-ntfy
|
||||
```
|
||||
|
||||
## 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](https://github.com/binwiederhier/ntfy/releases/download/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz),
|
||||
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_darwin_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/v2.15.0/ntfy_2.15.0_darwin_all.tar.gz > ntfy_2.15.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.15.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.15.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_darwin_all.tar.gz > ntfy_2.16.0_darwin_all.tar.gz
|
||||
tar zxvf ntfy_2.16.0_darwin_all.tar.gz
|
||||
sudo cp -a ntfy_2.16.0_darwin_all/ntfy /usr/local/bin/ntfy
|
||||
mkdir ~/Library/Application\ Support/ntfy
|
||||
cp ntfy_2.15.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
cp ntfy_2.16.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
|
||||
ntfy --help
|
||||
```
|
||||
|
||||
@@ -221,6 +233,8 @@ ntfy --help
|
||||
development as well. Check out the [build instructions](develop.md) for details.
|
||||
|
||||
## Homebrew
|
||||
<span class="community-badge" title="This package is maintained by the community, not the ntfy developers"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg> Community maintained</span>
|
||||
|
||||
To install the [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) via Homebrew (Linux and macOS),
|
||||
simply run:
|
||||
```
|
||||
@@ -228,19 +242,29 @@ brew install ntfy
|
||||
```
|
||||
|
||||
## 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/v2.15.0/ntfy_2.15.0_windows_amd64.zip),
|
||||
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
|
||||
To install, you can either
|
||||
|
||||
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.16.0/ntfy_2.16.0_windows_amd64.zip),
|
||||
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
|
||||
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
|
||||
|
||||
The default path for the client config file is at `%AppData%\ntfy\client.yml` (not created automatically, sample in the ZIP file).
|
||||
Once installed, you can run the ntfy CLI commands like so:
|
||||
|
||||
Also available in [Scoop's](https://scoop.sh) Main repository:
|
||||
```
|
||||
ntfy.exe -h
|
||||
```
|
||||
|
||||
`scoop install ntfy`
|
||||
The default configuration file location on Windows is `%ProgramData%\ntfy\server.yml` (e.g., `C:\ProgramData\ntfy\server.yml`)
|
||||
for the server, and `%AppData%\ntfy\client.yml` for the client. You may need to create the directory and config file manually.
|
||||
|
||||
!!! info
|
||||
There is currently no installer for Windows, and the binary is not signed. If this is desired, please create a
|
||||
[GitHub issue](https://github.com/binwiederhier/ntfy/issues) to let me know.
|
||||
To install the ntfy server as a Windows service, you can use the built-in `sc` command. For example, run this in an
|
||||
elevated command prompt (adjust the path to `ntfy.exe` accordingly):
|
||||
|
||||
```
|
||||
sc create ntfy binPath="C:\path\to\ntfy.exe serve" start=auto
|
||||
sc start ntfy
|
||||
```
|
||||
|
||||
## Docker
|
||||
The [ntfy image](https://hub.docker.com/r/binwiederhier/ntfy) is available for amd64, armv6, armv7 and arm64. It should
|
||||
|
||||
@@ -90,7 +90,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
|
||||
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications
|
||||
- [ntfy svelte front-end](https://github.com/novatorem/Ntfy) - Front-end built with svelte
|
||||
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
|
||||
- [ntfysh-windows](https://github.com/lucas-bortoli/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
|
||||
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
|
||||
- [ntfy.py](https://github.com/ioqy/ntfy-client-python) - ntfy.py is a simple nfty.sh client for sending notifications
|
||||
- [wlzntfy](https://github.com/Walzen-Group/ntfy-toaster) - A minimalistic, receive-only toast notification client for Windows 11
|
||||
|
||||
2319
docs/publish.md
@@ -1174,7 +1174,7 @@ keys $myDict | sortAlpha
|
||||
```
|
||||
|
||||
When supplying multiple dictionaries, the keys will be concatenated. Use the `uniq`
|
||||
function along with `sortAlpha` to get a unqiue, sorted list of keys.
|
||||
function along with `sortAlpha` to get a unique, sorted list of keys.
|
||||
|
||||
```
|
||||
keys $myDict $myOtherDict | uniq | sortAlpha
|
||||
|
||||
122
docs/releases.md
@@ -4,14 +4,107 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Current stable releases
|
||||
|
||||
| Component | Version | Release date |
|
||||
|------------------------------------------|---------|--------------|
|
||||
| ntfy server | v2.15.0 | Nov 16, 2025 |
|
||||
| ntfy Android app (_is being rolled out_) | v1.20.0 | Dec 28, 2025 |
|
||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||
| Component | Version | Release date |
|
||||
|------------------|---------|--------------|
|
||||
| ntfy server | v2.16.0 | Jan 19, 2026 |
|
||||
| ntfy Android app | v1.22.1 | Jan 20, 2026 |
|
||||
| ntfy iOS app | v1.3 | Nov 26, 2023 |
|
||||
|
||||
Please check out the release notes for [upcoming releases](#not-released-yet) below.
|
||||
|
||||
### ntfy Android app v1.22.1
|
||||
Released January 20, 2026
|
||||
|
||||
This release adds support for [updating and deleting notifications](publish.md#updating--deleting-notifications) (requires server v2.16.0),
|
||||
as well as [certificate management for self-signed certs and mTLS client certificates](subscribe/phone.md#manage-certificates),
|
||||
and a new connection error dialog to help [troubleshoot connection issues](subscribe/phone.md#troubleshooting).
|
||||
|
||||
<div id="v1221-screenshots-1" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-notification-update-1.png"><img src="../../static/img/android-screenshot-notification-update-1.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-notification-update-2.png"><img src="../../static/img/android-screenshot-notification-update-2.png"/></a>
|
||||
</div>
|
||||
|
||||
<div id="v1221-screenshots-2" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications)
|
||||
([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8)
|
||||
for the initial implementation)
|
||||
* Support for self-signed certs and client certs for mTLS ([#215](https://github.com/binwiederhier/ntfy/issues/215),
|
||||
[#530](https://github.com/binwiederhier/ntfy/issues/530), [ntfy-android#149](https://github.com/binwiederhier/ntfy-android/pull/149),
|
||||
thanks to [@cyb3rko](https://github.com/cyb3rko) for reviewing)
|
||||
* Connection error dialog to help diagnose connection issues
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Use server-specific user for attachment downloads ([#1529](https://github.com/binwiederhier/ntfy/issues/1529),
|
||||
thanks to [@ManInDark](https://github.com/ManInDark) for reporting and testing)
|
||||
* Fix crash in sharing dialog (thanks to [@rogeliodh](https://github.com/rogeliodh))
|
||||
* Fix crash when exiting multi-delete in detail view
|
||||
* Fix potential crashes with icon downloader and backuper
|
||||
|
||||
## ntfy server v2.16.0
|
||||
Released January 19, 2026
|
||||
|
||||
This release adds support for updating and deleting notifications, heartbeat-style / dead man's switch notifications,
|
||||
custom Twilio call formats, and makes `ntfy serve` work on Windows. It also adds a "New version available" banner to the web app.
|
||||
|
||||
This one is very exciting, as it brings a lot of highly requested features to ntfy.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Support for [updating and deleting notifications](publish.md#updating-deleting-notifications) ([#303](https://github.com/binwiederhier/ntfy/issues/303), [#1536](https://github.com/binwiederhier/ntfy/pull/1536),
|
||||
[ntfy-android#151](https://github.com/binwiederhier/ntfy-android/pull/151), thanks to [@wunter8](https://github.com/wunter8) for the initial implementation)
|
||||
* Support for heartbeat-style / [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) notifications aka
|
||||
[updating and deleting scheduled notifications](publish.md#scheduled-delivery) ([#1556](https://github.com/binwiederhier/ntfy/pull/1556),
|
||||
[#1142](https://github.com/binwiederhier/ntfy/pull/1142), [#954](https://github.com/binwiederhier/ntfy/issues/954),
|
||||
thanks to [@GamerGirlandCo](https://github.com/GamerGirlandCo) for the initial implementation)
|
||||
* Configure [custom Twilio call format](config.md#phone-calls) for phone calls ([#1289](https://github.com/binwiederhier/ntfy/pull/1289), thanks to [@mmichaa](https://github.com/mmichaa) for the initial implementation)
|
||||
* `ntfy serve` now works on Windows, including support for running it as a Windows service ([#1104](https://github.com/binwiederhier/ntfy/issues/1104),
|
||||
[#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328),
|
||||
thanks to [@wtf911](https://github.com/wtf911))
|
||||
* Web app: "New version available" banner ([#1554](https://github.com/binwiederhier/ntfy/pull/1554))
|
||||
|
||||
## ntfy Android app v1.21.1
|
||||
Released January 6, 2026
|
||||
|
||||
This is the first feature release in a long time. After all the SDK updates, fixes to comply with the Google Play policies
|
||||
and the framework updates, this release ships a lot of highly requested features: Sending messages through the app (WhatsApp-style),
|
||||
support for passing headers to your proxy, an in-app language switcher, and more.
|
||||
|
||||
<div id="v1211-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
|
||||
</div>
|
||||
|
||||
If you are waiting for a feature, please 👍 the corresponding [GitHub issue](https://github.com/binwiederhier/ntfy/issues?q=is%3Aissue%20state%3Aopen%20sort%3Areactions-%2B1-desc).
|
||||
If you like ntfy, please consider purchasing [ntfy Pro](https://ntfy.sh/app) to support us.
|
||||
|
||||
**Features:**
|
||||
|
||||
* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))
|
||||
* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)
|
||||
* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)
|
||||
* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))
|
||||
* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))
|
||||
* Fix crash in user add dialog (onAddUser)
|
||||
* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))
|
||||
* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))
|
||||
|
||||
## ntfy Android app v1.20.0
|
||||
Released December 28, 2025
|
||||
|
||||
@@ -1572,21 +1665,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|
||||
|
||||
## Not released yet
|
||||
|
||||
### ntfy Android app v1.21.1-rc1 (IN TESTING)
|
||||
|
||||
**Features:**
|
||||
|
||||
* Allow publishing messages through the message bar and publish dialog ([#98](https://github.com/binwiederhier/ntfy/issues/98), [ntfy-android#144](https://github.com/binwiederhier/ntfy-android/pull/144))
|
||||
* Define custom HTTP headers to support authenticated proxies, tunnels and SSO ([ntfy-android#116](https://github.com/binwiederhier/ntfy-android/issues/116), [#1018](https://github.com/binwiederhier/ntfy/issues/1018), [ntfy-android#132](https://github.com/binwiederhier/ntfy-android/pull/132), [ntfy-android#146](https://github.com/binwiederhier/ntfy-android/pull/146), thanks to [@CrazyWolf13](https://github.com/CrazyWolf13))
|
||||
* Implement UnifiedPush "raise to foreground" requirement ([ntfy-android#98](https://github.com/binwiederhier/ntfy-android/pull/98), [ntfy-android#148](https://github.com/binwiederhier/ntfy-android/pull/148), thanks to [@p1gp1g](https://github.com/p1gp1g))
|
||||
* Language selector to allow overriding the system language ([#1508](https://github.com/binwiederhier/ntfy/issues/1508), [ntfy-android#145](https://github.com/binwiederhier/ntfy-android/pull/145), thanks to [@hudsonm62](https://github.com/hudsonm62) for reporting)
|
||||
* Highlight phone numbers and email addresses in notifications ([#957](https://github.com/binwiederhier/ntfy/issues/957), [ntfy-android#71](https://github.com/binwiederhier/ntfy-android/pull/71), thanks to [@brennenputh](https://github.com/brennenputh), and [@XylenSky](https://github.com/XylenSky) for reporting)
|
||||
* Support for port and display name in [ntfy://](subscribe/phone.md#ntfy-links) links ([ntfy-android#130](https://github.com/binwiederhier/ntfy-android/pull/130), thanks to [@godovski](https://github.com/godovski))
|
||||
|
||||
**Bug fixes + maintenance:**
|
||||
|
||||
* Add support for (technically incorrect) 'image/jpg' MIME type ([ntfy-android#142](https://github.com/binwiederhier/ntfy-android/pull/142), thanks to [@Murilobeluco](https://github.com/Murilobeluco))
|
||||
* Unify "copy to clipboard" notifications, use Android 13 style ([ntfy-android#61](https://github.com/binwiederhier/ntfy-android/pull/61), thanks to [@thgoebel](https://github.com/thgoebel))
|
||||
* Fix crash in user add dialog (onAddUser)
|
||||
* Fix ForegroundServiceDidNotStartInTimeException (attempt 2, see [#1520](https://github.com/binwiederhier/ntfy/issues/1520))
|
||||
* Hide "Exact alarms" setting if battery optimization exemption has been granted ([#1456](https://github.com/binwiederhier/ntfy/issues/1456), thanks for reporting [@HappyLer](https://github.com/HappyLer))
|
||||
_Nothing here_
|
||||
47
docs/static/css/extra.css
vendored
@@ -1,10 +1,10 @@
|
||||
:root > * {
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color: #338574;
|
||||
--md-primary-fg-color--light: #338574;
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
--md-footer-bg-color: #353744;
|
||||
--md-text-font: "Roboto";
|
||||
--md-code-font: "Roboto Mono";
|
||||
--md-primary-fg-color--dark: #338574;
|
||||
--md-footer-bg-color: #353744;
|
||||
--md-text-font: "Roboto";
|
||||
--md-code-font: "Roboto Mono";
|
||||
}
|
||||
|
||||
.md-header__button.md-logo :is(img, svg) {
|
||||
@@ -34,7 +34,7 @@ figure img, figure video {
|
||||
}
|
||||
|
||||
header {
|
||||
background: linear-gradient(150deg, rgba(51,133,116,1) 0%, rgba(86,189,168,1) 100%);
|
||||
background: linear-gradient(150deg, rgba(51, 133, 116, 1) 0%, rgba(86, 189, 168, 1) 100%);
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="default"] header {
|
||||
@@ -92,8 +92,8 @@ figure video {
|
||||
}
|
||||
|
||||
.screenshots img {
|
||||
max-height: 230px;
|
||||
max-width: 300px;
|
||||
max-height: 350px;
|
||||
max-width: 350px;
|
||||
margin: 3px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(2px 2px 2px #ddd);
|
||||
@@ -107,7 +107,7 @@ figure video {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: fixed;
|
||||
left:0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
@@ -119,7 +119,7 @@ figure video {
|
||||
}
|
||||
|
||||
.lightbox.show {
|
||||
background-color: rgba(0,0,0, 0.75);
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
z-index: 1000;
|
||||
@@ -214,3 +214,30 @@ figure video {
|
||||
font-weight: 400;
|
||||
src: url('../fonts/roboto-mono-v22-latin-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Community maintained badge */
|
||||
.community-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
background-color: rgba(51, 133, 116, 0.1);
|
||||
border: 1px solid rgba(51, 133, 116, 0.3);
|
||||
border-radius: 0.7em;
|
||||
padding: 0.1em 0.7em;
|
||||
font-size: 0.75rem;
|
||||
color: #338574;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.community-badge svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
body[data-md-color-scheme="slate"] .community-badge {
|
||||
background-color: rgba(86, 189, 168, 0.15);
|
||||
border-color: rgba(86, 189, 168, 0.4);
|
||||
color: #56bda8;
|
||||
}
|
||||
|
||||
BIN
docs/static/img/android-screenshot-certs-manage.jpg
vendored
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/static/img/android-screenshot-certs-warning-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
docs/static/img/android-screenshot-connection-error-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
docs/static/img/android-screenshot-connection-error-warning.jpg
vendored
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/static/img/android-screenshot-custom-headers-add.jpg
vendored
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/static/img/android-screenshot-custom-headers.jpg
vendored
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/static/img/android-screenshot-language-chinese.jpg
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/static/img/android-screenshot-language-german.jpg
vendored
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
docs/static/img/android-screenshot-language-hebrew.jpg
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/static/img/android-screenshot-language-selection.jpg
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/static/img/android-screenshot-notification-update-1.png
vendored
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/static/img/android-screenshot-notification-update-2.png
vendored
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/static/img/android-screenshot-publish-dialog.jpg
vendored
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/static/img/android-screenshot-publish-message-bar.jpg
vendored
Normal file
|
After Width: | Height: | Size: 95 KiB |
@@ -324,20 +324,21 @@ format of the message. It's very straight forward:
|
||||
|
||||
**Message**:
|
||||
|
||||
| Field | Required | Type | Example | Description |
|
||||
|--------------|----------|---------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
|
||||
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
|
||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||
| Field | Required | Type | Example | Description |
|
||||
|---------------|----------|---------------------------------------------------------------------------------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
|
||||
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
|
||||
| `expires` | (✔)️ | *number* | `1673542291` | Unix time stamp indicating when the message will be deleted, not set if `Cache: no` is sent |
|
||||
| `event` | ✔️ | `open`, `keepalive`, `message`, `message_delete`, `message_clear`, `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
|
||||
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
|
||||
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](../publish.md#updating-deleting-notifications) |
|
||||
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
|
||||
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
|
||||
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
|
||||
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
|
||||
| `actions` | - | *JSON array* | *see [actions buttons](../publish.md#action-buttons)* | [Action buttons](../publish.md#action-buttons) that can be displayed in the notification |
|
||||
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
|
||||
|
||||
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
|
||||
|
||||
|
||||
@@ -100,7 +100,24 @@ The reason for this is [Firebase Cloud Messaging (FCM)](https://firebase.google.
|
||||
notifications. Firebase is overall pretty bad at delivering messages in time, but on Android, most apps are stuck with it.
|
||||
|
||||
The ntfy Android app uses Firebase only for the main host `ntfy.sh`, and only in the Google Play flavor of the app.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the the F-Droid flavor.
|
||||
It won't use Firebase for any self-hosted servers, and not at all in the F-Droid flavor.
|
||||
|
||||
## Publishing messages
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
The Android app allows you to **publish messages directly from the app**, without needing to use curl or any other
|
||||
tool. When enabled in the settings (Settings → General → Show message bar), a **message bar** appears at the bottom
|
||||
of the topic view (it's enabled by default). You can type a message and tap the send button to publish it instantly.
|
||||
If the message bar is disabled, you can tap the floating action button (FAB) at the bottom right instead.
|
||||
|
||||
For more options, tap the expand button next to the send button to open the full **publish dialog**. The dialog lets
|
||||
you compose a full notification with all available options, including title, tags, priority, click URL, email
|
||||
forwarding, delayed delivery, attachments, Markdown formatting, and phone calls.
|
||||
|
||||
<div id="publish-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-publish-message-bar.jpg"><img src="../../static/img/android-screenshot-publish-message-bar.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-publish-dialog.jpg"><img src="../../static/img/android-screenshot-publish-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
## Share to topic
|
||||
_Supported on:_ :material-android:
|
||||
@@ -135,6 +152,67 @@ or to simply directly link to a topic from a mobile website.
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?display=<name>`</span> | `ntfy://ntfy.sh/mytopic?display=My+Topic` | Same as above, but also defines a display name for the topic. |
|
||||
| <span style="white-space: nowrap">`ntfy://<host>/<topic>?secure=false`</span> | `ntfy://example.com/mytopic?secure=false` | Same as above, except that this will use HTTP instead of HTTPS as topic URL. This is equivalent to the web view `http://example.com/mytopic` (HTTP!) |
|
||||
|
||||
## Advanced settings
|
||||
|
||||
### Custom headers
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If your ntfy server is behind an **authenticated proxy or tunnel** (e.g., Cloudflare Access, Tailscale Funnel, or
|
||||
a reverse proxy with basic auth), you can configure custom HTTP headers that will be sent with every request to
|
||||
that server. You could set headers such as `Authorization`, `CF-Access-Client-Id`, or any other headers required by
|
||||
your setup. To add custom headers, go to **Settings → Advanced → Custom headers**.
|
||||
|
||||
<div id="custom-headers-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-custom-headers.jpg"><img src="../../static/img/android-screenshot-custom-headers.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-custom-headers-add.jpg"><img src="../../static/img/android-screenshot-custom-headers-add.jpg"/></a>
|
||||
</div>
|
||||
|
||||
!!! warning
|
||||
If you have a user configured for a server, you cannot add an `Authorization` header for that server, as ntfy
|
||||
sets this header automatically. Similarly, if you have a custom `Authorization` header, you cannot add a user
|
||||
for that server.
|
||||
|
||||
### Manage certificates
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If you're running a self-hosted ntfy server with a **self-signed certificate** or need to use **mutual TLS (mTLS)**
|
||||
for client authentication, you can manage certificates in the app settings.
|
||||
|
||||
Go to **Settings → Advanced → Manage certificates** to:
|
||||
|
||||
- **Add trusted certificates**: Import a server certificate (PEM format) to trust when connecting to your ntfy server.
|
||||
This is useful for self-signed certificates that are not trusted by the Android system.
|
||||
- **Add client certificates**: Import a client certificate (PKCS#12 format) for mutual TLS authentication. This
|
||||
certificate will be presented to the server when connecting.
|
||||
|
||||
When you subscribe to a topic on a server with an untrusted certificate, the app will show a security warning and
|
||||
allow you to review and trust the certificate.
|
||||
|
||||
<div id="certificates-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-certs-manage.jpg"><img src="../../static/img/android-screenshot-certs-manage.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-certs-warning-dialog.jpg"><img src="../../static/img/android-screenshot-certs-warning-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
### Language
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
The Android app supports many languages and uses the **system language by default**. If you'd like to use the app in
|
||||
a different language than your system, you can override it in **Settings → General → Language**.
|
||||
|
||||
<div id="language-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-language-selection.jpg"><img src="../../static/img/android-screenshot-language-selection.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-german.jpg"><img src="../../static/img/android-screenshot-language-german.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-hebrew.jpg"><img src="../../static/img/android-screenshot-language-hebrew.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-language-chinese.jpg"><img src="../../static/img/android-screenshot-language-chinese.jpg"/></a>
|
||||
</div>
|
||||
|
||||
The app currently supports over 30 languages, including English, German, French, Spanish, Chinese, Japanese, and many
|
||||
more. Languages with more than 80% of strings translated are shown in the language picker.
|
||||
|
||||
!!! tip "Help translate ntfy"
|
||||
If you'd like to help translate ntfy into your language or improve existing translations, please visit the
|
||||
[ntfy Weblate project](https://hosted.weblate.org/projects/ntfy/). Contributions are very welcome!
|
||||
|
||||
## Integrations
|
||||
|
||||
### UnifiedPush
|
||||
@@ -168,10 +246,13 @@ Here's an example using [MacroDroid](https://play.google.com/store/apps/details?
|
||||
and [Tasker](https://play.google.com/store/apps/details?id=net.dinglisch.android.taskerm), but any app that can catch
|
||||
broadcasts is supported:
|
||||
|
||||
<div id="integration-screenshots-receive" class="screenshots">
|
||||
<div id="integration-screenshots-receive-1" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-macrodroid-overview.png"><img src="../../static/img/android-screenshot-macrodroid-overview.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-macrodroid-trigger.png"><img src="../../static/img/android-screenshot-macrodroid-trigger.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-macrodroid-action.png"><img src="../../static/img/android-screenshot-macrodroid-action.png"/></a>
|
||||
</div>
|
||||
|
||||
<div id="integration-screenshots-receive-2" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-tasker-profiles.png"><img src="../../static/img/android-screenshot-tasker-profiles.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-event-edit.png"><img src="../../static/img/android-screenshot-tasker-event-edit.png"/></a>
|
||||
<a href="../../static/img/android-screenshot-tasker-task-edit.png"><img src="../../static/img/android-screenshot-tasker-task-edit.png"/></a>
|
||||
@@ -239,3 +320,29 @@ The following intent extras are supported when for the intent with the `io.hecke
|
||||
| `message` ❤️ | ✔ | *String* | `Some message` | Message body; **you must set this** |
|
||||
| `tags` | - | *String* | `tag1,tag2,..` | Comma-separated list of [tags](../publish.md#tags-emojis) |
|
||||
| `priority` | - | *String or Int (between 1-5)* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection error dialog
|
||||
_Supported on:_ :material-android:
|
||||
|
||||
If the app has trouble connecting to a ntfy server, a **warning icon** will appear in the app bar. Tapping it opens
|
||||
the **connection error dialog**, which shows detailed information about the connection problem and helps you diagnose
|
||||
the issue.
|
||||
|
||||
<div id="connection-error-screenshots" class="screenshots">
|
||||
<a href="../../static/img/android-screenshot-connection-error-warning.jpg"><img src="../../static/img/android-screenshot-connection-error-warning.jpg"/></a>
|
||||
<a href="../../static/img/android-screenshot-connection-error-dialog.jpg"><img src="../../static/img/android-screenshot-connection-error-dialog.jpg"/></a>
|
||||
</div>
|
||||
|
||||
Common connection errors include:
|
||||
|
||||
| Error | Description |
|
||||
|-------|-------------|
|
||||
| Connection refused | The server may be down or the address may be incorrect |
|
||||
| WebSocket not supported | The server may not support WebSocket connections, or a proxy is blocking them |
|
||||
| Not authorized (401/403) | Username/password may be incorrect, or access credentials have expired |
|
||||
| Certificate not trusted | The server is using a self-signed certificate (see [Manage certificates](#manage-certificates)) |
|
||||
|
||||
If you're having persistent connection issues, you can also check the app logs under **Settings → Advanced → Record logs**
|
||||
and share them for debugging.
|
||||
|
||||
@@ -129,3 +129,15 @@ keyboard.
|
||||
|
||||
## iOS app
|
||||
Sorry, there is no way to debug or get the logs from the iOS app (yet), outside of running the app in Xcode.
|
||||
|
||||
## Other
|
||||
|
||||
### "Reconnecting..." / Late notifications on mobile (self-hosted)
|
||||
|
||||
If all of your topics are showing as "Reconnecting" and notifications are taking a long time (30+ minutes) to come in, or if you're only getting new pushes with a manual refresh, double-check your configuration:
|
||||
|
||||
* If ntfy is behind a reverse proxy (such as Nginx):
|
||||
* Make sure `behind-proxy` is enabled in ntfy's config.
|
||||
* Make sure WebSockets are enabled in the reverse proxy config.
|
||||
* Make sure you have granted permission to access all of your topics, either to a logged-in user account or to `everyone`. All subscribed topics are joined into a single WebSocket/JSON request, so a single topic that receives `403 Forbidden` will prevent the entire request from going through.
|
||||
* In particular, double-check that `everyone` has permission to write to `up*` and your user has permission to read `up*` if you are using UnifiedPush.
|
||||
|
||||
32
go.mod
@@ -5,23 +5,23 @@ go 1.24.0
|
||||
toolchain go1.24.5
|
||||
|
||||
require (
|
||||
cloud.google.com/go/firestore v1.20.0 // indirect
|
||||
cloud.google.com/go/storage v1.58.0 // indirect
|
||||
cloud.google.com/go/firestore v1.21.0 // indirect
|
||||
cloud.google.com/go/storage v1.59.1 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/emersion/go-smtp v0.18.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.12
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/mattn/go-sqlite3 v1.14.32
|
||||
github.com/mattn/go-sqlite3 v1.14.33
|
||||
github.com/olebedev/when v1.1.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/crypto v0.47.0
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.38.0
|
||||
golang.org/x/term v0.39.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.258.0
|
||||
google.golang.org/api v0.260.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
@@ -35,7 +35,8 @@ require (
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/stripe/stripe-go/v74 v74.30.0
|
||||
golang.org/x/text v0.32.0
|
||||
golang.org/x/sys v0.40.0
|
||||
golang.org/x/text v0.33.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -45,7 +46,7 @@ require (
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/longrunning v0.7.0 // indirect
|
||||
cloud.google.com/go/longrunning v0.8.0 // indirect
|
||||
cloud.google.com/go/monitoring v1.24.3 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 // indirect
|
||||
@@ -69,14 +70,14 @@ require (
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
@@ -92,13 +93,12 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/net v0.49.0 // indirect
|
||||
google.golang.org/appengine/v2 v2.0.6 // indirect
|
||||
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
64
go.sum
@@ -8,18 +8,18 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/firestore v1.20.0 h1:JLlT12QP0fM2SJirKVyu2spBCO8leElaW0OOtPm6HEo=
|
||||
cloud.google.com/go/firestore v1.20.0/go.mod h1:jqu4yKdBmDN5srneWzx3HlKrHFWFdlkgjgQ6BKIOFQo=
|
||||
cloud.google.com/go/firestore v1.21.0 h1:BhopUsx7kh6NFx77ccRsHhrtkbJUmDAxNY3uapWdjcM=
|
||||
cloud.google.com/go/firestore v1.21.0/go.mod h1:1xH6HNcnkf/gGyR8udd6pFO4Z7GWJSwLKQMx/u6UrP4=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/logging v1.13.1 h1:O7LvmO0kGLaHY/gq8cV7T0dyp6zJhYAOtZPX4TF3QtY=
|
||||
cloud.google.com/go/logging v1.13.1/go.mod h1:XAQkfkMBxQRjQek96WLPNze7vsOmay9H5PqfsNYDqvw=
|
||||
cloud.google.com/go/longrunning v0.7.0 h1:FV0+SYF1RIj59gyoWDRi45GiYUMM3K1qO51qoboQT1E=
|
||||
cloud.google.com/go/longrunning v0.7.0/go.mod h1:ySn2yXmjbK9Ba0zsQqunhDkYi0+9rlXIwnoAf+h+TPY=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
|
||||
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
|
||||
cloud.google.com/go/storage v1.58.0 h1:PflFXlmFJjG/nBeR9B7pKddLQWaFaRWx4uUi/LyNxxo=
|
||||
cloud.google.com/go/storage v1.58.0/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/storage v1.59.1 h1:DXAZLcTimtiXdGqDSnebROVPd9QvRsFVVlptz02Wk58=
|
||||
cloud.google.com/go/storage v1.59.1/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI=
|
||||
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
firebase.google.com/go/v4 v4.18.0 h1:S+g0P72oDGqOaG4wlLErX3zQmU9plVdu7j+Bc3R1qFw=
|
||||
@@ -96,8 +96,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y=
|
||||
github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
@@ -112,8 +112,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
@@ -131,8 +131,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -184,8 +184,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -200,8 +200,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -225,8 +225,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
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=
|
||||
@@ -236,8 +236,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -249,8 +249,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -263,18 +263,18 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.258.0 h1:IKo1j5FBlN74fe5isA2PVozN3Y5pwNKriEgAXPOkDAc=
|
||||
google.golang.org/api v0.258.0/go.mod h1:qhOMTQEZ6lUps63ZNq9jhODswwjkjYYguA7fA3TBFww=
|
||||
google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4=
|
||||
google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o=
|
||||
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw=
|
||||
google.golang.org/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
|
||||
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2 h1:stRtB2UVzFOWnorVuwF0BVVEjQ3AN6SjHWdg811UIQM=
|
||||
google.golang.org/genproto v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2 h1:7LRqPCEdE4TP4/9psdaB7F2nhZFfBiGJomA5sojLWdU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3 h1:rUamZFBwsWVWg4Yb7iTbwYp81XVHUvOXNdrFCoYRRNE=
|
||||
google.golang.org/genproto v0.0.0-20260114163908-3f89685c29c3/go.mod h1:wE6SUYr3iNtF/D0GxVAjT+0CbDFktQNssYs9PVptCt4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3 h1:X9z6obt+cWRX8XjDVOn+SZWhWe5kZHm46TThU9j+jss=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260114163908-3f89685c29c3/go.mod h1:dd646eSK+Dk9kxVBl1nChEOhJPtMXriCcVb4x3o6J+E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
|
||||
19
main.go
@@ -2,12 +2,14 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/cmd"
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
"heckel.io/ntfy/v2/cmd"
|
||||
)
|
||||
|
||||
// These variables are set during build time using -ldflags
|
||||
var (
|
||||
version = "dev"
|
||||
commit = "unknown"
|
||||
@@ -24,13 +26,24 @@ the Matrix room (https://matrix.to/#/#ntfy:matrix.org).
|
||||
|
||||
ntfy %s (%s), runtime %s, built at %s
|
||||
Copyright (C) Philipp C. Heckel, licensed under Apache License 2.0 & GPLv2
|
||||
`, version, commit[:7], runtime.Version(), date)
|
||||
`, version, maybeShortCommit(commit), runtime.Version(), date)
|
||||
|
||||
app := cmd.New()
|
||||
app.Version = version
|
||||
app.Metadata = map[string]any{
|
||||
cmd.MetadataKeyDate: date,
|
||||
cmd.MetadataKeyCommit: commit,
|
||||
}
|
||||
|
||||
if err := app.Run(os.Args); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func maybeShortCommit(commit string) string {
|
||||
if len(commit) > 7 {
|
||||
return commit[:7]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/netip"
|
||||
"reflect"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"heckel.io/ntfy/v2/user"
|
||||
@@ -11,8 +16,6 @@ import (
|
||||
// Defines default config settings (excluding limits, see below)
|
||||
const (
|
||||
DefaultListenHTTP = ":80"
|
||||
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||
DefaultCacheDuration = 12 * time.Hour
|
||||
DefaultCacheBatchTimeout = time.Duration(0)
|
||||
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
|
||||
@@ -26,6 +29,12 @@ const (
|
||||
DefaultStripePriceCacheDuration = 3 * time.Hour // Time to keep Stripe prices cached in memory before a refresh is needed
|
||||
)
|
||||
|
||||
// Platform-specific default paths (set in config_unix.go or config_windows.go)
|
||||
var (
|
||||
DefaultConfigFile string
|
||||
DefaultTemplateDir string
|
||||
)
|
||||
|
||||
// Defines default Web Push settings
|
||||
const (
|
||||
DefaultWebPushExpiryWarningDuration = 55 * 24 * time.Hour
|
||||
@@ -128,6 +137,7 @@ type Config struct {
|
||||
TwilioCallsBaseURL string
|
||||
TwilioVerifyBaseURL string
|
||||
TwilioVerifyService string
|
||||
TwilioCallFormat *template.Template
|
||||
MetricsEnable bool
|
||||
MetricsListenHTTP string
|
||||
ProfileListenHTTP string
|
||||
@@ -173,7 +183,9 @@ type Config struct {
|
||||
WebPushStartupQueries string
|
||||
WebPushExpiryDuration time.Duration
|
||||
WebPushExpiryWarningDuration time.Duration
|
||||
Version string // injected by App
|
||||
BuildVersion string // Injected by App
|
||||
BuildDate string // Injected by App
|
||||
BuildCommit string // Injected by App
|
||||
}
|
||||
|
||||
// NewConfig instantiates a default new server config
|
||||
@@ -226,6 +238,7 @@ func NewConfig() *Config {
|
||||
TwilioPhoneNumber: "",
|
||||
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
|
||||
TwilioVerifyService: "",
|
||||
TwilioCallFormat: nil,
|
||||
MessageSizeLimit: DefaultMessageSizeLimit,
|
||||
MessageDelayMin: DefaultMessageDelayMin,
|
||||
MessageDelayMax: DefaultMessageDelayMax,
|
||||
@@ -259,12 +272,32 @@ func NewConfig() *Config {
|
||||
EnableReservations: false,
|
||||
RequireLogin: false,
|
||||
AccessControlAllowOrigin: "*",
|
||||
Version: "",
|
||||
WebPushPrivateKey: "",
|
||||
WebPushPublicKey: "",
|
||||
WebPushFile: "",
|
||||
WebPushEmailAddress: "",
|
||||
WebPushExpiryDuration: DefaultWebPushExpiryDuration,
|
||||
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
|
||||
BuildVersion: "",
|
||||
BuildDate: "",
|
||||
BuildCommit: "",
|
||||
}
|
||||
}
|
||||
|
||||
// Hash computes an SHA-256 hash of the configuration. This is used to detect
|
||||
// configuration changes for the web app version check feature. It uses reflection
|
||||
// to include all JSON-serializable fields automatically.
|
||||
func (c *Config) Hash() string {
|
||||
v := reflect.ValueOf(*c)
|
||||
t := v.Type()
|
||||
var result string
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Field(i)
|
||||
fieldName := t.Field(i).Name
|
||||
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
|
||||
if b, err := json.Marshal(field.Interface()); err == nil {
|
||||
result += fmt.Sprintf("%s:%s|", fieldName, string(b))
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%x", sha256.Sum256([]byte(result)))
|
||||
}
|
||||
|
||||
8
server/config_unix.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
func init() {
|
||||
DefaultConfigFile = "/etc/ntfy/server.yml"
|
||||
DefaultTemplateDir = "/etc/ntfy/templates"
|
||||
}
|
||||
17
server/config_windows.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func init() {
|
||||
programData := os.Getenv("ProgramData")
|
||||
if programData == "" {
|
||||
programData = `C:\ProgramData`
|
||||
}
|
||||
DefaultConfigFile = filepath.Join(programData, "ntfy", "server.yml")
|
||||
DefaultTemplateDir = filepath.Join(programData, "ntfy", "templates")
|
||||
}
|
||||
@@ -3,8 +3,9 @@ package server
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"net/http"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
)
|
||||
|
||||
// errHTTP is a generic HTTP error for any non-200 HTTP error
|
||||
@@ -125,6 +126,7 @@ var (
|
||||
errHTTPBadRequestInvalidUsername = &errHTTP{40046, http.StatusBadRequest, "invalid request: invalid username", "", nil}
|
||||
errHTTPBadRequestTemplateFileNotFound = &errHTTP{40047, http.StatusBadRequest, "invalid request: template file not found", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestTemplateFileInvalid = &errHTTP{40048, http.StatusBadRequest, "invalid request: template file invalid", "https://ntfy.sh/docs/publish/#message-templating", nil}
|
||||
errHTTPBadRequestSequenceIDInvalid = &errHTTP{40049, http.StatusBadRequest, "invalid request: sequence ID invalid", "https://ntfy.sh/docs/publish/#updating-deleting-notifications", nil}
|
||||
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
|
||||
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
|
||||
|
||||
@@ -29,7 +29,9 @@ const (
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
mid TEXT NOT NULL,
|
||||
sequence_id TEXT NOT NULL,
|
||||
time INT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
expires INT NOT NULL,
|
||||
topic TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
@@ -52,6 +54,7 @@ const (
|
||||
published INT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mid ON messages (mid);
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_time ON messages (time);
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
CREATE INDEX IF NOT EXISTS idx_expires ON messages (expires);
|
||||
@@ -66,50 +69,52 @@ const (
|
||||
COMMIT;
|
||||
`
|
||||
insertMessageQuery = `
|
||||
INSERT INTO messages (mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO messages (mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||
selectMessagesByIDQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
deleteMessageQuery = `DELETE FROM messages WHERE mid = ?`
|
||||
selectScheduledMessageIDsBySeqIDQuery = `SELECT mid FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
|
||||
deleteScheduledBySequenceIDQuery = `DELETE FROM messages WHERE topic = ? AND sequence_id = ? AND published = 0`
|
||||
updateMessagesForTopicExpiryQuery = `UPDATE messages SET expires = ? WHERE topic = ?`
|
||||
selectRowIDFromMessageID = `SELECT id FROM messages WHERE mid = ?` // Do not include topic, see #336 and TestServer_PollSinceID_MultipleTopics
|
||||
selectMessagesByIDQuery = `
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE mid = ?
|
||||
`
|
||||
selectMessagesSinceTimeQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceTimeIncludeScheduledQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND time >= ?
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
WHERE topic = ? AND id > ? AND published = 1
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesSinceIDIncludeScheduledQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND (id > ? OR published = 0)
|
||||
ORDER BY time, id
|
||||
`
|
||||
selectMessagesLatestQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE topic = ? AND published = 1
|
||||
ORDER BY time DESC, id DESC
|
||||
LIMIT 1
|
||||
`
|
||||
selectMessagesDueQuery = `
|
||||
SELECT mid, time, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
|
||||
FROM messages
|
||||
WHERE time <= ? AND published = 0
|
||||
ORDER BY time, id
|
||||
@@ -131,7 +136,7 @@ const (
|
||||
|
||||
// Schema management queries
|
||||
const (
|
||||
currentSchemaVersion = 13
|
||||
currentSchemaVersion = 14
|
||||
createSchemaVersionTableQuery = `
|
||||
CREATE TABLE IF NOT EXISTS schemaVersion (
|
||||
id INT PRIMARY KEY,
|
||||
@@ -260,6 +265,13 @@ const (
|
||||
migrate12To13AlterMessagesTableQuery = `
|
||||
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
|
||||
`
|
||||
|
||||
//13 -> 14
|
||||
migrate13To14AlterMessagesTableQuery = `
|
||||
ALTER TABLE messages ADD COLUMN sequence_id TEXT NOT NULL DEFAULT('');
|
||||
ALTER TABLE messages ADD COLUMN event TEXT NOT NULL DEFAULT('message');
|
||||
CREATE INDEX IF NOT EXISTS idx_sequence_id ON messages (sequence_id);
|
||||
`
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -277,6 +289,7 @@ var (
|
||||
10: migrateFrom10,
|
||||
11: migrateFrom11,
|
||||
12: migrateFrom12,
|
||||
13: migrateFrom13,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -369,7 +382,7 @@ func (c *messageCache) addMessages(ms []*message) error {
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, m := range ms {
|
||||
if m.Event != messageEvent {
|
||||
if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent {
|
||||
return errUnexpectedMessageType
|
||||
}
|
||||
published := m.Time <= time.Now().Unix()
|
||||
@@ -397,7 +410,9 @@ func (c *messageCache) addMessages(ms []*message) error {
|
||||
}
|
||||
_, err := stmt.Exec(
|
||||
m.ID,
|
||||
m.SequenceID,
|
||||
m.Time,
|
||||
m.Event,
|
||||
m.Expires,
|
||||
m.Topic,
|
||||
m.Message,
|
||||
@@ -594,6 +609,44 @@ func (c *messageCache) DeleteMessages(ids ...string) error {
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteScheduledBySequenceID deletes unpublished (scheduled) messages with the given topic and sequence ID.
|
||||
// It returns the message IDs of the deleted messages, which can be used to clean up attachment files.
|
||||
func (c *messageCache) DeleteScheduledBySequenceID(topic, sequenceID string) ([]string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
tx, err := c.db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// First, get the message IDs of scheduled messages to be deleted
|
||||
rows, err := tx.Query(selectScheduledMessageIDsBySeqIDQuery, topic, sequenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows.Close() // Close rows before executing delete in same transaction
|
||||
// Then delete the messages
|
||||
if _, err := tx.Exec(deleteScheduledBySequenceIDQuery, topic, sequenceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (c *messageCache) ExpireMessages(topics ...string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -706,10 +759,12 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
|
||||
func readMessage(rows *sql.Rows) (*message, error) {
|
||||
var timestamp, expires, attachmentSize, attachmentExpires int64
|
||||
var priority int
|
||||
var id, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
|
||||
var id, sequenceID, event, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding string
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
&sequenceID,
|
||||
×tamp,
|
||||
&event,
|
||||
&expires,
|
||||
&topic,
|
||||
&msg,
|
||||
@@ -758,9 +813,10 @@ func readMessage(rows *sql.Rows) (*message, error) {
|
||||
}
|
||||
return &message{
|
||||
ID: id,
|
||||
SequenceID: sequenceID,
|
||||
Time: timestamp,
|
||||
Expires: expires,
|
||||
Event: messageEvent,
|
||||
Event: event,
|
||||
Topic: topic,
|
||||
Message: msg,
|
||||
Title: title,
|
||||
@@ -1030,3 +1086,19 @@ func migrateFrom12(db *sql.DB, _ time.Duration) error {
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func migrateFrom13(db *sql.DB, _ time.Duration) error {
|
||||
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(migrate13To14AlterMessagesTableQuery); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(updateSchemaVersion, 14); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -319,6 +319,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
expires1 := time.Now().Add(-4 * time.Hour).Unix() // Expired
|
||||
m := newDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.SequenceID = "m1"
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &attachment{
|
||||
Name: "flower.jpg",
|
||||
@@ -332,6 +333,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
expires2 := time.Now().Add(2 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("mytopic", "sending you a car")
|
||||
m.ID = "m2"
|
||||
m.SequenceID = "m2"
|
||||
m.Sender = netip.MustParseAddr("1.2.3.4")
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
@@ -345,6 +347,7 @@ func testCacheAttachments(t *testing.T, c *messageCache) {
|
||||
expires3 := time.Now().Add(1 * time.Hour).Unix() // Future
|
||||
m = newDefaultMessage("another-topic", "sending you another car")
|
||||
m.ID = "m3"
|
||||
m.SequenceID = "m3"
|
||||
m.User = "u_BAsbaAa"
|
||||
m.Sender = netip.MustParseAddr("5.6.7.8")
|
||||
m.Attachment = &attachment{
|
||||
@@ -400,11 +403,13 @@ func TestMemCache_Attachments_Expired(t *testing.T) {
|
||||
func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
||||
m := newDefaultMessage("mytopic", "flower for you")
|
||||
m.ID = "m1"
|
||||
m.SequenceID = "m1"
|
||||
m.Expires = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, c.AddMessage(m))
|
||||
|
||||
m = newDefaultMessage("mytopic", "message with attachment")
|
||||
m.ID = "m2"
|
||||
m.SequenceID = "m2"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
@@ -417,6 +422,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
||||
|
||||
m = newDefaultMessage("mytopic", "message with external attachment")
|
||||
m.ID = "m3"
|
||||
m.SequenceID = "m3"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "car.jpg",
|
||||
@@ -428,6 +434,7 @@ func testCacheAttachmentsExpired(t *testing.T, c *messageCache) {
|
||||
|
||||
m = newDefaultMessage("mytopic2", "message with expired attachment")
|
||||
m.ID = "m4"
|
||||
m.SequenceID = "m4"
|
||||
m.Expires = time.Now().Add(2 * time.Hour).Unix()
|
||||
m.Attachment = &attachment{
|
||||
Name: "expired-car.jpg",
|
||||
@@ -696,6 +703,79 @@ func testSender(t *testing.T, c *messageCache) {
|
||||
require.Equal(t, messages[1].Sender, netip.Addr{})
|
||||
}
|
||||
|
||||
func TestSqliteCache_DeleteScheduledBySequenceID(t *testing.T) {
|
||||
testDeleteScheduledBySequenceID(t, newSqliteTestCache(t))
|
||||
}
|
||||
|
||||
func TestMemCache_DeleteScheduledBySequenceID(t *testing.T) {
|
||||
testDeleteScheduledBySequenceID(t, newMemTestCache(t))
|
||||
}
|
||||
|
||||
func testDeleteScheduledBySequenceID(t *testing.T, c *messageCache) {
|
||||
// Create a scheduled (unpublished) message
|
||||
scheduledMsg := newDefaultMessage("mytopic", "scheduled message")
|
||||
scheduledMsg.ID = "scheduled1"
|
||||
scheduledMsg.SequenceID = "seq123"
|
||||
scheduledMsg.Time = time.Now().Add(time.Hour).Unix() // Future time makes it scheduled
|
||||
require.Nil(t, c.AddMessage(scheduledMsg))
|
||||
|
||||
// Create a published message with different sequence ID
|
||||
publishedMsg := newDefaultMessage("mytopic", "published message")
|
||||
publishedMsg.ID = "published1"
|
||||
publishedMsg.SequenceID = "seq456"
|
||||
publishedMsg.Time = time.Now().Add(-time.Hour).Unix() // Past time makes it published
|
||||
require.Nil(t, c.AddMessage(publishedMsg))
|
||||
|
||||
// Create a scheduled message in a different topic
|
||||
otherTopicMsg := newDefaultMessage("othertopic", "other scheduled")
|
||||
otherTopicMsg.ID = "other1"
|
||||
otherTopicMsg.SequenceID = "seq123" // Same sequence ID as scheduledMsg
|
||||
otherTopicMsg.Time = time.Now().Add(time.Hour).Unix()
|
||||
require.Nil(t, c.AddMessage(otherTopicMsg))
|
||||
|
||||
// Verify all messages exist (including scheduled)
|
||||
messages, err := c.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 2, len(messages))
|
||||
|
||||
messages, err = c.Messages("othertopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
|
||||
// Delete scheduled message by sequence ID and verify returned IDs
|
||||
deletedIDs, err := c.DeleteScheduledBySequenceID("mytopic", "seq123")
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(deletedIDs))
|
||||
require.Equal(t, "scheduled1", deletedIDs[0])
|
||||
|
||||
// Verify scheduled message is deleted
|
||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "published message", messages[0].Message)
|
||||
|
||||
// Verify other topic's message still exists (topic-scoped deletion)
|
||||
messages, err = c.Messages("othertopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "other scheduled", messages[0].Message)
|
||||
|
||||
// Deleting non-existent sequence ID should return empty list
|
||||
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "nonexistent")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, deletedIDs)
|
||||
|
||||
// Deleting published message should not affect it (only deletes unpublished)
|
||||
deletedIDs, err = c.DeleteScheduledBySequenceID("mytopic", "seq456")
|
||||
require.Nil(t, err)
|
||||
require.Empty(t, deletedIDs)
|
||||
|
||||
messages, err = c.Messages("mytopic", sinceAllMessages, true)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "published message", messages[0].Message)
|
||||
}
|
||||
|
||||
func checkSchemaVersion(t *testing.T, db *sql.DB) {
|
||||
rows, err := db.Query(`SELECT version FROM schemaVersion`)
|
||||
require.Nil(t, err)
|
||||
|
||||
169
server/server.go
@@ -80,15 +80,17 @@ var (
|
||||
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
|
||||
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
|
||||
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
|
||||
updatePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}$`)
|
||||
clearPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/(read|clear)$`)
|
||||
sequenceIDRegex = topicRegex
|
||||
|
||||
webConfigPath = "/config.js"
|
||||
webManifestPath = "/manifest.webmanifest"
|
||||
webRootHTMLPath = "/app.html"
|
||||
webServiceWorkerPath = "/sw.js"
|
||||
accountPath = "/account"
|
||||
matrixPushPath = "/_matrix/push/v1/notify"
|
||||
metricsPath = "/metrics"
|
||||
apiHealthPath = "/v1/health"
|
||||
apiConfigPath = "/v1/config"
|
||||
apiStatsPath = "/v1/stats"
|
||||
apiWebPushPath = "/v1/webpush"
|
||||
apiTiersPath = "/v1/tiers"
|
||||
@@ -108,7 +110,7 @@ var (
|
||||
apiAccountBillingSubscriptionCheckoutSuccessTemplate = "/v1/account/billing/subscription/success/{CHECKOUT_SESSION_ID}"
|
||||
apiAccountBillingSubscriptionCheckoutSuccessRegex = regexp.MustCompile(`/v1/account/billing/subscription/success/(.+)$`)
|
||||
apiAccountReservationSingleRegex = regexp.MustCompile(`/v1/account/reservation/([-_A-Za-z0-9]{1,64})$`)
|
||||
staticRegex = regexp.MustCompile(`^/static/.+`)
|
||||
staticRegex = regexp.MustCompile(`^/(static/.+|app.html|sw.js|sw.js.map)$`)
|
||||
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
|
||||
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
|
||||
urlRegex = regexp.MustCompile(`^https?://`)
|
||||
@@ -137,7 +139,7 @@ var (
|
||||
const (
|
||||
firebaseControlTopic = "~control" // See Android if changed
|
||||
firebasePollTopic = "~poll" // See iOS if changed (DISABLED for now)
|
||||
emptyMessageBody = "triggered" // Used if message body is empty
|
||||
emptyMessageBody = "triggered" // Used when a message body is empty
|
||||
newMessageBody = "New message" // Used in poll requests as generic message
|
||||
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
|
||||
encodingBase64 = "base64" // Used mainly for binary UnifiedPush messages
|
||||
@@ -276,9 +278,9 @@ func (s *Server) Run() error {
|
||||
if s.config.ProfileListenHTTP != "" {
|
||||
listenStr += fmt.Sprintf(" %s[http/profile]", s.config.ProfileListenHTTP)
|
||||
}
|
||||
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.Version, log.CurrentLevel().String())
|
||||
log.Tag(tagStartup).Info("Listening on%s, ntfy %s, log level is %s", listenStr, s.config.BuildVersion, log.CurrentLevel().String())
|
||||
if log.IsFile() {
|
||||
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.Version)
|
||||
fmt.Fprintf(os.Stderr, "Listening on%s, ntfy %s\n", listenStr, s.config.BuildVersion)
|
||||
fmt.Fprintf(os.Stderr, "Logs are written to %s\n", log.File())
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
@@ -459,6 +461,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.ensureWebEnabled(s.handleEmpty)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiHealthPath {
|
||||
return s.handleHealth(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == apiConfigPath {
|
||||
return s.handleConfig(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webConfigPath {
|
||||
return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == webManifestPath {
|
||||
@@ -531,7 +535,7 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.handleMatrixDiscovery(w)
|
||||
} else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil {
|
||||
return s.handleMetrics(w, r, v)
|
||||
} else if r.Method == http.MethodGet && (staticRegex.MatchString(r.URL.Path) || r.URL.Path == webServiceWorkerPath || r.URL.Path == webRootHTMLPath) {
|
||||
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleStatic)(w, r, v)
|
||||
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
|
||||
return s.ensureWebEnabled(s.handleDocs)(w, r, v)
|
||||
@@ -543,8 +547,12 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
|
||||
return s.transformBodyJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish)))(w, r, v)
|
||||
} else if r.Method == http.MethodPost && r.URL.Path == matrixPushPath {
|
||||
return s.transformMatrixJSON(s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublishMatrix)))(w, r, v)
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
|
||||
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && (topicPathRegex.MatchString(r.URL.Path) || updatePathRegex.MatchString(r.URL.Path)) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodDelete && updatePathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleDelete))(w, r, v)
|
||||
} else if r.Method == http.MethodPut && clearPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleClear))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
|
||||
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handlePublish))(w, r, v)
|
||||
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
|
||||
@@ -595,8 +603,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request, _ *visitor
|
||||
return s.writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
return s.writeJSON(w, s.configResponse())
|
||||
}
|
||||
|
||||
func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &apiConfigResponse{
|
||||
b, err := json.MarshalIndent(s.configResponse(), "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Server) configResponse() *apiConfigResponse {
|
||||
return &apiConfigResponse{
|
||||
BaseURL: "", // Will translate to window.location.origin
|
||||
AppRoot: s.config.WebRoot,
|
||||
EnableLogin: s.config.EnableLogin,
|
||||
@@ -610,21 +634,14 @@ func (s *Server) handleWebConfig(w http.ResponseWriter, _ *http.Request, _ *visi
|
||||
BillingContact: s.config.BillingContact,
|
||||
WebPushPublicKey: s.config.WebPushPublicKey,
|
||||
DisallowedTopics: s.config.DisallowedTopics,
|
||||
ConfigHash: s.config.Hash(),
|
||||
}
|
||||
b, err := json.MarshalIndent(response, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
_, err = io.WriteString(w, fmt.Sprintf("// Generated server configuration\nvar config = %s;\n", string(b)))
|
||||
return err
|
||||
}
|
||||
|
||||
// handleWebManifest serves the web app manifest for the progressive web app (PWA)
|
||||
func (s *Server) handleWebManifest(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
|
||||
response := &webManifestResponse{
|
||||
Name: "ntfy web",
|
||||
Name: "ntfy",
|
||||
Description: "ntfy lets you send push notifications via scripts from any computer or phone",
|
||||
ShortName: "ntfy",
|
||||
Scope: "/",
|
||||
@@ -846,6 +863,17 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*message, e
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
|
||||
}
|
||||
if cache {
|
||||
// Delete any existing scheduled message with the same sequence ID
|
||||
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, m.SequenceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Delete attachment files for deleted scheduled messages
|
||||
if s.fileCache != nil && len(deletedIDs) > 0 {
|
||||
if err := s.fileCache.Remove(deletedIDs...); err != nil {
|
||||
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
|
||||
}
|
||||
}
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Adding message to cache")
|
||||
if err := s.messageCache.AddMessage(m); err != nil {
|
||||
return nil, err
|
||||
@@ -872,7 +900,7 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
|
||||
return err
|
||||
}
|
||||
minc(metricMessagesPublishedSuccess)
|
||||
return s.writeJSON(w, m)
|
||||
return s.writeJSON(w, m.forJSON())
|
||||
}
|
||||
|
||||
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
@@ -900,6 +928,71 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
|
||||
return writeMatrixSuccess(w)
|
||||
}
|
||||
|
||||
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.handleActionMessage(w, r, v, messageDeleteEvent)
|
||||
}
|
||||
|
||||
func (s *Server) handleClear(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
return s.handleActionMessage(w, r, v, messageClearEvent)
|
||||
}
|
||||
|
||||
func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *visitor, event string) error {
|
||||
t, err := fromContext[*topic](r, contextTopic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vrate, err := fromContext[*visitor](r, contextRateVisitor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
|
||||
return errHTTPTooManyRequestsLimitMessages.With(t)
|
||||
}
|
||||
sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
|
||||
if e != nil {
|
||||
return e.With(t)
|
||||
}
|
||||
// Create an action message with the given event type
|
||||
m := newActionMessage(event, t.ID, sequenceID)
|
||||
m.Sender = v.IP()
|
||||
m.User = v.MaybeUserID()
|
||||
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
|
||||
// Publish to subscribers
|
||||
if err := t.Publish(v, m); err != nil {
|
||||
return err
|
||||
}
|
||||
// Send to Firebase for Android clients
|
||||
if s.firebaseClient != nil {
|
||||
go s.sendToFirebase(v, m)
|
||||
}
|
||||
// Send to web push endpoints
|
||||
if s.config.WebPushPublicKey != "" {
|
||||
go s.publishToWebPushEndpoints(v, m)
|
||||
}
|
||||
if event == messageDeleteEvent {
|
||||
// Delete any existing scheduled message with the same sequence ID
|
||||
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete attachment files for deleted scheduled messages
|
||||
if s.fileCache != nil && len(deletedIDs) > 0 {
|
||||
if err := s.fileCache.Remove(deletedIDs...); err != nil {
|
||||
logvrm(v, r, m).Tag(tagPublish).Err(err).Warn("Error removing attachments for deleted scheduled messages")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add to message cache
|
||||
if err := s.messageCache.AddMessage(m); err != nil {
|
||||
return err
|
||||
}
|
||||
logvrm(v, r, m).Tag(tagPublish).Debug("Published %s for sequence ID %s", event, sequenceID)
|
||||
s.mu.Lock()
|
||||
s.messages++
|
||||
s.mu.Unlock()
|
||||
return s.writeJSON(w, m.forJSON())
|
||||
}
|
||||
|
||||
func (s *Server) sendToFirebase(v *visitor, m *message) {
|
||||
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
|
||||
if err := s.firebaseClient.Send(v, m); err != nil {
|
||||
@@ -934,7 +1027,7 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
logvm(v, m).Err(err).Warn("Unable to publish poll request")
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Set("X-Poll-ID", m.ID)
|
||||
if s.config.UpstreamAccessToken != "" {
|
||||
req.Header.Set("Authorization", util.BearerAuth(s.config.UpstreamAccessToken))
|
||||
@@ -957,6 +1050,24 @@ func (s *Server) forwardPollRequest(v *visitor, m *message) {
|
||||
}
|
||||
|
||||
func (s *Server) parsePublishParams(r *http.Request, m *message) (cache bool, firebase bool, email, call string, template templateMode, unifiedpush bool, err *errHTTP) {
|
||||
if r.Method != http.MethodGet && updatePathRegex.MatchString(r.URL.Path) {
|
||||
pathSequenceID, err := s.sequenceIDFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
return false, false, "", "", "", false, err
|
||||
}
|
||||
m.SequenceID = pathSequenceID
|
||||
} else {
|
||||
sequenceID := readParam(r, "x-sequence-id", "sequence-id", "sid")
|
||||
if sequenceID != "" {
|
||||
if sequenceIDRegex.MatchString(sequenceID) {
|
||||
m.SequenceID = sequenceID
|
||||
} else {
|
||||
return false, false, "", "", "", false, errHTTPBadRequestSequenceIDInvalid
|
||||
}
|
||||
} else {
|
||||
m.SequenceID = m.ID
|
||||
}
|
||||
}
|
||||
cache = readBoolParam(r, true, "x-cache", "cache")
|
||||
firebase = readBoolParam(r, true, "x-firebase", "firebase")
|
||||
m.Title = readParam(r, "x-title", "title", "t")
|
||||
@@ -1271,7 +1382,7 @@ func (s *Server) handleBodyAsAttachment(r *http.Request, v *visitor, m *message,
|
||||
func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
@@ -1282,10 +1393,10 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
|
||||
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
|
||||
encoder := func(msg *message) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(&msg); err != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if msg.Event != messageEvent {
|
||||
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
|
||||
return fmt.Sprintf("event: %s\ndata: %s\n", msg.Event, buf.String()), nil // Browser's .onmessage() does not fire on this!
|
||||
}
|
||||
return fmt.Sprintf("data: %s\n", buf.String()), nil
|
||||
@@ -1695,6 +1806,15 @@ func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
|
||||
return topics, parts[1], nil
|
||||
}
|
||||
|
||||
// sequenceIDFromPath returns the sequence ID from a path like /mytopic/sequenceIdHere
|
||||
func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) < 3 {
|
||||
return "", errHTTPBadRequestSequenceIDInvalid
|
||||
}
|
||||
return parts[2], nil
|
||||
}
|
||||
|
||||
// topicsFromIDs returns the topics with the given IDs, creating them if they don't exist.
|
||||
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
|
||||
s.mu.Lock()
|
||||
@@ -1949,6 +2069,9 @@ func (s *Server) transformBodyJSON(next handleFunc) handleFunc {
|
||||
if m.Firebase != "" {
|
||||
r.Header.Set("X-Firebase", m.Firebase)
|
||||
}
|
||||
if m.SequenceID != "" {
|
||||
r.Header.Set("X-Sequence-ID", m.SequenceID)
|
||||
}
|
||||
return next(w, r, v)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,11 +216,13 @@
|
||||
# - twilio-auth-token is the Twilio auth token, e.g. affebeef258625862586258625862586
|
||||
# - twilio-phone-number is the outgoing phone number you purchased, e.g. +18775132586
|
||||
# - twilio-verify-service is the Twilio Verify service SID, e.g. VA12345beefbeef67890beefbeef122586
|
||||
# - twilio-call-format is the custom TwiML send to the Call API (optional, see https://www.twilio.com/docs/voice/twiml)
|
||||
#
|
||||
# twilio-account:
|
||||
# twilio-auth-token:
|
||||
# twilio-phone-number:
|
||||
# twilio-verify-service:
|
||||
# twilio-call-format:
|
||||
|
||||
# Interval in which keepalive messages are sent to the client. This is to prevent
|
||||
# intermediaries closing the connection for inactivity.
|
||||
|
||||
@@ -143,6 +143,15 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
||||
"poll_id": m.PollID,
|
||||
}
|
||||
apnsConfig = createAPNSAlertConfig(m, data)
|
||||
case messageDeleteEvent, messageClearEvent:
|
||||
data = map[string]string{
|
||||
"id": m.ID,
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
"sequence_id": m.SequenceID,
|
||||
}
|
||||
apnsConfig = createAPNSBackgroundConfig(data)
|
||||
case messageEvent:
|
||||
if auther != nil {
|
||||
// If "anonymous read" for a topic is not allowed, we cannot send the message along
|
||||
@@ -161,6 +170,7 @@ func toFirebaseMessage(m *message, auther user.Auther) (*messaging.Message, erro
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": m.Event,
|
||||
"topic": m.Topic,
|
||||
"sequence_id": m.SequenceID,
|
||||
"priority": fmt.Sprintf("%d", m.Priority),
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": m.Click,
|
||||
|
||||
@@ -177,6 +177,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"priority": "4",
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": "https://google.com",
|
||||
@@ -199,6 +200,7 @@ func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "message",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"priority": "4",
|
||||
"tags": strings.Join(m.Tags, ","),
|
||||
"click": "https://google.com",
|
||||
@@ -232,6 +234,7 @@ func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
|
||||
"time": fmt.Sprintf("%d", m.Time),
|
||||
"event": "poll_request",
|
||||
"topic": "mytopic",
|
||||
"sequence_id": "",
|
||||
"message": "New message",
|
||||
"title": "",
|
||||
"tags": "",
|
||||
|
||||
@@ -8,8 +8,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -24,7 +22,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
@@ -678,6 +678,86 @@ func TestServer_PublishInvalidTopic(t *testing.T) {
|
||||
require.Equal(t, 40010, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishWithSIDInPath(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "POST", "/mytopic/sid", "message", nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg.ID)
|
||||
require.Equal(t, "sid", msg.SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_PublishWithSIDInHeader(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "message", map[string]string{
|
||||
"sid": "sid",
|
||||
})
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg.ID)
|
||||
require.Equal(t, "sid", msg.SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_PublishWithSIDInPathAndHeader(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic/sid1", "message", map[string]string{
|
||||
"sid": "sid2",
|
||||
})
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg.ID)
|
||||
require.Equal(t, "sid1", msg.SequenceID) // Sequence ID in path has priority over header
|
||||
}
|
||||
|
||||
func TestServer_PublishWithSIDInQuery(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "PUT", "/mytopic?sid=sid1", "message", nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg.ID)
|
||||
require.Equal(t, "sid1", msg.SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_PublishWithSIDViaGet(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "GET", "/mytopic/publish?sid=sid1", "message", nil)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg.ID)
|
||||
require.Equal(t, "sid1", msg.SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_PublishAsJSON_WithSequenceID(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
body := `{"topic":"mytopic","message":"A message","sequence_id":"my-sequence-123"}`
|
||||
response := request(t, s, "PUT", "/", body, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg.ID)
|
||||
require.Equal(t, "my-sequence-123", msg.SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_PublishWithInvalidSIDInPath(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "POST", "/mytopic/.", "message", nil)
|
||||
|
||||
require.Equal(t, 404, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_PublishWithInvalidSIDInHeader(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
response := request(t, s, "POST", "/mytopic", "message", map[string]string{
|
||||
"X-Sequence-ID": "*&?",
|
||||
})
|
||||
|
||||
require.Equal(t, 400, response.Code)
|
||||
require.Equal(t, 40049, toHTTPError(t, response.Body.String()).Code)
|
||||
}
|
||||
|
||||
func TestServer_PollWithQueryFilters(t *testing.T) {
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
@@ -3209,6 +3289,368 @@ func TestServer_MessageTemplate_Until100_000(t *testing.T) {
|
||||
require.Contains(t, toHTTPError(t, response.Body.String()).Message, "too many iterations")
|
||||
}
|
||||
|
||||
func TestServer_DeleteMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish a message with a sequence ID
|
||||
response := request(t, s, "PUT", "/mytopic/seq123", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", msg.SequenceID)
|
||||
require.Equal(t, "message", msg.Event)
|
||||
|
||||
// Delete the message using DELETE method
|
||||
response = request(t, s, "DELETE", "/mytopic/seq123", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq123", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Poll and verify both messages are returned
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
|
||||
require.Equal(t, 2, len(lines))
|
||||
|
||||
msg1 := toMessage(t, lines[0])
|
||||
msg2 := toMessage(t, lines[1])
|
||||
require.Equal(t, "message", msg1.Event)
|
||||
require.Equal(t, "message_delete", msg2.Event)
|
||||
require.Equal(t, "seq123", msg1.SequenceID)
|
||||
require.Equal(t, "seq123", msg2.SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish a message with a sequence ID
|
||||
response := request(t, s, "PUT", "/mytopic/seq456", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq456", msg.SequenceID)
|
||||
require.Equal(t, "message", msg.Event)
|
||||
|
||||
// Clear the message using PUT /topic/seq/clear
|
||||
response = request(t, s, "PUT", "/mytopic/seq456/clear", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq456", clearMsg.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg.Event)
|
||||
|
||||
// Poll and verify both messages are returned
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
|
||||
require.Equal(t, 2, len(lines))
|
||||
|
||||
msg1 := toMessage(t, lines[0])
|
||||
msg2 := toMessage(t, lines[1])
|
||||
require.Equal(t, "message", msg1.Event)
|
||||
require.Equal(t, "message_clear", msg2.Event)
|
||||
require.Equal(t, "seq456", msg1.SequenceID)
|
||||
require.Equal(t, "seq456", msg2.SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage_ReadEndpoint(t *testing.T) {
|
||||
// Test that /topic/seq/read also works
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish a message
|
||||
response := request(t, s, "PUT", "/mytopic/seq789", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Clear using /read endpoint
|
||||
response = request(t, s, "PUT", "/mytopic/seq789/read", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
clearMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "seq789", clearMsg.SequenceID)
|
||||
require.Equal(t, "message_clear", clearMsg.Event)
|
||||
}
|
||||
|
||||
func TestServer_UpdateMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish original message
|
||||
response := request(t, s, "PUT", "/mytopic/update-seq", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg1 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "update-seq", msg1.SequenceID)
|
||||
require.Equal(t, "original message", msg1.Message)
|
||||
|
||||
// Update the message (same sequence ID, new content)
|
||||
response = request(t, s, "PUT", "/mytopic/update-seq", "updated message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg2 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "update-seq", msg2.SequenceID)
|
||||
require.Equal(t, "updated message", msg2.Message)
|
||||
require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs
|
||||
|
||||
// Poll and verify both versions are returned
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
|
||||
require.Equal(t, 2, len(lines))
|
||||
|
||||
polledMsg1 := toMessage(t, lines[0])
|
||||
polledMsg2 := toMessage(t, lines[1])
|
||||
require.Equal(t, "original message", polledMsg1.Message)
|
||||
require.Equal(t, "updated message", polledMsg2.Message)
|
||||
require.Equal(t, "update-seq", polledMsg1.SequenceID)
|
||||
require.Equal(t, "update-seq", polledMsg2.SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_UpdateMessage_UsingMessageID(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish original message without a sequence ID
|
||||
response := request(t, s, "PUT", "/mytopic", "original message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg1 := toMessage(t, response.Body.String())
|
||||
require.NotEmpty(t, msg1.ID)
|
||||
require.Empty(t, msg1.SequenceID) // No sequence ID provided
|
||||
require.Equal(t, "original message", msg1.Message)
|
||||
|
||||
// Update the message using the message ID as the sequence ID
|
||||
response = request(t, s, "PUT", "/mytopic/"+msg1.ID, "updated message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg2 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, msg1.ID, msg2.SequenceID) // Message ID is now used as sequence ID
|
||||
require.Equal(t, "updated message", msg2.Message)
|
||||
require.NotEqual(t, msg1.ID, msg2.ID) // Different message IDs
|
||||
|
||||
// Poll and verify both versions are returned
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
lines := strings.Split(strings.TrimSpace(response.Body.String()), "\n")
|
||||
require.Equal(t, 2, len(lines))
|
||||
|
||||
polledMsg1 := toMessage(t, lines[0])
|
||||
polledMsg2 := toMessage(t, lines[1])
|
||||
require.Equal(t, "original message", polledMsg1.Message)
|
||||
require.Equal(t, "updated message", polledMsg2.Message)
|
||||
require.Empty(t, polledMsg1.SequenceID) // Original has no sequence ID
|
||||
require.Equal(t, msg1.ID, polledMsg2.SequenceID) // Update uses original message ID as sequence ID
|
||||
}
|
||||
|
||||
func TestServer_DeleteAndClear_InvalidSequenceID(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Test invalid sequence ID for delete (returns 404 because route doesn't match)
|
||||
response := request(t, s, "DELETE", "/mytopic/invalid*seq", "", nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
|
||||
// Test invalid sequence ID for clear (returns 404 because route doesn't match)
|
||||
response = request(t, s, "PUT", "/mytopic/invalid*seq/clear", "", nil)
|
||||
require.Equal(t, 404, response.Code)
|
||||
}
|
||||
|
||||
func TestServer_DeleteMessage_WithFirebase(t *testing.T) {
|
||||
sender := newTestFirebaseSender(10)
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
|
||||
|
||||
// Publish a message
|
||||
response := request(t, s, "PUT", "/mytopic/firebase-seq", "test message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
time.Sleep(100 * time.Millisecond) // Firebase publishing happens
|
||||
require.Equal(t, 1, len(sender.Messages()))
|
||||
require.Equal(t, "message", sender.Messages()[0].Data["event"])
|
||||
|
||||
// Delete the message
|
||||
response = request(t, s, "DELETE", "/mytopic/firebase-seq", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
time.Sleep(100 * time.Millisecond) // Firebase publishing happens
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
require.Equal(t, "message_delete", sender.Messages()[1].Data["event"])
|
||||
require.Equal(t, "firebase-seq", sender.Messages()[1].Data["sequence_id"])
|
||||
}
|
||||
|
||||
func TestServer_ClearMessage_WithFirebase(t *testing.T) {
|
||||
sender := newTestFirebaseSender(10)
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
|
||||
|
||||
// Publish a message
|
||||
response := request(t, s, "PUT", "/mytopic/firebase-clear-seq", "test message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.Equal(t, 1, len(sender.Messages()))
|
||||
|
||||
// Clear the message
|
||||
response = request(t, s, "PUT", "/mytopic/firebase-clear-seq/clear", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.Equal(t, 2, len(sender.Messages()))
|
||||
require.Equal(t, "message_clear", sender.Messages()[1].Data["event"])
|
||||
require.Equal(t, "firebase-clear-seq", sender.Messages()[1].Data["sequence_id"])
|
||||
}
|
||||
|
||||
func TestServer_UpdateScheduledMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish a scheduled message (future delivery)
|
||||
response := request(t, s, "PUT", "/mytopic/sched-seq?delay=1h", "original scheduled message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg1 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "sched-seq", msg1.SequenceID)
|
||||
require.Equal(t, "original scheduled message", msg1.Message)
|
||||
|
||||
// Verify scheduled message exists
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "original scheduled message", messages[0].Message)
|
||||
|
||||
// Update the scheduled message (same sequence ID, new content)
|
||||
response = request(t, s, "PUT", "/mytopic/sched-seq?delay=2h", "updated scheduled message", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg2 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "sched-seq", msg2.SequenceID)
|
||||
require.Equal(t, "updated scheduled message", msg2.Message)
|
||||
require.NotEqual(t, msg1.ID, msg2.ID)
|
||||
|
||||
// Verify only the updated message exists (old scheduled was deleted)
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "updated scheduled message", messages[0].Message)
|
||||
require.Equal(t, msg2.ID, messages[0].ID)
|
||||
}
|
||||
|
||||
func TestServer_DeleteScheduledMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish a scheduled message (future delivery)
|
||||
response := request(t, s, "PUT", "/mytopic/delete-sched-seq?delay=1h", "scheduled message to delete", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", msg.SequenceID)
|
||||
|
||||
// Verify scheduled message exists
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "scheduled message to delete", messages[0].Message)
|
||||
|
||||
// Delete the scheduled message
|
||||
response = request(t, s, "DELETE", "/mytopic/delete-sched-seq", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-sched-seq", deleteMsg.SequenceID)
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Verify scheduled message was deleted, only delete event remains
|
||||
response = request(t, s, "GET", "/mytopic/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "message_delete", messages[0].Event)
|
||||
require.Equal(t, "delete-sched-seq", messages[0].SequenceID)
|
||||
}
|
||||
|
||||
func TestServer_UpdateScheduledMessage_TopicScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish scheduled messages with same sequence ID in different topics
|
||||
response := request(t, s, "PUT", "/topic1/shared-seq?delay=1h", "topic1 scheduled", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
response = request(t, s, "PUT", "/topic2/shared-seq?delay=1h", "topic2 scheduled", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Update scheduled message in topic1 only
|
||||
response = request(t, s, "PUT", "/topic1/shared-seq?delay=2h", "topic1 updated", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
|
||||
// Verify topic1 has only the updated message
|
||||
response = request(t, s, "GET", "/topic1/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages := toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "topic1 updated", messages[0].Message)
|
||||
|
||||
// Verify topic2 still has its original scheduled message (not affected)
|
||||
response = request(t, s, "GET", "/topic2/json?poll=1&scheduled=1", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
messages = toMessages(t, response.Body.String())
|
||||
require.Equal(t, 1, len(messages))
|
||||
require.Equal(t, "topic2 scheduled", messages[0].Message)
|
||||
}
|
||||
|
||||
func TestServer_UpdateScheduledMessage_WithAttachment(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish a scheduled message with an attachment
|
||||
content := util.RandomString(5000) // > 4096 to trigger attachment
|
||||
response := request(t, s, "PUT", "/mytopic/attach-seq?delay=1h", content, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg1 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "attach-seq", msg1.SequenceID)
|
||||
require.NotNil(t, msg1.Attachment)
|
||||
|
||||
// Verify attachment file exists
|
||||
attachmentFile1 := filepath.Join(s.config.AttachmentCacheDir, msg1.ID)
|
||||
require.FileExists(t, attachmentFile1)
|
||||
|
||||
// Update the scheduled message with a new attachment
|
||||
newContent := util.RandomString(5000)
|
||||
response = request(t, s, "PUT", "/mytopic/attach-seq?delay=2h", newContent, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg2 := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "attach-seq", msg2.SequenceID)
|
||||
require.NotEqual(t, msg1.ID, msg2.ID)
|
||||
|
||||
// Verify old attachment file was deleted
|
||||
require.NoFileExists(t, attachmentFile1)
|
||||
|
||||
// Verify new attachment file exists
|
||||
attachmentFile2 := filepath.Join(s.config.AttachmentCacheDir, msg2.ID)
|
||||
require.FileExists(t, attachmentFile2)
|
||||
}
|
||||
|
||||
func TestServer_DeleteScheduledMessage_WithAttachment(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := newTestServer(t, newTestConfig(t))
|
||||
|
||||
// Publish a scheduled message with an attachment
|
||||
content := util.RandomString(5000) // > 4096 to trigger attachment
|
||||
response := request(t, s, "PUT", "/mytopic/delete-attach-seq?delay=1h", content, nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
msg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "delete-attach-seq", msg.SequenceID)
|
||||
require.NotNil(t, msg.Attachment)
|
||||
|
||||
// Verify attachment file exists
|
||||
attachmentFile := filepath.Join(s.config.AttachmentCacheDir, msg.ID)
|
||||
require.FileExists(t, attachmentFile)
|
||||
|
||||
// Delete the scheduled message
|
||||
response = request(t, s, "DELETE", "/mytopic/delete-attach-seq", "", nil)
|
||||
require.Equal(t, 200, response.Code)
|
||||
deleteMsg := toMessage(t, response.Body.String())
|
||||
require.Equal(t, "message_delete", deleteMsg.Event)
|
||||
|
||||
// Verify attachment file was deleted
|
||||
require.NoFileExists(t, attachmentFile)
|
||||
}
|
||||
|
||||
func newTestConfig(t *testing.T) *Config {
|
||||
conf := NewConfig()
|
||||
conf.BaseURL = "http://127.0.0.1:12345"
|
||||
|
||||
@@ -4,33 +4,49 @@ import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"heckel.io/ntfy/v2/log"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
const (
|
||||
twilioCallFormat = `
|
||||
// defaultTwilioCallFormatTemplate is the default TwiML template used for Twilio calls.
|
||||
// It can be overridden in the server configuration's twilio-call-format field.
|
||||
//
|
||||
// The format uses Go template syntax with the following fields:
|
||||
// {{.Topic}}, {{.Title}}, {{.Message}}, {{.Priority}}, {{.Tags}}, {{.Sender}}
|
||||
// String fields are automatically XML-escaped.
|
||||
var defaultTwilioCallFormatTemplate = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say loop="3">
|
||||
You have a message from notify on topic %s. Message:
|
||||
You have a message from notify on topic {{.Topic}}. Message:
|
||||
<break time="1s"/>
|
||||
%s
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
End of message.
|
||||
<break time="1s"/>
|
||||
This message was sent by user %s. It will be repeated three times.
|
||||
This message was sent by user {{.Sender}}. It will be repeated three times.
|
||||
To unsubscribe from calls like this, remove your phone number in the notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say>Goodbye.</Say>
|
||||
</Response>`
|
||||
)
|
||||
</Response>`))
|
||||
|
||||
// twilioCallData holds the data passed to the Twilio call format template
|
||||
type twilioCallData struct {
|
||||
Topic string
|
||||
Title string
|
||||
Message string
|
||||
Priority int
|
||||
Tags []string
|
||||
Sender string
|
||||
}
|
||||
|
||||
// convertPhoneNumber checks if the given phone number is verified for the given user, and if so, returns the verified
|
||||
// phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
|
||||
@@ -65,7 +81,29 @@ func (s *Server) callPhone(v *visitor, r *http.Request, m *message, to string) {
|
||||
if u != nil {
|
||||
sender = u.Name
|
||||
}
|
||||
body := fmt.Sprintf(twilioCallFormat, xmlEscapeText(m.Topic), xmlEscapeText(m.Message), xmlEscapeText(sender))
|
||||
tmpl := defaultTwilioCallFormatTemplate
|
||||
if s.config.TwilioCallFormat != nil {
|
||||
tmpl = s.config.TwilioCallFormat
|
||||
}
|
||||
tags := make([]string, len(m.Tags))
|
||||
for i, tag := range m.Tags {
|
||||
tags[i] = xmlEscapeText(tag)
|
||||
}
|
||||
templateData := &twilioCallData{
|
||||
Topic: xmlEscapeText(m.Topic),
|
||||
Title: xmlEscapeText(m.Title),
|
||||
Message: xmlEscapeText(m.Message),
|
||||
Priority: m.Priority,
|
||||
Tags: tags,
|
||||
Sender: xmlEscapeText(sender),
|
||||
}
|
||||
var bodyBuf bytes.Buffer
|
||||
if err := tmpl.Execute(&bodyBuf, templateData); err != nil {
|
||||
logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
|
||||
minc(metricCallsMadeFailure)
|
||||
return
|
||||
}
|
||||
body := bodyBuf.String()
|
||||
data := url.Values{}
|
||||
data.Set("From", s.config.TwilioPhoneNumber)
|
||||
data.Set("To", to)
|
||||
@@ -87,7 +125,7 @@ func (s *Server) callPhoneInternal(data url.Values) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
@@ -111,7 +149,7 @@ func (s *Server) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, cha
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
@@ -137,7 +175,7 @@ func (s *Server) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.Version)
|
||||
req.Header.Set("User-Agent", "ntfy/"+s.config.BuildVersion)
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Authorization", util.BasicAuth(s.config.TwilioAccount, s.config.TwilioAuthToken))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"text/template"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"heckel.io/ntfy/v2/user"
|
||||
"heckel.io/ntfy/v2/util"
|
||||
)
|
||||
|
||||
func TestServer_Twilio_Call_Add_Verify_Call_Delete_Success(t *testing.T) {
|
||||
@@ -202,6 +204,67 @@ func TestServer_Twilio_Call_Success_With_Yes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_Success_with_custom_twiml(t *testing.T) {
|
||||
var called atomic.Bool
|
||||
twilioServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if called.Load() {
|
||||
t.Fatal("Should be only called once")
|
||||
}
|
||||
body, err := io.ReadAll(r.Body)
|
||||
require.Nil(t, err)
|
||||
require.Equal(t, "/2010-04-01/Accounts/AC1234567890/Calls.json", r.URL.Path)
|
||||
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
|
||||
require.Equal(t, "From=%2B1234567890&To=%2B11122233344&Twiml=%0A%3CResponse%3E%0A%09%3CPause+length%3D%221%22%2F%3E%0A%09%3CSay+language%3D%22de-DE%22+loop%3D%223%22%3E%0A%09%09Du+hast+eine+Nachricht+von+notify+im+Thema+mytopic.+Nachricht%3A%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09hi+there%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Ende+der+Nachricht.%0A%09%09%3Cbreak+time%3D%221s%22%2F%3E%0A%09%09Diese+Nachricht+wurde+von+Benutzer+phil+gesendet.+Sie+wird+drei+Mal+wiederholt.%0A%09%09Um+dich+von+Anrufen+wie+diesen+abzumelden%2C+entferne+deine+Telefonnummer+in+der+notify+web+app.%0A%09%09%3Cbreak+time%3D%223s%22%2F%3E%0A%09%3C%2FSay%3E%0A%09%3CSay+language%3D%22de-DE%22%3EAuf+Wiederh%C3%B6ren.%3C%2FSay%3E%0A%3C%2FResponse%3E", string(body))
|
||||
called.Store(true)
|
||||
}))
|
||||
defer twilioServer.Close()
|
||||
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = twilioServer.URL
|
||||
c.TwilioAccount = "AC1234567890"
|
||||
c.TwilioAuthToken = "AAEAA1234567890"
|
||||
c.TwilioPhoneNumber = "+1234567890"
|
||||
c.TwilioCallFormat = template.Must(template.New("twiml").Parse(`
|
||||
<Response>
|
||||
<Pause length="1"/>
|
||||
<Say language="de-DE" loop="3">
|
||||
Du hast eine Nachricht von notify im Thema {{.Topic}}. Nachricht:
|
||||
<break time="1s"/>
|
||||
{{.Message}}
|
||||
<break time="1s"/>
|
||||
Ende der Nachricht.
|
||||
<break time="1s"/>
|
||||
Diese Nachricht wurde von Benutzer {{.Sender}} gesendet. Sie wird drei Mal wiederholt.
|
||||
Um dich von Anrufen wie diesen abzumelden, entferne deine Telefonnummer in der notify web app.
|
||||
<break time="3s"/>
|
||||
</Say>
|
||||
<Say language="de-DE">Auf Wiederhören.</Say>
|
||||
</Response>`))
|
||||
s := newTestServer(t, c)
|
||||
|
||||
// Add tier and user
|
||||
require.Nil(t, s.userManager.AddTier(&user.Tier{
|
||||
Code: "pro",
|
||||
MessageLimit: 10,
|
||||
CallLimit: 1,
|
||||
}))
|
||||
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
|
||||
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
|
||||
u, err := s.userManager.User("phil")
|
||||
require.Nil(t, err)
|
||||
require.Nil(t, s.userManager.AddPhoneNumber(u.ID, "+11122233344"))
|
||||
|
||||
// Do the thing
|
||||
response := request(t, s, "POST", "/mytopic", "hi there", map[string]string{
|
||||
"authorization": util.BasicAuth("phil", "phil"),
|
||||
"x-call": "+11122233344",
|
||||
})
|
||||
require.Equal(t, "hi there", toMessage(t, response.Body.String()).Message)
|
||||
waitFor(t, func() bool {
|
||||
return called.Load()
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_Twilio_Call_UnverifiedNumber(t *testing.T) {
|
||||
c := newTestConfigWithAuthFile(t)
|
||||
c.TwilioCallsBaseURL = "http://dummy.invalid"
|
||||
|
||||
@@ -89,7 +89,7 @@ func (s *Server) publishToWebPushEndpoints(v *visitor, m *message) {
|
||||
return
|
||||
}
|
||||
log.Tag(tagWebPush).With(v, m).Debug("Publishing web push message to %d subscribers", len(subscriptions))
|
||||
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m))
|
||||
payload, err := json.Marshal(newWebPushPayload(fmt.Sprintf("%s/%s", s.config.BaseURL, m.Topic), m.forJSON()))
|
||||
if err != nil {
|
||||
log.Tag(tagWebPush).Err(err).With(v, m).Warn("Unable to marshal expiring payload")
|
||||
return
|
||||
|
||||
@@ -12,10 +12,12 @@ import (
|
||||
|
||||
// List of possible events
|
||||
const (
|
||||
openEvent = "open"
|
||||
keepaliveEvent = "keepalive"
|
||||
messageEvent = "message"
|
||||
pollRequestEvent = "poll_request"
|
||||
openEvent = "open"
|
||||
keepaliveEvent = "keepalive"
|
||||
messageEvent = "message"
|
||||
messageDeleteEvent = "message_delete"
|
||||
messageClearEvent = "message_clear"
|
||||
pollRequestEvent = "poll_request"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -24,10 +26,11 @@ const (
|
||||
|
||||
// message represents a message published to a topic
|
||||
type message struct {
|
||||
ID string `json:"id"` // Random message ID
|
||||
Time int64 `json:"time"` // Unix time in seconds
|
||||
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
||||
Event string `json:"event"` // One of the above
|
||||
ID string `json:"id"` // Random message ID
|
||||
SequenceID string `json:"sequence_id,omitempty"` // Message sequence ID for updating message contents (omitted if same as ID)
|
||||
Time int64 `json:"time"` // Unix time in seconds
|
||||
Expires int64 `json:"expires,omitempty"` // Unix time in seconds (not required for open/keepalive)
|
||||
Event string `json:"event"` // One of the above
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
@@ -39,18 +42,19 @@ type message struct {
|
||||
Attachment *attachment `json:"attachment,omitempty"`
|
||||
PollID string `json:"poll_id,omitempty"`
|
||||
ContentType string `json:"content_type,omitempty"` // text/plain by default (if empty), or text/markdown
|
||||
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
|
||||
Encoding string `json:"encoding,omitempty"` // Empty for raw UTF-8, or "base64" for encoded bytes
|
||||
Sender netip.Addr `json:"-"` // IP address of uploader, used for rate limiting
|
||||
User string `json:"-"` // UserID of the uploader, used to associated attachments
|
||||
}
|
||||
|
||||
func (m *message) Context() log.Context {
|
||||
fields := map[string]any{
|
||||
"topic": m.Topic,
|
||||
"message_id": m.ID,
|
||||
"message_time": m.Time,
|
||||
"message_event": m.Event,
|
||||
"message_body_size": len(m.Message),
|
||||
"topic": m.Topic,
|
||||
"message_id": m.ID,
|
||||
"message_sequence_id": m.SequenceID,
|
||||
"message_time": m.Time,
|
||||
"message_event": m.Event,
|
||||
"message_body_size": len(m.Message),
|
||||
}
|
||||
if m.Sender.IsValid() {
|
||||
fields["message_sender"] = m.Sender.String()
|
||||
@@ -61,6 +65,17 @@ func (m *message) Context() log.Context {
|
||||
return fields
|
||||
}
|
||||
|
||||
// forJSON returns a copy of the message suitable for JSON output.
|
||||
// It clears the SequenceID if it equals the ID to reduce redundancy.
|
||||
func (m *message) forJSON() *message {
|
||||
if m.SequenceID == m.ID {
|
||||
clone := *m
|
||||
clone.SequenceID = ""
|
||||
return &clone
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
type attachment struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
@@ -91,22 +106,23 @@ func newAction() *action {
|
||||
|
||||
// publishMessage is used as input when publishing as JSON
|
||||
type publishMessage struct {
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Click string `json:"click"`
|
||||
Icon string `json:"icon"`
|
||||
Actions []action `json:"actions"`
|
||||
Attach string `json:"attach"`
|
||||
Markdown bool `json:"markdown"`
|
||||
Filename string `json:"filename"`
|
||||
Email string `json:"email"`
|
||||
Call string `json:"call"`
|
||||
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
|
||||
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
|
||||
Delay string `json:"delay"`
|
||||
Topic string `json:"topic"`
|
||||
SequenceID string `json:"sequence_id"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Click string `json:"click"`
|
||||
Icon string `json:"icon"`
|
||||
Actions []action `json:"actions"`
|
||||
Attach string `json:"attach"`
|
||||
Markdown bool `json:"markdown"`
|
||||
Filename string `json:"filename"`
|
||||
Email string `json:"email"`
|
||||
Call string `json:"call"`
|
||||
Cache string `json:"cache"` // use string as it defaults to true (or use &bool instead)
|
||||
Firebase string `json:"firebase"` // use string as it defaults to true (or use &bool instead)
|
||||
Delay string `json:"delay"`
|
||||
}
|
||||
|
||||
// messageEncoder is a function that knows how to encode a message
|
||||
@@ -145,6 +161,13 @@ func newPollRequestMessage(topic, pollID string) *message {
|
||||
return m
|
||||
}
|
||||
|
||||
// newActionMessage creates a new action message (message_delete or message_clear)
|
||||
func newActionMessage(event, topic, sequenceID string) *message {
|
||||
m := newMessage(event, topic, "")
|
||||
m.SequenceID = sequenceID
|
||||
return m
|
||||
}
|
||||
|
||||
func validMessageID(s string) bool {
|
||||
return util.ValidRandomString(s, messageIDLength)
|
||||
}
|
||||
@@ -223,7 +246,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
|
||||
}
|
||||
|
||||
func (q *queryFilter) Pass(msg *message) bool {
|
||||
if msg.Event != messageEvent {
|
||||
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageClearEvent {
|
||||
return true // filters only apply to messages
|
||||
} else if q.ID != "" && msg.ID != q.ID {
|
||||
return false
|
||||
@@ -459,6 +482,7 @@ type apiConfigResponse struct {
|
||||
BillingContact string `json:"billing_contact"`
|
||||
WebPushPublicKey string `json:"web_push_public_key"`
|
||||
DisallowedTopics []string `json:"disallowed_topics"`
|
||||
ConfigHash string `json:"config_hash"`
|
||||
}
|
||||
|
||||
type apiAccountBillingPrices struct {
|
||||
|
||||
27
tools/shrink-png.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Shrinks PNG files to a max height of 1200px
|
||||
# Usage: ./shrink-png.sh file1.png file2.png ...
|
||||
#
|
||||
|
||||
MAX_HEIGHT=1200
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 file1.png file2.png ..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "$@"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "File not found: $file"
|
||||
continue
|
||||
fi
|
||||
|
||||
height=$(identify -format "%h" "$file")
|
||||
if [ "$height" -gt "$MAX_HEIGHT" ]; then
|
||||
echo "Shrinking $file (${height}px -> ${MAX_HEIGHT}px)"
|
||||
convert "$file" -resize "x${MAX_HEIGHT}" "$file"
|
||||
else
|
||||
echo "Skipping $file (${height}px <= ${MAX_HEIGHT}px)"
|
||||
fi
|
||||
done
|
||||
825
web/package-lock.json
generated
@@ -19,4 +19,5 @@ var config = {
|
||||
billing_contact: "",
|
||||
web_push_public_key: "",
|
||||
disallowed_topics: ["docs", "static", "file", "app", "account", "settings", "signup", "login", "v1"],
|
||||
config_hash: "dev", // Placeholder for development; actual value is generated server-side
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 37 KiB |
@@ -403,5 +403,7 @@
|
||||
"prefs_appearance_theme_light": "Světlý režim",
|
||||
"web_push_subscription_expiring_title": "Oznámení budou pozastavena",
|
||||
"web_push_unknown_notification_title": "Neznámé oznámení přijaté ze serveru",
|
||||
"web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace"
|
||||
"web_push_unknown_notification_body": "Možná bude nutné aktualizovat ntfy otevřením webové aplikace",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Přiděleného uživatele nelze upravovat ani odstranit",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Nelze upravit ani odstranit přidělený token"
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"common_add": "Add",
|
||||
"common_back": "Back",
|
||||
"common_copy_to_clipboard": "Copy to clipboard",
|
||||
"common_refresh": "Refresh",
|
||||
"version_update_available_title": "New version available",
|
||||
"version_update_available_description": "The ntfy server has been updated. Please refresh the page.",
|
||||
"signup_title": "Create a ntfy account",
|
||||
"signup_form_username": "Username",
|
||||
"signup_form_password": "Password",
|
||||
|
||||
@@ -406,5 +406,7 @@
|
||||
"web_push_subscription_expiring_body": "Abrir ntfy para seguir recibindo notificacións",
|
||||
"web_push_unknown_notification_title": "Recibida unha notificación descoñecida desde o servidor",
|
||||
"web_push_unknown_notification_body": "Poderías ter que actualizar ntfy abrindo a app web",
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada"
|
||||
"subscribe_dialog_subscribe_use_another_background_info": "As notificacións procedentes doutros servidores non se van recibir cando a app web estea pechada",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Unha usuaria predefinida non se pode editar ou eliminar",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Non se pode editar un token de usuaria predefinida"
|
||||
}
|
||||
|
||||
@@ -50,10 +50,10 @@
|
||||
"publish_dialog_progress_uploading": "Mengunggah …",
|
||||
"notifications_more_details": "Untuk informasi lanjut, lihat <websiteLink>situs web</websiteLink> atau <docsLink>dokumentasi</docsLink>.",
|
||||
"publish_dialog_progress_uploading_detail": "Mengunggah {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "Notifikasi terpublikasi",
|
||||
"publish_dialog_message_published": "Notifikasi dipublikasikan",
|
||||
"notifications_loading": "Memuat notifikasi …",
|
||||
"publish_dialog_base_url_label": "URL Layanan",
|
||||
"publish_dialog_title_placeholder": "Judul notifikasi, mis. Peringatan ruang disk",
|
||||
"publish_dialog_title_placeholder": "Judul notifikasi, contoh: Peringatan ruang penyimpanan disk",
|
||||
"publish_dialog_tags_label": "Tanda",
|
||||
"publish_dialog_priority_label": "Prioritas",
|
||||
"publish_dialog_base_url_placeholder": "URL Layanan, mis. https://contoh.com",
|
||||
@@ -71,12 +71,12 @@
|
||||
"publish_dialog_priority_high": "Prioritas tinggi",
|
||||
"publish_dialog_priority_max": "Prioritas maksimal",
|
||||
"publish_dialog_topic_label": "Nama topik",
|
||||
"publish_dialog_message_placeholder": "Ketik sebuah pesan di sini",
|
||||
"publish_dialog_message_placeholder": "Tulis pesan di sini",
|
||||
"publish_dialog_click_label": "Klik URL",
|
||||
"publish_dialog_tags_placeholder": "Daftar tanda yang dipisah dengan koma, mis. peringatan, cadangan-srv1",
|
||||
"publish_dialog_tags_placeholder": "Daftar label yang dipisahkan koma, contoh: peringatan, cadangan-srv1",
|
||||
"publish_dialog_click_placeholder": "URL yang dibuka ketika notifikasi diklik",
|
||||
"publish_dialog_email_label": "Email",
|
||||
"publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, mis. andi@contoh.com",
|
||||
"publish_dialog_email_placeholder": "Alamat untuk meneruskan notifikasi, contoh: phil@example.com",
|
||||
"publish_dialog_attach_label": "URL Lampiran",
|
||||
"publish_dialog_filename_label": "Nama File",
|
||||
"publish_dialog_filename_placeholder": "Nama file lampiran",
|
||||
@@ -404,5 +404,7 @@
|
||||
"web_push_subscription_expiring_title": "Notifikasi akan dijeda",
|
||||
"web_push_subscription_expiring_body": "Buka ntfy untuk terus menerima notifikasi",
|
||||
"web_push_unknown_notification_title": "Notifikasi yang tidak diketahui diterima dari server",
|
||||
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web"
|
||||
"web_push_unknown_notification_body": "Anda mungkin harus memperbarui ntfy dengan membuka aplikasi web",
|
||||
"account_basics_cannot_edit_or_delete_provisioned_user": "Pengguna yang telah ditetapkan tidak dapat diedit atau dihapus",
|
||||
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Tidak dapat mengedit atau menghapus token yang disediakan"
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"publish_dialog_title_no_topic": "通知を送信",
|
||||
"publish_dialog_progress_uploading": "アップロード中…",
|
||||
"publish_dialog_progress_uploading_detail": "アップロード中 {{loaded}}/{{total}} ({{percent}}%) …",
|
||||
"publish_dialog_message_published": "通知を送信しました",
|
||||
"publish_dialog_message_published": "通知送信済み",
|
||||
"publish_dialog_title_label": "タイトル",
|
||||
"publish_dialog_filename_label": "ファイル名",
|
||||
"subscribe_dialog_login_description": "このトピックはログインする必要があります。ユーザー名とパスワードを入力してください。",
|
||||
@@ -69,10 +69,10 @@
|
||||
"publish_dialog_attachment_limits_quota_reached": "クォータを超過しました、残り{{remainingBytes}}",
|
||||
"publish_dialog_priority_high": "優先度 高",
|
||||
"publish_dialog_topic_placeholder": "トピック名の例 phil_alerts",
|
||||
"publish_dialog_title_placeholder": "通知タイトル 例: ディスクスペース警告",
|
||||
"publish_dialog_title_placeholder": "通知タイトル、例: ディスクスペース警告",
|
||||
"publish_dialog_message_placeholder": "メッセージ本文を入力してください",
|
||||
"publish_dialog_tags_label": "タグ",
|
||||
"publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください 例: warning, srv1-backup",
|
||||
"publish_dialog_tags_placeholder": "コンマ区切りでタグを列挙してください、例: warning, srv1-backup",
|
||||
"publish_dialog_topic_label": "トピック名",
|
||||
"publish_dialog_delay_label": "遅延",
|
||||
"publish_dialog_click_placeholder": "通知をクリックしたときに開くURL",
|
||||
|
||||
@@ -50,5 +50,47 @@
|
||||
"nav_topics_title": "Претплатени теми",
|
||||
"nav_button_all_notifications": "Сите нотификации",
|
||||
"nav_button_publish_message": "Објави нотификација",
|
||||
"nav_button_subscribe": "Претплати се на тема"
|
||||
"nav_button_subscribe": "Претплати се на тема",
|
||||
"action_bar_unmute_notifications": "Одглуши ги нотификациите",
|
||||
"action_bar_toggle_mute": "Заглуши/Загуши ги нотификациите",
|
||||
"message_bar_publish": "Објави порака",
|
||||
"nav_button_connecting": "се конектира",
|
||||
"nav_upgrade_banner_label": "Надградете на ntfy Pro",
|
||||
"nav_upgrade_banner_description": "Резервирајте теми, повеќе пораки и е-пораки и поголеми прилози",
|
||||
"alert_notification_permission_required_title": "Известувањата се исклучени",
|
||||
"alert_notification_permission_required_description": "Дајте му дозвола на вашиот прелистувач да прикажува известувања",
|
||||
"nav_button_muted": "Известувањата се загушени",
|
||||
"alert_not_supported_title": "Известувањата не се поддржани",
|
||||
"alert_not_supported_description": "Известувањата не се поддржани во вашиот прелистувач",
|
||||
"alert_not_supported_context_description": "Известувањата се поддржани само преку HTTPS. Ова е ограничување на <mdnLink>Notifications API </mdnLink>.",
|
||||
"notifications_list": "Список на известувања",
|
||||
"notifications_list_item": "Известување",
|
||||
"notifications_mark_read": "Означи како прочитано",
|
||||
"publish_dialog_attached_file_filename_placeholder": "Име на фајл за прилог",
|
||||
"notifications_attachment_file_app": "Фајл со апликација за Android",
|
||||
"notifications_attachment_file_document": "друг документ",
|
||||
"alert_notification_permission_required_button": "Дајте дозвола сега",
|
||||
"alert_notification_permission_denied_title": "Известувањата се блокирани",
|
||||
"alert_notification_permission_denied_description": "Ве молиме повторно овозможете ги во вашиот пребарувач",
|
||||
"alert_notification_ios_install_required_title": "Потребна е инсталација на iOS",
|
||||
"alert_notification_ios_install_required_description": "Кликнете на иконата Сподели и Додај на почетниот екран за да овозможите известувања на iOS",
|
||||
"notifications_delete": "Избриши",
|
||||
"notifications_copied_to_clipboard": "Копирано во таблата со исечоци",
|
||||
"notifications_tags": "Ознаки",
|
||||
"notifications_priority_x": "Приоритет {{приоритет}}",
|
||||
"notifications_new_indicator": "Ново известување",
|
||||
"notifications_attachment_image": "Слика од прилог",
|
||||
"notifications_attachment_copy_url_title": "Копирај URL-адресата на прилогот во таблата со исечоци",
|
||||
"notifications_attachment_open_title": "Оди на {{url}}",
|
||||
"notifications_attachment_open_button": "Отвори го прилогот",
|
||||
"notifications_attachment_link_expires": "линкот истекува {{date}}",
|
||||
"notifications_attachment_link_expired": "линкот за преземање е истечен",
|
||||
"notifications_attachment_file_image": "слика фајл",
|
||||
"notifications_attachment_file_video": "видео фајл",
|
||||
"notifications_attachment_file_audio": "аудио фајл",
|
||||
"notifications_click_copy_url_button": "Копирај линк",
|
||||
"notifications_click_open_button": "Отвори линк",
|
||||
"notifications_actions_open_url_title": "Оди на {{url}}",
|
||||
"notifications_actions_not_supported": "Дејството не е поддржано во веб-апликацијата",
|
||||
"notifications_actions_http_request_title": "Испрати HTTP {{method}} на {{url}}"
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
"subscribe_dialog_subscribe_topic_placeholder": "主题名,例如 phil_alerts",
|
||||
"notifications_no_subscriptions_description": "单击 \"{{linktext}}\" 链接以创建或订阅主题。之后,您可以使用 PUT 或 POST 发送消息,您将在这里收到通知。",
|
||||
"publish_dialog_attachment_limits_file_reached": "超过 {{fileSizeLimit}} 文件限制",
|
||||
"publish_dialog_title_placeholder": "主题标题,例如 磁盘空间告警",
|
||||
"publish_dialog_title_placeholder": "通知标题,如磁盘空间告警",
|
||||
"publish_dialog_email_label": "电子邮件",
|
||||
"publish_dialog_button_send": "发送",
|
||||
"publish_dialog_checkbox_markdown": "格式化为 Markdown",
|
||||
@@ -206,7 +206,7 @@
|
||||
"publish_dialog_tags_placeholder": "英文逗号分隔的标签列表,例如 warning, srv1-backup",
|
||||
"publish_dialog_details_examples_description": "有关所有发送功能的示例和详细说明,请参阅<docsLink>文档</docsLink>。",
|
||||
"subscribe_dialog_subscribe_description": "主题可能不受密码保护,因此请选择一个不容易被猜中的名字。订阅后,您可以使用 PUT/POST 通知。",
|
||||
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}}或「{{naturalLanguage}}」(仅限英语)",
|
||||
"publish_dialog_delay_placeholder": "延期投递,例如 {{unixTimestamp}}、{{relativeTime}} 或 {{naturalLanguage}} (仅限英语)",
|
||||
"account_usage_basis_ip_description": "此账户的使用统计信息和限制基于您的 IP 地址,因此可能会与其他用户共享。上面显示的限制是基于现有速率限制的近似值。",
|
||||
"account_usage_cannot_create_portal_session": "无法打开计费门户",
|
||||
"account_delete_title": "删除账户",
|
||||
|
||||
167
web/public/sw.js
@@ -3,11 +3,16 @@ import { cleanupOutdatedCaches, createHandlerBoundToURL, precacheAndRoute } from
|
||||
import { NavigationRoute, registerRoute } from "workbox-routing";
|
||||
import { NetworkFirst } from "workbox-strategies";
|
||||
import { clientsClaim } from "workbox-core";
|
||||
|
||||
import { dbAsync } from "../src/app/db";
|
||||
|
||||
import { toNotificationParams, icon, badge } from "../src/app/notificationUtils";
|
||||
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
|
||||
import initI18n from "../src/app/i18n";
|
||||
import {
|
||||
EVENT_MESSAGE,
|
||||
EVENT_MESSAGE_CLEAR,
|
||||
EVENT_MESSAGE_DELETE,
|
||||
WEBPUSH_EVENT_MESSAGE,
|
||||
WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
|
||||
} from "../src/app/events";
|
||||
|
||||
/**
|
||||
* General docs for service workers and PWAs:
|
||||
@@ -21,25 +26,6 @@ import initI18n from "../src/app/i18n";
|
||||
|
||||
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
|
||||
|
||||
const addNotification = async ({ subscriptionId, message }) => {
|
||||
const db = await dbAsync();
|
||||
|
||||
await db.notifications.add({
|
||||
...message,
|
||||
subscriptionId,
|
||||
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
new: 1,
|
||||
});
|
||||
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: message.id,
|
||||
});
|
||||
|
||||
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
|
||||
self.navigator.setAppBadge?.(badgeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a received web push message and show notification.
|
||||
*
|
||||
@@ -48,25 +34,127 @@ const addNotification = async ({ subscriptionId, message }) => {
|
||||
*/
|
||||
const handlePushMessage = async (data) => {
|
||||
const { subscription_id: subscriptionId, message } = data;
|
||||
const db = await dbAsync();
|
||||
|
||||
broadcastChannel.postMessage(message); // To potentially play sound
|
||||
console.log("[ServiceWorker] Message received", data);
|
||||
|
||||
// Look up subscription for baseUrl and topic
|
||||
const subscription = await db.subscriptions.get(subscriptionId);
|
||||
if (!subscription) {
|
||||
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete existing notification with same sequence ID (if any)
|
||||
const sequenceId = message.sequence_id || message.id;
|
||||
if (sequenceId) {
|
||||
await db.notifications.where({ subscriptionId, sequenceId }).delete();
|
||||
}
|
||||
|
||||
// Add notification to database
|
||||
await db.notifications.add({
|
||||
...messageWithSequenceId(message),
|
||||
subscriptionId,
|
||||
new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
});
|
||||
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: message.id,
|
||||
});
|
||||
|
||||
// Update badge in PWA
|
||||
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||
self.navigator.setAppBadge?.(badgeCount);
|
||||
|
||||
// Broadcast the message to potentially play a sound
|
||||
broadcastChannel.postMessage(message);
|
||||
|
||||
await addNotification({ subscriptionId, message });
|
||||
await self.registration.showNotification(
|
||||
...toNotificationParams({
|
||||
subscriptionId,
|
||||
message,
|
||||
defaultTitle: message.topic,
|
||||
topicRoute: new URL(message.topic, self.location.origin).toString(),
|
||||
baseUrl: subscription.baseUrl,
|
||||
topic: subscription.topic,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a message_delete event: delete the notification from the database.
|
||||
*/
|
||||
const handlePushMessageDelete = async (data) => {
|
||||
const { subscription_id: subscriptionId, message } = data;
|
||||
const db = await dbAsync();
|
||||
console.log("[ServiceWorker] Deleting notification sequence", data);
|
||||
|
||||
// Look up subscription for baseUrl and topic
|
||||
const subscription = await db.subscriptions.get(subscriptionId);
|
||||
if (!subscription) {
|
||||
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete notification with the same sequence_id
|
||||
const sequenceId = message.sequence_id;
|
||||
if (sequenceId) {
|
||||
await db.notifications.where({ subscriptionId, sequenceId }).delete();
|
||||
}
|
||||
|
||||
// Close browser notification with matching tag (scoped by topic)
|
||||
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach((notification) => notification.close());
|
||||
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: message.id,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a message_clear event: clear/dismiss the notification.
|
||||
*/
|
||||
const handlePushMessageClear = async (data) => {
|
||||
const { subscription_id: subscriptionId, message } = data;
|
||||
const db = await dbAsync();
|
||||
console.log("[ServiceWorker] Marking notification as read", data);
|
||||
|
||||
// Look up subscription for baseUrl and topic
|
||||
const subscription = await db.subscriptions.get(subscriptionId);
|
||||
if (!subscription) {
|
||||
console.log("[ServiceWorker] Subscription not found", subscriptionId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark notification as read (set new = 0)
|
||||
const sequenceId = message.sequence_id;
|
||||
if (sequenceId) {
|
||||
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
// Close browser notification with matching tag (scoped by topic)
|
||||
const tag = notificationTag(subscription.baseUrl, subscription.topic, message.sequence_id || message.id);
|
||||
const notifications = await self.registration.getNotifications({ tag });
|
||||
notifications.forEach((notification) => notification.close());
|
||||
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await db.subscriptions.update(subscriptionId, {
|
||||
last: message.id,
|
||||
});
|
||||
|
||||
// Update badge count
|
||||
const badgeCount = await db.notifications.where({ new: 1 }).count();
|
||||
self.navigator.setAppBadge?.(badgeCount);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a received web push subscription expiring.
|
||||
*/
|
||||
const handlePushSubscriptionExpiring = async (data) => {
|
||||
const t = await initI18n();
|
||||
console.log("[ServiceWorker] Handling incoming subscription expiring event", data);
|
||||
|
||||
await self.registration.showNotification(t("web_push_subscription_expiring_title"), {
|
||||
body: t("web_push_subscription_expiring_body"),
|
||||
@@ -82,6 +170,7 @@ const handlePushSubscriptionExpiring = async (data) => {
|
||||
*/
|
||||
const handlePushUnknown = async (data) => {
|
||||
const t = await initI18n();
|
||||
console.log("[ServiceWorker] Unknown event received", data);
|
||||
|
||||
await self.registration.showNotification(t("web_push_unknown_notification_title"), {
|
||||
body: t("web_push_unknown_notification_body"),
|
||||
@@ -96,13 +185,26 @@ const handlePushUnknown = async (data) => {
|
||||
* @param {object} data see server/types.go, type webPushPayload
|
||||
*/
|
||||
const handlePush = async (data) => {
|
||||
if (data.event === "message") {
|
||||
await handlePushMessage(data);
|
||||
} else if (data.event === "subscription_expiring") {
|
||||
await handlePushSubscriptionExpiring(data);
|
||||
} else {
|
||||
await handlePushUnknown(data);
|
||||
// This logic is (partially) duplicated in
|
||||
// - Android: SubscriberService::onNotificationReceived()
|
||||
// - Android: FirebaseService::onMessageReceived()
|
||||
// - Web app: hooks.js:handleNotification()
|
||||
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
|
||||
|
||||
if (data.event === WEBPUSH_EVENT_MESSAGE) {
|
||||
const { message } = data;
|
||||
if (message.event === EVENT_MESSAGE) {
|
||||
return await handlePushMessage(data);
|
||||
} else if (message.event === EVENT_MESSAGE_DELETE) {
|
||||
return await handlePushMessageDelete(data);
|
||||
} else if (message.event === EVENT_MESSAGE_CLEAR) {
|
||||
return await handlePushMessageClear(data);
|
||||
}
|
||||
} else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
|
||||
return await handlePushSubscriptionExpiring(data);
|
||||
}
|
||||
|
||||
return await handlePushUnknown(data);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -113,10 +215,8 @@ const handleClick = async (event) => {
|
||||
const t = await initI18n();
|
||||
|
||||
const clients = await self.clients.matchAll({ type: "window" });
|
||||
|
||||
const rootUrl = new URL(self.location.origin);
|
||||
const rootClient = clients.find((client) => client.url === rootUrl.toString());
|
||||
// perhaps open on another topic
|
||||
const fallbackClient = clients[0];
|
||||
|
||||
if (!event.notification.data?.message) {
|
||||
@@ -232,6 +332,7 @@ precacheAndRoute(
|
||||
|
||||
// Claim all open windows
|
||||
clientsClaim();
|
||||
|
||||
// Delete any cached old dist files from previous service worker versions
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import { basicAuth, bearerAuth, encodeBase64Url, topicShortUrl, topicUrlWs } from "./utils";
|
||||
import { EVENT_OPEN, isNotificationEvent } from "./events";
|
||||
|
||||
const retryBackoffSeconds = [5, 10, 20, 30, 60, 120];
|
||||
|
||||
@@ -48,10 +49,11 @@ class Connection {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Message received from server: ${event.data}`);
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === "open") {
|
||||
if (data.event === EVENT_OPEN) {
|
||||
return;
|
||||
}
|
||||
const relevantAndValid = data.event === "message" && "id" in data && "time" in data && "message" in data;
|
||||
// Accept message, message_delete, and message_clear events
|
||||
const relevantAndValid = isNotificationEvent(data.event) && "id" in data && "time" in data;
|
||||
if (!relevantAndValid) {
|
||||
console.log(`[Connection, ${this.shortUrl}, ${this.connectionId}] Unexpected message. Ignoring.`);
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { playSound, topicDisplayName, topicShortUrl, urlB64ToUint8Array } from "./utils";
|
||||
import { toNotificationParams } from "./notificationUtils";
|
||||
import { notificationTag, toNotificationParams } from "./notificationUtils";
|
||||
import prefs from "./Prefs";
|
||||
import routes from "../components/routes";
|
||||
|
||||
@@ -23,14 +23,31 @@ class Notifier {
|
||||
const registration = await this.serviceWorkerRegistration();
|
||||
await registration.showNotification(
|
||||
...toNotificationParams({
|
||||
subscriptionId: subscription.id,
|
||||
message: notification,
|
||||
defaultTitle,
|
||||
topicRoute: new URL(routes.forSubscription(subscription), window.location.origin).toString(),
|
||||
baseUrl: subscription.baseUrl,
|
||||
topic: subscription.topic,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async cancel(subscription, notification) {
|
||||
if (!this.supported()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const sequenceId = notification.sequence_id || notification.id;
|
||||
const tag = notificationTag(subscription.baseUrl, subscription.topic, sequenceId);
|
||||
console.log(`[Notifier] Cancelling notification with tag ${tag}`);
|
||||
const registration = await this.serviceWorkerRegistration();
|
||||
const notifications = await registration.getNotifications({ tag });
|
||||
notifications.forEach((n) => n.close());
|
||||
} catch (e) {
|
||||
console.log(`[Notifier] Error cancelling notification`, e);
|
||||
}
|
||||
}
|
||||
|
||||
async playSound() {
|
||||
// Play sound
|
||||
const sound = await prefs.sound();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import api from "./Api";
|
||||
import prefs from "./Prefs";
|
||||
import subscriptionManager from "./SubscriptionManager";
|
||||
import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE } from "./events";
|
||||
|
||||
const delayMillis = 2000; // 2 seconds
|
||||
const intervalMillis = 300000; // 5 minutes
|
||||
@@ -42,12 +44,35 @@ class Poller {
|
||||
|
||||
const since = subscription.last;
|
||||
const notifications = await api.poll(subscription.baseUrl, subscription.topic, since);
|
||||
if (!notifications || notifications.length === 0) {
|
||||
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
||||
return;
|
||||
|
||||
// Filter out notifications older than the prune threshold
|
||||
const deleteAfterSeconds = await prefs.deleteAfter();
|
||||
const pruneThresholdTimestamp = deleteAfterSeconds > 0 ? Math.round(Date.now() / 1000) - deleteAfterSeconds : 0;
|
||||
const recentNotifications =
|
||||
pruneThresholdTimestamp > 0 ? notifications.filter((n) => n.time >= pruneThresholdTimestamp) : notifications;
|
||||
|
||||
// Find the latest notification for each sequence ID
|
||||
const latestBySequenceId = this.latestNotificationsBySequenceId(recentNotifications);
|
||||
|
||||
// Delete all existing notifications for which the latest notification is marked as deleted
|
||||
const deletedSequenceIds = Object.entries(latestBySequenceId)
|
||||
.filter(([, notification]) => notification.event === EVENT_MESSAGE_DELETE)
|
||||
.map(([sequenceId]) => sequenceId);
|
||||
if (deletedSequenceIds.length > 0) {
|
||||
console.log(`[Poller] Deleting notifications with deleted sequence IDs for ${subscription.id}`, deletedSequenceIds);
|
||||
await Promise.all(
|
||||
deletedSequenceIds.map((sequenceId) => subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId))
|
||||
);
|
||||
}
|
||||
|
||||
// Add only the latest notification for each non-deleted sequence
|
||||
const notificationsToAdd = Object.values(latestBySequenceId).filter((n) => n.event === EVENT_MESSAGE);
|
||||
if (notificationsToAdd.length > 0) {
|
||||
console.log(`[Poller] Adding ${notificationsToAdd.length} notification(s) for ${subscription.id}`);
|
||||
await subscriptionManager.addNotifications(subscription.id, notificationsToAdd);
|
||||
} else {
|
||||
console.log(`[Poller] No new notifications found for ${subscription.id}`);
|
||||
}
|
||||
console.log(`[Poller] Adding ${notifications.length} notification(s) for ${subscription.id}`);
|
||||
await subscriptionManager.addNotifications(subscription.id, notifications);
|
||||
}
|
||||
|
||||
pollInBackground(subscription) {
|
||||
@@ -59,6 +84,21 @@ class Poller {
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups notifications by sequenceId and returns only the latest (highest time) for each sequence.
|
||||
* Returns an object mapping sequenceId -> latest notification.
|
||||
*/
|
||||
latestNotificationsBySequenceId(notifications) {
|
||||
const latestBySequenceId = {};
|
||||
notifications.forEach((notification) => {
|
||||
const sequenceId = notification.sequence_id || notification.id;
|
||||
if (!(sequenceId in latestBySequenceId) || notification.time >= latestBySequenceId[sequenceId].time) {
|
||||
latestBySequenceId[sequenceId] = notification;
|
||||
}
|
||||
});
|
||||
return latestBySequenceId;
|
||||
}
|
||||
}
|
||||
|
||||
const poller = new Poller();
|
||||
|
||||
@@ -19,7 +19,11 @@ class Pruner {
|
||||
}
|
||||
|
||||
stopWorker() {
|
||||
clearTimeout(this.timer);
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
console.log("[Pruner] Stopped worker");
|
||||
}
|
||||
|
||||
async prune() {
|
||||
|
||||
@@ -3,6 +3,8 @@ import notifier from "./Notifier";
|
||||
import prefs from "./Prefs";
|
||||
import db from "./db";
|
||||
import { topicUrl } from "./utils";
|
||||
import { messageWithSequenceId } from "./notificationUtils";
|
||||
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
|
||||
|
||||
class SubscriptionManager {
|
||||
constructor(dbImpl) {
|
||||
@@ -48,16 +50,17 @@ class SubscriptionManager {
|
||||
}
|
||||
|
||||
async notify(subscriptionId, notification) {
|
||||
if (notification.event !== EVENT_MESSAGE) {
|
||||
return;
|
||||
}
|
||||
const subscription = await this.get(subscriptionId);
|
||||
if (subscription.mutedUntil > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const priority = notification.priority ?? 3;
|
||||
if (priority < (await prefs.minPriority())) {
|
||||
return;
|
||||
}
|
||||
|
||||
await notifier.notify(subscription, notification);
|
||||
}
|
||||
|
||||
@@ -157,7 +160,7 @@ class SubscriptionManager {
|
||||
// killing performance. See https://dexie.org/docs/Collection/Collection.offset()#a-better-paging-approach
|
||||
|
||||
return this.db.notifications
|
||||
.orderBy("time") // Sort by time first
|
||||
.orderBy("time") // Sort by time
|
||||
.filter((n) => n.subscriptionId === subscriptionId)
|
||||
.reverse()
|
||||
.toArray();
|
||||
@@ -173,17 +176,22 @@ class SubscriptionManager {
|
||||
/** Adds notification, or returns false if it already exists */
|
||||
async addNotification(subscriptionId, notification) {
|
||||
const exists = await this.db.notifications.get(notification.id);
|
||||
if (exists) {
|
||||
if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// sw.js duplicates this logic, so if you change it here, change it there too
|
||||
// Note: Service worker (sw.js) and addNotifications() duplicates this logic,
|
||||
// so if you change it here, change it there too.
|
||||
|
||||
// Add notification to database
|
||||
await this.db.notifications.add({
|
||||
...notification,
|
||||
...messageWithSequenceId(notification),
|
||||
subscriptionId,
|
||||
// New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
new: 1,
|
||||
}); // FIXME consider put() for double tab
|
||||
new: 1, // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
|
||||
});
|
||||
|
||||
// FIXME consider put() for double tab
|
||||
// Update subscription last message id (for ?since=... queries)
|
||||
await this.db.subscriptions.update(subscriptionId, {
|
||||
last: notification.id,
|
||||
});
|
||||
@@ -195,7 +203,10 @@ class SubscriptionManager {
|
||||
|
||||
/** Adds/replaces notifications, will not throw if they exist */
|
||||
async addNotifications(subscriptionId, notifications) {
|
||||
const notificationsWithSubscriptionId = notifications.map((notification) => ({ ...notification, subscriptionId }));
|
||||
const notificationsWithSubscriptionId = notifications.map((notification) => ({
|
||||
...messageWithSequenceId(notification),
|
||||
subscriptionId,
|
||||
}));
|
||||
const lastNotificationId = notifications.at(-1).id;
|
||||
await this.db.notifications.bulkPut(notificationsWithSubscriptionId);
|
||||
await this.db.subscriptions.update(subscriptionId, {
|
||||
@@ -220,6 +231,10 @@ class SubscriptionManager {
|
||||
await this.db.notifications.delete(notificationId);
|
||||
}
|
||||
|
||||
async deleteNotificationBySequenceId(subscriptionId, sequenceId) {
|
||||
await this.db.notifications.where({ subscriptionId, sequenceId }).delete();
|
||||
}
|
||||
|
||||
async deleteNotifications(subscriptionId) {
|
||||
await this.db.notifications.where({ subscriptionId }).delete();
|
||||
}
|
||||
@@ -228,6 +243,10 @@ class SubscriptionManager {
|
||||
await this.db.notifications.where({ id: notificationId }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
async markNotificationReadBySequenceId(subscriptionId, sequenceId) {
|
||||
await this.db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
async markNotificationsRead(subscriptionId) {
|
||||
await this.db.notifications.where({ subscriptionId, new: 1 }).modify({ new: 0 });
|
||||
}
|
||||
|
||||
72
web/src/app/VersionChecker.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* VersionChecker polls the /v1/config endpoint to detect new server versions
|
||||
* or configuration changes, prompting users to refresh the page.
|
||||
*/
|
||||
|
||||
const intervalMillis = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
class VersionChecker {
|
||||
constructor() {
|
||||
this.initialConfigHash = null;
|
||||
this.listener = null;
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the version checker worker. It stores the initial config hash
|
||||
* from the config.js and polls the server every 5 minutes.
|
||||
*/
|
||||
startWorker() {
|
||||
// Store initial config hash from the config loaded at page load
|
||||
this.initialConfigHash = window.config?.config_hash || "";
|
||||
console.log("[VersionChecker] Starting version checker");
|
||||
this.timer = setInterval(() => this.checkVersion(), intervalMillis);
|
||||
}
|
||||
|
||||
stopWorker() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
console.log("[VersionChecker] Stopped version checker");
|
||||
}
|
||||
|
||||
registerListener(listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
resetListener() {
|
||||
this.listener = null;
|
||||
}
|
||||
|
||||
async checkVersion() {
|
||||
if (!this.initialConfigHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${window.config?.base_url || ""}/v1/config`);
|
||||
if (!response.ok) {
|
||||
console.log("[VersionChecker] Failed to fetch config:", response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const currentHash = data.config_hash;
|
||||
|
||||
if (currentHash && currentHash !== this.initialConfigHash) {
|
||||
console.log("[VersionChecker] Version or config changed, showing banner");
|
||||
if (this.listener) {
|
||||
this.listener();
|
||||
}
|
||||
} else {
|
||||
console.log("[VersionChecker] No version change detected");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[VersionChecker] Error checking config:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const versionChecker = new VersionChecker();
|
||||
export default versionChecker;
|
||||
@@ -11,13 +11,20 @@ const createDatabase = (username) => {
|
||||
const dbName = username ? `ntfy-${username}` : "ntfy"; // IndexedDB database is based on the logged-in user
|
||||
const db = new Dexie(dbName);
|
||||
|
||||
db.version(2).stores({
|
||||
db.version(3).stores({
|
||||
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
|
||||
notifications: "&id,subscriptionId,time,new,[subscriptionId+new]", // compound key for query performance
|
||||
notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]",
|
||||
users: "&baseUrl,username",
|
||||
prefs: "&key",
|
||||
});
|
||||
|
||||
// When another connection (e.g., service worker or another tab) wants to upgrade,
|
||||
// close this connection gracefully to allow the upgrade to proceed
|
||||
db.on("versionchange", () => {
|
||||
console.log("[db] versionchange event: closing database");
|
||||
db.close();
|
||||
});
|
||||
|
||||
return db;
|
||||
};
|
||||
|
||||
|
||||
15
web/src/app/events.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Event types for ntfy messages
|
||||
// These correspond to the server event types in server/types.go
|
||||
|
||||
export const EVENT_OPEN = "open";
|
||||
export const EVENT_KEEPALIVE = "keepalive";
|
||||
export const EVENT_MESSAGE = "message";
|
||||
export const EVENT_MESSAGE_DELETE = "message_delete";
|
||||
export const EVENT_MESSAGE_CLEAR = "message_clear";
|
||||
export const EVENT_POLL_REQUEST = "poll_request";
|
||||
|
||||
export const WEBPUSH_EVENT_MESSAGE = "message";
|
||||
export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
|
||||
|
||||
// Check if an event is a notification event (message, delete, or read)
|
||||
export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;
|
||||
@@ -25,13 +25,13 @@ const formatTitleWithDefault = (m, fallback) => {
|
||||
|
||||
export const formatMessage = (m) => {
|
||||
if (m.title) {
|
||||
return m.message;
|
||||
return m.message || "";
|
||||
}
|
||||
const emojiList = toEmojis(m.tags);
|
||||
if (emojiList.length > 0) {
|
||||
return `${emojiList.join(" ")} ${m.message}`;
|
||||
return `${emojiList.join(" ")} ${m.message || ""}`;
|
||||
}
|
||||
return m.message;
|
||||
return m.message || "";
|
||||
};
|
||||
|
||||
const imageRegex = /\.(png|jpe?g|gif|webp)$/i;
|
||||
@@ -50,8 +50,16 @@ export const isImage = (attachment) => {
|
||||
export const icon = "/static/images/ntfy.png";
|
||||
export const badge = "/static/images/mask-icon.svg";
|
||||
|
||||
export const toNotificationParams = ({ subscriptionId, message, defaultTitle, topicRoute }) => {
|
||||
/**
|
||||
* Computes a unique notification tag scoped by baseUrl, topic, and sequence ID.
|
||||
* This ensures notifications from different topics with the same sequence ID don't collide.
|
||||
*/
|
||||
export const notificationTag = (baseUrl, topic, sequenceId) => `${baseUrl}/${topic}/${sequenceId}`;
|
||||
|
||||
export const toNotificationParams = ({ message, defaultTitle, topicRoute, baseUrl, topic }) => {
|
||||
const image = isImage(message.attachment) ? message.attachment.url : undefined;
|
||||
const sequenceId = message.sequence_id || message.id;
|
||||
const tag = notificationTag(baseUrl, topic, sequenceId);
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
|
||||
return [
|
||||
@@ -61,8 +69,8 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
|
||||
badge,
|
||||
icon,
|
||||
image,
|
||||
timestamp: message.time * 1_000,
|
||||
tag: subscriptionId,
|
||||
timestamp: message.time * 1000,
|
||||
tag, // Scoped by baseUrl/topic/sequenceId to avoid cross-topic collisions
|
||||
renotify: true,
|
||||
silent: false,
|
||||
// This is used by the notification onclick event
|
||||
@@ -79,3 +87,10 @@ export const toNotificationParams = ({ subscriptionId, message, defaultTitle, to
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const messageWithSequenceId = (message) => {
|
||||
if (message.sequenceId) {
|
||||
return message;
|
||||
}
|
||||
return { ...message, sequenceId: message.sequence_id || message.id };
|
||||
};
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import {
|
||||
Drawer,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Divider,
|
||||
List,
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
Link,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
Portal,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Box,
|
||||
IconButton,
|
||||
Button,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import * as React from "react";
|
||||
@@ -44,7 +44,7 @@ import UpgradeDialog from "./UpgradeDialog";
|
||||
import { AccountContext } from "./App";
|
||||
import { PermissionDenyAll, PermissionRead, PermissionReadWrite, PermissionWrite } from "./ReserveIcons";
|
||||
import { SubscriptionPopup } from "./SubscriptionPopup";
|
||||
import { useNotificationPermissionListener } from "./hooks";
|
||||
import { useNotificationPermissionListener, useVersionChangeListener } from "./hooks";
|
||||
|
||||
const navWidth = 280;
|
||||
|
||||
@@ -91,6 +91,13 @@ const NavList = (props) => {
|
||||
const { account } = useContext(AccountContext);
|
||||
const [subscribeDialogKey, setSubscribeDialogKey] = useState(0);
|
||||
const [subscribeDialogOpen, setSubscribeDialogOpen] = useState(false);
|
||||
const [versionChanged, setVersionChanged] = useState(false);
|
||||
|
||||
const handleVersionChange = () => {
|
||||
setVersionChanged(true);
|
||||
};
|
||||
|
||||
useVersionChangeListener(handleVersionChange);
|
||||
|
||||
const handleSubscribeReset = () => {
|
||||
setSubscribeDialogOpen(false);
|
||||
@@ -119,6 +126,7 @@ const NavList = (props) => {
|
||||
const showNotificationContextNotSupportedBox = notifier.browserSupported() && !notifier.contextSupported(); // Only show if notifications are generally supported in the browser
|
||||
|
||||
const alertVisible =
|
||||
versionChanged ||
|
||||
showNotificationPermissionRequired ||
|
||||
showNotificationPermissionDenied ||
|
||||
showNotificationIOSInstallRequired ||
|
||||
@@ -129,6 +137,7 @@ const NavList = (props) => {
|
||||
<>
|
||||
<Toolbar sx={{ display: { xs: "none", sm: "block" } }} />
|
||||
<List component="nav" sx={{ paddingTop: { xs: 0, sm: alertVisible ? 0 : "" } }}>
|
||||
{versionChanged && <VersionUpdateBanner />}
|
||||
{showNotificationPermissionRequired && <NotificationPermissionRequired />}
|
||||
{showNotificationPermissionDenied && <NotificationPermissionDeniedAlert />}
|
||||
{showNotificationBrowserNotSupportedBox && <NotificationBrowserNotSupportedAlert />}
|
||||
@@ -425,4 +434,20 @@ const NotificationContextNotSupportedAlert = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const VersionUpdateBanner = () => {
|
||||
const { t } = useTranslation();
|
||||
const handleRefresh = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
return (
|
||||
<Alert severity="info" sx={{ paddingTop: 2 }}>
|
||||
<AlertTitle>{t("version_update_available_title")}</AlertTitle>
|
||||
<Typography gutterBottom>{t("version_update_available_description")}</Typography>
|
||||
<Button sx={{ float: "right" }} color="inherit" size="small" onClick={handleRefresh}>
|
||||
{t("common_refresh")}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navigation;
|
||||
|
||||
@@ -9,9 +9,11 @@ import poller from "../app/Poller";
|
||||
import pruner from "../app/Pruner";
|
||||
import session from "../app/Session";
|
||||
import accountApi from "../app/AccountApi";
|
||||
import versionChecker from "../app/VersionChecker";
|
||||
import { UnauthorizedError } from "../app/errors";
|
||||
import notifier from "../app/Notifier";
|
||||
import prefs from "../app/Prefs";
|
||||
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events";
|
||||
|
||||
/**
|
||||
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
|
||||
@@ -49,10 +51,29 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotification = async (subscriptionId, notification) => {
|
||||
const added = await subscriptionManager.addNotification(subscriptionId, notification);
|
||||
if (added) {
|
||||
await subscriptionManager.notify(subscriptionId, notification);
|
||||
const handleNotification = async (subscription, notification) => {
|
||||
// This logic is (partially) duplicated in
|
||||
// - Android: SubscriberService::onNotificationReceived()
|
||||
// - Android: FirebaseService::onMessageReceived()
|
||||
// - Web app: hooks.js:handleNotification()
|
||||
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
|
||||
|
||||
if (notification.event === EVENT_MESSAGE_DELETE && notification.sequence_id) {
|
||||
await subscriptionManager.deleteNotificationBySequenceId(subscription.id, notification.sequence_id);
|
||||
await notifier.cancel(subscription, notification);
|
||||
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
|
||||
await subscriptionManager.markNotificationReadBySequenceId(subscription.id, notification.sequence_id);
|
||||
await notifier.cancel(subscription, notification);
|
||||
} else {
|
||||
// Regular message: delete existing and add new
|
||||
const sequenceId = notification.sequence_id || notification.id;
|
||||
if (sequenceId) {
|
||||
await subscriptionManager.deleteNotificationBySequenceId(subscription.id, sequenceId);
|
||||
}
|
||||
const added = await subscriptionManager.addNotification(subscription.id, notification);
|
||||
if (added) {
|
||||
await subscriptionManager.notify(subscription.id, notification);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -68,7 +89,7 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
|
||||
if (subscription.internal) {
|
||||
await handleInternalMessage(message);
|
||||
} else {
|
||||
await handleNotification(subscriptionId, message);
|
||||
await handleNotification(subscription, message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,7 +252,9 @@ export const useIsLaunchedPWA = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isIOSStandalone) {
|
||||
return () => {}; // No need to listen for events on iOS
|
||||
return () => {
|
||||
// No need to listen for events on iOS
|
||||
};
|
||||
}
|
||||
const handler = (evt) => {
|
||||
console.log(`[useIsLaunchedPWA] App is now running ${evt.matches ? "standalone" : "in the browser"}`);
|
||||
@@ -270,12 +293,14 @@ const startWorkers = () => {
|
||||
poller.startWorker();
|
||||
pruner.startWorker();
|
||||
accountApi.startWorker();
|
||||
versionChecker.startWorker();
|
||||
};
|
||||
|
||||
const stopWorkers = () => {
|
||||
poller.stopWorker();
|
||||
pruner.stopWorker();
|
||||
accountApi.stopWorker();
|
||||
versionChecker.stopWorker();
|
||||
};
|
||||
|
||||
export const useBackgroundProcesses = () => {
|
||||
@@ -301,3 +326,15 @@ export const useAccountListener = (setAccount) => {
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to detect version/config changes and call the provided callback when a change is detected.
|
||||
*/
|
||||
export const useVersionChangeListener = (onVersionChange) => {
|
||||
useEffect(() => {
|
||||
versionChecker.registerListener(onVersionChange);
|
||||
return () => {
|
||||
versionChecker.resetListener();
|
||||
};
|
||||
}, [onVersionChange]);
|
||||
};
|
||||
|
||||
@@ -5,10 +5,19 @@ import { registerSW as viteRegisterSW } from "virtual:pwa-register";
|
||||
const intervalMS = 60 * 60 * 1000;
|
||||
|
||||
// https://vite-pwa-org.netlify.app/guide/periodic-sw-updates.html
|
||||
const registerSW = () =>
|
||||
const registerSW = () => {
|
||||
console.log("[ServiceWorker] Registering service worker");
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
console.warn("[ServiceWorker] Service workers not supported");
|
||||
return;
|
||||
}
|
||||
|
||||
viteRegisterSW({
|
||||
onRegisteredSW(swUrl, registration) {
|
||||
console.log("[ServiceWorker] Registered:", { swUrl, registration });
|
||||
|
||||
if (!registration) {
|
||||
console.warn("[ServiceWorker] No registration returned");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -23,9 +32,16 @@ const registerSW = () =>
|
||||
},
|
||||
});
|
||||
|
||||
if (resp?.status === 200) await registration.update();
|
||||
if (resp?.status === 200) {
|
||||
console.log("[ServiceWorker] Updating service worker");
|
||||
await registration.update();
|
||||
}
|
||||
}, intervalMS);
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.error("[ServiceWorker] Registration error:", error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default registerSW;
|
||||
|
||||