Compare commits

...

56 Commits

Author SHA1 Message Date
binwiederhier
64b0bd63af Deps 2026-01-17 18:05:36 -05:00
binwiederhier
220372d65a Move client config file logic, docs 2026-01-17 17:51:33 -05:00
binwiederhier
353fedb93f Docs, lint 2026-01-17 14:59:43 -05:00
binwiederhier
dfd12528f3 Manual nits 2026-01-17 14:48:32 -05:00
binwiederhier
6d5cc6aeac Windows server support 2026-01-17 14:43:43 -05:00
binwiederhier
9f3883eaf0 Build server on Windows 2026-01-17 14:00:08 -05:00
Philipp C. Heckel
a0ebd64461 Merge pull request #1551 from mshafer1/dev/update_link_to_ntfysh-windows
Update link to ntfysh-windows client
2026-01-17 12:20:09 -05:00
Maker By Night
dafd130fe5 Update link to ntfysh-windows client
Since lucas-bortoli marked the original project as archived/abandoned, I intend to continue maintenance on my fork. Therefore, updating the integration link to point to it.
2026-01-17 10:03:29 -06:00
binwiederhier
11e9e1e6a0 Merge branch 'feature/twilio-call-format-file' 2026-01-17 05:00:03 -05:00
binwiederhier
b23f6632b1 Use Go templates, update docs 2026-01-17 04:59:46 -05:00
binwiederhier
6bacf7dafc Works 2026-01-17 04:34:32 -05:00
binwiederhier
0e200b96e0 Merge branch 'main' of github.com:binwiederhier/ntfy into feature/twilio-call-format-file 2026-01-17 03:49:52 -05:00
binwiederhier
3ce56879ae Tidy 2026-01-17 03:49:33 -05:00
binwiederhier
48efdffa57 Fix indent 2026-01-17 03:29:40 -05:00
Philipp C. Heckel
9135bb277b Merge pull request #1534 from Pixelguin/feat/ws-permissions-note
Add troubleshooting steps for "Reconnecting" error on mobile
2026-01-17 03:21:07 -05:00
binwiederhier
711899ad35 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-01-17 03:20:47 -05:00
binwiederhier
01435d5fea Bump 2026-01-17 03:20:41 -05:00
Philipp C. Heckel
8ce2188b28 Merge pull request #1536 from binwiederhier/303-update-notifications
Update/delete/clear notifications
2026-01-16 10:10:50 -05:00
binwiederhier
c1ee163cab Remove cache thing 2026-01-16 10:07:09 -05:00
binwiederhier
fcf57a04e1 Move event up 2026-01-16 09:36:27 -05:00
binwiederhier
0ad06e808f Self-review 2026-01-15 22:14:56 -05:00
binwiederhier
2cc4bf7d28 Fix webpush stuff 2026-01-15 21:55:20 -05:00
binwiederhier
5ae3774f19 Refine, docs 2026-01-15 19:06:05 -05:00
binwiederhier
94eb121f38 Docs updates 2026-01-15 13:07:07 -05:00
binwiederhier
e81be48bf3 MOre docs update 2026-01-15 10:32:48 -05:00
binwiederhier
db4a4776d3 Reword 2026-01-15 10:07:05 -05:00
binwiederhier
3d54260f79 Docs 2026-01-15 09:30:37 -05:00
waclaw66
a712d78e4c Translated using Weblate (Czech)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/cs/
2026-01-15 14:01:47 +01:00
binwiederhier
ea3008c707 Move, add screenshots 2026-01-14 22:18:57 -05:00
binwiederhier
2e39a1329c AI docs, needs work 2026-01-14 21:48:21 -05:00
binwiederhier
ff2b8167b4 Make closing notifications work 2026-01-14 21:17:21 -05:00
binwiederhier
96638b516c Fix service worker handling for updating/deleting 2026-01-14 20:46:18 -05:00
binwiederhier
dd9b36cf0a Fix db crash 2026-01-13 21:29:44 -05:00
binwiederhier
44f20f6b4c Add tests, fix firebase 2026-01-13 20:50:31 -05:00
binwiederhier
a3c16d81f8 Rename to clear 2026-01-13 16:31:13 -05:00
cyberboh
c0a5a1fb35 Translated using Weblate (Indonesian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2026-01-13 10:30:17 +01:00
binwiederhier
b5f24b12dc Merge branch 'main' of github.com:binwiederhier/ntfy into 303-update-notifications 2026-01-12 21:38:55 -05:00
binwiederhier
898a52ec63 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-01-11 09:27:01 -05:00
binwiederhier
7066ba92a6 Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-01-11 09:26:58 -05:00
binwiederhier
8960459520 Changelog 2026-01-11 09:26:44 -05:00
Kristijan \"Fremen\" Velkovski
f581af6d27 Translated using Weblate (Macedonian)
Currently translated at 23.0% (94 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-01-11 07:01:51 +00:00
Shoshin Akamine
88016a2549 Translated using Weblate (Japanese)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ja/
2026-01-11 07:01:49 +00:00
大王叫我来巡山
a7603d1dfb Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/zh_Hans/
2026-01-11 07:01:47 +00:00
Philipp C. Heckel
65e2af9efb Merge pull request #1542 from ecntu/fix-typos
fix typos in docs
2026-01-10 22:29:22 -05:00
Emilio Cantú
090cd720c1 fix typos 2026-01-10 22:03:41 -05:00
binwiederhier
7bc8edf7c3 Bump, release notes 2026-01-10 20:47:59 -05:00
Kristijan \"Fremen\" Velkovski
13768d4289 Translated using Weblate (Macedonian)
Currently translated at 16.4% (67 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-01-10 05:01:49 +01:00
Kristijan \"Fremen\" Velkovski
b3721c1b71 Translated using Weblate (Macedonian)
Currently translated at 14.7% (60 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-01-07 14:01:52 +00:00
cyberboh
544f7ef1cb Translated using Weblate (Indonesian)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/id/
2026-01-07 14:01:47 +00:00
Kristijan \"Fremen\" Velkovski
16bec00b38 Translated using Weblate (Macedonian)
Currently translated at 13.5% (55 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/mk/
2026-01-06 06:01:47 +00:00
Pixelguin
1c32ee7613 Clarify wording 2026-01-05 08:36:56 -08:00
Pixelguin
f356309f70 Clarify up* r/w permissions 2026-01-05 08:13:53 -08:00
Pixelguin
39936a95f8 Add troubleshooting steps for "Reconnecting" error on mobile 2026-01-05 08:11:20 -08:00
josé m.
20ccee5d7d Translated using Weblate (Galician)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/gl/
2026-01-04 16:01:47 +01:00
Michael Nowak
16900d2c10 Set twilio-call-format config option in serve command 2025-06-16 15:14:13 +02:00
Michael Nowak
950ba1e2e1 Add optional twilio-call-format config option
To be able to set custom TwiML send to the Call API.
2025-03-11 09:45:32 +00:00
62 changed files with 1862 additions and 663 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
@@ -493,6 +503,14 @@ func execServe(c *cli.Context) error {
conf.WebPushExpiryWarningDuration = webPushExpiryWarningDuration
conf.Version = c.App.Version
// 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 +525,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)
@@ -653,25 +655,3 @@ 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())
}
overrides, err := inputSource.StringSlice("log-level-overrides")
if err != nil {
return fmt.Errorf("cannot load log level overrides (1): %s", err.Error())
}
log.ResetLevelOverrides()
if err := applyLogLevelOverrides(overrides); err != nil {
return fmt.Errorf("cannot load log level overrides (2): %s", err.Error())
}
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
}

55
cmd/serve_unix.go Normal file
View 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
View 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
}

View File

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

View File

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

View File

@@ -12,7 +12,3 @@ or ~/.config/ntfy/client.yml for all other users.`
var (
scriptLauncher = []string{"sh", "-c"}
)
func defaultClientConfigFile() (string, error) {
return defaultClientConfigFileUnix()
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -228,19 +228,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.15.0/ntfy_2.15.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

View File

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

View File

@@ -937,6 +937,445 @@ Here's an example with a custom message, tags and a priority:
file_get_contents('https://ntfy.sh/mywebhook/publish?message=Webhook+triggered&priority=high&tags=warning,skull');
```
## Updating + deleting notifications
_Supported on:_ :material-android: :material-firefox:
!!! info
**This feature is not yet released.** It will be available in ntfy v2.16.x and later and ntfy Android v1.22.x and later.
You can **update, clear (mark as read and dismiss), or delete notifications** that have already been delivered. This is useful for scenarios
like download progress updates, replacing outdated information, or dismissing notifications that are no longer relevant.
* [Updating notifications](#updating-notifications) will alter the content of an existing notification.
* [Clearing notifications](#clearing-notifications) will mark them as read and dismiss them from the notification drawer.
* [Deleting notifications](#deleting-notifications) will remove them from the notification drawer and remove them in the clients as well (if supported).
Here's an example of a download progress notification being updated over time on Android:
<div id="updating-notifications-screenshots" 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>
To facilitate updating notifications and altering existing notifications, ntfy messages are linked together in a sequence,
using a **sequence ID**. When a notification is meant to be updated, cleared, or deleted, you publish a new message with the
same sequence ID and the clients will perform the appropriate action on the existing notification.
Existing ntfy messages will not be updated on the server or in the message cache. Instead, a new message is created that indicates
the update, clear, or delete action. This append-only behavior ensures that message history remains intact.
### Updating notifications
To update an existing notification, publish a new message with the same sequence ID. Clients will replace the previous
notification with the new one. You can either:
1. **Use the message ID**: First publish like normal to `POST /<topic>` without a sequence ID, then use the returned message `id` as the sequence ID for updates
2. **Use a custom sequence ID**: Publish directly to `POST /<topic>/<sequence_id>` with your own identifier, or use `POST /<topic>` with the
`X-Sequence-ID` header (or any of its aliases: `Sequence-ID` or`SID`)
If you don't know the sequence ID ahead of time, you can publish a message first and then use the returned
message `id` to update it. Here's an example:
=== "Command line (curl)"
```bash
# First, publish a message and capture the message ID
curl -d "Downloading file..." ntfy.sh/mytopic
# Returns: {"id":"xE73Iyuabi","time":1673542291,...}
# Then use the message ID to update it (via URL path)
curl -d "Download 50% ..." ntfy.sh/mytopic/xE73Iyuabi
# Or update using the X-Sequence-ID header
curl -H "X-Sequence-ID: xE73Iyuabi" -d "Download complete" ntfy.sh/mytopic
```
=== "ntfy CLI"
```bash
# First, publish a message and capture the message ID
ntfy pub mytopic "Downloading file..."
# Returns: {"id":"xE73Iyuabi","time":1673542291,...}
# Then use the message ID to update it
ntfy pub --sequence-id=xE73Iyuabi mytopic "Download 50% ..."
# Update again with the same sequence ID
ntfy pub -S xE73Iyuabi mytopic "Download complete"
```
=== "HTTP"
``` http
# First, publish a message and capture the message ID
POST /mytopic HTTP/1.1
Host: ntfy.sh
Downloading file...
# Returns: {"id":"xE73Iyuabi","time":1673542291,...}
# Then use the message ID to update it
POST /mytopic/xE73Iyuabi HTTP/1.1
Host: ntfy.sh
Download 50% ...
# Update again with the same sequence ID, this time using the header
POST /mytopic HTTP/1.1
Host: ntfy.sh
X-Sequence-ID: xE73Iyuabi
Download complete
```
=== "JavaScript"
``` javascript
// First, publish and get the message ID
const response = await fetch('https://ntfy.sh/mytopic', {
method: 'POST',
body: 'Downloading file...'
});
const { id } = await response.json();
// Update via URL path
await fetch(`https://ntfy.sh/mytopic/${id}`, {
method: 'POST',
body: 'Download 50% ...'
});
// Or update using the X-Sequence-ID header
await fetch('https://ntfy.sh/mytopic', {
method: 'POST',
headers: { 'X-Sequence-ID': id },
body: 'Download complete'
});
```
=== "Go"
``` go
// Publish and parse the response to get the message ID
resp, _ := http.Post("https://ntfy.sh/mytopic", "text/plain",
strings.NewReader("Downloading file..."))
var msg struct { ID string `json:"id"` }
json.NewDecoder(resp.Body).Decode(&msg)
// Update via URL path
http.Post("https://ntfy.sh/mytopic/"+msg.ID, "text/plain",
strings.NewReader("Download 50% ..."))
// Or update using the X-Sequence-ID header
req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
strings.NewReader("Download complete"))
req.Header.Set("X-Sequence-ID", msg.ID)
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
# Publish and get the message ID
$response = Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" -Body "Downloading file..."
$messageId = $response.id
# Update via URL path
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/$messageId" -Body "Download 50% ..."
# Or update using the X-Sequence-ID header
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" `
-Headers @{"X-Sequence-ID"=$messageId} -Body "Download complete"
```
=== "Python"
``` python
import requests
# Publish and get the message ID
response = requests.post("https://ntfy.sh/mytopic", data="Downloading file...")
message_id = response.json()["id"]
# Update via URL path
requests.post(f"https://ntfy.sh/mytopic/{message_id}", data="Download 50% ...")
# Or update using the X-Sequence-ID header
requests.post("https://ntfy.sh/mytopic",
headers={"X-Sequence-ID": message_id}, data="Download complete")
```
=== "PHP"
``` php-inline
// Publish and get the message ID
$response = file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
'http' => ['method' => 'POST', 'content' => 'Downloading file...']
]));
$messageId = json_decode($response)->id;
// Update via URL path
file_get_contents("https://ntfy.sh/mytopic/$messageId", false, stream_context_create([
'http' => ['method' => 'POST', 'content' => 'Download 50% ...']
]));
// Or update using the X-Sequence-ID header
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "X-Sequence-ID: $messageId",
'content' => 'Download complete'
]
]));
```
You can also use a **custom sequence ID** (e.g., a download ID, job ID, etc.) when publishing the first message.
**This is less cumbersome**, since you don't need to capture the message ID first. Just publish directly to
`/<topic>/<sequence_id>`:
=== "Command line (curl)"
```bash
# Publish with a custom sequence ID
curl -d "Downloading file..." ntfy.sh/mytopic/my-download-123
# Update using the same sequence ID (via URL path)
curl -d "Download 50% ..." ntfy.sh/mytopic/my-download-123
# Or update using the X-Sequence-ID header
curl -H "X-Sequence-ID: my-download-123" -d "Download complete" ntfy.sh/mytopic
```
=== "ntfy CLI"
```bash
# Publish with a sequence ID
ntfy pub --sequence-id=my-download-123 mytopic "Downloading file..."
# Update using the same sequence ID
ntfy pub --sequence-id=my-download-123 mytopic "Download 50% ..."
# Update again
ntfy pub -S my-download-123 mytopic "Download complete"
```
=== "HTTP"
``` http
# Publish a message with a custom sequence ID
POST /mytopic/my-download-123 HTTP/1.1
Host: ntfy.sh
Downloading file...
# Update again using the X-Sequence-ID header
POST /mytopic HTTP/1.1
Host: ntfy.sh
X-Sequence-ID: my-download-123
Download complete
```
=== "JavaScript"
``` javascript
// First message
await fetch('https://ntfy.sh/mytopic/my-download-123', {
method: 'POST',
body: 'Downloading file...'
});
// Update via URL path
await fetch('https://ntfy.sh/mytopic/my-download-123', {
method: 'POST',
body: 'Download 50% ...'
});
// Or update using the X-Sequence-ID header
await fetch('https://ntfy.sh/mytopic', {
method: 'POST',
headers: { 'X-Sequence-ID': 'my-download-123' },
body: 'Download complete'
});
```
=== "Go"
``` go
// Publish with sequence ID in URL path
http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain",
strings.NewReader("Downloading file..."))
// Update via URL path
http.Post("https://ntfy.sh/mytopic/my-download-123", "text/plain",
strings.NewReader("Download 50% ..."))
// Or update using the X-Sequence-ID header
req, _ := http.NewRequest("POST", "https://ntfy.sh/mytopic",
strings.NewReader("Download complete"))
req.Header.Set("X-Sequence-ID", "my-download-123")
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
# Publish with sequence ID
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Downloading file..."
# Update via URL path
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic/my-download-123" -Body "Download 50% ..."
# Or update using the X-Sequence-ID header
Invoke-RestMethod -Method POST -Uri "https://ntfy.sh/mytopic" `
-Headers @{"X-Sequence-ID"="my-download-123"} -Body "Download complete"
```
=== "Python"
``` python
import requests
# Publish with sequence ID
requests.post("https://ntfy.sh/mytopic/my-download-123", data="Downloading file...")
# Update via URL path
requests.post("https://ntfy.sh/mytopic/my-download-123", data="Download 50% ...")
# Or update using the X-Sequence-ID header
requests.post("https://ntfy.sh/mytopic",
headers={"X-Sequence-ID": "my-download-123"}, data="Download complete")
```
=== "PHP"
``` php-inline
// Publish with sequence ID
file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
'http' => ['method' => 'POST', 'content' => 'Downloading file...']
]));
// Update via URL path
file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
'http' => ['method' => 'POST', 'content' => 'Download 50% ...']
]));
// Or update using the X-Sequence-ID header
file_get_contents('https://ntfy.sh/mytopic', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'X-Sequence-ID: my-download-123',
'content' => 'Download complete'
]
]));
```
You can also set the sequence ID via the `sequence-id` [query parameter](#list-of-all-parameters), or when
[publishing as JSON](#publish-as-json) using the `sequence_id` field.
If the message ID (`id`) and the sequence ID (`sequence_id`) are different, the ntfy server will include the `sequence_id`
field the response. A sequence of updates may look like this (first example from above):
```json
{"id":"xE73Iyuabi","time":1673542291,"event":"message","topic":"mytopic","message":"Downloading file..."}
{"id":"yF84Jzvbcj","time":1673542295,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download 50% ..."}
{"id":"zG95Kawdde","time":1673542300,"event":"message","topic":"mytopic","sequence_id":"xE73Iyuabi","message":"Download complete"}
```
### Clearing notifications
Clearing a notification means **marking it as read and dismissing it from the notification drawer**.
To do this, send a PUT request to the `/<topic>/<sequence_id>/clear` endpoint (or `/<topic>/<sequence_id>/read` as an alias).
This will then emit a `message_clear` event that is used by the clients (web app and Android app) to update the read status
and dismiss the notification.
=== "Command line (curl)"
```bash
curl -X PUT ntfy.sh/mytopic/my-download-123/clear
```
=== "HTTP"
``` http
PUT /mytopic/my-download-123/clear HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
await fetch('https://ntfy.sh/mytopic/my-download-123/clear', {
method: 'PUT'
});
```
=== "Go"
``` go
req, _ := http.NewRequest("PUT", "https://ntfy.sh/mytopic/my-download-123/clear", nil)
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
Invoke-RestMethod -Method PUT -Uri "https://ntfy.sh/mytopic/my-download-123/clear"
```
=== "Python"
``` python
requests.put("https://ntfy.sh/mytopic/my-download-123/clear")
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic/my-download-123/clear', false, stream_context_create([
'http' => ['method' => 'PUT']
]));
```
An example response from the server with the `message_clear` event may look like this:
```json
{"id":"jkl012","time":1673542305,"event":"message_clear","topic":"mytopic","sequence_id":"my-download-123"}
```
### Deleting notifications
Deleting a notification means **removing it from the notification drawer and from the client's database**.
To do this, send a DELETE request to the `/<topic>/<sequence_id>` endpoint. This will emit a `message_delete` event
that is used by the clients (web app and Android app) to remove the notification entirely.
=== "Command line (curl)"
```bash
curl -X DELETE ntfy.sh/mytopic/my-download-123
```
=== "HTTP"
``` http
DELETE /mytopic/my-download-123 HTTP/1.1
Host: ntfy.sh
```
=== "JavaScript"
``` javascript
await fetch('https://ntfy.sh/mytopic/my-download-123', {
method: 'DELETE'
});
```
=== "Go"
``` go
req, _ := http.NewRequest("DELETE", "https://ntfy.sh/mytopic/my-download-123", nil)
http.DefaultClient.Do(req)
```
=== "PowerShell"
``` powershell
Invoke-RestMethod -Method DELETE -Uri "https://ntfy.sh/mytopic/my-download-123"
```
=== "Python"
``` python
requests.delete("https://ntfy.sh/mytopic/my-download-123")
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.sh/mytopic/my-download-123', false, stream_context_create([
'http' => ['method' => 'DELETE']
]));
```
An example response from the server with the `message_delete` event may look like this:
```json
{"id":"mno345","time":1673542400,"event":"message_delete","topic":"mytopic","sequence_id":"my-download-123"}
```
!!! info
Deleted sequences can be revived by publishing a new message with the same sequence ID. The notification will
reappear as a new message.
## Message templating
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -1418,22 +1857,23 @@ The JSON message format closely mirrors the format of the message you can consum
(see [JSON message format](subscribe/api.md#json-message-format) for details), but is not exactly identical. Here's an overview of
all the supported fields:
| Field | Required | Type | Example | Description |
|------------|----------|----------------------------------|-------------------------------------------|-----------------------------------------------------------------------|
| `topic` | ✔️ | *string* | `topic1` | Target topic name |
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
| `title` | - | *string* | `Some title` | Message [title](#message-title) |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| Field | Required | Type | Example | Description |
|---------------|----------|----------------------------------|-------------------------------------------|-------------------------------------------------------------------------------------------|
| `topic` | ✔️ | *string* | `topic1` | Target topic name |
| `message` | - | *string* | `Some message` | Message body; set to `triggered` if empty or not passed |
| `title` | - | *string* | `Some title` | Message [title](#message-title) |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](#tags-emojis) that may or not map to emojis |
| `priority` | - | *int (one of: 1, 2, 3, 4, or 5)* | `4` | Message [priority](#message-priority) with 1=min, 3=default and 5=max |
| `actions` | - | *JSON array* | *(see [action buttons](#action-buttons))* | Custom [user action buttons](#action-buttons) for notifications |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](#click-action) |
| `attach` | - | *URL* | `https://example.com/file.jpg` | URL of an attachment, see [attach via URL](#attach-file-from-a-url) |
| `markdown` | - | *bool* | `true` | Set to true if the `message` is Markdown-formatted |
| `icon` | - | *string* | `https://example.com/icon.png` | URL to use as notification [icon](#icons) |
| `filename` | - | *string* | `file.jpg` | File name of the attachment |
| `delay` | - | *string* | `30min`, `9am` | Timestamp or duration for delayed delivery |
| `email` | - | *e-mail address* | `phil@example.com` | E-mail address for e-mail notifications |
| `call` | - | *phone number or 'yes'* | `+1222334444` or `yes` | Phone number to use for [voice call](#phone-calls) |
| `sequence_id` | - | *string* | `my-sequence-123` | Sequence ID for [updating/deleting notifications](#updating-deleting-notifications) |
## Action buttons
_Supported on:_ :material-android: :material-apple: :material-firefox:
@@ -3931,6 +4371,7 @@ table in their canonical form.
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
| `X-Sequence-ID` | `Sequence-ID`, `SID` | [Sequence ID](#updating-deleting-notifications) for updating/clearing/deleting notifications |
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |

View File

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

View File

@@ -4,14 +4,41 @@ 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.15.0 | Nov 16, 2025 |
| ntfy Android app | v1.21.1 | Jan 6, 2025 |
| 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.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.
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 +1599,32 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy Android app v1.21.1-rc1 (IN TESTING)
### ntfy server v2.16.x (UNRELEASED)
**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))
* 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)
* 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 ([#1552](https://github.com/binwiederhier/ntfy/pull/1552), originally [#1328](https://github.com/binwiederhier/ntfy/pull/1328), thanks to [@wtf911](https://github.com/wtf911))
### ntfy Android app v1.22.x (UNRELEASED)
**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:**
* 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))
* 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

View File

@@ -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 {
@@ -93,7 +93,7 @@ figure video {
.screenshots img {
max-height: 230px;
max-width: 300px;
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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

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

View File

@@ -100,7 +100,7 @@ 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.
## Share to topic
_Supported on:_ :material-android:

View File

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

26
go.mod
View File

@@ -5,8 +5,8 @@ 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
@@ -16,12 +16,12 @@ require (
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.259.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,7 +70,7 @@ 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
@@ -92,12 +93,11 @@ 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-20251222181119-0a764e51fe1b // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // 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

52
go.sum
View File

@@ -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=
@@ -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,16 +263,16 @@ 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.259.0 h1:90TaGVIxScrh1Vn/XI2426kRpBqHwWIzVBzJsVZ5XrQ=
google.golang.org/api v0.259.0/go.mod h1:LC2ISWGWbRoyQVpxGntWwLWN/vLNxxKBK9KuJRI8Te4=
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-20251222181119-0a764e51fe1b h1:kqShdsddZrS6q+DGBCA73CzHsKDu5vW4qw78tFnbVvY=
google.golang.org/genproto v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:gw1DtiPCt5uh/HV9STVEeaO00S5ATsJiJ2LsZV8lcDI=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
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=

View File

@@ -3,6 +3,7 @@ package server
import (
"io/fs"
"net/netip"
"text/template"
"time"
"heckel.io/ntfy/v2/user"
@@ -11,8 +12,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 +25,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 +133,7 @@ type Config struct {
TwilioCallsBaseURL string
TwilioVerifyBaseURL string
TwilioVerifyService string
TwilioCallFormat *template.Template
MetricsEnable bool
MetricsListenHTTP string
ProfileListenHTTP string
@@ -226,6 +232,7 @@ func NewConfig() *Config {
TwilioPhoneNumber: "",
TwilioVerifyBaseURL: "https://verify.twilio.com", // Override for tests
TwilioVerifyService: "",
TwilioCallFormat: nil,
MessageSizeLimit: DefaultMessageSizeLimit,
MessageDelayMin: DefaultMessageDelayMin,
MessageDelayMax: DefaultMessageDelayMax,

8
server/config_unix.go Normal file
View 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
View 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")
}

View File

@@ -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,7 +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/#TODO", 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}

View File

@@ -31,6 +31,7 @@ const (
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,
@@ -50,8 +51,7 @@ const (
user TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published INT NOT NULL,
event TEXT NOT NULL
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);
@@ -69,57 +69,57 @@ const (
COMMIT;
`
insertMessageQuery = `
INSERT INTO messages (mid, sequence_id, 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, event)
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, sequence_id, 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, event
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, sequence_id, 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, event
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, sequence_id, 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, event
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, sequence_id, 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, event
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
ORDER BY time, id
`
selectMessagesSinceIDIncludeScheduledQuery = `
SELECT mid, sequence_id, 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, event
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, sequence_id, 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, event
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, sequence_id, 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, event
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
`
selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
selectMessagesExpiredQuery = `SELECT mid FROM messages WHERE expires <= ? AND published = 1`
updateMessagePublishedQuery = `UPDATE messages SET published = 1 WHERE mid = ?`
selectMessagesCountQuery = `SELECT COUNT(*) FROM messages`
selectMessageCountPerTopicQuery = `SELECT topic, COUNT(*) FROM messages GROUP BY topic`
selectTopicsQuery = `SELECT topic FROM messages GROUP BY topic`
@@ -380,7 +380,7 @@ func (c *messageCache) addMessages(ms []*message) error {
}
defer stmt.Close()
for _, m := range ms {
if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageReadEvent {
if m.Event != messageEvent && m.Event != messageDeleteEvent && m.Event != messageClearEvent {
return errUnexpectedMessageType
}
published := m.Time <= time.Now().Unix()
@@ -410,6 +410,7 @@ func (c *messageCache) addMessages(ms []*message) error {
m.ID,
m.SequenceID,
m.Time,
m.Event,
m.Expires,
m.Topic,
m.Message,
@@ -430,7 +431,6 @@ func (c *messageCache) addMessages(ms []*message) error {
m.ContentType,
m.Encoding,
published,
m.Event,
)
if err != nil {
return err
@@ -719,11 +719,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, sequenceID, topic, msg, title, tagsStr, click, icon, actionsStr, attachmentName, attachmentType, attachmentURL, sender, user, contentType, encoding, event 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,
&timestamp,
&event,
&expires,
&topic,
&msg,
@@ -742,7 +743,6 @@ func readMessage(rows *sql.Rows) (*message, error) {
&user,
&contentType,
&encoding,
&event,
)
if err != nil {
return nil, err
@@ -771,10 +771,6 @@ func readMessage(rows *sql.Rows) (*message, error) {
URL: attachmentURL,
}
}
// Clear SequenceID if it equals ID (we do not want the SequenceID in the message output)
if sequenceID == id {
sequenceID = ""
}
return &message{
ID: id,
SequenceID: sequenceID,

View File

@@ -81,13 +81,11 @@ var (
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}$`)
markReadPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/[-_A-Za-z0-9]{1,64}/read$`)
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"
@@ -111,7 +109,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?://`)
@@ -534,7 +532,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)
@@ -550,8 +548,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
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 && markReadPathRegex.MatchString(r.URL.Path) {
return s.limitRequestsWithTopic(s.authorizeTopicWrite(s.handleMarkRead))(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) {
@@ -908,14 +906,14 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
return s.handleActionMessage(w, r, v, messageDeleteEvent, s.sequenceIDFromPath)
return s.handleActionMessage(w, r, v, messageDeleteEvent)
}
func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request, v *visitor) error {
return s.handleActionMessage(w, r, v, messageReadEvent, s.sequenceIDFromMarkReadPath)
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, extractSequenceID func(string) (string, *errHTTP)) error {
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
@@ -927,7 +925,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
if !util.ContainsIP(s.config.VisitorRequestExemptPrefixes, v.ip) && !vrate.MessageAllowed() {
return errHTTPTooManyRequestsLimitMessages.With(t)
}
sequenceID, e := extractSequenceID(r.URL.Path)
sequenceID, e := s.sequenceIDFromPath(r.URL.Path)
if e != nil {
return e.With(t)
}
@@ -1362,7 +1360,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v
if err := json.NewEncoder(&buf).Encode(msg.forJSON()); err != nil {
return "", err
}
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent {
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
@@ -1781,15 +1779,6 @@ func (s *Server) sequenceIDFromPath(path string) (string, *errHTTP) {
return parts[2], nil
}
// sequenceIDFromMarkReadPath returns the sequence ID from a path like /mytopic/sequenceIdHere/read
func (s *Server) sequenceIDFromMarkReadPath(path string) (string, *errHTTP) {
parts := strings.Split(path, "/")
if len(parts) < 4 || parts[3] != "read" {
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()

View File

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

View File

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

View File

@@ -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": "",

View File

@@ -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"
)
@@ -3289,6 +3289,212 @@ 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 newTestConfig(t *testing.T) *Config {
conf := NewConfig()
conf.BaseURL = "http://127.0.0.1:12345"

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ const (
keepaliveEvent = "keepalive"
messageEvent = "message"
messageDeleteEvent = "message_delete"
messageReadEvent = "message_read"
messageClearEvent = "message_clear"
pollRequestEvent = "poll_request"
)
@@ -33,7 +33,7 @@ type message struct {
Event string `json:"event"` // One of the above
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message"` // Allow empty message body
Message string `json:"message,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
@@ -161,7 +161,7 @@ func newPollRequestMessage(topic, pollID string) *message {
return m
}
// newActionMessage creates a new action message (message_delete or message_read)
// 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
@@ -246,7 +246,7 @@ func parseQueryFilters(r *http.Request) (*queryFilter, error) {
}
func (q *queryFilter) Pass(msg *message) bool {
if msg.Event != messageEvent && msg.Event != messageDeleteEvent && msg.Event != messageReadEvent {
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

574
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "删除账户",

View File

@@ -3,13 +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, toNotificationParams } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n";
import { messageWithSequenceId } from "../src/app/utils";
import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../src/app/events";
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:
@@ -23,10 +26,17 @@ import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../src/
const broadcastChannel = new BroadcastChannel("web-push-broadcast");
const addNotification = async ({ subscriptionId, message }) => {
/**
* Handle a received web push message and show notification.
*
* Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running)
* receives the broadcast and plays a sound (see web/src/app/WebPush.js).
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
// Note: SubscriptionManager duplicates this logic, so if you change it here, change it there too
console.log("[ServiceWorker] Message received", data);
// Delete existing notification with same sequence ID (if any)
const sequenceId = message.sequence_id || message.id;
@@ -46,22 +56,9 @@ const addNotification = async ({ subscriptionId, message }) => {
last: message.id,
});
// Update badge in PWA
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.
*
* Since the service worker cannot play a sound, we send a broadcast to the web app, which (if it is running)
* receives the broadcast and plays a sound (see web/src/app/WebPush.js).
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
// Add notification to database
await addNotification({ subscriptionId, message });
// Broadcast the message to potentially play a sound
broadcastChannel.postMessage(message);
@@ -82,14 +79,21 @@ const handlePushMessage = async (data) => {
const handlePushMessageDelete = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
console.log("[ServiceWorker] Deleting notification sequence", data);
// Delete notification with the same sequence_id
const sequenceId = message.sequence_id;
if (sequenceId) {
console.log("[ServiceWorker] Deleting notification with sequenceId", { subscriptionId, sequenceId });
await db.notifications.where({ subscriptionId, sequenceId }).delete();
}
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
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,
@@ -97,19 +101,26 @@ const handlePushMessageDelete = async (data) => {
};
/**
* Handle a message_read event: mark the notification as read.
* Handle a message_clear event: clear/dismiss the notification.
*/
const handlePushMessageRead = async (data) => {
const handlePushMessageClear = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
console.log("[ServiceWorker] Marking notification as read", data);
// Mark notification as read (set new = 0)
const sequenceId = message.sequence_id;
if (sequenceId) {
console.log("[ServiceWorker] Marking notification as read", { subscriptionId, sequenceId });
await db.notifications.where({ subscriptionId, sequenceId }).modify({ new: 0 });
}
// Close browser notification with matching tag
const tag = message.sequence_id || message.id;
if (tag) {
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,
@@ -117,7 +128,6 @@ const handlePushMessageRead = async (data) => {
// Update badge count
const badgeCount = await db.notifications.where({ new: 1 }).count();
console.log("[ServiceWorker] Setting new app badge count", { badgeCount });
self.navigator.setAppBadge?.(badgeCount);
};
@@ -126,6 +136,7 @@ const handlePushMessageRead = async (data) => {
*/
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"),
@@ -141,6 +152,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"),
@@ -155,17 +167,26 @@ const handlePushUnknown = async (data) => {
* @param {object} data see server/types.go, type webPushPayload
*/
const handlePush = async (data) => {
if (data.event === EVENT_MESSAGE) {
await handlePushMessage(data);
} else if (data.event === EVENT_MESSAGE_DELETE) {
await handlePushMessageDelete(data);
} else if (data.event === EVENT_MESSAGE_READ) {
await handlePushMessageRead(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);
};
/**
@@ -176,10 +197,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) {
@@ -295,6 +314,7 @@ precacheAndRoute(
// Claim all open windows
clientsClaim();
// Delete any cached old dist files from previous service worker versions
cleanupOutdatedCaches();

View File

@@ -52,7 +52,7 @@ class Connection {
if (data.event === EVENT_OPEN) {
return;
}
// Accept message, message_delete, and message_read events
// 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.`);

View File

@@ -31,6 +31,21 @@ class Notifier {
);
}
async cancel(notification) {
if (!this.supported()) {
return;
}
try {
const tag = notification.sequence_id || notification.id;
console.log(`[Notifier] Cancelling notification with ${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();

View File

@@ -66,9 +66,7 @@ class Poller {
}
// Add only the latest notification for each non-deleted sequence
const notificationsToAdd = Object
.values(latestBySequenceId)
.filter(n => n.event === EVENT_MESSAGE);
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);

View File

@@ -2,8 +2,9 @@ import api from "./Api";
import notifier from "./Notifier";
import prefs from "./Prefs";
import db from "./db";
import { messageWithSequenceId, topicUrl } from "./utils";
import { EVENT_MESSAGE, EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "./events";
import { topicUrl } from "./utils";
import { messageWithSequenceId } from "./notificationUtils";
import { EVENT_MESSAGE, EVENT_MESSAGE_CLEAR, EVENT_MESSAGE_DELETE } from "./events";
class SubscriptionManager {
constructor(dbImpl) {
@@ -16,7 +17,7 @@ class SubscriptionManager {
return Promise.all(
subscriptions.map(async (s) => ({
...s,
new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count()
new: await this.db.notifications.where({ subscriptionId: s.id, new: 1 }).count(),
}))
);
}
@@ -84,7 +85,7 @@ class SubscriptionManager {
baseUrl,
topic,
mutedUntil: 0,
last: null
last: null,
};
await this.db.subscriptions.put(subscription);
@@ -102,7 +103,7 @@ class SubscriptionManager {
const local = await this.add(remote.base_url, remote.topic, {
displayName: remote.display_name, // May be undefined
reservation // May be null!
reservation, // May be null!
});
return local.id;
@@ -175,7 +176,7 @@ 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 || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_READ) {
if (exists || notification.event === EVENT_MESSAGE_DELETE || notification.event === EVENT_MESSAGE_CLEAR) {
return false;
}
try {
@@ -186,13 +187,13 @@ class SubscriptionManager {
await this.db.notifications.add({
...messageWithSequenceId(notification),
subscriptionId,
new: 1 // New marker (used for bubble indicator); cannot be boolean; Dexie index limitation
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
last: notification.id,
});
} catch (e) {
console.error(`[SubscriptionManager] Error adding notification`, e);
@@ -202,13 +203,14 @@ class SubscriptionManager {
/** Adds/replaces notifications, will not throw if they exist */
async addNotifications(subscriptionId, notifications) {
const notificationsWithSubscriptionId = notifications.map((notification) => {
return { ...messageWithSequenceId(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, {
last: lastNotificationId
last: lastNotificationId,
});
}
@@ -251,19 +253,19 @@ class SubscriptionManager {
async setMutedUntil(subscriptionId, mutedUntil) {
await this.db.subscriptions.update(subscriptionId, {
mutedUntil
mutedUntil,
});
}
async setDisplayName(subscriptionId, displayName) {
await this.db.subscriptions.update(subscriptionId, {
displayName
displayName,
});
}
async setReservation(subscriptionId, reservation) {
await this.db.subscriptions.update(subscriptionId, {
reservation
reservation,
});
}

View File

@@ -15,7 +15,14 @@ const createDatabase = (username) => {
subscriptions: "&id,baseUrl,[baseUrl+mutedUntil]",
notifications: "&id,sequenceId,subscriptionId,time,new,[subscriptionId+new],[subscriptionId+sequenceId]",
users: "&baseUrl,username",
prefs: "&key"
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;

View File

@@ -5,10 +5,11 @@ 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_READ = "message_read";
export const EVENT_MESSAGE_CLEAR = "message_clear";
export const EVENT_POLL_REQUEST = "poll_request";
// 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_READ;
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;

View File

@@ -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,7 +50,7 @@ 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 }) => {
export const toNotificationParams = ({ message, defaultTitle, topicRoute }) => {
const image = isImage(message.attachment) ? message.attachment.url : undefined;
// https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API
@@ -79,3 +79,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 };
};

View File

@@ -103,13 +103,6 @@ export const maybeActionErrors = (notification) => {
return actionErrors;
};
export const messageWithSequenceId = (message) => {
if (!message.sequenceId) {
message.sequenceId = message.sequence_id || message.id;
}
return message;
};
export const shuffle = (arr) => {
const returnArr = [...arr];

View File

@@ -12,7 +12,7 @@ import accountApi from "../app/AccountApi";
import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier";
import prefs from "../app/Prefs";
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_READ } from "../app/events";
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -51,15 +51,18 @@ export const useConnectionListeners = (account, subscriptions, users, webPushTop
};
const handleNotification = async (subscriptionId, notification) => {
// Note: This logic is duplicated in the Android app in SubscriberService::onNotificationReceived()
// and FirebaseService::handleMessage().
// 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) {
// Handle delete: remove notification from database
await subscriptionManager.deleteNotificationBySequenceId(subscriptionId, notification.sequence_id);
} else if (notification.event === EVENT_MESSAGE_READ && notification.sequence_id) {
// Handle read: mark notification as read
await notifier.cancel(notification);
} else if (notification.event === EVENT_MESSAGE_CLEAR && notification.sequence_id) {
await subscriptionManager.markNotificationReadBySequenceId(subscriptionId, notification.sequence_id);
await notifier.cancel(notification);
} else {
// Regular message: delete existing and add new
const sequenceId = notification.sequence_id || notification.id;

View File

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