Compare commits

...
62 Commits
Author SHA1 Message Date
binwiederhier 4f52663dda Fix exact count that caused the RO replicate to fall over... 2026-09-23 23:13:22 +00:00
binwiederhier 10cb6506f8 Bump 2026-08-27 20:16:48 +00:00
binwiederhier 7826c86f67 Remove message-poll-limit again, hardcode title/tags/etc limits, count polls toward bandwidth limit 2026-08-27 19:42:30 +00:00
binwiederhier cae7816e5d Add message-poll-limit ... 2026-08-27 16:38:21 +00:00
binwiederhier a0a0a5fde3 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-08-27 14:14:03 +00:00
binwiederhier fbcd4b6290 Expands bandwidth limit to also include excessive polling 2026-08-27 14:12:01 +00:00
Philipp C. Heckel f1bdb6bfe1 Merge pull request #1909 from Pimak/add-ntfy-logging-library
docs: add ntfy-logging to the Libraries section
2026-08-20 10:18:56 -04:00
PimakandClaude Opus 5 2f53e15a20 docs: add ntfy-logging to the Libraries section
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:16:24 +02:00
Philipp C. Heckel 4c2b69e059 Merge pull request #1885 from binwiederhier/revert-1882-main
Revert "add tzdata to docker arm build"
2026-08-04 07:24:30 +02:00
Philipp C. Heckel ea6b1ca520 Revert "add tzdata to docker arm build" 2026-08-04 01:24:15 -04:00
binwiederhier 423063893d Bump install notes 2026-08-04 07:05:29 +02:00
Philipp C. Heckel 143b9fb55c Merge pull request #1847 from nexus-uw/patch-2
Add action and template directories to Dockerfile-build
2026-08-04 06:47:50 +02:00
Philipp C. Heckel 3104ad20e2 Merge pull request #1820 from houllette/add-ex-ntfy-library
Add ex_ntfy (Elixir library) to integrations
2026-08-04 06:47:03 +02:00
Philipp C. Heckel 00e3c1351a Merge pull request #1880 from rubixvi/patch-1
remove 404 abandoned project
2026-08-04 06:46:34 +02:00
Philipp C. Heckel c23dc0b30c Merge pull request #1882 from 0xpsyduck/main
add tzdata to docker arm build
2026-08-04 06:46:13 +02:00
Philipp C. Heckel 01af61d228 Merge pull request #1884 from binwiederhier/dependabot/github_actions/all-85123b4e12
Bump docker/login-action from 4.4.0 to 4.5.2 in the all group across 1 directory
2026-08-04 06:40:10 +02:00
binwiederhier 9328f1f3c8 Bump fast-uri 2026-08-03 23:07:09 +02:00
dependabot[bot] 61873b593f Bump docker/login-action in the all group across 1 directory
Bumps the all group with 1 update in the / directory: [docker/login-action](https://github.com/docker/login-action).


Updates `docker/login-action` from 4.4.0 to 4.5.2
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/af1e73f918a031802d376d3c8bbc3fe56130a9b0...371161bbe7024a29a25c5e19bfcbc0804fe9ad2c)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.5.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 20:41:24 +00:00
binwiederhier 9dc30e4a97 Update GH actions 2026-08-03 22:39:01 +02:00
binwiederhier cd333f130f Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-08-03 18:38:38 +02:00
binwiederhier f42326605e Update deps 2026-08-03 18:38:29 +02:00
binwiederhier 7ed7fea081 Limit memory usage in templates 2026-08-03 18:34:29 +02:00
psyduck 01c13e186a add tzdata to docker arm build 2026-08-03 07:39:54 +00:00
binwiederhier 0ecba37334 Combine message dispatching into a dispatch function 2026-08-03 08:11:51 +02:00
Vincent Vu fc808db251 remove 404 project
Removed duplicate entry for 'ntfy-desktop' and updated the list of integrations.
2026-08-02 19:22:41 +10:00
Philipp C. Heckel dc11655153 Merge pull request #1874 from binwiederhier/schema-compare-tests
Schema compare tests
2026-07-29 08:10:20 +02:00
binwiederhier bd96177fdf Move pg tests to its own file 2026-07-29 08:05:50 +02:00
binwiederhier a3d43190c9 Harden migration 2026-07-29 07:25:12 +02:00
binwiederhier 95ad323d1c Schema compare tests 2026-07-29 07:12:28 +02:00
Philipp C. Heckel e7efdaeb3b Merge pull request #1873 from binwiederhier/user-schema-migration
Use schema/ package in user/ package
2026-07-29 06:45:43 +02:00
binwiederhier 244a9bc06d Use schema/ package in user/ package 2026-07-29 06:23:24 +02:00
Philipp C. Heckel 5e13ca05d5 Merge pull request #1871 from binwiederhier/message-cache-migration2
Use schema migration lib for message cache
2026-07-28 23:02:29 +02:00
binwiederhier fdaf3316a0 Use schema migration lib for message cache 2026-07-28 22:22:13 +02:00
Philipp C. Heckel 310a5aa8df Merge pull request #1868 from binwiederhier/schema-migration
Schema migration library
2026-07-28 20:49:35 +02:00
Edgars Andersons 0ff1cd5bab Translated using Weblate (Latvian)
Currently translated at 26.7% (123 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/lv/
2026-07-28 14:02:00 +00:00
binwiederhier 4abdeb8d57 Comment 2026-07-28 07:34:27 +02:00
binwiederhier 7fb1d25740 Restructure a little 2026-07-27 23:48:43 +02:00
binwiederhier ef121a3f6c Schema migration 2026-07-27 17:51:32 +02:00
Vadym Nekhai 4a0f66e258 Translated using Weblate (Ukrainian)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-26 13:02:00 +02:00
Oğuz Ersen 53bbd12cd7 Translated using Weblate (Turkish)
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/tr/
2026-07-26 13:01:54 +02:00
Vadym Nekhai e620fbe95b Translated using Weblate (Ukrainian)
Currently translated at 98.2% (451 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-24 10:50:01 +02:00
Joker88 b703627d7f Translated using Weblate (Russian)
Currently translated at 88.0% (404 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/ru/
2026-07-24 10:50:01 +02:00
Vadym Nekhai 6f4f9e6407 Translated using Weblate (Ukrainian)
Currently translated at 96.9% (445 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/uk/
2026-07-23 17:01:27 +02:00
binwiederhier f2d5c1ce6c Do not include secrets in the config hash 2026-07-23 08:13:27 +02:00
binwiederhier 7680cb4906 Ban-feed 2026-07-20 23:49:17 +02:00
binwiederhier 706fa3b491 Remove "experimental" from postgres option 2026-07-20 23:48:17 +02:00
binwiederhier 2bc145f3ae Merge branch 'release-2.26.x' 2026-07-20 23:42:03 +02:00
binwiederhier 311138ef7b Derp 2026-07-20 23:23:42 +02:00
binwiederhier f6b03b44dd Bump release notes 2026-07-20 23:10:22 +02:00
binwiederhier c674985699 Redo the banning logic, ban.Service 2026-07-20 21:34:47 +02:00
binwiederhier 469d263a5c Hotfix: Add ban-file/ban-threshold/ban-weights as a more lightweight mechanism abuse counter 2026-07-18 08:41:25 +02:00
binwiederhier f8d2fcd7a6 Move metrics to metrics/ pacakge 2026-07-17 22:08:21 +02:00
binwiederhier ac63a2eea0 Move Twilio to twilio/ package 2026-07-17 13:48:48 +02:00
Simon Ramsay 07e1260b89 Add action and template directories to Dockerfile-build
Added new directories for action and template to the build process.
2026-07-16 20:47:51 -07:00
dashhrafa af4d85ec03 Translated using Weblate (Portuguese (Brazil))
Currently translated at 100.0% (459 of 459 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/pt_BR/
2026-07-16 22:01:23 +02:00
binwiederhier 24bc50b585 Allow logging in via email 2026-07-16 21:42:42 +02:00
binwiederhier b55e78a918 Release notes 2026-07-12 10:23:38 +02:00
Philipp C. Heckel 6638699d48 Merge pull request #1830 from binwiederhier/template-exec-context
Template exec context, redone
2026-07-10 21:19:38 +02:00
binwiederhier 3f56dae54a Template exec context, redone 2026-07-10 13:18:11 +02:00
binwiederhier 1e4e3b6e36 Remove unreachable link 2026-07-09 23:07:07 +02:00
binwiederhier 75c687de1c Bump Android pre-release 2026-07-09 22:50:11 +02:00
Holden Oullette b15213e531 Add ex_ntfy (Elixir library) to integrations 2026-07-05 22:32:25 -06:00
92 changed files with 5372 additions and 2609 deletions
+3 -3
View File
@@ -8,13 +8,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go - name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version-file: '.go-version' go-version-file: '.go-version'
- name: Install node - name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: '24' node-version: '24'
cache: 'npm' cache: 'npm'
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps: steps:
- -
name: Checkout ntfy code name: Checkout ntfy code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- -
name: Checkout docs pages code name: Checkout docs pages code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with: with:
repository: binwiederhier/ntfy-docs.github.io repository: binwiederhier/ntfy-docs.github.io
path: build/ntfy-docs.github.io path: build/ntfy-docs.github.io
+4 -4
View File
@@ -25,19 +25,19 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go - name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version-file: '.go-version' go-version-file: '.go-version'
- name: Install node - name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: '24' node-version: '24'
cache: 'npm' cache: 'npm'
cache-dependency-path: './web/package-lock.json' cache-dependency-path: './web/package-lock.json'
- name: Docker login - name: Docker login
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with: with:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }} password: ${{ secrets.DOCKER_HUB_TOKEN }}
+3 -3
View File
@@ -25,13 +25,13 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }} NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go - name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with: with:
go-version-file: '.go-version' go-version-file: '.go-version'
- name: Install node - name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version: '24' node-version: '24'
cache: 'npm' cache: 'npm'
+1 -1
View File
@@ -1 +1 @@
1.26.5 1.27.0
+1 -1
View File
@@ -60,7 +60,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier), reported to the community leaders responsible for enforcement via Discord/Matrix (binwiederhier),
or email (ntfy@heckel.io). All complaints will be reviewed and investigated promptly or email (contact@mail.ntfy.sh). All complaints will be reviewed and investigated promptly
and fairly. and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the
+3
View File
@@ -48,6 +48,9 @@ ADD ./webpush ./webpush
ADD ./attachment ./attachment ADD ./attachment ./attachment
ADD ./mail ./mail ADD ./mail ./mail
ADD ./s3 ./s3 ADD ./s3 ./s3
ADD ./action ./action
ADD ./template/gotext ./template/gotext
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build make VERSION=$VERSION COMMIT=$COMMIT cli-linux-server
FROM alpine FROM alpine
+185
View File
@@ -0,0 +1,185 @@
// Package ban implements the abuse ban-feed: it tracks per-prefix weighted "strikes" from rejected
// requests and appends breaching prefixes to a ban file that fail2ban tails. Keying by prefix (not
// by visitor) makes the accounting match the unit fail2ban bans, even for shared account visitors.
package ban
import (
"fmt"
"net/netip"
"os"
"sync"
"time"
"golang.org/x/time/rate"
"heckel.io/ntfy/v2/log"
)
const (
tag = "ban"
pruneInterval = 10 * time.Minute
writeInterval = 3 * time.Second
)
// Config is the Service's config, kept separate from server.Config to avoid an import cycle.
type Config struct {
File string // Ban file that fail2ban tails (must be non-empty; the caller decides whether the feature is enabled)
Window time.Duration // Rolling window over which weighted strikes are counted
Threshold int // Weighted strikes per Window before a prefix is banned
Weights Weights // Code matcher -> strike weight (0 = exempt)
PrefixBitsIPv4 int // Mask width for the ban unit, e.g. 32 (matches rate-limiting granularity)
PrefixBitsIPv6 int // Mask width for the ban unit, e.g. 64
}
// tracker is the per-prefix strike state: a weighted breach detector plus timestamps for pruning and throttling.
type tracker struct {
limiter *rate.Limiter
seen time.Time // Last strike, for pruning idle prefixes
emitted time.Time // Last ban-line write for this prefix, throttles re-emits to once per Window
}
// Service owns the ban-feed: per-prefix strike accounting, buffered file writes, and idle-prefix
// pruning. The caller owns the enable/disable decision -- only construct a Service when the feature
// is on (see server.New, which builds one only when a ban file is configured).
type Service struct {
conf *Config
mu sync.Mutex // Guards trackers and pending
trackers map[netip.Prefix]*tracker
pending []string // Formatted ban lines buffered by Record, flushed to the ban file by runWriteLoop
writeDone chan struct{} // Closed when runWriteLoop exits after its final flush
closeChan chan struct{}
closeOnce sync.Once
}
// NewService builds a Service and starts its background loops. The caller must only call it when the
// feature is enabled (conf non-nil, File non-empty); the Service does not model a disabled state.
func NewService(conf *Config) *Service {
s := &Service{
conf: conf,
trackers: make(map[netip.Prefix]*tracker),
closeChan: make(chan struct{}),
writeDone: make(chan struct{}),
}
go s.runPruneLoop()
go s.runWriteLoop()
return s
}
// Record counts one rejection against the IP's prefix bucket and, on breach, buffers a ban line
// (throttled to once per Window per prefix). No-ops for a non-4xx/5xx status or a zero-weight code.
func (s *Service) Record(ip netip.Addr, httpCode, errorCode int) {
if httpCode < 400 {
return
}
weight := s.conf.Weights.WeightFor(errorCode)
if weight == 0 {
return // Weight 0: exempt, no strike
}
prefix := s.prefix(ip)
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
t := s.trackers[prefix]
if t == nil {
t = &tracker{limiter: rate.NewLimiter(rate.Limit(float64(s.conf.Threshold)/s.conf.Window.Seconds()), s.conf.Threshold)}
s.trackers[prefix] = t
}
t.seen = now
if t.limiter.AllowN(now, weight) {
return // Within the strike budget, no breach
}
if !t.emitted.IsZero() && now.Sub(t.emitted) < s.conf.Window {
return // Already emitted this prefix within the window (one ban line per prefix per window)
}
t.emitted = now
s.pending = append(s.pending, formatBanLine(now, ip, prefix, httpCode, errorCode))
}
// prefix masks ip to the ban unit (PrefixBitsIPv4/IPv6) -- what fail2ban bans, e.g. a whole /64.
func (s *Service) prefix(ip netip.Addr) netip.Prefix {
if ip.Is4() {
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv4).Masked()
}
return netip.PrefixFrom(ip, s.conf.PrefixBitsIPv6).Masked()
}
// formatBanLine builds the "<RFC3339-UTC> <ip> <prefix> <http> <ntfy>" line the fail2ban filter
// parses. The timestamp is captured at breach time, not flush time.
func formatBanLine(t time.Time, ip netip.Addr, prefix netip.Prefix, httpCode, errorCode int) string {
return fmt.Sprintf("%s %s %s %d %d\n", t.UTC().Format(time.RFC3339), ip.String(), prefix.String(), httpCode, errorCode)
}
// runPruneLoop prunes idle prefixes until Close.
func (s *Service) runPruneLoop() {
ticker := time.NewTicker(pruneInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.prune()
case <-s.closeChan:
return
}
}
}
// runWriteLoop flushes buffered ban lines every writeInterval, plus a final flush on Close.
func (s *Service) runWriteLoop() {
defer close(s.writeDone)
ticker := time.NewTicker(writeInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.flush()
case <-s.closeChan:
s.flush()
return
}
}
}
// flush appends the buffered lines to the file in one open/write. Best-effort: a batch is dropped on
// error. Concurrent calls are safe -- pending is drained under mu, so only one flush writes a batch.
func (s *Service) flush() {
s.mu.Lock()
lines := s.pending
s.pending = nil
s.mu.Unlock()
if len(lines) == 0 {
return
}
f, err := os.OpenFile(s.conf.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
log.Tag(tag).Err(err).Warn("Cannot open ban file %s, dropped %d ban(s)", s.conf.File, len(lines))
return
}
defer f.Close()
for i, line := range lines {
if _, err := f.WriteString(line); err != nil {
log.Tag(tag).Err(err).Warn("Cannot write to ban file %s, dropped %d ban(s)", s.conf.File, len(lines)-i)
return
}
}
}
// prune drops prefixes idle for a full Window -- their bucket has refilled, so forgetting them is a
// no-op that bounds memory under a flood of distinct IPs.
func (s *Service) prune() {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for prefix, t := range s.trackers {
if now.Sub(t.seen) >= s.conf.Window {
delete(s.trackers, prefix)
}
}
}
// Close stops the loops and blocks until the final flush completes. Idempotent.
func (s *Service) Close() {
s.closeOnce.Do(func() {
close(s.closeChan)
<-s.writeDone
})
}
+253
View File
@@ -0,0 +1,253 @@
package ban
import (
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
var testIP = netip.MustParseAddr("1.2.3.4")
// newTestService creates a Service wired for testing, with the given ban file, weighted-bucket
// threshold, and weights, plus a 1-minute window (so the emit throttle only fires once per test).
func newTestService(t *testing.T, banFile string, threshold int, weights map[string]int) *Service {
t.Helper()
s := NewService(&Config{
File: banFile,
Window: time.Minute,
Threshold: threshold,
Weights: weights,
PrefixBitsIPv4: 32,
PrefixBitsIPv6: 64,
})
t.Cleanup(s.Close)
return s
}
// flushAndRead forces a synchronous flush of the buffered bans (writes are otherwise async, on the
// runWriteLoop ticker) and returns the ban file's lines.
func flushAndRead(t *testing.T, s *Service, path string) []string {
t.Helper()
s.flush()
data, err := os.ReadFile(path)
require.NoError(t, err)
return strings.Split(strings.TrimRight(string(data), "\n"), "\n")
}
func TestService_Record_Weight2BansAtHalfThreshold(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 2 -> the budget covers exactly 5 hits, so the 6th breaches.
s := newTestService(t, banFile, 10, map[string]int{"*": 2})
for i := 0; i < 5; i++ {
s.Record(testIP, 400, 40001)
}
s.flush()
require.NoFileExists(t, banFile) // 5 hits * weight 2 = 10 == budget, exactly at the limit, not over
s.Record(testIP, 400, 40001) // 6th hit cannot be covered -> breach
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001")) // <ip> <prefix> <http> <ntfy-code>
}
func TestService_Record_Weight10BansFast(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// Threshold 10, code weight 10 -> a single hit drains the whole budget, so the 2nd breaches.
s := newTestService(t, banFile, 10, map[string]int{"42909": 10, "*": 1})
s.Record(testIP, 429, 42909)
s.flush()
require.NoFileExists(t, banFile)
s.Record(testIP, 429, 42909) // 2nd hit cannot be covered -> breach
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42909"))
}
func TestService_Record_Weight0NeverBans(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// The legit-quota code is exempt (weight 0), so no number of hits ever bans.
s := newTestService(t, banFile, 10, map[string]int{"42908": 0, "*": 1})
for i := 0; i < 100; i++ {
s.Record(testIP, 429, 42908)
}
s.flush()
require.NoFileExists(t, banFile)
}
func TestService_Record_SingleBucketNoRelaxation(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
// One shared bucket per prefix: different codes draw down the SAME budget, so mixing them creates
// no extra headroom (unlike per-code buckets, which would relax the effective limit for a mixed
// offender).
s := newTestService(t, banFile, 10, map[string]int{"403*": 2, "*": 1})
s.Record(testIP, 403, 40301) // weight 2 -> 8 left
s.Record(testIP, 403, 40301) // weight 2 -> 6 left
s.Record(testIP, 403, 40301) // weight 2 -> 4 left
s.flush()
require.NoFileExists(t, banFile)
for i := 0; i < 4; i++ {
s.Record(testIP, 400, 40001) // weight 1 each -> drains the remaining 4 -> 0 left
}
s.flush()
require.NoFileExists(t, banFile) // 6 + 4 = 10 == budget exactly, still not over
s.Record(testIP, 400, 40001) // one more cannot be covered -> breach
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
}
func TestService_Record_ExactLineFormat(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
before := time.Now().UTC().Truncate(time.Second)
for i := 0; i < 3; i++ {
s.Record(testIP, 429, 42901)
}
after := time.Now().UTC()
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
parts := strings.Split(lines[0], " ")
require.Len(t, parts, 5) // "<timestamp> <ip> <prefix> <http-code> <ntfy-code>"
ts, err := time.Parse(time.RFC3339, parts[0])
require.NoError(t, err)
require.Equal(t, time.UTC, ts.Location())
require.False(t, ts.Before(before))
require.False(t, ts.After(after.Add(time.Second)))
require.Equal(t, "1.2.3.4", parts[1]) // full IP
require.Equal(t, "1.2.3.4/32", parts[2]) // masked to the default IPv4 prefix (/32)
require.Equal(t, "429", parts[3]) // HTTP status
require.Equal(t, "42901", parts[4]) // ntfy code
}
func TestService_Record_IPv6MaskedToPrefix(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
ip := netip.MustParseAddr("2001:db8::abcd")
for i := 0; i < 3; i++ {
s.Record(ip, 429, 42901)
}
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
require.Len(t, parts, 5)
require.Equal(t, "2001:db8::abcd", parts[1]) // full IPv6 address
require.Equal(t, "2001:db8::/64", parts[2]) // masked to the default IPv6 prefix (/64)
}
func TestService_Record_PerPrefixIsolation(t *testing.T) {
// Each source prefix gets its own bucket: one IP hammering to a breach must not push a different,
// quiet IP over the edge. This is the whole point of keying by prefix instead of by visitor.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 2, map[string]int{"*": 1})
noisy := netip.MustParseAddr("1.1.1.1")
quiet := netip.MustParseAddr("2.2.2.2")
for i := 0; i < 5; i++ {
s.Record(noisy, 429, 42901) // breaches its own bucket
}
s.Record(quiet, 429, 42901) // single hit, well under threshold
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1) // only the noisy prefix is written
require.True(t, strings.HasSuffix(lines[0], " 1.1.1.1 1.1.1.1/32 429 42901"))
}
func TestService_Record_OncePerWindowThrottle(t *testing.T) {
// Once a prefix has been written, further breaches within the window must not re-append it, so a
// persistent offender produces exactly one line per window (mirrors the old per-visitor banEmit).
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
for i := 0; i < 50; i++ {
s.Record(testIP, 429, 42901)
}
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
}
func TestService_Record_BansPassedIP(t *testing.T) {
// The Service bans the exact IP passed to Record -- the caller passes the offending request's IP,
// which for an account-keyed visitor is not the visitor's stored IP.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
offender := netip.MustParseAddr("5.6.7.8")
for i := 0; i < 3; i++ {
s.Record(offender, 429, 42901)
}
parts := strings.Split(flushAndRead(t, s, banFile)[0], " ")
require.Equal(t, "5.6.7.8", parts[1]) // the IP passed to Record
require.Equal(t, "5.6.7.8/32", parts[2]) // its prefix
}
func TestService_Record_Ignores2xx3xx(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 3, map[string]int{"*": 1})
// Success and redirects must never count toward a ban, even over the threshold -- otherwise a
// legit high-volume publisher (lots of 200s) would get banned.
for i := 0; i < 20; i++ {
s.Record(testIP, 200, 20000)
s.Record(testIP, 302, 30000)
}
s.flush()
require.NoFileExists(t, banFile)
// A 4xx over the same budget still gets written.
for i := 0; i < 5; i++ {
s.Record(testIP, 400, 40001)
}
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 400 40001"))
}
func TestService_Record_BuffersUntilFlush(t *testing.T) {
// Writes are async: a breach buffers the ban line rather than writing it synchronously on the
// request path. The line only reaches the file when runWriteLoop (or an explicit flush) runs.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 1, map[string]int{"*": 1})
for i := 0; i < 3; i++ {
s.Record(testIP, 429, 42901)
}
require.NoFileExists(t, banFile) // not written synchronously
s.mu.Lock()
require.Len(t, s.pending, 1) // one line buffered (throttled to once per window)
s.mu.Unlock()
lines := flushAndRead(t, s, banFile)
require.Len(t, lines, 1)
require.True(t, strings.HasSuffix(lines[0], " 1.2.3.4 1.2.3.4/32 429 42901"))
}
func TestService_Close_FlushesPending(t *testing.T) {
// Close must flush buffered bans so nothing is lost on shutdown, and must block until it has.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := NewService(&Config{File: banFile, Window: time.Minute, Threshold: 1, Weights: Weights{"*": 1}, PrefixBitsIPv4: 32, PrefixBitsIPv6: 64})
for i := 0; i < 3; i++ {
s.Record(testIP, 429, 42901)
}
require.NoFileExists(t, banFile) // still buffered
s.Close() // blocks until the final flush completes
data, err := os.ReadFile(banFile)
require.NoError(t, err)
require.Len(t, strings.Split(strings.TrimRight(string(data), "\n"), "\n"), 1)
}
func TestService_Prune_DropsIdlePrefixes(t *testing.T) {
// A prefix idle for a full window has a refilled bucket, so prune drops it to bound memory. An
// active prefix (seen within the window) is kept.
banFile := filepath.Join(t.TempDir(), "ban.log")
s := newTestService(t, banFile, 10, map[string]int{"*": 1})
idle := netip.MustParseAddr("9.9.9.9")
s.Record(idle, 400, 40001)
s.Record(testIP, 400, 40001)
require.Len(t, s.trackers, 2)
// Backdate the idle prefix past the window, then prune.
idlePrefix := s.prefix(idle)
s.mu.Lock()
s.trackers[idlePrefix].seen = time.Now().Add(-2 * time.Minute)
s.mu.Unlock()
s.prune()
require.Len(t, s.trackers, 1)
_, ok := s.trackers[idlePrefix]
require.False(t, ok) // idle prefix dropped
_, ok = s.trackers[s.prefix(testIP)]
require.True(t, ok) // active prefix kept
}
+83
View File
@@ -0,0 +1,83 @@
package ban
import (
"fmt"
"strconv"
"strings"
)
// Weights maps a matcher key to a strike weight for the abuse ban-feed (see ParseWeights, WeightFor).
type Weights map[string]int
// ParseWeights normalizes a list like ["42909:10","403:2","*:1"] into a Weights map. A key is an
// exact ntfy code, a family ("429*"), a bare HTTP status ("403" -> "403*"), or "*"; weights are ints
// >= 0 (0 = exempt). Malformed entries are rejected so misconfiguration fails at startup.
func ParseWeights(entries []string) (Weights, error) {
out := make(Weights, len(entries))
for _, entry := range entries {
key, weightStr, ok := strings.Cut(entry, ":")
if !ok {
return nil, fmt.Errorf("invalid ban-weight %q, want KEY:WEIGHT", entry)
}
weight, err := strconv.Atoi(strings.TrimSpace(weightStr))
if err != nil || weight < 0 {
return nil, fmt.Errorf("invalid ban-weight value in %q, want a non-negative integer", entry)
}
key = strings.TrimSpace(key)
if !validWeightKey(key) {
return nil, fmt.Errorf("invalid ban-weight key in %q, want %q, an ntfy code, an HTTP status, or a PREFIX*", entry, "*")
}
// A bare 3-digit HTTP status is shorthand for the whole family (e.g. "403" -> "403*").
if len(key) == 3 && isAllDigits(key) {
key += "*"
}
out[key] = weight
}
return out, nil
}
// WeightFor returns the strike weight for an ntfy error code, longest-match-wins (exact > family > "*").
// If nothing matches (no "*" catch-all) it returns the implied default 1, so a forgotten "*" still
// bans; use "*:0" to exempt everything not explicitly weighted.
func (w Weights) WeightFor(errorCode int) int {
code := strconv.Itoa(errorCode)
weight, bestLen, matched := 0, -1, false
for key, wt := range w {
matchLen := -1
switch {
case key == "*":
matchLen = 0
case strings.HasSuffix(key, "*"):
if prefix := strings.TrimSuffix(key, "*"); strings.HasPrefix(code, prefix) {
matchLen = len(prefix)
}
case key == code:
matchLen = len(code)
}
if matchLen > bestLen {
weight, bestLen, matched = wt, matchLen, true
}
}
if !matched {
return 1
}
return weight
}
// validWeightKey reports whether key is a legal matcher: "*", an all-digits code, or DIGITS*.
func validWeightKey(key string) bool {
if key == "*" {
return true
}
digits := strings.TrimSuffix(key, "*")
return digits != "" && isAllDigits(digits)
}
func isAllDigits(s string) bool {
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return s != ""
}
+73
View File
@@ -0,0 +1,73 @@
package ban
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestParseWeights(t *testing.T) {
// Exact codes, a bare 3-digit HTTP status (normalized to a family), an exempt code, and "*".
weights, err := ParseWeights([]string{"42909:10", "403:2", "42908:0", "*:1"})
require.NoError(t, err)
require.Equal(t, Weights{"42909": 10, "403*": 2, "42908": 0, "*": 1}, weights)
// A bare 3-digit HTTP status normalizes to its family.
weights, err = ParseWeights([]string{"429:5"})
require.NoError(t, err)
require.Equal(t, Weights{"429*": 5}, weights)
// An explicit family key stays as-is.
weights, err = ParseWeights([]string{"429*:5"})
require.NoError(t, err)
require.Equal(t, Weights{"429*": 5}, weights)
// Weight 0 is valid and means exempt.
weights, err = ParseWeights([]string{"42908:0"})
require.NoError(t, err)
require.Equal(t, Weights{"42908": 0}, weights)
_, err = ParseWeights([]string{"401"}) // Missing weight
require.Error(t, err)
_, err = ParseWeights([]string{"401:-1"}) // Negative weight
require.Error(t, err)
_, err = ParseWeights([]string{"401:abc"}) // Non-integer weight
require.Error(t, err)
_, err = ParseWeights([]string{"abc:10"}) // Non-numeric key
require.Error(t, err)
_, err = ParseWeights([]string{"4*3:10"}) // Star not at the end
require.Error(t, err)
}
func TestWeights_WeightFor(t *testing.T) {
weights, err := ParseWeights([]string{"42908:0", "42903:0", "42905:0", "42910:0", "42909:10", "429*:1", "403*:2", "4*:1", "5*:1"})
require.NoError(t, err)
// Longest-match-wins: exact 5-digit beats "429*" beats "4*" beats "*".
require.Equal(t, 0, weights.WeightFor(42908))
require.Equal(t, 10, weights.WeightFor(42909))
require.Equal(t, 1, weights.WeightFor(42901))
require.Equal(t, 2, weights.WeightFor(40311))
require.Equal(t, 1, weights.WeightFor(40011))
require.Equal(t, 1, weights.WeightFor(50312))
// No rule matches (this config has 4*/5* but no "*"), so the implied default weight 1 applies.
require.Equal(t, 1, weights.WeightFor(30012))
}
func TestWeights_WeightFor_NoStarRuleImpliesWeight1(t *testing.T) {
// With no "*" rule, a code that matches nothing defaults to weight 1 (can be banned), so the
// feature can't be silently turned into a no-op by forgetting "*". Explicit codes still win.
weights, err := ParseWeights([]string{"42908:0", "42909:10"})
require.NoError(t, err)
require.Equal(t, 0, weights.WeightFor(42908)) // explicitly exempt
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
require.Equal(t, 1, weights.WeightFor(42901)) // unmatched -> implied 1
require.Equal(t, 1, weights.WeightFor(40001)) // unmatched -> implied 1
}
func TestWeights_WeightFor_ExplicitStarZeroExemptsAll(t *testing.T) {
// An explicit "*:0" is the opt-out: exempt everything not otherwise weighted.
weights, err := ParseWeights([]string{"42909:10", "*:0"})
require.NoError(t, err)
require.Equal(t, 10, weights.WeightFor(42909)) // explicit
require.Equal(t, 0, weights.WeightFor(42901)) // *:0 -> exempt everything else
}
+33 -1
View File
@@ -10,6 +10,7 @@ import (
"net" "net"
"net/netip" "net/netip"
"net/url" "net/url"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"text/template" "text/template"
@@ -17,6 +18,7 @@ import (
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc" "github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/server"
@@ -87,7 +89,7 @@ var flagsServe = append(
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-subscription-limit", Aliases: []string{"visitor_subscription_limit"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIPTION_LIMIT"}, Value: server.DefaultVisitorSubscriptionLimit, Usage: "number of subscriptions per visitor"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "visitor-subscriber-rate-limiting", Aliases: []string{"visitor_subscriber_rate_limiting"}, EnvVars: []string{"NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING"}, Value: false, Usage: "enables subscriber-based rate limiting"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-total-size-limit", Aliases: []string{"visitor_attachment_total_size_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultVisitorAttachmentTotalSizeLimit), Usage: "total storage limit used for attachments per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily attachment download/upload bandwidth limit per visitor"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-attachment-daily-bandwidth-limit", Aliases: []string{"visitor_attachment_daily_bandwidth_limit"}, EnvVars: []string{"NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT"}, Value: "500M", Usage: "total daily bandwidth limit per visitor, for attachment downloads/uploads and messages replayed from the cache by poll requests"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-request-limit-burst", Aliases: []string{"visitor_request_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_BURST"}, Value: server.DefaultVisitorRequestLimitBurst, Usage: "initial limit of requests per visitor"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-replenish", Aliases: []string{"visitor_request_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorRequestLimitReplenish), Usage: "interval at which burst limit is replenished (one per x)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-request-limit-exempt-hosts", Aliases: []string{"visitor_request_limit_exempt_hosts"}, EnvVars: []string{"NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS"}, Value: "", Usage: "hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit"}),
@@ -98,6 +100,10 @@ var flagsServe = append(
altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "visitor-topic-creation-limit-replenish", Aliases: []string{"visitor_topic_creation_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH"}, Value: util.FormatDuration(server.DefaultVisitorTopicCreationLimitReplenish), Usage: "interval at which topic-creation tokens are refilled (one per x)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv4", Aliases: []string{"visitor_prefix_bits_ipv4"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV4"}, Value: server.DefaultVisitorPrefixBitsIPv4, Usage: "number of bits of the IPv4 address to use for rate limiting (default: 32, full address)"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}), altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-prefix-bits-ipv6", Aliases: []string{"visitor_prefix_bits_ipv6"}, EnvVars: []string{"NTFY_VISITOR_PREFIX_BITS_IPV6"}, Value: server.DefaultVisitorPrefixBitsIPv6, Usage: "number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-file", Aliases: []string{"ban_file"}, EnvVars: []string{"NTFY_BAN_FILE"}, Value: "", Usage: "if set, append IPs of abusive visitors to this file for fail2ban to tail (empty disables)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "ban-window", Aliases: []string{"ban_window"}, EnvVars: []string{"NTFY_BAN_WINDOW"}, Value: util.FormatDuration(server.DefaultBanWindow), Usage: "rolling window over which weighted strikes are counted for the ban file"}),
altsrc.NewIntFlag(&cli.IntFlag{Name: "ban-threshold", Aliases: []string{"ban_threshold"}, EnvVars: []string{"NTFY_BAN_THRESHOLD"}, Value: server.DefaultBanThreshold, Usage: "weighted strikes per window before an offender is banned"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "ban-weights", Aliases: []string{"ban_weights"}, EnvVars: []string{"NTFY_BAN_WEIGHTS"}, Value: cli.NewStringSlice(server.DefaultBanWeights...), Usage: "per-code strike weights as KEY:WEIGHT, where KEY is an ntfy code, an HTTP status, a PREFIX*, or '*' (weight 0 exempts)"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}), altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-forwarded-header", Aliases: []string{"proxy_forwarded_header"}, EnvVars: []string{"NTFY_PROXY_FORWARDED_HEADER"}, Value: "X-Forwarded-For", Usage: "use specified header to determine visitor IP address (for rate limiting)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}), altsrc.NewStringFlag(&cli.StringFlag{Name: "proxy-trusted-hosts", Aliases: []string{"proxy_trusted_hosts"}, EnvVars: []string{"NTFY_PROXY_TRUSTED_HOSTS"}, Value: "", Usage: "comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header"}),
@@ -215,6 +221,10 @@ func execServe(c *cli.Context) error {
visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish") visitorTopicCreationLimitReplenishStr := c.String("visitor-topic-creation-limit-replenish")
visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4") visitorPrefixBitsIPv4 := c.Int("visitor-prefix-bits-ipv4")
visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6") visitorPrefixBitsIPv6 := c.Int("visitor-prefix-bits-ipv6")
banFile := c.String("ban-file")
banWindowStr := c.String("ban-window")
banThreshold := c.Int("ban-threshold")
banWeightsRaw := c.StringSlice("ban-weights")
behindProxy := c.Bool("behind-proxy") behindProxy := c.Bool("behind-proxy")
proxyForwardedHeader := c.String("proxy-forwarded-header") proxyForwardedHeader := c.String("proxy-forwarded-header")
proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",") proxyTrustedHosts := util.SplitNoEmpty(c.String("proxy-trusted-hosts"), ",")
@@ -270,6 +280,16 @@ func execServe(c *cli.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr) return fmt.Errorf("invalid web push expiry warning duration: %s", webPushExpiryWarningDurationStr)
} }
banWindow, err := util.ParseDuration(banWindowStr)
if err != nil {
return fmt.Errorf("invalid ban window: %s", banWindowStr)
}
// Parse abuse ban-feed weights ("KEY:WEIGHT" list, "*" fallback)
banWeights, err := ban.ParseWeights(banWeightsRaw)
if err != nil {
return err
}
// Convert sizes to bytes // Convert sizes to bytes
messageSizeLimit, err := util.ParseSize(messageSizeLimitStr) messageSizeLimit, err := util.ParseSize(messageSizeLimitStr)
@@ -372,6 +392,14 @@ func execServe(c *cli.Context) error {
return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32") return errors.New("visitor-prefix-bits-ipv4 must be between 1 and 32")
} else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 { } else if visitorPrefixBitsIPv6 < 1 || visitorPrefixBitsIPv6 > 128 {
return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128") return errors.New("visitor-prefix-bits-ipv6 must be between 1 and 128")
} else if banFile != "" && banWindow <= 0 {
return errors.New("if ban-file is set, ban-window must be greater than zero")
} else if banFile != "" && banThreshold <= 0 {
return errors.New("if ban-file is set, ban-threshold must be greater than zero")
} else if banFile != "" && len(banWeights) == 0 {
return errors.New("if ban-file is set, ban-weights must not be empty")
} else if banFile != "" && !util.FileExists(filepath.Dir(banFile)) {
return fmt.Errorf("if ban-file is set, its directory (%s) must exist", filepath.Dir(banFile))
} else if runtime.GOOS == "windows" && listenUnix != "" { } else if runtime.GOOS == "windows" && listenUnix != "" {
return errors.New("listen-unix is not supported on Windows") return errors.New("listen-unix is not supported on Windows")
} }
@@ -512,6 +540,10 @@ func execServe(c *cli.Context) error {
conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish conf.VisitorTopicCreationLimitReplenish = visitorTopicCreationLimitReplenish
conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4 conf.VisitorPrefixBitsIPv4 = visitorPrefixBitsIPv4
conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6 conf.VisitorPrefixBitsIPv6 = visitorPrefixBitsIPv6
conf.BanFile = banFile
conf.BanWindow = banWindow
conf.BanThreshold = banThreshold
conf.BanWeights = banWeights
conf.BehindProxy = behindProxy conf.BehindProxy = behindProxy
conf.ProxyForwardedHeader = proxyForwardedHeader conf.ProxyForwardedHeader = proxyForwardedHeader
conf.ProxyTrustedPrefixes = trustedProxyPrefixes conf.ProxyTrustedPrefixes = trustedProxyPrefixes
+6
View File
@@ -13,6 +13,12 @@ import (
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
) )
// Advisory lock keys. PostgreSQL advisory locks share one database-wide key space, so every
// ntfy key is defined here, following the "ntfy"+2586+letter scheme
const (
SchemaLockKey = int64(0x6e7466792586a) // Schema setup serialization (transaction-scoped, see db/schema)
)
// Open opens a PostgreSQL connection pool for a primary database. It pings the database // Open opens a PostgreSQL connection pool for a primary database. It pings the database
// to verify connectivity before returning. // to verify connectivity before returning.
func Open(dsn string) (*db.Host, error) { func Open(dsn string) (*db.Host, error) {
+111
View File
@@ -0,0 +1,111 @@
// Package schema tracks and migrates database schemas, and Migrate creates or upgrades a
// store's schema inside a single transaction. On PostgreSQL, all stores share one database, so
// versions live in a shared schema_version table keyed by store name. On SQLite, every store is
// its own database file, so the version lives in the schemaVersion table keyed by id = 1.
package schema
import (
"database/sql"
"errors"
"fmt"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log"
)
const (
tag = "schema"
)
const (
sqliteCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`
sqliteSelectVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
sqliteUpsertVersionQuery = `INSERT INTO schemaVersion (id, version) VALUES (1, ?) ON CONFLICT (id) DO UPDATE SET version = excluded.version`
postgresCreateVersionTableQuery = `CREATE TABLE IF NOT EXISTS schema_version (store TEXT PRIMARY KEY, version INT NOT NULL)`
postgresSelectVersionQuery = `SELECT version FROM schema_version WHERE store = $1`
postgresUpsertVersionQuery = `INSERT INTO schema_version (store, version) VALUES ($1, $2) ON CONFLICT (store) DO UPDATE SET version = EXCLUDED.version`
postgresAdvisoryLockQuery = `SELECT pg_advisory_xact_lock($1)` // Transaction-scoped lock to avoid migration races
)
// Migrate creates or upgrades the named store's schema to targetVersion in one transaction, or
// creates a new database using the "create" function.
func Migrate(db *sql.DB, dialect Dialect, store string, targetVersion int, create MigrateFunc, migrations map[int]MigrateFunc) error {
if dialect != Postgres && dialect != SQLite {
return fmt.Errorf("unsupported schema dialect %d", dialect)
}
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("cannot begin %s schema transaction: %w", store, err)
}
defer tx.Rollback()
if dialect == Postgres {
// Serialize setup across nodes: CREATE TABLE IF NOT EXISTS is not atomic, and
// concurrently cold-booting nodes would otherwise race on DDL and crash
if _, err := tx.Exec(postgresAdvisoryLockQuery, pg.SchemaLockKey); err != nil {
return fmt.Errorf("cannot acquire %s schema advisory lock: %w", store, err)
}
}
if _, err := tx.Exec(createVersionTableQuery(dialect)); err != nil {
return fmt.Errorf("cannot create schema version table: %w", err)
}
version, err := readVersion(tx, dialect, store)
if errors.Is(err, sql.ErrNoRows) {
// Fresh database: create the store's tables at the target version
if err := create(tx); err != nil {
return fmt.Errorf("cannot create %s schema: %w", store, err)
}
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
return fmt.Errorf("cannot write %s schema version: %w", store, err)
}
return tx.Commit()
} else if err != nil {
return fmt.Errorf("cannot read %s schema version: %w", store, err)
}
if version == targetVersion {
return tx.Commit()
}
if version > targetVersion {
return fmt.Errorf("unexpected %s schema version %d, this version of ntfy supports up to %d", store, version, targetVersion)
}
for v := version; v < targetVersion; v++ {
migrate, ok := migrations[v]
if !ok {
return fmt.Errorf("cannot find %s migration step from version %d to %d", store, v, v+1)
}
log.Tag(tag).Info("Migrating %s database schema: from %d to %d", store, v, v+1)
if err := migrate(tx); err != nil {
return fmt.Errorf("%s migration step from version %d to %d failed: %w", store, v, v+1, err)
}
}
if err := writeVersion(tx, dialect, store, targetVersion); err != nil {
return fmt.Errorf("cannot write %s schema version: %w", store, err)
}
return tx.Commit()
}
func createVersionTableQuery(dialect Dialect) string {
if dialect == Postgres {
return postgresCreateVersionTableQuery
}
return sqliteCreateVersionTableQuery
}
func readVersion(tx *sql.Tx, dialect Dialect, store string) (version int, err error) {
if dialect == Postgres {
err = tx.QueryRow(postgresSelectVersionQuery, store).Scan(&version)
} else {
err = tx.QueryRow(sqliteSelectVersionQuery).Scan(&version)
}
return
}
func writeVersion(tx *sql.Tx, dialect Dialect, store string, version int) error {
var err error
if dialect == Postgres {
_, err = tx.Exec(postgresUpsertVersionQuery, store, version)
} else {
_, err = tx.Exec(sqliteUpsertVersionQuery, version)
}
return err
}
+190
View File
@@ -0,0 +1,190 @@
package schema_test
import (
"database/sql"
"fmt"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/db/schema"
dbtest "heckel.io/ntfy/v2/db/test"
_ "github.com/mattn/go-sqlite3"
)
const testCreateQuery = `CREATE TABLE IF NOT EXISTS things (id TEXT PRIMARY KEY, name TEXT NOT NULL)`
func testCreate(tx *sql.Tx) error {
_, err := tx.Exec(testCreateQuery)
return err
}
func openTestPostgres(t *testing.T) *sql.DB {
t.Helper()
host, err := pg.Open(dbtest.CreateTestPostgresSchema(t))
require.Nil(t, err)
t.Cleanup(func() { host.DB.Close() })
return host.DB
}
func openTestSQLite(t *testing.T) *sql.DB {
t.Helper()
d, err := sql.Open("sqlite3", filepath.Join(t.TempDir(), "test.db"))
require.Nil(t, err)
t.Cleanup(func() { d.Close() })
return d
}
func forEachDialect(t *testing.T, f func(t *testing.T, d *sql.DB, dialect schema.Dialect)) {
t.Run("postgres", func(t *testing.T) {
f(t, openTestPostgres(t), schema.Postgres)
})
t.Run("sqlite", func(t *testing.T) {
f(t, openTestSQLite(t), schema.SQLite)
})
}
func TestMigrate_FreshCreate(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
// A fresh database jumps straight to the target version; migration steps are not consulted
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
require.Nil(t, err)
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
// Idempotent: a second node boots against the migrated schema
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, nil))
})
}
func TestMigrate_AppliesMigrationSteps(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
// A newer version of the code migrates 1 -> 3 step by step, in order
migrations := map[int]schema.MigrateFunc{
1: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
return err
},
2: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN size INT NOT NULL DEFAULT 0`)
return err
},
}
require.Nil(t, schema.Migrate(d, dialect, "things", 3, testCreate, migrations))
_, err := d.Exec(`INSERT INTO things (id, name, color, size) VALUES ('b', 'thing b', 'red', 2)`)
require.Nil(t, err)
require.Equal(t, 3, storeVersion(t, d, dialect, "things"))
})
}
func TestMigrate_ClosureCarriesConfig(t *testing.T) {
// Migrations needing config take it via closure at map-construction time; there is no
// params plumbing in the framework itself
migrationsFor := func(defaultName string) map[int]schema.MigrateFunc {
return map[int]schema.MigrateFunc{
1: schema.AsMigrateFunc(fmt.Sprintf(`ALTER TABLE things ADD COLUMN nick TEXT NOT NULL DEFAULT '%s'`, defaultName)),
}
}
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
_, err := d.Exec(`INSERT INTO things (id, name) VALUES ('a', 'thing a')`)
require.Nil(t, err)
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, migrationsFor("configured-default")))
var nick string
require.Nil(t, d.QueryRow(`SELECT nick FROM things WHERE id = 'a'`).Scan(&nick))
require.Equal(t, "configured-default", nick)
})
}
func TestMigrate_InvalidDialect(t *testing.T) {
d := openTestSQLite(t)
err := schema.Migrate(d, schema.Dialect(99), "things", 1, testCreate, nil)
require.Error(t, err)
}
func TestMigrate_RefusesFutureVersion(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 2, testCreate, map[int]schema.MigrateFunc{}))
err := schema.Migrate(d, dialect, "things", 1, testCreate, nil)
require.Error(t, err)
})
}
func TestMigrate_MissingStepFails(t *testing.T) {
forEachDialect(t, func(t *testing.T, d *sql.DB, dialect schema.Dialect) {
require.Nil(t, schema.Migrate(d, dialect, "things", 1, testCreate, nil))
err := schema.Migrate(d, dialect, "things", 3, testCreate, nil) // No step 1 -> 2 registered
require.Error(t, err)
})
}
func TestMigrate_StoresAreIndependent(t *testing.T) {
// Postgres only: stores share one database, tracked as rows in schema_version. On SQLite
// every store has its own database file, so independence is by file.
d := openTestPostgres(t)
require.Nil(t, schema.Migrate(d, schema.Postgres, "things", 1, testCreate, nil))
require.Nil(t, schema.Migrate(d, schema.Postgres, "gadgets", 4, func(tx *sql.Tx) error {
_, err := tx.Exec(`CREATE TABLE IF NOT EXISTS gadgets (id TEXT PRIMARY KEY)`)
return err
}, nil))
require.Equal(t, 1, storeVersion(t, d, schema.Postgres, "things"))
require.Equal(t, 4, storeVersion(t, d, schema.Postgres, "gadgets"))
}
func TestMigrate_SQLiteReadsExistingSchemaVersionTable(t *testing.T) {
// Existing ntfy SQLite databases (message, user, webpush) track their version in a
// schemaVersion (id, version) table keyed by id = 1; the framework uses that table as-is
// on SQLite, so existing databases migrate without any adoption step
d := openTestSQLite(t)
_, err := d.Exec(testCreateQuery)
require.Nil(t, err)
_, err = d.Exec(`CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL)`)
require.Nil(t, err)
_, err = d.Exec(`INSERT INTO schemaVersion VALUES (1, 1)`)
require.Nil(t, err)
migrations := map[int]schema.MigrateFunc{
1: func(tx *sql.Tx) error {
_, err := tx.Exec(`ALTER TABLE things ADD COLUMN color TEXT NOT NULL DEFAULT ''`)
return err
},
}
require.Nil(t, schema.Migrate(d, schema.SQLite, "things", 2, testCreate, migrations))
_, err = d.Exec(`INSERT INTO things (id, name, color) VALUES ('a', 'thing a', 'red')`)
require.Nil(t, err)
require.Equal(t, 2, storeVersion(t, d, schema.SQLite, "things"))
}
func TestMigrate_ConcurrentFreshCreate(t *testing.T) {
// Postgres only: concurrent cold-boots must not race on DDL (CREATE TABLE IF NOT EXISTS is
// not atomic); Migrate serializes via an advisory lock. SQLite has a single writer.
schemaDSN := dbtest.CreateTestPostgresSchema(t)
const n = 8
errs := make(chan error, n)
for i := 0; i < n; i++ {
go func() {
host, err := pg.Open(schemaDSN)
if err != nil {
errs <- err
return
}
defer host.DB.Close()
errs <- schema.Migrate(host.DB, schema.Postgres, "things", 1, testCreate, nil)
}()
}
for i := 0; i < n; i++ {
require.Nil(t, <-errs)
}
}
func storeVersion(t *testing.T, d *sql.DB, dialect schema.Dialect, store string) int {
t.Helper()
var version int
if dialect == schema.Postgres {
require.Nil(t, d.QueryRow(`SELECT version FROM schema_version WHERE store = $1`, store).Scan(&version), fmt.Sprintf("store %s", store))
} else {
require.Nil(t, d.QueryRow(`SELECT version FROM schemaVersion WHERE id = 1`).Scan(&version), fmt.Sprintf("store %s", store))
}
return version
}
+31
View File
@@ -0,0 +1,31 @@
package schema
import "database/sql"
// Dialect selects the SQL flavor Migrate speaks to the version table.
type Dialect int
// Supported dialects; SQLite is the zero value
const (
SQLite Dialect = iota
Postgres
)
// MigrateFunc applies one schema change inside the setup transaction: the initial creation of
// a store's tables, or one step upgrading a store from version N to N+1. Migrations needing
// config capture it via closure, e.g. func migrations(cacheDuration time.Duration) map[int]MigrateFunc.
type MigrateFunc func(tx *sql.Tx) error
// AsMigrateFunc converts a simple query to a migration function
func AsMigrateFunc(query string) MigrateFunc {
return func(tx *sql.Tx) error {
_, err := tx.Exec(query)
return err
}
}
// NopMigrateFunc is a migration step that does nothing, for versions where a dialect has no
// work to do (e.g. when only the other dialect's schema changed).
func NopMigrateFunc(_ *sql.Tx) error {
return nil
}
+185
View File
@@ -0,0 +1,185 @@
package dbtest
import (
"database/sql"
"fmt"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Querier is the subset of *sql.DB / *db.DB needed to introspect a schema.
type Querier interface {
Query(query string, args ...any) (*sql.Rows, error)
}
// SQLiteSchema returns a normalized, comparable description of the database schema: tables
// with their columns, named indexes, and foreign keys. Column order, declared type spelling
// (INT vs INTEGER) and default values are not part of the description, so the schema produced
// by a migration chain can be compared to a freshly created one.
func SQLiteSchema(t testing.TB, d Querier) string {
t.Helper()
lines := make([]string, 0)
for _, table := range sqliteTables(t, d) {
lines = append(lines, "table "+table)
lines = append(lines, sqliteColumns(t, d, table)...)
lines = append(lines, sqliteForeignKeys(t, d, table)...)
lines = append(lines, sqliteIndexes(t, d, table)...)
}
return strings.Join(lines, "\n")
}
// PostgresSchema is SQLiteSchema's PostgreSQL counterpart, describing the current schema's
// tables, columns, constraints and indexes in a normalized, comparable way.
func PostgresSchema(t testing.TB, d Querier) string {
t.Helper()
lines := make([]string, 0)
for _, table := range postgresTables(t, d) {
lines = append(lines, "table "+table)
lines = append(lines, postgresColumns(t, d, table)...)
}
lines = append(lines, postgresConstraints(t, d)...)
lines = append(lines, postgresIndexes(t, d)...)
return strings.Join(lines, "\n")
}
func sqliteTables(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name`)
}
func sqliteColumns(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA table_info(%q)`, table))
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var cid, notNull, pk int
var name, typ string
var dflt sql.NullString
require.Nil(t, rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk))
typ = strings.ToUpper(typ)
if typ == "INT" { // INT and INTEGER are the same affinity; migrations spell them inconsistently
typ = "INTEGER"
}
lines = append(lines, fmt.Sprintf(" col %s %s notnull=%d pk=%d", name, typ, notNull, pk))
}
require.Nil(t, rows.Err())
sort.Strings(lines)
return lines
}
func sqliteForeignKeys(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA foreign_key_list(%q)`, table))
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var id, seq int
var refTable, from, onUpdate, onDelete, match string
var to sql.NullString // NULL when referencing the parent's primary key implicitly
require.Nil(t, rows.Scan(&id, &seq, &refTable, &from, &to, &onUpdate, &onDelete, &match))
lines = append(lines, fmt.Sprintf(" fk %s -> %s(%s) on_delete=%s", from, refTable, to.String, onDelete))
}
require.Nil(t, rows.Err())
sort.Strings(lines)
return lines
}
func sqliteIndexes(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(fmt.Sprintf(`PRAGMA index_list(%q)`, table))
require.Nil(t, err)
type index struct {
name string
unique, partial int
}
indexes := make([]index, 0)
for rows.Next() {
var seq, unique, partial int
var name, origin string
require.Nil(t, rows.Scan(&seq, &name, &unique, &origin, &partial))
// Skip auto-indexes backing PRIMARY KEY/UNIQUE table constraints; those are described
// by the column and constraint listings already
if strings.HasPrefix(name, "sqlite_autoindex_") {
continue
}
indexes = append(indexes, index{name, unique, partial})
}
require.Nil(t, rows.Err())
require.Nil(t, rows.Close())
lines := make([]string, 0, len(indexes))
for _, idx := range indexes {
cols := queryStrings(t, d, fmt.Sprintf(`SELECT name FROM pragma_index_info(%q) ORDER BY seqno`, idx.name))
lines = append(lines, fmt.Sprintf(" index %s unique=%d partial=%d cols=(%s)", idx.name, idx.unique, idx.partial, strings.Join(cols, ",")))
}
sort.Strings(lines)
return lines
}
func postgresTables(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE' ORDER BY table_name`)
}
func postgresColumns(t testing.TB, d Querier, table string) []string {
t.Helper()
rows, err := d.Query(`SELECT column_name, data_type, is_nullable FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`, table)
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var name, typ, nullable string
require.Nil(t, rows.Scan(&name, &typ, &nullable))
lines = append(lines, fmt.Sprintf(" col %s %s nullable=%s", name, typ, nullable))
}
require.Nil(t, rows.Err())
return lines
}
func postgresConstraints(t testing.TB, d Querier) []string {
t.Helper()
return queryStrings(t, d, `
SELECT 'constraint ' || conrelid::regclass::text || ': ' || pg_get_constraintdef(oid)
FROM pg_constraint
WHERE connamespace = current_schema()::regnamespace
ORDER BY 1
`)
}
func postgresIndexes(t testing.TB, d Querier) []string {
t.Helper()
rows, err := d.Query(`SELECT indexname, indexdef, schemaname FROM pg_indexes WHERE schemaname = current_schema() ORDER BY indexname`)
require.Nil(t, err)
defer rows.Close()
lines := make([]string, 0)
for rows.Next() {
var name, def, schema string
require.Nil(t, rows.Scan(&name, &def, &schema))
// The index definition qualifies the table with the (test-specific) schema name; strip
// it so snapshots from different test schemas compare equal
def = strings.ReplaceAll(def, schema+".", "")
lines = append(lines, "index "+def)
}
require.Nil(t, rows.Err())
return lines
}
func queryStrings(t testing.TB, d Querier, query string) []string {
t.Helper()
rows, err := d.Query(query)
require.Nil(t, err)
defer rows.Close()
values := make([]string, 0)
for rows.Next() {
var value string
require.Nil(t, rows.Scan(&value))
values = append(values, value)
}
require.Nil(t, rows.Err())
return values
}
+82 -9
View File
@@ -379,7 +379,7 @@ This generator helps you configure your self-hosted ntfy instance. It's not full
</div> </div>
</div> </div>
<div class="cg-panel" id="cg-panel-database"> <div class="cg-panel" id="cg-panel-database">
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql-experimental" target="_blank">PostgreSQL</a> for details.</div> <div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql" target="_blank">PostgreSQL</a> for details.</div>
<div class="cg-field"> <div class="cg-field">
<label>Database URL</label> <label>Database URL</label>
<input type="text" data-key="database-url" placeholder="postgres://user:pass@host:5432/ntfy"> <input type="text" data-key="database-url" placeholder="postgres://user:pass@host:5432/ntfy">
@@ -417,7 +417,7 @@ no external dependencies:
* `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth. * `auth-file`: Database file for authentication and [access control](#access-control). If set, enables auth.
* `web-push-file`: Database file for [web push](#web-push) subscriptions. * `web-push-file`: Database file for [web push](#web-push) subscriptions.
### PostgreSQL (EXPERIMENTAL) ### PostgreSQL
As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the As an alternative, you can configure ntfy to use PostgreSQL for **all** database-backed stores by setting the
`database-url` option to a PostgreSQL connection string. `database-url` option to a PostgreSQL connection string.
@@ -1656,7 +1656,7 @@ a database to keep track of the browser's subscriptions, and an admin email addr
- `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`) - `web-push-expiry-duration` defines the duration after which unused subscriptions will expire (default is `60d`)
Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url` Alternatively, you can use PostgreSQL instead of SQLite by setting `database-url`
(see [PostgreSQL database](#postgresql-experimental)). (see [PostgreSQL database](#postgresql)).
Limitations: Limitations:
@@ -1911,7 +1911,9 @@ per-visitor limits:
* `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M. * `visitor-attachment-total-size-limit` is the total storage limit used for attachments per visitor. It defaults to 100M.
The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`, The per-visitor storage is automatically decreased as attachments expire. External attachments (attached via `X-Attach`,
see [publishing docs](publish.md#attachments)) do not count here. see [publishing docs](publish.md#attachments)) do not count here.
* `visitor-attachment-daily-bandwidth-limit` is the total daily attachment download/upload bandwidth limit per visitor, * `visitor-attachment-daily-bandwidth-limit` is the total daily bandwidth limit per visitor. It covers attachment
downloads/uploads, and messages replayed from the message cache by poll requests (a poll without a `since` cursor
returns a topic's entire cache, so a busy topic can be re-read for many times its own size),
including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in including PUT and GET requests. This is to protect your precious bandwidth from abuse, since egress costs money in
most cloud providers. This defaults to 500M. most cloud providers. This defaults to 500M.
@@ -2129,6 +2131,72 @@ chain.
The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to The official ntfy.sh server uses fail2ban to ban IPs. Check out ntfy.sh's [Ansible fail2ban role](https://github.com/binwiederhier/ntfy-ansible/tree/main/roles/fail2ban) for details. Ban actors are banned for 1 hour initially, and up to
4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix. 4 hours at a time for repeated offenses. IPv4 addresses are banned individually, while IPv6 addresses are banned by their `/56` prefix.
### Ban-feed
In addition to the fail2ban setup above, ntfy can detect abusive visitors itself and write their IP
addresses to a file for fail2ban to ban from. ntfy keeps a per-prefix weighted "strike" budget, and
each rejected request costs strikes based on its response code -- the ntfy error code, or its HTTP
status (see `ban-weights`) -- so different kinds of rejection can be weighted differently or exempted
entirely. When a prefix exceeds the budget, ntfy appends the offending IP address to `ban-file`. Since
every line is already a confirmed offender, the fail2ban jail can ban on first sight (`maxretry = 1`).
- `ban-file` is the file offenders are appended to. If it is not set, the ban-feed is disabled. Its
parent directory must exist and be writable by ntfy. Be sure to rotate it (e.g. with logrotate and
`copytruncate`) so it does not grow unbounded.
- `ban-window` is the rolling window over which weighted strikes are counted, per IP prefix.
- `ban-threshold` is the number of weighted strikes per `ban-window` before a prefix is written to
`ban-file`. Each prefix has one shared budget, so it cannot be gamed by mixing error codes.
- `ban-weights` assigns a strike weight per matcher key, formatted as `KEY:WEIGHT`. A key is an exact
ntfy error code (`42909`), a code family (`429*`, `403*`, `4*`), a bare HTTP status (`403`, short for
`403*`), or `*`. The longest matching key wins. A weight of `0` exempts a code entirely (it never
counts toward a ban), useful to spare a specific code from a `*` catch-all. Heavier weights ban faster. If you do not
include a `*` rule, any code that matches nothing defaults to weight `1` (i.e. it can be banned);
set `*:0` to exempt everything that is not explicitly weighted.
Only rejections (4xx/5xx) count towards a ban; successful requests never do. Because the budget
refills over `ban-window`, the trigger is a sustained rate: a prefix is only written out once it
exceeds `ban-threshold / ban-window` rejections per second (with the defaults, `100 / 10m` = ~0.17/s).
Each line in `ban-file` has the format `<RFC3339-timestamp> <ip> <prefix> <http-code> <ntfy-code>`, for example:
```
2026-01-15T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901
2026-01-15T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909
```
`<prefix>` is `<ip>` masked to the rate-limiting prefix (`visitor-prefix-bits-ipv4`/`-ipv6`) -- the
same unit ntfy rate-limits by. Have the fail2ban filter capture the bare `<ip>` (the action then
applies the prefix):
=== "server.yml"
```yaml
ban-file: "/var/log/ntfy/ban.log"
ban-window: "10m"
ban-threshold: 100
ban-weights:
- "42909:10" # too many auth failures -> brute force, ban fast
# everything else 4xx/5xx defaults to weight 1
```
=== "/etc/fail2ban/filter.d/ntfy-ban.conf"
```
[Definition]
failregex = ^\S+ <HOST> \S+ \d+ \d+$
datepattern = ^%%Y-%%m-%%dT%%H:%%M:%%S
ignoreregex =
```
=== "/etc/fail2ban/jail.d/ntfy-ban.local"
```
[ntfy-ban]
enabled = true
filter = ntfy-ban
action = iptables-multiport[name=ntfy-ban, port="http,https", protocol=tcp]
logpath = /var/log/ntfy/ban.log
maxretry = 1
findtime = 1m
bantime = 1h
```
## IPv6 support ## IPv6 support
ntfy fully supports IPv6, though there are a few things to keep in mind. ntfy fully supports IPv6, though there are a few things to keep in mind.
@@ -2159,13 +2227,14 @@ See [Installation for Docker](install.md#docker) for an example of how this coul
If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to If configured, ntfy can expose a `/metrics` endpoint for [Prometheus](https://prometheus.io/), which can then be used to
create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)). create dashboards and alerts (e.g. via [Grafana](https://grafana.com/)).
To configure the metrics endpoint, either set `enable-metrics` and/or set the `metrics-listen-http` option to a dedicated To configure the metrics endpoint, either set `enable-metrics`, or set the `metrics-listen-http` option to a dedicated
listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are listen address. Metrics may be considered sensitive information, so before you enable them, be sure you know what you are
doing, and/or secure access to the endpoint in your reverse proxy. doing, and/or secure access to the endpoint in your reverse proxy.
- `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket) - `enable-metrics` enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
- `metrics-listen-http` exposes the metrics endpoint via a dedicated `[IP]:port`. If set, this option implicitly - `metrics-listen-http` moves the metrics endpoint to a dedicated `[IP]:port`, e.g. "10.0.1.1:9090" or ":9090". It
enables metrics as well, e.g. "10.0.1.1:9090" or ":9090" implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default ntfy server
does not serve /metrics, even if `enable-metrics` is also set.
=== "server.yml (Using default port)" === "server.yml (Using default port)"
```yaml ```yaml
@@ -2315,7 +2384,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers | | `upstream-base-url` | `NTFY_UPSTREAM_BASE_URL` | *URL* | `https://ntfy.sh` | Forward poll request to an upstream server, this is needed for iOS push notifications for self-hosted servers |
| `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth | | `upstream-access-token` | `NTFY_UPSTREAM_ACCESS_TOKEN` | *string* | `tk_zyYLYj...` | Access token to use for the upstream server; needed only if upstream rate limits are exceeded or upstream server requires auth |
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. | | `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. | | `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily traffic limit per visitor, covering attachment downloads/uploads and messages replayed from the cache by poll requests. This is to protect your bandwidth costs from exploding. |
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor | | `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled | | `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
| `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. | | `visitor-message-daily-limit` | `NTFY_VISITOR_MESSAGE_DAILY_LIMIT` | *number* | - | Rate limiting: Allowed number of messages per day per visitor, reset every day at midnight (UTC). By default, this value is unset. |
@@ -2328,6 +2397,10 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). | | `visitor-topic-creation-limit-replenish` | `NTFY_VISITOR_TOPIC_CREATION_LIMIT_REPLENISH` | *duration* | 1m | Rate limiting: Rate at which the per-visitor topic-creation bucket is refilled (one new topic per x). |
| `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 | | `visitor-prefix-bits-ipv4` | `NTFY_VISITOR_PREFIX_BITS_IPV4` | *number* | 32 | Rate limiting: Number of bits to use for IPv4 visitor prefix, e.g. 24 for /24 |
| `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 | | `visitor-prefix-bits-ipv6` | `NTFY_VISITOR_PREFIX_BITS_IPV6` | *number* | 64 | Rate limiting: Number of bits to use for IPv6 visitor prefix, e.g. 48 for /48 |
| `ban-file` | `NTFY_BAN_FILE` | *filename* | - | Abuse ban-feed: file confirmed abusive visitor IPs are appended to, for fail2ban to tail. Empty disables the feature. See [Banning bad actors](#banning-bad-actors-fail2ban) |
| `ban-window` | `NTFY_BAN_WINDOW` | *duration* | 10m | Abuse ban-feed: rolling window over which weighted strikes are counted, per IP prefix |
| `ban-threshold` | `NTFY_BAN_THRESHOLD` | *number* | 100 | Abuse ban-feed: weighted strikes per `ban-window` before a prefix is written to `ban-file` |
| `ban-weights` | `NTFY_BAN_WEIGHTS` | *list of KEY:WEIGHT* | `42909:10`| Abuse ban-feed: per-code strike weights (exact code, family `429*`, or `*`; longest match wins; `0` exempts). See [Banning bad actors](#banning-bad-actors-fail2ban) |
| `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) | | `web-root` | `NTFY_WEB_ROOT` | *path*, e.g. `/` or `/app`, or `disable` | `/` | Sets root of the web app (e.g. /, or /app), or disables it entirely (disable) |
| `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API | | `enable-signup` | `NTFY_ENABLE_SIGNUP` | *boolean* (`true` or `false`) | `false` | Allows users to sign up via the web app, or API |
| `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API | | `enable-login` | `NTFY_ENABLE_LOGIN` | *boolean* (`true` or `false`) | `false` | Allows users to log in via the web app, or API |
@@ -2427,7 +2500,7 @@ OPTIONS:
--visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT] --visitor-subscription-limit value, --visitor_subscription_limit value number of subscriptions per visitor (default: 30) [$NTFY_VISITOR_SUBSCRIPTION_LIMIT]
--visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING] --visitor-subscriber-rate-limiting, --visitor_subscriber_rate_limiting enables subscriber-based rate limiting (default: false) [$NTFY_VISITOR_SUBSCRIBER_RATE_LIMITING]
--visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT] --visitor-attachment-total-size-limit value, --visitor_attachment_total_size_limit value total storage limit used for attachments per visitor (default: "100M") [$NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT]
--visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily attachment download/upload bandwidth limit per visitor (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT] --visitor-attachment-daily-bandwidth-limit value, --visitor_attachment_daily_bandwidth_limit value total daily bandwidth limit per visitor, for attachment downloads/uploads and messages replayed from the cache by poll requests (default: "500M") [$NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT]
--visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST] --visitor-request-limit-burst value, --visitor_request_limit_burst value initial limit of requests per visitor (default: 60) [$NTFY_VISITOR_REQUEST_LIMIT_BURST]
--visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: "5s") [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH] --visitor-request-limit-replenish value, --visitor_request_limit_replenish value interval at which burst limit is replenished (one per x) (default: "5s") [$NTFY_VISITOR_REQUEST_LIMIT_REPLENISH]
--visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS] --visitor-request-limit-exempt-hosts value, --visitor_request_limit_exempt_hosts value hostnames and/or IP addresses of hosts that will be exempt from the visitor request limit [$NTFY_VISITOR_REQUEST_LIMIT_EXEMPT_HOSTS]
+6 -1
View File
@@ -28,7 +28,7 @@ via the following channels:
| Channel | Contact | Description | | Channel | Contact | Description |
|-----------------------|-----------------------------------------------------|------------------------------------------| |-----------------------|-----------------------------------------------------|------------------------------------------|
| **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers | | **General Support** | [support@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Direct email support for Pro subscribers |
| **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:support@mail.ntfy.sh) | Inquire about billing issues | | **Billing Inquiries** | [billing@mail.ntfy.sh](mailto:billing@mail.ntfy.sh) | Inquire about billing issues |
| **Discord/Matrix** | Mention your Pro status | Priority responses in community channels | | **Discord/Matrix** | Mention your Pro status | Priority responses in community channels |
Please include your ntfy.sh username when contacting support so we can verify your subscription status. Please include your ntfy.sh username when contacting support so we can verify your subscription status.
@@ -37,6 +37,11 @@ Please include your ntfy.sh username when contacting support so we can verify yo
If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md). If you discover a security vulnerability, please report it responsibly via [security@mail.ntfy.sh](mailto:security@mail.ntfy.sh). See also: [SECURITY.md](https://github.com/binwiederhier/ntfy/blob/main/SECURITY.md).
## Abuse reports
To report spam, phishing, or other abuse of ntfy.sh, please email [abuse@mail.ntfy.sh](mailto:abuse@mail.ntfy.sh).
Please include the topic name and any relevant message details so we can investigate.
## Other inquiries ## Other inquiries
For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights For questions about our [privacy policy](privacy.md), data handling, or to exercise your data rights
+38 -38
View File
@@ -34,37 +34,37 @@ as a service starting at boot time.
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.tar.gz
tar zxvf ntfy_2.26.0_linux_amd64.tar.gz tar zxvf ntfy_2.28.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.26.0_linux_amd64/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.28.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_amd64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.tar.gz
tar zxvf ntfy_2.26.0_linux_armv6.tar.gz tar zxvf ntfy_2.28.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.26.0_linux_armv6/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.28.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv6/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.tar.gz
tar zxvf ntfy_2.26.0_linux_armv7.tar.gz tar zxvf ntfy_2.28.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.26.0_linux_armv7/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.28.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_armv7/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.tar.gz wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.tar.gz
tar zxvf ntfy_2.26.0_linux_arm64.tar.gz tar zxvf ntfy_2.28.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.26.0_linux_arm64/ntfy /usr/bin/ntfy sudo cp -a ntfy_2.28.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.0_linux_arm64/{client,server}/*.yml /etc/ntfy sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve sudo ntfy serve
``` ```
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.service /etc/systemd/system/ sudo mv ntfy_2.28.0_linux_amd64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service sudo chmod 644 /etc/systemd/system/ntfy.service
``` ```
=== "armv6" === "armv6"
```bash ```bash
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.service /etc/systemd/system/ sudo mv ntfy_2.28.0_linux_armv6/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service sudo chmod 644 /etc/systemd/system/ntfy.service
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.service /etc/systemd/system/ sudo mv ntfy_2.28.0_linux_armv7/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service sudo chmod 644 /etc/systemd/system/ntfy.service
``` ```
=== "arm64" === "arm64"
```bash ```bash
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.service /etc/systemd/system/ sudo mv ntfy_2.28.0_linux_arm64/server/ntfy.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/ntfy.service sudo chmod 644 /etc/systemd/system/ntfy.service
``` ```
@@ -118,25 +118,25 @@ Install the ntfy server service script:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
sudo mv ntfy_2.26.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy sudo mv ntfy_2.28.0_linux_amd64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy
``` ```
=== "armv6" === "armv6"
```bash ```bash
sudo mv ntfy_2.26.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy sudo mv ntfy_2.28.0_linux_armv6/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
sudo mv ntfy_2.26.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy sudo mv ntfy_2.28.0_linux_armv7/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy
``` ```
=== "arm64" === "arm64"
```bash ```bash
sudo mv ntfy_2.26.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy sudo mv ntfy_2.28.0_linux_arm64/server/ntfy.openrc /etc/init.d/ntfy
sudo chmod 755 /etc/init.d/ntfy sudo chmod 755 /etc/init.d/ntfy
``` ```
@@ -204,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -212,7 +212,7 @@ Manually installing the .deb file:
=== "armv6" === "armv6"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -220,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -228,7 +228,7 @@ Manually installing the .deb file:
=== "arm64" === "arm64"
```bash ```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.deb wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
@@ -238,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64" === "x86_64/amd64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_amd64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv6" === "armv6"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv6.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "armv7/armhf" === "armv7/armhf"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_armv7.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
=== "arm64" === "arm64"
```bash ```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_linux_arm64.rpm sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.rpm
sudo systemctl enable ntfy sudo systemctl enable ntfy
sudo systemctl start ntfy sudo systemctl start ntfy
``` ```
@@ -301,18 +301,18 @@ pkg install go-ntfy
## macOS ## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well. The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz), To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`). extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball). `~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash ```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_darwin_all.tar.gz > ntfy_2.26.0_darwin_all.tar.gz curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_darwin_all.tar.gz > ntfy_2.28.0_darwin_all.tar.gz
tar zxvf ntfy_2.26.0_darwin_all.tar.gz tar zxvf ntfy_2.28.0_darwin_all.tar.gz
sudo cp -a ntfy_2.26.0_darwin_all/ntfy /usr/local/bin/ntfy sudo cp -a ntfy_2.28.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.26.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml cp ntfy_2.28.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help ntfy --help
``` ```
@@ -333,7 +333,7 @@ brew install ntfy
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service. 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 To install, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.0/ntfy_2.26.0_windows_amd64.zip), * [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`. 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` * Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`
+2 -1
View File
@@ -84,13 +84,14 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP) - [symfony/ntfy-notifier](https://symfony.com/components/NtfyNotifier) ⭐ - Symfony Notifier integration for ntfy (PHP)
- [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java) - [ntfy-java](https://github.com/MaheshBabu11/ntfy-java/) - A Java package to interact with a ntfy server (Java)
- [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python) - [aiontfy](https://github.com/tr4nt0r/aiontfy) - Asynchronous client library for publishing and subscribing to ntfy (Python)
- [ex_ntfy](https://github.com/houllette/ex_ntfy) - Elixir SDK covering publishing, polling, and streaming subscriptions for ntfy servers (Elixir)
- [ntfy-logging](https://github.com/Pimak/ntfy-logging) - Turns JVM error logs into ntfy notifications, with zero-code adapters for java.util.logging, Logback, Log4j2, Spring Boot, Micronaut and Quarkus (Java)
## CLIs + GUIs ## CLIs + GUIs
- [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events - [ntfy.sh.sh](https://github.com/mininmobile/ntfy.sh.sh) - Run scripts on ntfy.sh events
- [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy - [ntfy-desktop](https://codeberg.org/zvava/ntfy-desktop) - Cross-platform desktop application for ntfy
- [ntfy-desktop](https://github.com/Aetherinox/ntfy-desktop) - Desktop client for Windows, Linux, and MacOS with push notifications - [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
- [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#) - [ntfy Desktop (Windows)](https://github.com/simoneferrari/ntfy-desktop) - Native Windows desktop client with multi-server support, toast notifications and message history, built with WPF and .NET (C#)
- [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic - [wio-ntfy-ticker](https://github.com/nachotp/wio-ntfy-ticker) - Ticker display for a ntfy.sh topic
- [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop - [ntfysh-windows](https://github.com/mshafer1/ntfysh-windows) - A ntfy client for Windows Desktop
+6 -2
View File
@@ -3227,7 +3227,10 @@ your templates there first ([example for Grafana alert](https://repeatit.io/#/sh
!!! info !!! info
A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`, A few Go template features are disabled for user-supplied templates: `{{define}}`, `{{template}}`,
`{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit -- `{{block}}`, and `{{call}}` are not allowed. Templates also run with a short execution time limit --
a template that loops too long is stopped and rejected with an HTTP 400 error. a template that loops too long is stopped and rejected with an HTTP 400 error. Templates are
limited to 32 KB in size, `printf` widths and precisions must be below 1000 (`%999d` is
allowed, `%1000d` is not), including the `%*d` form that takes the width from an argument, and
`indent`/`nindent` are limited to 100 spaces.
### Template functions ### Template functions
ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig), ntfy supports a subset of the **[Sprig template functions](publish/template-functions.md)** (originally copied from [Sprig](https://github.com/Masterminds/sprig),
@@ -4928,7 +4931,8 @@ but just in case, let's list them all:
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. | | **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. | | **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. On ntfy.sh, the attachment size limit is 2 MB, and the per-visitor total is 20 MB. |
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. | | **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. | | **Title and tag size** | The message title is limited to 1 KB, and all tags combined to 512 bytes. Requests exceeding either are rejected with HTTP 400. |
| **Daily bandwidth** | By default, the server allows 500 MB of traffic per visitor in a 24 hour period, covering attachment GET/PUT/POST traffic and messages replayed from the cache by [poll requests](subscribe/api.md#replay-limits). Traffic exceeding that is rejected. On ntfy.sh, the daily bandwidth limit is 200 MB. |
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. | | **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing These limits can be changed on a per-user basis using [tiers](config.md#tiers). If [payments](config.md#payments) are enabled, a user tier can be changed by purchasing
+92 -36
View File
@@ -6,12 +6,98 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date | | Component | Version | Release date |
|------------------|---------|---------------| |------------------|---------|---------------|
| ntfy server | v2.26.0 | July 9, 2026 | | ntfy server | v2.28.0 | Aug 27, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 | | ntfy Android app | v1.25.2 | July 23, 2026 |
| ntfy iOS app | v1.7.0 | May 30, 2026 | | ntfy iOS app | v1.7.0 | May 30, 2026 |
Please check out the release notes for [upcoming releases](#not-released-yet) below. Please check out the release notes for [upcoming releases](#not-released-yet) below.
### ntfy server v2.28.0
Released August 27, 2026
This is a hardening release. A single topic on ntfy.sh was polled continuously with `poll=1` and no
`since` cursor, which replays a topic's entire cache on every request. The changes below bound what one
replay can cost, close two fields that had no size limit at all, and fix an ordering bug found while
digging into it.
**Bug fixes + maintenance:**
* Fix messages being returned out of publish order when polling or replaying **several topics at once** (`/topic1,topic2/json?poll=1`). `Message.Time` has second granularity, so a multi-topic replay sorts many equal keys; the sort was unstable, which could shuffle a single topic's own messages. Single-topic replays were not affected ([#1297](https://github.com/binwiederhier/ntfy/issues/1297))
* Limit the message title to 1 KB and all tags combined to 512 bytes, rejecting larger requests with HTTP 400 (error codes `40057` and `40058`). Neither field had a size limit before, unlike the message body; on ntfy.sh the 99.9th percentile is 212 bytes for titles and 244 for tags
* Cap a single cache replay at 10 MB of messages per topic. A poll without a `since` cursor returns a topic's entire cache, which was previously unbounded and could reach tens of megabytes on a busy topic, so one request could allocate that much on the server. The newest messages that fit are kept and a truncated response carries an `X-Messages-Truncated: 1` header
* `visitor-attachment-daily-bandwidth-limit` now also covers messages replayed from the message cache by poll requests, not just attachment traffic. A poll without a `since` cursor returns a topic's entire cache, so a topic that is cheap to fill can be re-read for many times its own size; polls beyond the budget are rejected with HTTP 429 (error code 42905) before anything is written. **Note that heavy pollers now consume the same budget as attachment downloads**, so operators serving both may want to raise the limit
### ntfy server v2.27.0
Released August 4, 2026
This release lets you sign in with your verified email address instead of your username, which should help if you ever
signed up with an email and then forgot which username you picked. It also hardens the message templating engine against
a few ways a small template could eat a lot of memory, and it drops the "experimental" label from
[PostgreSQL support](config.md#postgresql), which has been running ntfy.sh for a while now.
I also did a bunch of refactoring in, mostly in preparation for being able to cluster ntfy nodes and scale the service
horizontally. It'll be a while until then, ... baby steps.
**Security:**
* Limit message templates (`Template: yes`) to 32 KB, limit `printf` widths and precisions to below 1000, and limit `indent`/`nindent` to 100 spaces, preventing excessive memory use from a single small template
* Exclude secrets from the config hash served to the web app, preventing a rather theoretical information leak
**Features:**
* Allow logging in with your verified primary email address (in addition to your username), so a password reset no longer leaves you unable to sign in when you only remember the email you signed up with
**Bug fixes + maintenance:**
* Fix Twilio phone calls and phone number verifications failing silently when Twilio rejected the request, and move the Twilio integration into its own `twilio` package
* Move the Prometheus metrics into a dedicated `metrics` package
* Message cache databases from ntfy older than v1.10.0 (November 2021) can no longer be migrated; upgrade via an older ntfy version first, or delete the cache database
* Fix `user_phone` table in the SQLite user database referencing a dropped table after the v2.14 schema migration; repaired automatically by a new migration
## ntfy Android v1.25.2
Released July 23, 2026
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
once connectivity returns.
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
**Features:**
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
**Bug fixes + maintenance:**
* Fix the "connection lost" alert briefly disappearing and re-firing when roaming between networks (e.g. Wi-Fi to cellular), by no longer cancelling it during the transient no-network gap of a handover
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
### ntfy server v2.26.3
Released July 20, 2026
This is a hotfix release, useful pretty much only for ntfy.sh. It was adds the ability to track abusive IPs more
efficiently, reducing the load on the IP banning services and preventing them from falling behind and leaving abusers
unbanned for too long. It works by tracking HTTP errors, and writing out a ban file that fail2ban can read and ban
offenders instantly. See [ban-feed](config.md#ban-feed) for details.
**Features:**
* Add an abuse ban-feed: when enabled via `ban-file`, ntfy tracks a weighted strike budget per visitor and appends abusive IPs to a file that fail2ban can tail and ban on sight (`ban-file`, `ban-window`, `ban-threshold`, `ban-weights`; see [ban-feed docs](config.md#ban-feed))
### ntfy server v2.26.0 ### ntfy server v2.26.0
Released July 9, 2026 Released July 9, 2026
@@ -23,7 +109,7 @@ and a fix that strips unsafe URL protocols from rendered Markdown.
**Security:** **Security:**
* Prevent a CPU denial of service via message templates (`Template: yes`) ([GHSA-rhwf-xgc9-m9fp](https://github.com/binwiederhier/ntfy/security/advisories/GHSA-rhwf-xgc9-m9fp), [#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881) for reporting) * Prevent a CPU denial of service via message templates (`Template: yes`) ([#1826](https://github.com/binwiederhier/ntfy/pull/1826), thanks to [@alanturing881](https://github.com/alanturing881), [@5ud0er](https://github.com/5ud0er) and [@jvoisin](https://github.com/jvoisin) for reporting)
**Features:** **Features:**
@@ -256,7 +342,7 @@ to the primary until the replica recovers.
**Features:** **Features:**
* Support [PostgreSQL read replicas](config.md#postgresql-experimental) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648)) * Support [PostgreSQL read replicas](config.md#postgresql) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654)) * Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
**Bug fixes + maintenance:** **Bug fixes + maintenance:**
@@ -268,7 +354,7 @@ to the primary until the replica recovers.
Released March 7, 2026 Released March 7, 2026
This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from This is the biggest release I've ever done on the server. It's 14,997 added lines of code, and 10,202 lines removed, all from
one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql-experimental). one [pull request](https://github.com/binwiederhier/ntfy/pull/1619) that adds [PostgreSQL support](config.md#postgresql).
The code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents, The code was written by Cursor and Claude, but reviewed and heavily tested over 2-3 weeks by me. I created comparison documents,
went through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests, went through all queries multiple times and reviewed the logic over and over again. I also did load tests and manual regression tests,
@@ -279,7 +365,7 @@ if things are working (or not working). There is a [one-off migration tool](http
**Features:** **Features:**
* Add experimental [PostgreSQL support](config.md#postgresql-experimental) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting) * Add experimental [PostgreSQL support](config.md#postgresql) as an alternative database backend (message cache, user manager, web push subscriptions) via `database-url` config option ([#1114](https://github.com/binwiederhier/ntfy/issues/1114)/[#1619](https://github.com/binwiederhier/ntfy/pull/1619), thanks to [@brettinternet](https://github.com/brettinternet) for reporting)
**Bug fixes + maintenance:** **Bug fixes + maintenance:**
@@ -2007,36 +2093,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet ## Not released yet
### ntfy Android v1.25.1 (UNRELEASED)
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
The release also tries to be smarter about not retrying the connection at all if the app is in flight mode, or has no network. If there
is no network, ntfy now keeps the foreground service alive and shows a "Waiting for network" notification, then resumes automatically
once connectivity returns.
Another change related to the networking is that we now force-reconnect when the connection is changed, e.g. during transitions
from Wi-Fi to cellular network, or vice versa. That should allow for faster transitions during hand-overs.
We also increase the client-side WebSocket ping interval from 1 minute to 3 minutes, which should slightly improve battery life,
especially when paired with increaseing the server-side `keepalive-interval` in your self-hosted server.
**Features:**
* Add configurable "Alert when connection is lost" setting, turned off by default ([#1665](https://github.com/binwiederhier/ntfy/issues/1665), [#1662](https://github.com/binwiederhier/ntfy/issues/1662), [#1652](https://github.com/binwiederhier/ntfy/issues/1652), [#1655](https://github.com/binwiederhier/ntfy/issues/1655), thanks to [@tintamarre](https://github.com/tintamarre), [@sjozs](https://github.com/sjozs), [@TheRealOne78](https://github.com/TheRealOne78), and [@DAE51D](https://github.com/DAE51D) for reporting)
* Handle "no network" gracefully: when the device is offline or in airplane mode, ntfy now stops retrying, suppresses the connection-lost alert, and keeps the foreground service alive with a "Waiting for network" notification, resuming instant delivery automatically when connectivity returns ([ntfy-android#165](https://github.com/binwiederhier/ntfy-android/pull/165), thanks to [@tintamarre](https://github.com/tintamarre) for the contribution, and [#1709](https://github.com/binwiederhier/ntfy/issues/1709), thanks to [@isaitgirl](https://github.com/isaitgirl) for reporting)
* Improve battery life by increasing WebSocket client ping interval from 1 min to 3 min, and reconnect instantly on Wi-Fi/cellular/VPN transitions ([ntfy-android#113](https://github.com/binwiederhier/ntfy-android/pull/113), thanks to [@ftilde](https://github.com/ftilde) for the investigation)
* Disable UnifiedPush components when UnifiedPush is disabled in settings ([ntfy-android#168](https://github.com/binwiederhier/ntfy-android/pull/168), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
**Bug fixes + maintenance:**
* Fix the "connection lost" alert repeatedly waking the screen while a server stayed unreachable, by no longer re-posting the alert once it is already showing
* Fix the "connection lost" alert firing late, erratically, or not at all when a connection kept dropping (e.g. a flaky server) rather than being refused outright, by tracking how long the connection has been down independently of whether the drop warrants a UI error
* Undo automatic phone number linking for numbers in message body ([ntfy-android#170](https://github.com/binwiederhier/ntfy-android/pull/170), thanks to [@acortelyou](https://github.com/acortelyou) for the contribution)
* Fix subscription icons disappearing after a few days due to Android clearing cache ([#1322](https://github.com/binwiederhier/ntfy/issues/1322), thanks to [@mcanning](https://github.com/mcanning) for reporting)
* Fix UnifiedPush `failed_reason` being sent as an enum instead of a string, which caused an exception in receiving apps that read it as a string extra ([ntfy-android#182](https://github.com/binwiederhier/ntfy-android/pull/182), thanks to [@p1gp1g](https://github.com/p1gp1g) for the contribution)
### ntfy iOS app v1.8.0 (UNRELEASED) ### ntfy iOS app v1.8.0 (UNRELEASED)
**Features:** **Features:**
+31 -1
View File
@@ -245,6 +245,9 @@ combined with `since=` (defaults to `since=all`).
curl -s "ntfy.sh/mytopic/json?poll=1" curl -s "ntfy.sh/mytopic/json?poll=1"
``` ```
Note that a poll without `since=` returns a topic's **entire cache**, which on a busy topic can be
large. See [replay limits](#replay-limits) below.
### Fetch cached messages ### Fetch cached messages
Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network Messages may be cached for a couple of hours (see [message caching](../config.md#message-cache)) to account for network
interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using interruptions of subscribers. If the server has configured message caching, you can read back what you missed by using
@@ -275,6 +278,28 @@ parameter (makes most sense with the `poll=1` parameter):
curl -s "ntfy.sh/mytopic/json?poll=1&sched=1" curl -s "ntfy.sh/mytopic/json?poll=1&sched=1"
``` ```
### Replay limits
Reading cached messages (a `poll=1` request, or any request with `since=`) replays messages the server
already stored, so unlike a live subscription its cost grows with the size of the topic's cache. Two
server-side limits apply, both of which a well-behaved client should handle:
* **The response is size-capped.** A replay returns only the newest messages that fit in 10 MB
**per topic** (counting body, title, tags and every other publisher-set field), and a capped response carries an `X-Messages-Truncated: 1` header. If you see that
header, older messages were dropped and you did not receive the full cache. In practice this only
affects very large topics; a client polling with `since=` never comes close.
* **Replayed bytes count against your daily bandwidth budget**, the same one attachment downloads use
(see [limitations](../publish.md#limitations)). Exceeding it returns `HTTP 429` with ntfy error code
`42905`, and no messages are written.
Both limits exist because a poll without `since=` re-reads the whole cache every time. If you are
polling repeatedly, **pass `since=<last message ID>`** rather than re-fetching everything. The limits
still apply to a `since=` replay, but it returns only what is new, so in practice you will not come
near either one:
```
curl -s "ntfy.sh/mytopic/json?poll=1&since=nFS3knfcQ1xe"
```
### Filter messages ### Filter messages
You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and You can filter which messages are returned based on the well-known message fields `id`, `message`, `title`, `priority` and
`tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags `tags`. Here's an example that only returns messages of high or urgent priority that contains the both tags
@@ -308,6 +333,11 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"} {"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
``` ```
When replaying cached messages for several topics at once, they are ordered by their `time` field.
Because `time` has **second granularity**, messages published within the same second share a sort key:
each topic's own messages stay in publish order, but the interleaving *between* topics is not defined.
If you need a total order across topics, sort by `time` and fall back to the order received.
### Authentication ### Authentication
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them. may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
@@ -427,7 +457,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
| Parameter | Aliases (case-insensitive) | Description | | Parameter | Aliases (case-insensitive) | Description |
|-------------|----------------------------|---------------------------------------------------------------------------------| |-------------|----------------------------|---------------------------------------------------------------------------------|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection | | `poll` | `X-Poll`, `po` | Return cached messages and close connection (see [replay limits](#replay-limits)) |
| `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID | | `since` | `X-Since`, `si` | Return cached messages since timestamp, duration or message ID |
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list | | `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
| `id` | `X-ID` | Filter: Only return messages that match this exact message ID | | `id` | `X-ID` | Filter: Only return messages that match this exact message ID |
+44 -44
View File
@@ -3,23 +3,23 @@ module heckel.io/ntfy/v2
go 1.25.8 go 1.25.8
require ( require (
cloud.google.com/go/firestore v1.22.0 // indirect cloud.google.com/go/firestore v1.25.0 // indirect
cloud.google.com/go/storage v1.63.0 // indirect cloud.google.com/go/storage v1.65.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.25.0
github.com/gabriel-vasile/mimetype v1.4.13 github.com/gabriel-vasile/mimetype v1.4.15
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/mattn/go-sqlite3 v1.14.47 github.com/mattn/go-sqlite3 v1.14.50
github.com/olebedev/when v1.1.0 github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.12.1
github.com/urfave/cli/v2 v2.27.7 github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.53.0 golang.org/x/crypto v0.55.0
golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.21.0 golang.org/x/sync v0.22.0
golang.org/x/term v0.44.0 golang.org/x/term v0.45.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
google.golang.org/api v0.287.0 google.golang.org/api v0.294.0
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
) )
@@ -28,78 +28,78 @@ replace github.com/emersion/go-smtp => github.com/emersion/go-smtp v0.17.0 // Pi
require github.com/pkg/errors v0.9.1 // indirect require github.com/pkg/errors v0.9.1 // indirect
require ( require (
firebase.google.com/go/v4 v4.20.0 firebase.google.com/go/v4 v4.21.0
github.com/SherClockHolmes/webpush-go v1.4.0 github.com/SherClockHolmes/webpush-go v1.4.0
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.24.1
github.com/stripe/stripe-go/v74 v74.30.0 github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/sys v0.46.0 golang.org/x/sys v0.47.0
golang.org/x/text v0.39.0 golang.org/x/text v0.41.0
) )
require ( require (
cel.dev/expr v0.25.2 // indirect cel.dev/expr v0.25.3 // indirect
cloud.google.com/go v0.123.0 // indirect cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect cloud.google.com/go/auth v0.23.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.11.0 // indirect cloud.google.com/go/iam v1.13.0 // indirect
cloud.google.com/go/longrunning v1.1.0 // indirect cloud.google.com/go/longrunning v1.2.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect cloud.google.com/go/monitoring v1.30.0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect github.com/AlekSi/pointer v1.2.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.39.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.21 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect github.com/googleapis/gax-go/v2 v2.24.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect github.com/prometheus/procfs v0.21.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect github.com/spiffe/go-spiffe/v2 v2.8.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.3 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.46.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/sdk v1.46.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect
golang.org/x/net v0.56.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/net v0.58.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/grpc v1.82.0 // indirect google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.12 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+93 -94
View File
@@ -1,41 +1,41 @@
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= cloud.google.com/go/auth v0.23.2/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= 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 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E= cloud.google.com/go/firestore v1.25.0 h1:yY3rQKyQXNhnhETdseNayF6W1p4x0bdg9ZYS4hKJfOw=
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU= cloud.google.com/go/firestore v1.25.0/go.mod h1:0PU6hj+r/QlhB6BLsRX+Kt/SYefTXrpYrBeHbYaSis8=
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM= cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4= cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0=
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k= cloud.google.com/go/logging v1.19.1 h1:7SsLhyTDBDrJw+Ll6Ns3I2mByqHXvJUc3rGjSlwiWgU=
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI= cloud.google.com/go/logging v1.19.1/go.mod h1:2IkQ/d8jVJqV2qW8ZUGUiMjdZG1gkLD2JReGbZ8isqg=
cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE= cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E= cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY= cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM= cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ= cloud.google.com/go/storage v1.65.1 h1:LRRpBJUTf+OXDPX9jZUKZ3mSLIsz3htG+qUpeNZovyA=
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE= cloud.google.com/go/storage v1.65.1/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM=
cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E= cloud.google.com/go/trace v1.16.0 h1:GmQovzFc5F0CNfl0VLgL64aoTtu7xsM0YajW2GlG9+E=
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0= cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU= firebase.google.com/go/v4 v4.21.0 h1:HBZV4jrLtFYj8EwWyqEZOuRLfkfkV2bpnfyyXHOhPxY=
firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8= firebase.google.com/go/v4 v4.21.0/go.mod h1:CDumIdA5oTiyDpLNVcQoW8ZrB5CTgyE2D45DuENIABg=
github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w= github.com/AlekSi/pointer v1.2.0 h1:glcy/gc4h8HnG2Z3ZECSzZ1IX1x2JxRVuDzaJwQE0+w=
github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0= github.com/AlekSi/pointer v1.2.0/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 h1:3SdxXLkgAfiHRWcGTq6fneq9jgoJzneiY0yPQnjoT2E=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0/go.mod h1:1iIdl0k+ppn9wT0wzR9H7HkSvIui/4qgtnKW10cQtds=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 h1:HldzheTs05E3ybqSitI/wHaof6+XERRudgZLjYbs3eE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0/go.mod h1:evkqaSczW9g2BQm1veCtgNhJ4wCCsRrOsSgNIn9LHQk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0 h1:Fx8NtDCmKH4ML2hUkPz4Dq250903vRDojMjVCDKwQuc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 h1:Oblia1QXBJlM/wOY9ARRUtsXdDYiMCzk3eCMikqoLbI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0/go.mod h1:SRAbhyZ4R4FagHMM9VtRgSY/lheRoht2fKelZXQUenk=
github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o=
github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
@@ -50,9 +50,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
@@ -60,21 +59,21 @@ github.com/emersion/go-smtp v0.17.0 h1:tq90evlrcyqRfE6DSXaWVH54oX6OuZOQECEmhWBME
github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.17.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= github.com/envoyproxy/go-control-plane/envoy v1.39.0 h1:1uwRDYPYG8BIBU9Mj1sUAebNmlM6beu/ZKKweSLDxk8=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A= github.com/envoyproxy/go-control-plane/envoy v1.39.0/go.mod h1:5e4ylfTZO723MEEFsCpSW4ZEBWR8mwkEyXfwJBTCZ9c=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -96,10 +95,10 @@ 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg= github.com/googleapis/enterprise-certificate-proxy v0.3.21 h1:OFdQ3tnCX/zaQ0Cedur3D3z7kI6HiLX9g3TiAN4/DFU=
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k= github.com/googleapis/enterprise-certificate-proxy v0.3.21/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -112,16 +111,16 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo= github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -133,14 +132,12 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -150,12 +147,12 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E= github.com/spiffe/go-spiffe/v2 v2.8.1 h1:eXZMLsu+3MLEPJyGJkolqtVrteZfQdUpOWj6LTiDl/E=
github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= github.com/spiffe/go-spiffe/v2 v2.8.1/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY= github.com/stripe/stripe-go/v74 v74.30.0 h1:0Kf0KkeFnY7iRhOwvTerX0Ia1BRw+eV1CVJ51mGYAUY=
github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw= github.com/stripe/stripe-go/v74 v74.30.0/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
@@ -165,38 +162,40 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBi
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw= go.opentelemetry.io/contrib/detectors/gcp v1.46.0 h1:PI8dGkqDaQkwJ8kOopqMhDTbrnK3UIeG/RCHH4HErbo=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/detectors/gcp v1.46.0/go.mod h1:nsrN5c/sOLoY2vsPxN/rQ0V0nvGrWJCqcW4UXLtqNG8=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA= go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk= go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -211,8 +210,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.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 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.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -222,8 +221,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -236,8 +235,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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -247,8 +246,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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 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.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -260,8 +259,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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -274,22 +273,22 @@ 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= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.287.0 h1:CQDMqUiqZZ0U/Yge3zyjAhNQ0OSYEH0PaA7l4xtEen4= google.golang.org/api v0.294.0 h1:8gASjJxdtcIieB3OqbkLcF0FfbXVNqKtU5iozD1ssvA=
google.golang.org/api v0.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ= google.golang.org/api v0.294.0/go.mod h1:02qB8+Ox1ZFzcaKFMguy1nQLJmSIyvV6Ff4txJEXtl4=
google.golang.org/appengine/v2 v2.0.6 h1:LvPZLGuchSBslPBp+LAhihBeGSiRh1myRoYK4NtuBIw= 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/appengine/v2 v2.0.6/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU= google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 h1:jPP56YzdY899KJ5W7efXHt/CkjlVfAaoFOwdi/IEAFA=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM= google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5/go.mod h1:gutZdP0DwAHp4vu5WaXgEK7tjsJ77ZEqzlOFWGZGziE=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+68 -16
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/netip" "net/netip"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -17,6 +18,10 @@ import (
const ( const (
tagMessageCache = "message_cache" tagMessageCache = "message_cache"
schemaStore = "message" // Store name in the schema_version table (see db/schema)
// NoLimit reads a topic's cached messages without a size budget.
NoLimit = 0
) )
var errNoRows = errors.New("no rows found") var errNoRows = errors.New("no rows found")
@@ -185,19 +190,29 @@ func (c *Cache) addMessages(ms []*model.Message) error {
return nil return nil
} }
// Messages returns messages for a topic since the given marker, optionally including scheduled messages // Messages returns all cached messages for a topic, oldest first. Prefer MessagesCapped on
// request paths: an uncapped replay of a busy topic is as large as the topic's entire cache.
func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { func (c *Cache) Messages(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) {
if since.IsNone() { messages, _, err := c.MessagesCapped(topic, since, scheduled, NoLimit)
return make([]*model.Message, 0), nil return messages, err
} else if since.IsLatest() {
return c.messagesLatest(topic)
} else if since.IsID() {
return c.messagesSinceID(topic, since, scheduled)
}
return c.messagesSinceTime(topic, since, scheduled)
} }
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { // MessagesCapped returns cached messages for a topic, oldest first, keeping the newest messages
// that fit in maxBytes worth of Message.Size (0 = no budget). The bool reports whether older messages
// were dropped, so the caller can tell the client that what it got is incomplete.
func (c *Cache) MessagesCapped(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
if since.IsNone() {
return make([]*model.Message, 0), false, nil
} else if since.IsLatest() {
messages, err := c.messagesLatest(topic)
return messages, false, err
} else if since.IsID() {
return c.messagesSinceID(topic, since, scheduled, maxBytes)
}
return c.messagesSinceTime(topic, since, scheduled, maxBytes)
}
func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
var rows *sql.Rows var rows *sql.Rows
var err error var err error
rdb := c.db.ReadOnly() rdb := c.db.ReadOnly()
@@ -207,12 +222,12 @@ func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, schedul
rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix()) rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
} }
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
return readMessages(rows) return readMessagesCapped(rows, maxBytes)
} }
func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool) ([]*model.Message, error) { func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled bool, maxBytes int64) ([]*model.Message, bool, error) {
var rows *sql.Rows var rows *sql.Rows
var err error var err error
rdb := c.db.ReadOnly() rdb := c.db.ReadOnly()
@@ -222,9 +237,9 @@ func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled
rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID()) rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID())
} }
if err != nil { if err != nil {
return nil, err return nil, false, err
} }
return readMessages(rows) return readMessagesCapped(rows, maxBytes)
} }
func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) { func (c *Cache) messagesLatest(topic string) ([]*model.Message, error) {
@@ -285,7 +300,8 @@ func (c *Cache) MarkPublished(m *model.Message) error {
return err return err
} }
// MessagesCount returns the total number of messages in the cache // MessagesCount returns the total number of messages in the cache. On Postgres, this is the
// planner's estimate once the table has been analyzed, not an exact count.
func (c *Cache) MessagesCount() (int, error) { func (c *Cache) MessagesCount() (int, error) {
rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesCount) rows, err := c.db.ReadOnly().Query(c.queries.selectMessagesCount)
if err != nil { if err != nil {
@@ -457,6 +473,42 @@ func (c *Cache) processMessageBatches() {
} }
} }
// readMessagesCapped reads a newest-first result set, keeping the newest messages that fit in
// maxBytes worth of Message.Size (0 = no budget), and reverses them into the oldest-first
// order callers expect. It stops scanning once the budget is spent rather than reading everything
// and trimming, so a replay of a huge topic never materializes the whole cache. The bool reports
// whether older messages were left behind.
func readMessagesCapped(rows *sql.Rows, maxBytes int64) ([]*model.Message, bool, error) {
defer rows.Close()
messages := make([]*model.Message, 0)
truncated := false
var total int64
for rows.Next() {
m, err := readMessage(rows)
if err != nil {
return nil, false, err
}
if maxBytes > 0 {
size := int64(m.Size())
// Always return at least one message, even if it alone exceeds the budget: an empty
// reply is less useful than an oversized one, and the per-field limits bound how big it gets.
if len(messages) > 0 && total+size > maxBytes {
truncated = true
break
}
total += size
}
messages = append(messages, m)
}
if !truncated {
if err := rows.Err(); err != nil {
return nil, false, err
}
}
slices.Reverse(messages)
return messages, truncated, nil
}
func readMessages(rows *sql.Rows) ([]*model.Message, error) { func readMessages(rows *sql.Rows) ([]*model.Message, error) {
defer rows.Close() defer rows.Close()
messages := make([]*model.Message, 0) messages := make([]*model.Message, 0)
+13 -7
View File
@@ -4,6 +4,7 @@ import (
"time" "time"
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
) )
// PostgreSQL runtime query constants // PostgreSQL runtime query constants
@@ -24,13 +25,13 @@ const (
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_id, content_type, encoding SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message FROM message
WHERE topic = $1 AND time >= $2 AND published = TRUE WHERE topic = $1 AND time >= $2 AND published = TRUE
ORDER BY time, id ORDER BY time DESC, id DESC
` `
postgresSelectMessagesSinceTimeIncludeScheduledQuery = ` postgresSelectMessagesSinceTimeIncludeScheduledQuery = `
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_id, content_type, encoding SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message FROM message
WHERE topic = $1 AND time >= $2 WHERE topic = $1 AND time >= $2
ORDER BY time, id ORDER BY time DESC, id DESC
` `
postgresSelectMessagesSinceIDQuery = ` postgresSelectMessagesSinceIDQuery = `
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_id, content_type, encoding SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
@@ -38,14 +39,14 @@ const (
WHERE topic = $1 WHERE topic = $1
AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0)
AND published = TRUE AND published = TRUE
ORDER BY time, id ORDER BY time DESC, id DESC
` `
postgresSelectMessagesSinceIDIncludeScheduledQuery = ` postgresSelectMessagesSinceIDIncludeScheduledQuery = `
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_id, content_type, encoding SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
FROM message FROM message
WHERE topic = $1 WHERE topic = $1
AND (id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) OR published = FALSE) AND (id > COALESCE((SELECT id FROM message WHERE mid = $2), 0) OR published = FALSE)
ORDER BY time, id ORDER BY time DESC, id DESC
` `
postgresSelectMessagesLatestQuery = ` postgresSelectMessagesLatestQuery = `
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_id, content_type, encoding SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user_id, content_type, encoding
@@ -61,8 +62,13 @@ const (
ORDER BY time, id ORDER BY time, id
` `
postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1` postgresUpdateMessagePublishedQuery = `UPDATE message SET published = TRUE WHERE mid = $1`
postgresSelectMessagesCountQuery = `SELECT COUNT(*) FROM message` // Planner estimate, since a COUNT(*) scans the whole table; reltuples is -1 if never analyzed
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic` postgresSelectMessagesCountQuery = `
SELECT CASE WHEN reltuples < 0 THEN (SELECT COUNT(*) FROM message) ELSE reltuples::BIGINT END
FROM pg_class
WHERE oid = 'message'::regclass
`
postgresSelectTopicsQuery = `SELECT topic FROM message GROUP BY topic`
postgresDeleteExpiredMessagesQuery = `DELETE FROM message WHERE mid IN (SELECT mid FROM message WHERE expires <= $1 AND published = TRUE LIMIT $2)` postgresDeleteExpiredMessagesQuery = `DELETE FROM message WHERE mid IN (SELECT mid FROM message WHERE expires <= $1 AND published = TRUE LIMIT $2)`
postgresMarkExpiredAttachmentsDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid IN (SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE LIMIT $2)` postgresMarkExpiredAttachmentsDeletedQuery = `UPDATE message SET attachment_deleted = TRUE WHERE mid IN (SELECT mid FROM message WHERE attachment_expires > 0 AND attachment_expires <= $1 AND attachment_deleted = FALSE LIMIT $2)`
@@ -102,7 +108,7 @@ var postgresQueries = queries{
// NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool. // NewPostgresStore creates a new PostgreSQL-backed message cache store using an existing database connection pool.
func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) { func NewPostgresStore(d *db.DB, batchSize int, batchTimeout time.Duration) (*Cache, error) {
if err := setupPostgres(d.Primary()); err != nil { if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
return nil, err return nil, err
} }
return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil return newCache(d, postgresQueries, nil, batchSize, batchTimeout, false), nil
+10 -64
View File
@@ -1,16 +1,13 @@
package message package message
import ( import (
"database/sql" "heckel.io/ntfy/v2/db/schema"
"fmt"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
) )
// Initial PostgreSQL schema // Initial PostgreSQL schema
const ( const (
postgresCreateTablesQuery = ` postgresCurrentSchemaVersion = 15
postgresCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS message ( CREATE TABLE IF NOT EXISTS message (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
mid TEXT NOT NULL, mid TEXT NOT NULL,
@@ -50,21 +47,9 @@ const (
value BIGINT value BIGINT
); );
INSERT INTO message_stats (key, value) VALUES ('messages', 0); INSERT INTO message_stats (key, value) VALUES ('messages', 0);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
` `
) )
// PostgreSQL schema management queries
const (
postgresCurrentSchemaVersion = 15
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('message', $1)`
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'message'`
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'message'`
)
// PostgreSQL schema migrations // PostgreSQL schema migrations
const ( const (
// 14 -> 15 // 14 -> 15
@@ -73,51 +58,12 @@ const (
` `
) )
var postgresMigrations = map[int]func(d *sql.DB) error{ var (
14: postgresMigrateFrom14, postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQuery)
}
func setupPostgres(d *sql.DB) error { // postgresMigrations maps a schema version to the migration upgrading it to the next
var schemaVersion int // version. Always append migrations at the end, never insert in the middle.
if err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil { postgresMigrations = map[int]schema.MigrateFunc{
return setupNewPostgresDB(d) 14: schema.AsMigrateFunc(postgresMigrate14To15CreateIndexQuery),
} else if schemaVersion == postgresCurrentSchemaVersion {
return nil
} else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
} }
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ { )
fn, ok := postgresMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(d); err != nil {
return err
}
}
return nil
}
func postgresMigrateFrom14(d *sql.DB) error {
log.Tag(tagMessageCache).Info("Migrating message cache database schema: from 14 to 15")
return db.ExecTx(d, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresMigrate14To15CreateIndexQuery); err != nil {
return err
}
if _, err := tx.Exec(postgresUpdateSchemaVersionQuery, 15); err != nil {
return err
}
return nil
})
}
func setupNewPostgresDB(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
+101
View File
@@ -0,0 +1,101 @@
package message_test
import (
"testing"
"github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model"
)
func TestPostgresStore_Migration_From14(t *testing.T) {
// A pre-framework database at version 14: full v14 schema, version tracked in the
// hand-rolled schema_version table, and no idx_message_attachment_expires yet
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(`
CREATE TABLE message (
id BIGSERIAL PRIMARY KEY,
mid TEXT NOT NULL,
sequence_id TEXT NOT NULL,
time BIGINT NOT NULL,
event TEXT NOT NULL,
expires BIGINT NOT NULL,
topic TEXT NOT NULL,
message TEXT NOT NULL,
title TEXT NOT NULL,
priority INT NOT NULL,
tags TEXT NOT NULL,
click TEXT NOT NULL,
icon TEXT NOT NULL,
actions TEXT NOT NULL,
attachment_name TEXT NOT NULL,
attachment_type TEXT NOT NULL,
attachment_size BIGINT NOT NULL,
attachment_expires BIGINT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_deleted BOOLEAN NOT NULL DEFAULT FALSE,
sender TEXT NOT NULL,
user_id TEXT NOT NULL,
content_type TEXT NOT NULL,
encoding TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_message_mid ON message (mid);
CREATE INDEX idx_message_sequence_id ON message (sequence_id);
CREATE INDEX idx_message_topic_published_time ON message (topic, published, time, id);
CREATE INDEX idx_message_published_expires ON message (published, expires);
CREATE INDEX idx_message_sender_attachment_expires ON message (sender, attachment_expires) WHERE user_id = '';
CREATE INDEX idx_message_user_id_attachment_expires ON message (user_id, attachment_expires);
CREATE TABLE message_stats (key TEXT PRIMARY KEY, value BIGINT);
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schema_version (store, version) VALUES ('message', 14);
`)
require.Nil(t, err)
store, err := message.NewPostgresStore(testDB, 0, 0)
require.Nil(t, err)
// The 14 -> 15 step ran: version bumped, partial index created
var version int
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'message'`).Scan(&version))
require.Equal(t, 15, version)
var indexCount int
require.Nil(t, testDB.QueryRow(`SELECT COUNT(*) FROM pg_indexes WHERE indexname = 'idx_message_attachment_expires' AND schemaname = current_schema()`).Scan(&indexCount))
require.Equal(t, 1, indexCount)
// And the store works
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "hi there")))
messages, err := store.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Len(t, messages, 1)
// The migrated database must be structurally identical to a freshly created one
freshDB := dbtest.CreateTestPostgres(t)
_, err = message.NewPostgresStore(freshDB, 0, 0)
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
}
func TestPostgresStore_MessagesCount_UsesPlannerEstimate(t *testing.T) {
// The manager calls MessagesCount every minute for a metric; a COUNT(*) scans the whole
// table on every call, so once the table has been analyzed, the planner's estimate is used
testDB := dbtest.CreateTestPostgres(t)
store, err := message.NewPostgresStore(testDB, 0, 0)
require.Nil(t, err)
for i := 0; i < 10; i++ {
require.Nil(t, store.AddMessage(model.NewDefaultMessage("mytopic", "some message")))
}
// Never analyzed: falls back to an exact count
count, err := store.MessagesCount()
require.Nil(t, err)
require.Equal(t, 10, count)
// Analyzed, then rows deleted: the estimate lags until the next (auto)analyze
_, err = testDB.Exec(`ANALYZE message`)
require.Nil(t, err)
_, err = testDB.Exec(`DELETE FROM message WHERE id IN (SELECT id FROM message LIMIT 4)`)
require.Nil(t, err)
count, err = store.MessagesCount()
require.Nil(t, err)
require.Equal(t, 10, count)
}
+9 -5
View File
@@ -9,6 +9,7 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -30,25 +31,25 @@ const (
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 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 FROM messages
WHERE topic = ? AND time >= ? AND published = 1 WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id ORDER BY time DESC, id DESC
` `
sqliteSelectMessagesSinceTimeIncludeScheduledQuery = ` sqliteSelectMessagesSinceTimeIncludeScheduledQuery = `
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 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 FROM messages
WHERE topic = ? AND time >= ? WHERE topic = ? AND time >= ?
ORDER BY time, id ORDER BY time DESC, id DESC
` `
sqliteSelectMessagesSinceIDQuery = ` sqliteSelectMessagesSinceIDQuery = `
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 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 FROM messages
WHERE topic = ? AND id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) AND published = 1 WHERE topic = ? AND id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) AND published = 1
ORDER BY time, id ORDER BY time DESC, id DESC
` `
sqliteSelectMessagesSinceIDIncludeScheduledQuery = ` sqliteSelectMessagesSinceIDIncludeScheduledQuery = `
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 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 FROM messages
WHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0) WHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0)
ORDER BY time, id ORDER BY time DESC, id DESC
` `
sqliteSelectMessagesLatestQuery = ` sqliteSelectMessagesLatestQuery = `
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 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
@@ -113,7 +114,10 @@ func NewSQLiteStore(filename, startupQueries string, cacheDuration time.Duration
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := setupSQLite(d, startupQueries, cacheDuration); err != nil { if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
return nil, err
}
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations(cacheDuration)); err != nil {
return nil, err return nil, err
} }
return newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil return newCache(db.New(&db.Host{DB: d}, nil), sqliteQueries, &sync.Mutex{}, batchSize, batchTimeout, nop), nil
+30 -283
View File
@@ -2,16 +2,15 @@ package message
import ( import (
"database/sql" "database/sql"
"fmt"
"time" "time"
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/log"
) )
// Initial SQLite schema // Initial SQLite schema
const ( const (
sqliteCreateTablesQuery = ` sqliteCurrentSchemaVersion = 15
sqliteCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
mid TEXT NOT NULL, mid TEXT NOT NULL,
@@ -55,29 +54,9 @@ const (
` `
) )
// Schema version management for SQLite // Schema migrations for SQLite. Databases older than schema version 1 (ntfy < v1.10.0,
// November 2021) can no longer be migrated.
const ( const (
sqliteCurrentSchemaVersion = 15
sqliteCreateSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
`
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
// Schema migrations for SQLite
const (
// 0 -> 1
sqliteMigrate0To1AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN title TEXT NOT NULL DEFAULT('');
ALTER TABLE messages ADD COLUMN priority INT NOT NULL DEFAULT(0);
ALTER TABLE messages ADD COLUMN tags TEXT NOT NULL DEFAULT('');
`
// 1 -> 2 // 1 -> 2
sqliteMigrate1To2AlterMessagesTableQuery = ` sqliteMigrate1To2AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1); ALTER TABLE messages ADD COLUMN published INT NOT NULL DEFAULT(1);
@@ -193,67 +172,35 @@ const (
) )
var ( var (
sqliteMigrations = map[int]func(db *sql.DB, cacheDuration time.Duration) error{ sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQuery)
0: sqliteMigrateFrom0,
1: sqliteMigrateFrom1,
2: sqliteMigrateFrom2,
3: sqliteMigrateFrom3,
4: sqliteMigrateFrom4,
5: sqliteMigrateFrom5,
6: sqliteMigrateFrom6,
7: sqliteMigrateFrom7,
8: sqliteMigrateFrom8,
9: sqliteMigrateFrom9,
10: sqliteMigrateFrom10,
11: sqliteMigrateFrom11,
12: sqliteMigrateFrom12,
13: sqliteMigrateFrom13,
14: sqliteMigrateFrom14,
}
) )
func setupSQLite(db *sql.DB, startupQueries string, cacheDuration time.Duration) error { // sqliteMigrations returns the migration steps, keyed by the version they upgrade FROM. The
if err := runSQLiteStartupQueries(db, startupQueries); err != nil { // cache duration is carried into the 9 -> 10 step via closure (it backfills "expires" from it).
return err // Always append migrations at the end, never insert in the middle.
} func sqliteMigrations(cacheDuration time.Duration) map[int]schema.MigrateFunc {
// If 'messages' table does not exist, this must be a new database return map[int]schema.MigrateFunc{
var messagesCount int 1: schema.AsMigrateFunc(sqliteMigrate1To2AlterMessagesTableQuery),
if err := db.QueryRow(sqliteSelectMessagesCountQuery).Scan(&messagesCount); err != nil { 2: schema.AsMigrateFunc(sqliteMigrate2To3AlterMessagesTableQuery),
return setupNewSQLite(db) 3: schema.AsMigrateFunc(sqliteMigrate3To4AlterMessagesTableQuery),
} 4: schema.AsMigrateFunc(sqliteMigrate4To5AlterMessagesTableQuery),
// If 'messages' table exists (schema >= 0), check 'schemaVersion' table 5: schema.AsMigrateFunc(sqliteMigrate5To6AlterMessagesTableQuery),
var schemaVersion int 6: schema.AsMigrateFunc(sqliteMigrate6To7AlterMessagesTableQuery),
db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion) // Error means schema version is zero! 7: schema.AsMigrateFunc(sqliteMigrate7To8AlterMessagesTableQuery),
// Do migrations 8: schema.AsMigrateFunc(sqliteMigrate8To9AlterMessagesTableQuery),
if schemaVersion == sqliteCurrentSchemaVersion { 9: func(tx *sql.Tx) error {
return nil if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
} else if schemaVersion > sqliteCurrentSchemaVersion { return err
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion) }
} _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds()))
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
fn, ok := sqliteMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db, cacheDuration); err != nil {
return err return err
} },
10: schema.AsMigrateFunc(sqliteMigrate10To11AlterMessagesTableQuery),
11: schema.AsMigrateFunc(sqliteMigrate11To12AlterMessagesTableQuery),
12: schema.AsMigrateFunc(sqliteMigrate12To13AlterMessagesTableQuery),
13: schema.AsMigrateFunc(sqliteMigrate13To14AlterMessagesTableQuery),
14: schema.NopMigrateFunc, // Corresponds to Postgres migration
} }
return nil
}
func setupNewSQLite(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
})
} }
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
@@ -264,203 +211,3 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
} }
return nil return nil
} }
func sqliteMigrateFrom0(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 0 to 1")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate0To1AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteCreateSchemaVersionTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, 1); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom1(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 1 to 2")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate1To2AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom2(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 2 to 3")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate2To3AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom3(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 3 to 4")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate3To4AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom4(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 4 to 5")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate4To5AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom5(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 5 to 6")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate5To6AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom6(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 6 to 7")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate6To7AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom7(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 7 to 8")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate7To8AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom8(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 8 to 9")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate8To9AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 9); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom9(sqlDB *sql.DB, cacheDuration time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 9 to 10")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate9To10AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteMigrate9To10UpdateMessageExpiryQuery, int64(cacheDuration.Seconds())); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 10); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom10(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 10 to 11")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate10To11AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 11); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom11(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 11 to 12")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate11To12AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 12); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom12(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 12 to 13")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate12To13AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 13); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom13(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 13 to 14")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate13To14AlterMessagesTableQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 14); err != nil {
return err
}
return nil
})
}
// sqliteMigrateFrom14 is a no-op; the corresponding Postgres migration adds
// idx_message_attachment_expires, which SQLite already has from the initial schema.
func sqliteMigrateFrom14(sqlDB *sql.DB, _ time.Duration) error {
log.Tag(tagMessageCache).Info("Migrating cache database schema: from 14 to 15")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 15); err != nil {
return err
}
return nil
})
}
+14 -40
View File
@@ -9,50 +9,11 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/model"
) )
func TestSqliteStore_Migration_From0(t *testing.T) {
filename := newSqliteTestStoreFile(t)
db, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
// Create "version 0" schema
_, err = db.Exec(`
BEGIN;
CREATE TABLE IF NOT EXISTS messages (
id VARCHAR(20) PRIMARY KEY,
time INT NOT NULL,
topic VARCHAR(64) NOT NULL,
message VARCHAR(1024) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
COMMIT;
`)
require.Nil(t, err)
// Insert a bunch of messages
for i := 0; i < 10; i++ {
_, err = db.Exec(`INSERT INTO messages (id, time, topic, message) VALUES (?, ?, ?, ?)`,
fmt.Sprintf("abcd%d", i), time.Now().Unix(), "mytopic", fmt.Sprintf("some message %d", i))
require.Nil(t, err)
}
require.Nil(t, db.Close())
// Create store to trigger migration
s := newSqliteTestStoreFromFile(t, filename, "")
checkSqliteSchemaVersion(t, filename)
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 10, len(messages))
require.Equal(t, "some message 5", messages[5].Message)
require.Equal(t, "", messages[5].Title)
require.Nil(t, messages[5].Tags)
require.Equal(t, 0, messages[5].Priority)
}
func TestSqliteStore_Migration_From1(t *testing.T) { func TestSqliteStore_Migration_From1(t *testing.T) {
filename := newSqliteTestStoreFile(t) filename := newSqliteTestStoreFile(t)
db, err := sql.Open("sqlite3", filename) db, err := sql.Open("sqlite3", filename)
@@ -90,6 +51,19 @@ func TestSqliteStore_Migration_From1(t *testing.T) {
s := newSqliteTestStoreFromFile(t, filename, "") s := newSqliteTestStoreFromFile(t, filename, "")
checkSqliteSchemaVersion(t, filename) checkSqliteSchemaVersion(t, filename)
// The migrated database must be structurally identical to a freshly created one
freshFile := newSqliteTestStoreFile(t)
fresh, err := message.NewSQLiteStore(freshFile, "", time.Hour, 0, 0, false)
require.Nil(t, err)
t.Cleanup(func() { fresh.Close() })
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
// Add delayed message // Add delayed message
delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message") delayedMessage := model.NewDefaultMessage("mytopic", "some delayed message")
delayedMessage.Time = time.Now().Add(time.Minute).Unix() delayedMessage.Time = time.Now().Add(time.Minute).Unix()
+107
View File
@@ -0,0 +1,107 @@
// Package metrics defines the Prometheus metrics exposed by the ntfy server, and registers them
// with the default Prometheus registry on import. It is decoupled from the ntfy server, so that
// call sites can update metrics without depending on the server package.
package metrics
import (
"github.com/prometheus/client_golang/prometheus"
)
// Collectors for all metrics exposed by the server.
//
// These are never nil, so that call sites can update them unconditionally. If metrics are
// disabled, the server never mounts the /metrics handler, and the values are simply never read.
var (
MessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_success",
})
MessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_failure",
})
MessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_messages_cached_total",
})
MessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_message_publish_duration_ms",
})
FirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_success",
})
FirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_failure",
})
EmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_success",
})
EmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_failure",
})
EmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_success",
})
EmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_failure",
})
CallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_success",
})
CallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_failure",
})
UnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_unifiedpush_published_success",
})
MatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_success",
})
MatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_failure",
})
AttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_attachments_total_size",
})
Visitors = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_visitors_total",
})
Users = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_users_total",
})
Subscribers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_subscribers_total",
})
Topics = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_topics_total",
})
HTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_http_requests_total",
}, []string{"http_code", "ntfy_code", "http_method"})
)
// init registers all collectors with the default Prometheus registry. Registration is
// unconditional: the collectors are only ever exposed if the server mounts the /metrics handler,
// so there is nothing to be gained by tying registration to the config.
func init() {
prometheus.MustRegister(
MessagesPublishedSuccess,
MessagesPublishedFailure,
MessagesCached,
MessagePublishDurationMillis,
FirebasePublishedSuccess,
FirebasePublishedFailure,
EmailsPublishedSuccess,
EmailsPublishedFailure,
EmailsReceivedSuccess,
EmailsReceivedFailure,
CallsMadeSuccess,
CallsMadeFailure,
UnifiedPushPublishedSuccess,
MatrixPublishedSuccess,
MatrixPublishedFailure,
AttachmentsTotalSize,
Visitors,
Users,
Subscribers,
Topics,
HTTPRequests,
)
}
+58
View File
@@ -0,0 +1,58 @@
package metrics
import (
"sort"
"strings"
"testing"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
)
// expectedMetricNames is the exact set of metrics the server exposes. These names are a public
// contract: renaming or dropping one silently breaks existing dashboards and alerts.
var expectedMetricNames = []string{
"ntfy_attachments_total_size",
"ntfy_calls_made_failure",
"ntfy_calls_made_success",
"ntfy_emails_received_failure",
"ntfy_emails_received_success",
"ntfy_emails_sent_failure",
"ntfy_emails_sent_success",
"ntfy_firebase_published_failure",
"ntfy_firebase_published_success",
"ntfy_http_requests_total",
"ntfy_matrix_published_failure",
"ntfy_matrix_published_success",
"ntfy_message_publish_duration_ms",
"ntfy_messages_cached_total",
"ntfy_messages_published_failure",
"ntfy_messages_published_success",
"ntfy_subscribers_total",
"ntfy_topics_total",
"ntfy_unifiedpush_published_success",
"ntfy_users_total",
"ntfy_visitors_total",
}
func TestRegisteredMetricNames(t *testing.T) {
HTTPRequests.WithLabelValues("200", "20000", "GET").Inc()
families, err := prometheus.DefaultGatherer.Gather()
require.Nil(t, err)
names := make([]string, 0)
for _, family := range families {
if strings.HasPrefix(family.GetName(), "ntfy_") {
names = append(names, family.GetName())
}
}
sort.Strings(names)
require.Equal(t, expectedMetricNames, names)
}
func TestCollectors_NeverNil(t *testing.T) {
// Call sites update metrics unconditionally, even when metrics are disabled, so no collector
// may ever be nil
MessagesPublishedSuccess.Inc()
MessagesCached.Set(1)
HTTPRequests.WithLabelValues("200", "20000", "PUT").Inc()
}
+30
View File
@@ -101,6 +101,24 @@ func (m *Message) ForJSON() *Message {
return m return m
} }
// Size returns an approximate byte size of the variable-length, publisher-controlled parts of a
// message. It is used to budget cache replays, so it deliberately counts every field a publisher
// can grow rather than trying to match the exact wire size.
func (m *Message) Size() int {
size := len(m.ID) + len(m.SequenceID) + len(m.Event) + len(m.Topic) + len(m.Title) +
len(m.Message) + len(m.Click) + len(m.Icon) + len(m.ContentType) + len(m.Encoding) + len(m.PollID)
for _, tag := range m.Tags {
size += len(tag)
}
for _, action := range m.Actions {
size += action.Size()
}
if m.Attachment != nil {
size += len(m.Attachment.Name) + len(m.Attachment.Type) + len(m.Attachment.URL)
}
return size
}
// Attachment represents a file attachment on a message // Attachment represents a file attachment on a message
type Attachment struct { type Attachment struct {
Name string `json:"name"` Name string `json:"name"`
@@ -125,6 +143,18 @@ type Action struct {
Value string `json:"value,omitempty"` // used in "copy" action Value string `json:"value,omitempty"` // used in "copy" action
} }
// Size returns an approximate byte size of an action's variable-length fields.
func (a *Action) Size() int {
size := len(a.ID) + len(a.Action) + len(a.Label) + len(a.URL) + len(a.Method) + len(a.Body) + len(a.Intent) + len(a.Value)
for key, value := range a.Headers {
size += len(key) + len(value)
}
for key, value := range a.Extras {
size += len(key) + len(value)
}
return size
}
// NewAction creates a new action with initialized maps // NewAction creates a new action with initialized maps
func NewAction() *Action { func NewAction() *Action {
return &Action{ return &Action{
+55
View File
@@ -0,0 +1,55 @@
package server
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/ban"
)
// TestServer_BanFeed_WritesOffenderToFile is the end-to-end wiring test: a rejected request flows
// through s.handle -> the error responder -> s.ban.Record, and once the offender's prefix
// breaches, its ban line lands in the ban file (flushed on Close).
func TestServer_BanFeed_WritesOffenderToFile(t *testing.T) {
banFile := filepath.Join(t.TempDir(), "ban.log")
conf := newTestConfig(t, "")
conf.BanFile = banFile
conf.BanWindow = time.Minute
conf.BanThreshold = 1 // capacity 1: the 2nd rejection breaches
conf.BanWeights = ban.Weights{"*": 1} // any 4xx counts one strike
s := newTestServer(t, conf)
require.NotNil(t, s.ban)
// A delayed message with caching disabled is a deterministic 400 (errHTTPBadRequestDelayNoCache).
// request() sends from RemoteAddr 9.9.9.9.
reject := map[string]string{"Cache": "no", "In": "30 min"}
for i := 0; i < 3; i++ {
response := request(t, s, "PUT", "/mytopic", "", reject)
require.Equal(t, 400, response.Code)
}
// Writes are async; Close flushes the buffer. The offender's prefix must be in the feed exactly
// once (throttled to one line per window).
s.ban.Close()
data, err := os.ReadFile(banFile)
require.NoError(t, err)
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
require.Len(t, lines, 1)
require.Contains(t, lines[0], " 9.9.9.9 9.9.9.9/32 400 ") // <ip> <prefix> <http-code> <ntfy-code>
}
// TestServer_BanFeed_DisabledByDefault verifies the feature is off with no ban file: s.ban is
// nil and the error path skips it (guarded), so a rejected request must not panic.
func TestServer_BanFeed_DisabledByDefault(t *testing.T) {
conf := newTestConfig(t, "") // no BanFile
s := newTestServer(t, conf)
require.Nil(t, s.ban)
reject := map[string]string{"Cache": "no", "In": "30 min"}
response := request(t, s, "PUT", "/mytopic", "", reject) // guarded callsite, no Record
require.Equal(t, 400, response.Code)
}
+54 -12
View File
@@ -10,6 +10,7 @@ import (
"text/template" "text/template"
"time" "time"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
) )
@@ -42,6 +43,24 @@ const (
DefaultWebPushExpiryDuration = 60 * 24 * time.Hour DefaultWebPushExpiryDuration = 60 * 24 * time.Hour
) )
// Defines default abuse ban-feed settings (see BanFile, BanWindow, BanThreshold, BanWeights)
const (
DefaultBanWindow = 10 * time.Minute
DefaultBanThreshold = 100 // Weighted strikes per BanWindow before a prefix is banned
)
// DefaultBanWeights is the ban-feed's default per-code strike weights: the auth-failure flood bans fast,
// and everything else defaults to weight 1 (no "*" rule needed; see BanWeights.WeightFor).
var DefaultBanWeights = []string{
banWeight(errHTTPTooManyRequestsLimitAuthFailure, 10), // brute-force auth flood -> ban fast
}
// banWeight formats a "CODE:WEIGHT" ban-feed default from an ntfy error, so the codes stay in sync with
// the errHTTP definitions instead of being duplicated as string literals.
func banWeight(err *errHTTP, weight int) string {
return fmt.Sprintf("%d:%d", err.Code, weight)
}
// Defines all global and per-visitor limits // Defines all global and per-visitor limits
// - message size limit: the max number of bytes for a message // - message size limit: the max number of bytes for a message
// - total topic limit: max number of topics overall // - total topic limit: max number of topics overall
@@ -54,6 +73,16 @@ const (
DefaultAttachmentExpiryDuration = 3 * time.Hour DefaultAttachmentExpiryDuration = 3 * time.Hour
DefaultAttachmentOrphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads DefaultAttachmentOrphanGracePeriod = time.Hour // Don't delete orphaned objects younger than this to avoid races with in-flight uploads
// DefaultMessagePollSizeLimit caps what one cache replay returns per topic. It is a backstop
// against a single request materializing an entire topic cache, not a tunable: on ntfy.sh it
// would fire on 2 of ~98k cached topics. See docs/subscribe/api.md#replay-limits.
DefaultMessagePollSizeLimit = 10 * 1024 * 1024
// messageTitleSizeLimit and messageTagsSizeLimit cap two publisher-controlled fields that
// otherwise have no limit of their own. Sized off ntfy.sh's own cache: title p999 is 212 bytes
// (16 of ~3M messages exceed 1 KB), tags p999 is 244 (197 exceed 512).
messageTitleSizeLimit = 1024
messageTagsSizeLimit = 512
) )
// Defines all per-visitor limits // Defines all per-visitor limits
@@ -111,9 +140,9 @@ type Config struct {
AuthFile string AuthFile string
AuthStartupQueries string AuthStartupQueries string
AuthDefault user.Permission AuthDefault user.Permission
AuthUsers []*user.User AuthUsers []*user.User `hash:"-"`
AuthAccess map[string][]*user.Grant AuthAccess map[string][]*user.Grant
AuthTokens map[string][]*user.Token AuthTokens map[string][]*user.Token `hash:"-"`
AuthBcryptCost int AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration AuthStatsQueueWriterInterval time.Duration
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only) AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
@@ -134,28 +163,28 @@ type Config struct {
FirebasePollInterval time.Duration FirebasePollInterval time.Duration
FirebaseQuotaExceededPenaltyDuration time.Duration FirebaseQuotaExceededPenaltyDuration time.Duration
UpstreamBaseURL string UpstreamBaseURL string
UpstreamAccessToken string UpstreamAccessToken string `hash:"-"`
SMTPSenderAddr string SMTPSenderAddr string
SMTPSenderUser string SMTPSenderUser string
SMTPSenderPass string SMTPSenderPass string `hash:"-"`
SMTPSenderFrom string SMTPSenderFrom string
SMTPSenderVerify bool SMTPSenderVerify bool
SMTPServerListen string SMTPServerListen string
SMTPServerDomain string SMTPServerDomain string
SMTPServerAddrPrefix string SMTPServerAddrPrefix string
TwilioAccount string TwilioAccount string
TwilioAuthToken string TwilioAuthToken string `hash:"-"`
TwilioPhoneNumber string TwilioPhoneNumber string
TwilioCallsBaseURL string TwilioCallsBaseURL string
TwilioVerifyBaseURL string TwilioVerifyBaseURL string
TwilioVerifyService string TwilioVerifyService string
TwilioCallFormat *template.Template TwilioCallFormat *template.Template
MetricsEnable bool
MetricsListenHTTP string MetricsListenHTTP string
ProfileListenHTTP string ProfileListenHTTP string
MessageDelayMin time.Duration MessageDelayMin time.Duration
MessageDelayMax time.Duration MessageDelayMax time.Duration
MessageSizeLimit int MessageSizeLimit int
MessagePollSizeLimit int64
TotalTopicLimit int TotalTopicLimit int
TotalAttachmentSizeLimit int64 TotalAttachmentSizeLimit int64
VisitorSubscriptionLimit int VisitorSubscriptionLimit int
@@ -180,8 +209,8 @@ type Config struct {
BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported) BehindProxy bool // If true, the server will trust the proxy client IP header to determine the client IP address (IPv4 and IPv6 supported)
ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported) ProxyForwardedHeader string // The header field to read the real/client IP address from, if BehindProxy is true, defaults to "X-Forwarded-For" (IPv4 and IPv6 supported)
ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true ProxyTrustedPrefixes []netip.Prefix // List of trusted proxy networks (IPv4 or IPv6) that will be stripped from the Forwarded header if BehindProxy is true
StripeSecretKey string StripeSecretKey string `hash:"-"`
StripeWebhookKey string StripeWebhookKey string `hash:"-"`
StripePriceCacheDuration time.Duration StripePriceCacheDuration time.Duration
BillingContact string BillingContact string
EnableSignup bool // Enable creation of accounts via API and UI EnableSignup bool // Enable creation of accounts via API and UI
@@ -190,16 +219,20 @@ type Config struct {
EnableReservations bool // Allow users with role "user" to own/reserve topics EnableReservations bool // Allow users with role "user" to own/reserve topics
EnableMetrics bool EnableMetrics bool
AccessControlAllowOrigin string // CORS header field to restrict access from web clients AccessControlAllowOrigin string // CORS header field to restrict access from web clients
WebPushPrivateKey string WebPushPrivateKey string `hash:"-"`
WebPushPublicKey string WebPushPublicKey string
WebPushFile string WebPushFile string
WebPushEmailAddress string WebPushEmailAddress string
WebPushStartupQueries string WebPushStartupQueries string
WebPushExpiryDuration time.Duration WebPushExpiryDuration time.Duration
WebPushExpiryWarningDuration time.Duration WebPushExpiryWarningDuration time.Duration
BuildVersion string // Injected by App BanFile string // Abuse ban-feed: file that fail2ban tails; empty string disables the feature
BuildDate string // Injected by App BanWindow time.Duration // Abuse ban-feed: rolling window over which weighted strikes are counted
BuildCommit string // Injected by App BanThreshold int // Abuse ban-feed: weighted strikes per window before a prefix is banned
BanWeights ban.Weights // Abuse ban-feed: code matcher -> strike weight (see ban.ParseWeights, ban.Weights.WeightFor)
BuildVersion string // Injected by App
BuildDate string // Injected by App
BuildCommit string // Injected by App
} }
// NewConfig instantiates a default new server config // NewConfig instantiates a default new server config
@@ -260,6 +293,7 @@ func NewConfig() *Config {
TwilioVerifyService: "", TwilioVerifyService: "",
TwilioCallFormat: nil, TwilioCallFormat: nil,
MessageSizeLimit: DefaultMessageSizeLimit, MessageSizeLimit: DefaultMessageSizeLimit,
MessagePollSizeLimit: DefaultMessagePollSizeLimit,
MessageDelayMin: DefaultMessageDelayMin, MessageDelayMin: DefaultMessageDelayMin,
MessageDelayMax: DefaultMessageDelayMax, MessageDelayMax: DefaultMessageDelayMax,
TotalTopicLimit: DefaultTotalTopicLimit, TotalTopicLimit: DefaultTotalTopicLimit,
@@ -300,6 +334,10 @@ func NewConfig() *Config {
WebPushEmailAddress: "", WebPushEmailAddress: "",
WebPushExpiryDuration: DefaultWebPushExpiryDuration, WebPushExpiryDuration: DefaultWebPushExpiryDuration,
WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration, WebPushExpiryWarningDuration: DefaultWebPushExpiryWarningDuration,
BanFile: "",
BanWindow: DefaultBanWindow,
BanThreshold: DefaultBanThreshold,
BanWeights: nil,
BuildVersion: "", BuildVersion: "",
BuildDate: "", BuildDate: "",
BuildCommit: "", BuildCommit: "",
@@ -316,6 +354,10 @@ func (c *Config) Hash() string {
for i := 0; i < v.NumField(); i++ { for i := 0; i < v.NumField(); i++ {
field := v.Field(i) field := v.Field(i)
fieldName := t.Field(i).Name fieldName := t.Field(i).Name
// Secrets must not feed the hash
if t.Field(i).Tag.Get("hash") == "-" {
continue
}
// Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix) // Try to marshal the field and skip if it fails (e.g. *template.Template, netip.Prefix)
if b, err := json.Marshal(field.Interface()); err == nil { if b, err := json.Marshal(field.Interface()); err == nil {
result += fmt.Sprintf("%s:%s|", fieldName, string(b)) result += fmt.Sprintf("%s:%s|", fieldName, string(b))
+22
View File
@@ -3,6 +3,7 @@ package server_test
import ( import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"heckel.io/ntfy/v2/server" "heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/user"
"testing" "testing"
) )
@@ -11,3 +12,24 @@ func TestConfig_New(t *testing.T) {
assert.Equal(t, ":80", c.ListenHTTP) assert.Equal(t, ":80", c.ListenHTTP)
assert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval) assert.Equal(t, server.DefaultKeepaliveInterval, c.KeepaliveInterval)
} }
func TestConfig_HashExcludesSecrets(t *testing.T) {
// The config hash is served to browsers (ConfigHash, for webapp change detection), so
// secret material must not feed it: a weak secret would otherwise be offline-brute-forceable
// against a publicly visible hash.
conf1 := server.NewConfig()
conf2 := server.NewConfig()
conf2.StripeSecretKey = "sk_live_topsecret"
conf2.StripeWebhookKey = "whsec_topsecret"
conf2.TwilioAuthToken = "twilio-auth-token"
conf2.UpstreamAccessToken = "tk_upstream"
conf2.WebPushPrivateKey = "web-push-private-key"
conf2.SMTPSenderPass = "hunter2"
conf2.AuthUsers = []*user.User{{Name: "phil", Hash: "$2a$10$somebcrypthash"}}
conf2.AuthTokens = map[string][]*user.Token{"phil": {{Value: "tk_secrettoken"}}}
assert.Equal(t, conf1.Hash(), conf2.Hash())
// Non-secret fields must still change the hash
conf3 := server.NewConfig()
conf3.BaseURL = "https://ntfy.example.com"
assert.NotEqual(t, conf1.Hash(), conf3.Hash())
}
+3
View File
@@ -148,6 +148,9 @@ var (
errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPBadRequestEmailAddressNotVerified = &errHTTP{40052, http.StatusBadRequest, "invalid request: email address not verified", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil} errHTTPBadRequestAnonymousEmailNotAllowed = &errHTTP{40053, http.StatusBadRequest, "invalid request: anonymous email sending is not allowed", "https://ntfy.sh/docs/publish/#e-mail-notifications", nil}
errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil} errHTTPBadRequestResetLinkInvalid = &errHTTP{40054, http.StatusBadRequest, "invalid request: password reset link invalid or expired", "", nil}
errHTTPBadRequestTemplateTooLarge = &errHTTP{40056, http.StatusBadRequest, "invalid request: template too large", "https://ntfy.sh/docs/publish/#message-templating", nil}
errHTTPBadRequestTitleTooLarge = &errHTTP{40057, http.StatusBadRequest, "invalid request: title is too large", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPBadRequestTagsTooLarge = &errHTTP{40058, http.StatusBadRequest, "invalid request: tags are too large", "https://ntfy.sh/docs/publish/#limitations", nil}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", 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} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil}
+15 -16
View File
@@ -16,22 +16,21 @@ import (
// Log tags // Log tags
const ( const (
tagStartup = "startup" tagStartup = "startup"
tagHTTP = "http" tagHTTP = "http"
tagPublish = "publish" tagPublish = "publish"
tagSubscribe = "subscribe" tagSubscribe = "subscribe"
tagFirebase = "firebase" tagFirebase = "firebase"
tagSMTP = "smtp" // Receive email tagSMTP = "smtp" // Receive email
tagEmail = "email" // Send email tagEmail = "email" // Send email
tagTwilio = "twilio" tagTwilio = "twilio"
tagMessageCache = "message_cache" tagStripe = "stripe"
tagStripe = "stripe" tagAccount = "account"
tagAccount = "account" tagManager = "manager"
tagManager = "manager" tagResetter = "resetter"
tagResetter = "resetter" tagWebsocket = "websocket"
tagWebsocket = "websocket" tagMatrix = "matrix"
tagMatrix = "matrix" tagWebPush = "webpush"
tagWebPush = "webpush"
) )
var ( var (
+152 -86
View File
@@ -31,13 +31,16 @@ import (
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"heckel.io/ntfy/v2/action" "heckel.io/ntfy/v2/action"
"heckel.io/ntfy/v2/attachment" "heckel.io/ntfy/v2/attachment"
"heckel.io/ntfy/v2/ban"
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/mail" "heckel.io/ntfy/v2/mail"
"heckel.io/ntfy/v2/message" "heckel.io/ntfy/v2/message"
"heckel.io/ntfy/v2/metrics"
"heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/payments" "heckel.io/ntfy/v2/payments"
"heckel.io/ntfy/v2/twilio"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
"heckel.io/ntfy/v2/webpush" "heckel.io/ntfy/v2/webpush"
@@ -57,8 +60,9 @@ type Server struct {
mailer mail.Sender mailer mail.Sender
topics map[string]*topic topics map[string]*topic
visitors map[string]*visitor // ip:<ip> or user:<user> visitors map[string]*visitor // ip:<ip> or user:<user>
ban *ban.Service // Abuse ban-feed; nil when the feature is disabled (no ban file)
firebaseClient *firebaseClient firebaseClient *firebaseClient
twilio *twilioClient twilio *twilio.Client
messages int64 // Total number of messages (persisted if messageCache enabled) messages int64 // Total number of messages (persisted if messageCache enabled)
messagesHistory []int64 // Last n values of the messages counter, used to determine rate messagesHistory []int64 // Last n values of the messages counter, used to determine rate
userManager *user.Manager // Might be nil! userManager *user.Manager // Might be nil!
@@ -110,6 +114,7 @@ var (
apiUsersPath = "/v1/users" apiUsersPath = "/v1/users"
apiUsersAccessPath = "/v1/users/access" apiUsersAccessPath = "/v1/users/access"
apiAccountPath = "/v1/account" apiAccountPath = "/v1/account"
apiAccountLoginPath = "/v1/account/login"
apiAccountTokenPath = "/v1/account/token" apiAccountTokenPath = "/v1/account/token"
apiAccountPasswordPath = "/v1/account/password" apiAccountPasswordPath = "/v1/account/password"
apiAccountSettingsPath = "/v1/account/settings" apiAccountSettingsPath = "/v1/account/settings"
@@ -145,12 +150,6 @@ var (
//go:embed docs //go:embed docs
docsStaticFs embed.FS docsStaticFs embed.FS
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs} docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
//go:embed templates
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
templatesDir = "templates"
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
) )
const ( const (
@@ -164,9 +163,6 @@ const (
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
messagesHistoryMax = 10 // Number of message count values to keep in memory messagesHistoryMax = 10 // Number of message count values to keep in memory
templateMaxExecutionTime = 100 * time.Millisecond // Maximum time a template can take to execute, used to prevent DoS attacks
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
) )
// WebSocket constants // WebSocket constants
@@ -247,6 +243,16 @@ func New(conf *Config) (*Server, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
twilioClient := twilio.NewClient(&twilio.Config{
Account: conf.TwilioAccount,
AuthToken: conf.TwilioAuthToken,
PhoneNumber: conf.TwilioPhoneNumber,
CallsBaseURL: conf.TwilioCallsBaseURL,
VerifyBaseURL: conf.TwilioVerifyBaseURL,
VerifyService: conf.TwilioVerifyService,
CallFormat: conf.TwilioCallFormat,
BuildVersion: conf.BuildVersion,
})
var userManager *user.Manager var userManager *user.Manager
if conf.AuthFile != "" || pool != nil { if conf.AuthFile != "" || pool != nil {
authConfig := &user.Config{ authConfig := &user.Config{
@@ -286,6 +292,17 @@ func New(conf *Config) (*Server, error) {
} }
firebaseClient = newFirebaseClient(sender, auther) firebaseClient = newFirebaseClient(sender, auther)
} }
var banner *ban.Service
if conf.BanFile != "" {
banner = ban.NewService(&ban.Config{
File: conf.BanFile,
Window: conf.BanWindow,
Threshold: conf.BanThreshold,
Weights: conf.BanWeights,
PrefixBitsIPv4: conf.VisitorPrefixBitsIPv4,
PrefixBitsIPv6: conf.VisitorPrefixBitsIPv6,
})
}
s := &Server{ s := &Server{
config: conf, config: conf,
db: pool, db: pool,
@@ -293,8 +310,9 @@ func New(conf *Config) (*Server, error) {
webPush: wp, webPush: wp,
attachment: attachmentStore, attachment: attachmentStore,
firebaseClient: firebaseClient, firebaseClient: firebaseClient,
twilio: newTwilioClient(conf, userManager), twilio: twilioClient,
mailer: sender, mailer: sender,
ban: banner,
topics: topics, topics: topics,
userManager: userManager, userManager: userManager,
messages: messages, messages: messages,
@@ -395,13 +413,11 @@ func (s *Server) Run() error {
}() }()
} }
if s.config.MetricsListenHTTP != "" { if s.config.MetricsListenHTTP != "" {
initMetrics()
s.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()} s.httpMetricsServer = &http.Server{Addr: s.config.MetricsListenHTTP, Handler: promhttp.Handler()}
go func() { go func() {
errChan <- s.httpMetricsServer.ListenAndServe() errChan <- s.httpMetricsServer.ListenAndServe()
}() }()
} else if s.config.EnableMetrics { } else if s.config.EnableMetrics {
initMetrics()
s.metricsHandler = promhttp.Handler() s.metricsHandler = promhttp.Handler()
} }
if s.config.ProfileListenHTTP != "" { if s.config.ProfileListenHTTP != "" {
@@ -450,6 +466,9 @@ func (s *Server) Stop() {
s.attachment.Close() s.attachment.Close()
} }
s.closeDatabases() s.closeDatabases()
if s.ban != nil {
s.ban.Close()
}
if s.closeChan != nil { if s.closeChan != nil {
close(s.closeChan) close(s.closeChan)
} }
@@ -472,7 +491,7 @@ func (s *Server) closeDatabases() {
// handle is the main entry point for all HTTP requests // handle is the main entry point for all HTTP requests
func (s *Server) handle(w http.ResponseWriter, r *http.Request) { func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
v, err := s.maybeAuthenticate(r) // Note: Always returns v, even when error is returned r, v, err := s.maybeAuthenticate(r) // Note: Always returns v (and r, with the client IP in its context), even on error
if err != nil { if err != nil {
s.handleError(w, r, v, err) s.handleError(w, r, v, err)
return return
@@ -489,9 +508,7 @@ func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.handleError(w, r, v, err) s.handleError(w, r, v, err)
return return
} }
if metricHTTPRequests != nil { metrics.HTTPRequests.WithLabelValues("200", "20000", r.Method).Inc()
metricHTTPRequests.WithLabelValues("200", "20000", r.Method).Inc()
}
}). }).
Debug("HTTP request finished") Debug("HTTP request finished")
} }
@@ -501,9 +518,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
if !ok { if !ok {
httpErr = errHTTPInternalError httpErr = errHTTPInternalError
} }
if metricHTTPRequests != nil { metrics.HTTPRequests.WithLabelValues(strconv.Itoa(httpErr.HTTPCode), strconv.Itoa(httpErr.Code), r.Method).Inc()
metricHTTPRequests.WithLabelValues(fmt.Sprintf("%d", httpErr.HTTPCode), fmt.Sprintf("%d", httpErr.Code), r.Method).Inc()
}
isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode) isRateLimiting := util.Contains(rateLimitingErrorCodes, httpErr.HTTPCode)
isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode) isNormalError := strings.Contains(err.Error(), "i/o timeout") || util.Contains(normalErrorCodes, httpErr.HTTPCode)
ev := logvr(v, r).Err(err) ev := logvr(v, r).Err(err)
@@ -538,6 +553,11 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor,
w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests w.Header().Set("Access-Control-Allow-Origin", s.config.AccessControlAllowOrigin) // CORS, allow cross-origin requests
w.WriteHeader(httpErr.HTTPCode) w.WriteHeader(httpErr.HTTPCode)
io.WriteString(w, httpErr.JSON()+"\n") io.WriteString(w, httpErr.JSON()+"\n")
if s.ban != nil {
if ip, err := fromContext[netip.Addr](r, contextVisitorIP); err == nil {
s.ban.Record(ip, httpErr.HTTPCode, httpErr.Code)
}
}
} }
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -575,6 +595,8 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v) return s.ensureUser(s.withAccountSync(s.handleAccountDelete))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath { } else if r.Method == http.MethodPost && r.URL.Path == apiAccountPasswordPath {
return s.ensureUser(s.handleAccountPasswordChange)(w, r, v) return s.ensureUser(s.handleAccountPasswordChange)(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountLoginPath {
return s.ensureUser(s.withAccountSync(s.handleAccountLogin))(w, r, v)
} else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath { } else if r.Method == http.MethodPost && r.URL.Path == apiAccountTokenPath {
return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v) return s.ensureUser(s.withAccountSync(s.handleAccountTokenCreate))(w, r, v)
} else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath { } else if r.Method == http.MethodPatch && r.URL.Path == apiAccountTokenPath {
@@ -798,6 +820,41 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
return writeMatrixDiscoveryResponse(w) return writeMatrixDiscoveryResponse(w)
} }
// dispatch delivers m to local subscribers and fires the requested side-effect targets. It is
// the single choke point through which every published message must pass; t may be nil when
// the topic has no local subscribers (delayed sender).
func (s *Server) dispatch(v *visitor, t *topic, m *model.Message, opts dispatchOpts) error {
// Deliver to local subscribers
if t != nil {
if opts.async {
go func() {
if err := t.Publish(v, m); err != nil {
logvm(v, m).Err(err).Warn("Unable to publish message")
}
}()
} else if err := t.Publish(v, m); err != nil {
return err
}
}
// Fire the requested side-effect targets
if s.firebaseClient != nil && opts.firebase {
go s.sendToFirebase(v, m)
}
if s.mailer != nil && opts.email != "" {
go s.sendEmail(v, m, opts.email)
}
if s.config.TwilioAccount != "" && opts.call != "" {
go s.callPhone(v, m, opts.call)
}
if s.config.UpstreamBaseURL != "" && opts.upstream {
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" && opts.webPush {
go s.publishToWebPushEndpoints(v, m)
}
return nil
}
func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) { func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Message, error) {
start := time.Now() start := time.Now()
t, err := fromContext[*topic](r, contextTopic) t, err := fromContext[*topic](r, contextTopic)
@@ -837,7 +894,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
} }
if call != "" { if call != "" {
var httpErr *errHTTP var httpErr *errHTTP
call, httpErr = s.twilio.convertPhoneNumber(v.User(), call) call, httpErr = s.convertPhoneNumber(v.User(), call)
if httpErr != nil { if httpErr != nil {
return nil, httpErr.With(t) return nil, httpErr.With(t)
} else if !vrate.CallAllowed() { } else if !vrate.CallAllowed() {
@@ -876,24 +933,16 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
ev.Debug("Received message") ev.Debug("Received message")
} }
if !delayed { if !delayed {
if err := t.Publish(v, m); err != nil { err := s.dispatch(v, t, m, dispatchOpts{
firebase: firebase,
email: email,
call: call,
upstream: !unifiedpush, // UP messages are not sent to upstream
webPush: true,
})
if err != nil {
return nil, err return nil, err
} }
if s.firebaseClient != nil && firebase {
go s.sendToFirebase(v, m)
}
if s.mailer != nil && email != "" {
go s.sendEmail(v, m, email)
}
if s.config.TwilioAccount != "" && call != "" {
go s.twilio.callPhone(v, r, m, call)
}
if s.config.UpstreamBaseURL != "" && !unifiedpush { // UP messages are not sent to upstream
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
} else { } else {
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later") logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
} }
@@ -922,27 +971,27 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
s.messages++ s.messages++
s.mu.Unlock() s.mu.Unlock()
if unifiedpush { if unifiedpush {
minc(metricUnifiedPushPublishedSuccess) metrics.UnifiedPushPublishedSuccess.Inc()
} }
mset(metricMessagePublishDurationMillis, time.Since(start).Milliseconds()) metrics.MessagePublishDurationMillis.Set(float64(time.Since(start).Milliseconds()))
return m, nil return m, nil
} }
func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visitor) error {
m, err := s.handlePublishInternal(r, v) m, err := s.handlePublishInternal(r, v)
if err != nil { if err != nil {
minc(metricMessagesPublishedFailure) metrics.MessagesPublishedFailure.Inc()
return err return err
} }
minc(metricMessagesPublishedSuccess) metrics.MessagesPublishedSuccess.Inc()
return s.writeJSON(w, m.ForJSON()) return s.writeJSON(w, m.ForJSON())
} }
func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *visitor) error {
_, err := s.handlePublishInternal(r, v) _, err := s.handlePublishInternal(r, v)
if err != nil { if err != nil {
minc(metricMessagesPublishedFailure) metrics.MessagesPublishedFailure.Inc()
minc(metricMatrixPublishedFailure) metrics.MatrixPublishedFailure.Inc()
if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode { if e, ok := err.(*errHTTP); ok && e.HTTPCode == errHTTPInsufficientStorageUnifiedPush.HTTPCode {
topic, err := fromContext[*topic](r, contextTopic) topic, err := fromContext[*topic](r, contextTopic)
if err != nil { if err != nil {
@@ -958,8 +1007,8 @@ func (s *Server) handlePublishMatrix(w http.ResponseWriter, r *http.Request, v *
} }
return err return err
} }
minc(metricMessagesPublishedSuccess) metrics.MessagesPublishedSuccess.Inc()
minc(metricMatrixPublishedSuccess) metrics.MatrixPublishedSuccess.Inc()
return writeMatrixSuccess(w) return writeMatrixSuccess(w)
} }
@@ -992,18 +1041,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
m.Sender = v.IP() m.Sender = v.IP()
m.User = v.MaybeUserID() m.User = v.MaybeUserID()
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
// Publish to subscribers // Publish to subscribers, Firebase (for Android clients), and web push endpoints
if err := t.Publish(v, m); err != nil { if err := s.dispatch(v, t, m, dispatchOpts{firebase: true, webPush: true}); err != nil {
return err return err
} }
// Send to Firebase for Android clients
if s.firebaseClient != nil {
go s.sendToFirebase(v, m)
}
// Send to web push endpoints
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
if event == model.MessageDeleteEvent { if event == model.MessageDeleteEvent {
// Delete any existing scheduled message with the same sequence ID // Delete any existing scheduled message with the same sequence ID
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID) deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
@@ -1031,7 +1072,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
func (s *Server) sendToFirebase(v *visitor, m *model.Message) { func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase") logvm(v, m).Tag(tagFirebase).Debug("Publishing to Firebase")
if err := s.firebaseClient.Send(v, m); err != nil { if err := s.firebaseClient.Send(v, m); err != nil {
minc(metricFirebasePublishedFailure) metrics.FirebasePublishedFailure.Inc()
if errors.Is(err, errFirebaseTemporarilyBanned) { if errors.Is(err, errFirebaseTemporarilyBanned) {
logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error()) logvm(v, m).Tag(tagFirebase).Err(err).Debug("Unable to publish to Firebase: %v", err.Error())
} else { } else {
@@ -1039,17 +1080,17 @@ func (s *Server) sendToFirebase(v *visitor, m *model.Message) {
} }
return return
} }
minc(metricFirebasePublishedSuccess) metrics.FirebasePublishedSuccess.Inc()
} }
func (s *Server) sendEmail(v *visitor, m *model.Message, email string) { func (s *Server) sendEmail(v *visitor, m *model.Message, email string) {
logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email) logvm(v, m).Tag(tagEmail).Field("email", email).Info("Sending email to %s", email)
if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil { if err := s.mailer.SendNotification(email, m, v.ip.String()); err != nil {
logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error()) logvm(v, m).Tag(tagEmail).Field("email", email).Err(err).Warn("Unable to send email to %s: %v", email, err.Error())
minc(metricEmailsPublishedFailure) metrics.EmailsPublishedFailure.Inc()
return return
} }
minc(metricEmailsPublishedSuccess) metrics.EmailsPublishedSuccess.Inc()
} }
func (s *Server) forwardPollRequest(v *visitor, m *model.Message) { func (s *Server) forwardPollRequest(v *visitor, m *model.Message) {
@@ -1106,6 +1147,9 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
cache = readBoolParam(r, true, "x-cache", "cache") cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase") firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = readParam(r, "x-title", "title", "t") m.Title = readParam(r, "x-title", "title", "t")
if len(m.Title) > messageTitleSizeLimit {
return false, false, "", "", "", false, "", errHTTPBadRequestTitleTooLarge
}
m.Click = readParam(r, "x-click", "click") m.Click = readParam(r, "x-click", "click")
icon := readParam(r, "x-icon", "icon") icon := readParam(r, "x-icon", "icon")
filename := readParam(r, "x-filename", "filename", "file", "f") filename := readParam(r, "x-filename", "filename", "file", "f")
@@ -1172,6 +1216,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
priorityStr = "" // Clear since it's already parsed priorityStr = "" // Clear since it's already parsed
} }
m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta") m.Tags = readCommaSeparatedParam(r, "x-tags", "tags", "tag", "ta")
// Measured across all tags, not each one: a publisher can add arbitrarily many
tagsSize := 0
for _, tag := range m.Tags {
tagsSize += len(tag)
}
if tagsSize > messageTagsSizeLimit {
return false, false, "", "", "", false, "", errHTTPBadRequestTagsTooLarge
}
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in") delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" { if delayStr != "" {
if !cache { if !cache {
@@ -1245,7 +1297,7 @@ func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *model.Message
} else if m.Attachment != nil && m.Attachment.Name != "" { } else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 4 return s.handleBodyAsAttachment(r, v, m, body) // Case 4
} else if template.Enabled() { } else if template.Enabled() {
return s.handleBodyAsTemplatedTextMessage(m, template, body, priorityStr) // Case 5 return s.handleBodyAsTemplatedTextMessage(r.Context(), m, template, body, priorityStr) // Case 5
} else if !body.LimitReached && utf8.Valid(body.PeekedBytes) { } else if !body.LimitReached && utf8.Valid(body.PeekedBytes) {
return s.handleBodyAsTextMessage(m, body) // Case 6 return s.handleBodyAsTextMessage(m, body) // Case 6
} }
@@ -1383,6 +1435,10 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
} }
var wlock sync.Mutex var wlock sync.Mutex
var closed bool var closed bool
// Only messages replayed from the cache are charged against the visitor's daily bandwidth
// budget, the same one attachment traffic uses. This is set in the poll branch below, which
// returns before any Subscribe, so sub() is never called concurrently while it is true.
meterPollBandwidth := false
defer func() { defer func() {
// This blocks until any in-flight sub() call finishes writing/flushing the response writer, // This blocks until any in-flight sub() call finishes writing/flushing the response writer,
// then marks the connection as closed so future sub() calls are no-ops. This prevents a panic // then marks the connection as closed so future sub() calls are no-ops. This prevents a panic
@@ -1397,16 +1453,22 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
if !filters.Pass(msg) { if !filters.Pass(msg) {
return nil return nil
} }
m, err := encoder(msg) encoded, err := encoder(msg)
if err != nil { if err != nil {
return err return err
} }
// Charge the encoded length, i.e. what actually goes over the wire. Charge before writing,
// so an exhausted budget fails the first message and surfaces as a clean 429 with nothing
// written.
if meterPollBandwidth && !v.BandwidthAllowed(int64(len(encoded))) {
return errHTTPTooManyRequestsLimitAttachmentBandwidth
}
wlock.Lock() wlock.Lock()
defer wlock.Unlock() defer wlock.Unlock()
if closed { if closed {
return nil return nil
} }
if _, err := w.Write([]byte(m)); err != nil { if _, err := w.Write([]byte(encoded)); err != nil {
return err return err
} }
if fl, ok := w.(http.Flusher); ok { if fl, ok := w.(http.Flusher); ok {
@@ -1423,7 +1485,8 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
for _, t := range topics { for _, t := range topics {
t.Keepalive() t.Keepalive()
} }
return s.sendOldMessages(topics, since, scheduled, v, sub) meterPollBandwidth = true
return s.sendOldMessages(w, topics, since, scheduled, v, sub)
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@@ -1439,7 +1502,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
return err return err
} }
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { if err := s.sendOldMessages(w, topics, since, scheduled, v, sub); err != nil {
return err return err
} }
for { for {
@@ -1574,7 +1637,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
for _, t := range topics { for _, t := range topics {
t.Keepalive() t.Keepalive()
} }
return s.sendOldMessages(topics, since, scheduled, v, sub) return s.sendOldMessages(w, topics, since, scheduled, v, sub)
} }
subscriberIDs := make([]int, 0) subscriberIDs := make([]int, 0)
for _, t := range topics { for _, t := range topics {
@@ -1588,7 +1651,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message if err := sub(v, model.NewOpenMessage(topicsStr)); err != nil { // Send out open message
return err return err
} }
if err := s.sendOldMessages(topics, since, scheduled, v, sub); err != nil { if err := s.sendOldMessages(w, topics, since, scheduled, v, sub); err != nil {
return err return err
} }
err = g.Wait() err = g.Wait()
@@ -1683,21 +1746,30 @@ func (s *Server) setRateVisitors(r *http.Request, v *visitor, rateTopics []*topi
// sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the // sendOldMessages selects old messages from the messageCache and calls sub for each of them. It uses since as the
// marker, returning only messages that are newer than the marker. // marker, returning only messages that are newer than the marker.
func (s *Server) sendOldMessages(topics []*topic, since model.SinceMarker, scheduled bool, v *visitor, sub subscriber) error { func (s *Server) sendOldMessages(w http.ResponseWriter, topics []*topic, since model.SinceMarker, scheduled bool, v *visitor, sub subscriber) error {
if since.IsNone() { if since.IsNone() {
return nil return nil
} }
messages := make([]*model.Message, 0) messages := make([]*model.Message, 0)
truncated := false
for _, t := range topics { for _, t := range topics {
topicMessages, err := s.messageCache.Messages(t.ID, since, scheduled) topicMessages, topicTruncated, err := s.messageCache.MessagesCapped(t.ID, since, scheduled, s.config.MessagePollSizeLimit)
if err != nil { if err != nil {
return err return err
} }
truncated = truncated || topicTruncated
messages = append(messages, topicMessages...) messages = append(messages, topicMessages...)
} }
sort.Slice(messages, func(i, j int) bool { // Stable: Time has second granularity, so a multi-topic replay has many equal keys. An unstable
// sort reorders them and a topic's own messages come back out of publish order (#1297).
sort.SliceStable(messages, func(i, j int) bool {
return messages[i].Time < messages[j].Time return messages[i].Time < messages[j].Time
}) })
// Must be set before the first message is written, or the header is already on the wire. On the
// WebSocket path the response has been hijacked by then, so this is a no-op there.
if truncated {
w.Header().Set("X-Messages-Truncated", "1")
}
for _, m := range messages { for _, m := range messages {
if err := sub(v, m); err != nil { if err := sub(v, m); err != nil {
return err return err
@@ -1952,24 +2024,18 @@ func (s *Server) sendDelayedMessages() error {
func (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error { func (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error {
logvm(v, m).Debug("Sending delayed message") logvm(v, m).Debug("Sending delayed message")
s.mu.RLock() s.mu.RLock()
t, ok := s.topics[m.Topic] // If no subscribers, just mark message as published t := s.topics[m.Topic] // May be nil if there are no local subscribers; dispatch handles that
s.mu.RUnlock() s.mu.RUnlock()
if ok { // We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler.
go func() { // Firebase subscribers may not show up in the topics map, so side effects fire regardless.
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler err := s.dispatch(v, t, m, dispatchOpts{
if err := t.Publish(v, m); err != nil { firebase: true,
logvm(v, m).Err(err).Warn("Unable to publish message") upstream: true,
} webPush: true,
}() async: true,
} })
if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map if err != nil {
go s.sendToFirebase(v, m) return err
}
if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
} }
if err := s.messageCache.MarkPublished(m); err != nil { if err := s.messageCache.MarkPublished(m); err != nil {
return err return err
+40 -3
View File
@@ -370,6 +370,39 @@
# visitor-topic-creation-limit-burst: 100 # visitor-topic-creation-limit-burst: 100
# visitor-topic-creation-limit-replenish: "1m" # visitor-topic-creation-limit-replenish: "1m"
# Abuse ban-feed: Count HTTP response statuses per visitor and append abusive IPs to a file that
# fail2ban (or similar) can tail and ban on sight. This captures ntfy-layer rejections (e.g. ACL
# 403s and ntfy's own 429s), so fail2ban does not have to regex-parse the full access log.
# - ban-file is the file abusive IPs are appended to; leave empty to disable the feature. Its
# directory must exist and be writable by ntfy. Rotate it (e.g. logrotate, copytruncate) so it
# cannot grow unbounded.
# - ban-window is the rolling window over which weighted strikes are counted, per visitor.
# - ban-threshold is the number of weighted strikes per window before a visitor is banned. Each
# visitor has ONE strike budget; rejections draw it down, so there is no way to game it by mixing
# codes.
# - ban-weights assigns a strike weight to a matcher KEY (KEY:WEIGHT). A KEY is an exact ntfy code
# ("42909"), a prefix family ("429*"), a bare HTTP status ("403", shorthand for "403*"), or "*".
# Longest match wins. A weight of 0 exempts a code (never contributes to a ban), so the legit quota
# 429s can be carved out from a "*" catch-all. Heavier weights ban faster (auth-failure floods).
#
# Each appended line has the exact format
# "<RFC3339-UTC-timestamp> <ip> <prefix> <http-code> <ntfy-code>", for example:
# 2026-07-17T20:56:32Z 1.2.3.4 1.2.3.4/32 429 42901
# 2026-07-17T20:56:32Z 2001:db8::abcd 2001:db8::/64 429 42909
# <prefix> is <ip> masked to the rate-limiting prefix (visitor-prefix-bits-ipv4/ipv6); that is the
# unit a fail2ban jail should ban, so a whole IPv6 subnet is banned as one.
#
# ban-file: "/var/log/ntfy-ban.log"
# ban-window: "1m"
# ban-threshold: 100
# ban-weights:
# - "42909:10" # auth-failure flood: bans in ~10
# - "42908:0" # daily message quota reached -> legit, never counts
# - "42903:0" # subscription limit -> legit
# - "42905:0" # daily bandwidth reached -> legit
# - "42910:0" # daily phone call quota reached -> legit
# - "*:1" # everything else 4xx/5xx
# Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting # Rate limiting: IPv4/IPv6 address prefix bits used for rate limiting
# - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address) # - visitor-prefix-bits-ipv4: number of bits of the IPv4 address to use for rate limiting (default: 32, full address)
# - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet) # - visitor-prefix-bits-ipv6: number of bits of the IPv6 address to use for rate limiting (default: 64, /64 subnet)
@@ -384,7 +417,10 @@
# Rate limiting: Attachment size and bandwidth limits per visitor: # Rate limiting: Attachment size and bandwidth limits per visitor:
# - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor # - visitor-attachment-total-size-limit is the total storage limit used for attachments per visitor
# - visitor-attachment-daily-bandwidth-limit is the total daily attachment download/upload traffic limit per visitor # - visitor-attachment-daily-bandwidth-limit is the total daily traffic limit per visitor. It covers
# attachment downloads/uploads AND messages replayed from the message cache by poll requests. A
# poll without a "since" cursor returns the topic's entire cache, so a busy topic can be re-read
# for many times its own size; charging it here caps what one visitor can pull per day.
# #
# visitor-attachment-total-size-limit: "100M" # visitor-attachment-total-size-limit: "100M"
# visitor-attachment-daily-bandwidth-limit: "500M" # visitor-attachment-daily-bandwidth-limit: "500M"
@@ -423,8 +459,9 @@
# doing, and/or secure access to the endpoint in your reverse proxy. # doing, and/or secure access to the endpoint in your reverse proxy.
# #
# - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket) # - enable-metrics enables the /metrics endpoint for the default ntfy server (i.e. HTTP, HTTPS and/or Unix socket)
# - metrics-listen-http exposes the metrics endpoint via a dedicated [IP]:port. If set, this option implicitly # - metrics-listen-http moves the metrics endpoint to a dedicated [IP]:port, e.g. "10.0.1.1:9090" or ":9090".
# enables metrics as well, e.g. "10.0.1.1:9090" or ":9090" # It implicitly enables metrics. If set, the metrics are served only on that dedicated port, and the default
# ntfy server does not serve /metrics, even if enable-metrics is also set.
# #
# enable-metrics: false # enable-metrics: false
# metrics-listen-http: # metrics-listen-http:
+25 -3
View File
@@ -10,6 +10,7 @@ import (
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/twilio"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -268,6 +269,24 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
} }
// handleAccountLogin authenticates a username-or-email + password (via the ensureUser wrapper's
// Basic Auth), mints a session token, and returns it together with the canonical username. Unlike
// the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a
// user in, so it also reports who they are (the identifier they typed may be a primary email).
func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error {
u := v.User()
logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name)
token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false)
if err != nil {
return err
}
response := &apiAccountLoginResponse{
Token: token.Value,
Username: u.Name,
}
return s.writeJSON(w, response)
}
func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! req, err := readJSONWithLimit[apiAccountTokenIssueRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body!
if err != nil { if err != nil {
@@ -613,7 +632,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R
} }
// Actually add the unverified number, and send verification // Actually add the unverified number, and send verification
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification") logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Sending phone number verification")
if err := s.twilio.verifyPhoneNumber(v, r, req.Number, req.Channel); err != nil { if err := s.twilio.Verify(req.Number, req.Channel); err != nil {
return err return err
} }
return s.writeJSON(w, newSuccessResponse()) return s.writeJSON(w, newSuccessResponse())
@@ -628,7 +647,10 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ
if !phoneNumberRegex.MatchString(req.Number) { if !phoneNumberRegex.MatchString(req.Number) {
return errHTTPBadRequestPhoneNumberInvalid return errHTTPBadRequestPhoneNumberInvalid
} }
if err := s.twilio.verifyPhoneNumberCheck(v, r, req.Number, req.Code); err != nil { if err := s.twilio.CheckVerify(req.Number, req.Code); err != nil {
if errors.Is(err, twilio.ErrVerificationExpired) {
return errHTTPGonePhoneVerificationExpired
}
return err return err
} }
logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified") logvr(v, r).Tag(tagAccount).Field("phone_number", req.Number).Debug("Adding phone number as verified")
@@ -963,7 +985,7 @@ func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
return err return err
} }
m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes)) m := model.NewDefaultMessage(syncTopic.ID, string(messageBytes))
if err := syncTopic.Publish(v, m); err != nil { if err := s.dispatch(v, syncTopic, m, dispatchOpts{}); err != nil {
return err return err
} }
return nil return nil
+16
View File
@@ -224,6 +224,22 @@ func canLogin(t *testing.T, s *Server, username, password string) bool {
return rr.Code == 200 return rr.Code == 200
} }
func TestAccount_LoginByPrimaryEmail(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL)
defer s.closeDatabases()
verifyEmailFor(t, s, mailer, auth, "ben@example.com")
// Basic Auth works with either the username or the verified primary email
require.True(t, canLogin(t, s, "ben", "ben"))
require.True(t, canLogin(t, s, "ben@example.com", "ben"))
// ...but not with the wrong password or an unknown email
require.False(t, canLogin(t, s, "ben@example.com", "wrong"))
require.False(t, canLogin(t, s, "nobody@example.com", "ben"))
})
}
func TestAccount_PasswordReset_ByUsername(t *testing.T) { func TestAccount_PasswordReset_ByUsername(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
s, mailer, auth := newEmailTestServer(t, databaseURL) s, mailer, auth := newEmailTestServer(t, databaseURL)
+52
View File
@@ -55,6 +55,58 @@ func TestAccount_Signup_Success(t *testing.T) {
}) })
} }
func TestAccount_Login_Success(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
u, err := s.userManager.User("phil")
require.Nil(t, err)
require.Nil(t, s.userManager.AddEmail(u.ID, "phil@example.com"))
require.Nil(t, s.userManager.SetPrimaryEmail(u.ID, "phil@example.com"))
// Login by username returns a token and the canonical username
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ := util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
// The returned token actually authenticates
rr = request(t, s, "GET", "/v1/account", "", map[string]string{
"Authorization": util.BearerAuth(resp.Token),
})
require.Equal(t, 200, rr.Code)
// Login by primary email returns the canonical username, not the email that was typed
rr = request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil@example.com", "mypass"),
})
require.Equal(t, 200, rr.Code)
resp, _ = util.UnmarshalJSON[apiAccountLoginResponse](io.NopCloser(rr.Body))
require.True(t, strings.HasPrefix(resp.Token, "tk_"))
require.Equal(t, "phil", resp.Username)
})
}
func TestAccount_Login_InvalidCredentials(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL)
s := newTestServer(t, conf)
defer s.closeDatabases()
require.Nil(t, s.userManager.AddUser("phil", "mypass", user.RoleUser, false))
rr := request(t, s, "POST", "/v1/account/login", "", map[string]string{
"Authorization": util.BasicAuth("phil", "wrongpass"),
})
require.Equal(t, 401, rr.Code)
})
}
func TestAccount_Signup_UserExists(t *testing.T) { func TestAccount_Signup_UserExists(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfigWithAuthFile(t, databaseURL) conf := newTestConfigWithAuthFile(t, databaseURL)
+11 -7
View File
@@ -21,31 +21,35 @@ import (
// //
// This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so // This function will ALWAYS return a visitor, even if an error occurs (e.g. unauthorized), so
// that subsequent logging calls still have a visitor context. // that subsequent logging calls still have a visitor context.
func (s *Server) maybeAuthenticate(r *http.Request) (*visitor, error) { func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, error) {
// Read the "Authorization" header value and exit out early if it's not set // Read the "Authorization" header value and exit out early if it's not set
ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes) ip := extractIPAddress(r, s.config.BehindProxy, s.config.ProxyForwardedHeader, s.config.ProxyTrustedPrefixes)
// Stash the extracted client IP in the request context so downstream code (the abuse ban-feed in
// handleError) can reuse it without re-parsing headers, and so an account-keyed (tier'd) visitor --
// whose shared visitor object has a stale v.ip -- is still attributed to the actual request IP.
r = withContext(r, map[contextKey]any{contextVisitorIP: ip})
vip := s.visitor(ip, nil) vip := s.visitor(ip, nil)
if s.userManager == nil { if s.userManager == nil {
return vip, nil return r, vip, nil
} }
header, err := readAuthHeader(r) header, err := readAuthHeader(r)
if err != nil { if err != nil {
return vip, err return r, vip, err
} else if !supportedAuthHeader(header) { } else if !supportedAuthHeader(header) {
return vip, nil return r, vip, nil
} }
// If we're trying to auth, check the rate limiter first // If we're trying to auth, check the rate limiter first
if !vip.AuthAllowed() { if !vip.AuthAllowed() {
return vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs! return r, vip, errHTTPTooManyRequestsLimitAuthFailure // Always return visitor, even when error occurs!
} }
u, err := s.authenticate(r, header) u, err := s.authenticate(r, header)
if err != nil { if err != nil {
vip.AuthFailed() vip.AuthFailed()
logr(r).Err(err).Debug("Authentication failed") logr(r).Err(err).Debug("Authentication failed")
return vip, errHTTPUnauthorized // Always return visitor, even when error occurs! return r, vip, errHTTPUnauthorized // Always return visitor, even when error occurs!
} }
// Authentication with user was successful // Authentication with user was successful
return s.visitor(ip, u), nil return r, s.visitor(ip, u), nil
} }
// authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...). // authenticate a user based on basic auth username/password (Authorization: Basic ...), or token auth (Authorization: Bearer ...).
+7 -6
View File
@@ -2,6 +2,7 @@ package server
import ( import (
"heckel.io/ntfy/v2/log" "heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/metrics"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -93,13 +94,13 @@ func (s *Server) execManager() {
"emails_sent_failure": sentMailFailure, "emails_sent_failure": sentMailFailure,
}). }).
Info("Server stats") Info("Server stats")
mset(metricMessagesCached, messagesCached) metrics.MessagesCached.Set(float64(messagesCached))
mset(metricVisitors, visitorsCount) metrics.Visitors.Set(float64(visitorsCount))
mset(metricUsers, usersCount) metrics.Users.Set(float64(usersCount))
mset(metricSubscribers, subscribers) metrics.Subscribers.Set(float64(subscribers))
mset(metricTopics, topicsCount) metrics.Topics.Set(float64(topicsCount))
if s.attachment != nil { if s.attachment != nil {
mset(metricAttachmentsTotalSize, s.attachment.Size()) metrics.AttachmentsTotalSize.Set(float64(s.attachment.Size()))
} }
} }
-132
View File
@@ -1,132 +0,0 @@
package server
import (
"github.com/prometheus/client_golang/prometheus"
)
var (
metricMessagesPublishedSuccess prometheus.Counter
metricMessagesPublishedFailure prometheus.Counter
metricMessagesCached prometheus.Gauge
metricMessagePublishDurationMillis prometheus.Gauge
metricFirebasePublishedSuccess prometheus.Counter
metricFirebasePublishedFailure prometheus.Counter
metricEmailsPublishedSuccess prometheus.Counter
metricEmailsPublishedFailure prometheus.Counter
metricEmailsReceivedSuccess prometheus.Counter
metricEmailsReceivedFailure prometheus.Counter
metricCallsMadeSuccess prometheus.Counter
metricCallsMadeFailure prometheus.Counter
metricUnifiedPushPublishedSuccess prometheus.Counter
metricMatrixPublishedSuccess prometheus.Counter
metricMatrixPublishedFailure prometheus.Counter
metricAttachmentsTotalSize prometheus.Gauge
metricVisitors prometheus.Gauge
metricSubscribers prometheus.Gauge
metricTopics prometheus.Gauge
metricUsers prometheus.Gauge
metricHTTPRequests *prometheus.CounterVec
)
func initMetrics() {
metricMessagesPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_success",
})
metricMessagesPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_messages_published_failure",
})
metricMessagesCached = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_messages_cached_total",
})
metricMessagePublishDurationMillis = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_message_publish_duration_ms",
})
metricFirebasePublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_success",
})
metricFirebasePublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_firebase_published_failure",
})
metricEmailsPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_success",
})
metricEmailsPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_sent_failure",
})
metricEmailsReceivedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_success",
})
metricEmailsReceivedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_emails_received_failure",
})
metricCallsMadeSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_success",
})
metricCallsMadeFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_calls_made_failure",
})
metricUnifiedPushPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_unifiedpush_published_success",
})
metricMatrixPublishedSuccess = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_success",
})
metricMatrixPublishedFailure = prometheus.NewCounter(prometheus.CounterOpts{
Name: "ntfy_matrix_published_failure",
})
metricAttachmentsTotalSize = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_attachments_total_size",
})
metricVisitors = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_visitors_total",
})
metricUsers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_users_total",
})
metricSubscribers = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_subscribers_total",
})
metricTopics = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "ntfy_topics_total",
})
metricHTTPRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_http_requests_total",
}, []string{"http_code", "ntfy_code", "http_method"})
prometheus.MustRegister(
metricMessagesPublishedSuccess,
metricMessagesPublishedFailure,
metricMessagesCached,
metricMessagePublishDurationMillis,
metricFirebasePublishedSuccess,
metricFirebasePublishedFailure,
metricEmailsPublishedSuccess,
metricEmailsPublishedFailure,
metricEmailsReceivedSuccess,
metricEmailsReceivedFailure,
metricCallsMadeSuccess,
metricCallsMadeFailure,
metricUnifiedPushPublishedSuccess,
metricMatrixPublishedSuccess,
metricMatrixPublishedFailure,
metricAttachmentsTotalSize,
metricVisitors,
metricUsers,
metricSubscribers,
metricTopics,
metricHTTPRequests,
)
}
// minc increments a prometheus.Counter if it is non-nil
func minc(counter prometheus.Counter) {
if counter != nil {
counter.Inc()
}
}
// mset sets a prometheus.Gauge if it is non-nil
func mset[T int | int64 | float64](gauge prometheus.Gauge, value T) {
if gauge != nil {
gauge.Set(float64(value))
}
}
+1
View File
@@ -13,6 +13,7 @@ const (
contextRateVisitor contextKey = iota + 2586 contextRateVisitor contextKey = iota + 2586
contextTopic contextTopic
contextMatrixPushKey contextMatrixPushKey
contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record)
) )
func (s *Server) limitRequests(next handleFunc) handleFunc { func (s *Server) limitRequests(next handleFunc) handleFunc {
+70 -16
View File
@@ -2,10 +2,14 @@ package server
import ( import (
"bytes" "bytes"
"context"
"embed"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
"text/template/parse" "text/template/parse"
"time" "time"
@@ -17,7 +21,33 @@ import (
"heckel.io/ntfy/v2/util/sprig" "heckel.io/ntfy/v2/util/sprig"
) )
func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error { var (
//go:embed templates
templatesFs embed.FS // Contains template config files (e.g. grafana.yml, github.yml, ...)
templatesDir = "templates"
templateNameRegex = regexp.MustCompile(`^[-_A-Za-z0-9]+$`)
// templatePrintfLargeSizeRegex matches a printf directive whose width or precision is a star
// (taken from an argument) or has four or more digits, i.e. is at least 1000. It deliberately
// scans the flag/width/precision characters after a % without requiring a well-formed
// directive: fmt pads even malformed ones (e.g. "%000 9999999#" emits 10 MB), so anything
// unrecognized must still be caught.
templatePrintfLargeSizeRegex = regexp.MustCompile(`%[-+# 0-9.*\[\]]*(\*|[0-9]{4})`)
// templateMaxExecutionTime is the wall-clock deadline for a single template render, a DoS guard
// (GHSA-rhwf-xgc9-m9fp). It is a var (not a const) solely so tests can raise it; it is never
// mutated in production.
templateMaxExecutionTime = 100 * time.Millisecond
)
const (
templateMaxOutputBytes = 1024 * 1024 // Maximum number of bytes a template can output, used to prevent DoS attacks
templateMaxTemplateBytes = 32 * 1024 // Maximum size of a template (inline or from a template file), used to prevent DoS attacks
templateFileExtension = ".yml" // Template files must end with this extension
)
func (s *Server) handleBodyAsTemplatedTextMessage(ctx context.Context, m *model.Message, template templateMode, body *util.PeekedReadCloser, priorityStr string) error {
body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit)) body, err := util.Peek(body, max(s.config.MessageSizeLimit, jsonBodyBytesLimit))
if err != nil { if err != nil {
return err return err
@@ -26,11 +56,11 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem
} }
peekedBody := strings.TrimSpace(string(body.PeekedBytes)) peekedBody := strings.TrimSpace(string(body.PeekedBytes))
if template.FileMode() { if template.FileMode() {
if err := s.renderTemplateFromFile(m, template.FileName(), peekedBody); err != nil { if err := s.renderTemplateFromFile(ctx, m, template.FileName(), peekedBody); err != nil {
return err return err
} }
} else { } else {
if err := s.renderTemplateFromParams(m, peekedBody, priorityStr); err != nil { if err := s.renderTemplateFromParams(ctx, m, peekedBody, priorityStr); err != nil {
return err return err
} }
} }
@@ -42,7 +72,7 @@ func (s *Server) handleBodyAsTemplatedTextMessage(m *model.Message, template tem
// renderTemplateFromFile transforms the JSON message body according to a template from the filesystem. // renderTemplateFromFile transforms the JSON message body according to a template from the filesystem.
// The template file must be in the templates directory, or in the configured template directory. // The template file must be in the templates directory, or in the configured template directory.
func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBody string) error { func (s *Server) renderTemplateFromFile(ctx context.Context, m *model.Message, templateName, peekedBody string) error {
if !templateNameRegex.MatchString(templateName) { if !templateNameRegex.MatchString(templateName) {
return errHTTPBadRequestTemplateFileNotFound return errHTTPBadRequestTemplateFileNotFound
} }
@@ -61,17 +91,17 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo
} }
var err error var err error
if tpl.Message != nil { if tpl.Message != nil {
if m.Message, err = s.renderTemplate(templateName+" (message)", *tpl.Message, peekedBody); err != nil { if m.Message, err = s.renderTemplate(ctx, templateName+" (message)", *tpl.Message, peekedBody); err != nil {
return err return err
} }
} }
if tpl.Title != nil { if tpl.Title != nil {
if m.Title, err = s.renderTemplate(templateName+" (title)", *tpl.Title, peekedBody); err != nil { if m.Title, err = s.renderTemplate(ctx, templateName+" (title)", *tpl.Title, peekedBody); err != nil {
return err return err
} }
} }
if tpl.Priority != nil { if tpl.Priority != nil {
renderedPriority, err := s.renderTemplate(templateName+" (priority)", *tpl.Priority, peekedBody) renderedPriority, err := s.renderTemplate(ctx, templateName+" (priority)", *tpl.Priority, peekedBody)
if err != nil { if err != nil {
return err return err
} }
@@ -84,16 +114,16 @@ func (s *Server) renderTemplateFromFile(m *model.Message, templateName, peekedBo
// renderTemplateFromParams transforms the JSON message body according to the inline template in the // renderTemplateFromParams transforms the JSON message body according to the inline template in the
// message, title, and priority parameters. // message, title, and priority parameters.
func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, priorityStr string) error { func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message, peekedBody string, priorityStr string) error {
var err error var err error
if m.Message, err = s.renderTemplate("priority query parameter", m.Message, peekedBody); err != nil { if m.Message, err = s.renderTemplate(ctx, "priority query parameter", m.Message, peekedBody); err != nil {
return err return err
} }
if m.Title, err = s.renderTemplate("title query parameter", m.Title, peekedBody); err != nil { if m.Title, err = s.renderTemplate(ctx, "title query parameter", m.Title, peekedBody); err != nil {
return err return err
} }
if priorityStr != "" { if priorityStr != "" {
renderedPriority, err := s.renderTemplate("priority query parameter", priorityStr, peekedBody) renderedPriority, err := s.renderTemplate(ctx, "priority query parameter", priorityStr, peekedBody)
if err != nil { if err != nil {
return err return err
} }
@@ -105,23 +135,30 @@ func (s *Server) renderTemplateFromParams(m *model.Message, peekedBody string, p
} }
// renderTemplate renders a template with the given JSON source data. // renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(name, tpl, source string) (string, error) { func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
if len(tpl) > templateMaxTemplateBytes {
return "", errHTTPBadRequestTemplateTooLarge
}
var data any var data any
if err := json.Unmarshal([]byte(source), &data); err != nil { if err := json.Unmarshal([]byte(source), &data); err != nil {
return "", errHTTPBadRequestTemplateMessageNotJSON return "", errHTTPBadRequestTemplateMessageNotJSON
} }
t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Parse(tpl) t, err := gotext.New("").Funcs(sprig.TxtFuncMap()).Funcs(gotext.FuncMap{"printf": templatePrintf}).Parse(tpl)
if err != nil { if err != nil {
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error()) return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
} }
if templateUsesDisallowedFeatures(t) { if templateUsesDisallowedFeatures(t) {
return "", errHTTPBadRequestTemplateDisallowedFunctionCalls return "", errHTTPBadRequestTemplateDisallowedFunctionCalls
} }
t.SetExecutionDeadline(time.Now().Add(templateMaxExecutionTime)) // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp) // Bail out of runaway templates (GHSA-rhwf-xgc9-m9fp). The deadline starts here, after the body
// has already been read, so a slow upload is not counted against it. Deriving from the request
// context means a client disconnect aborts the render too.
execCtx, cancel := context.WithTimeout(ctx, templateMaxExecutionTime)
defer cancel()
var buf bytes.Buffer var buf bytes.Buffer
limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes)) limitWriter := util.NewLimitWriter(&buf, util.NewFixedLimiter(templateMaxOutputBytes))
if err := t.Execute(limitWriter, data); err != nil { if err := t.ExecuteContext(execCtx, limitWriter, data); err != nil {
if errors.Is(err, gotext.ErrExecutionInterrupted) { if errors.Is(err, context.DeadlineExceeded) {
return "", errHTTPBadRequestTemplateExecutionTimeout return "", errHTTPBadRequestTemplateExecutionTimeout
} }
return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error()) return "", errHTTPBadRequestTemplateExecuteFailed.Wrap("template %s: %s", name, err.Error())
@@ -164,6 +201,8 @@ func treeContainsDisallowedNode(node parse.Node) bool {
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList) return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
case *parse.TemplateNode: // {{template}} or {{block}} invocation case *parse.TemplateNode: // {{template}} or {{block}} invocation
return true return true
case *parse.ChainNode: // A term followed by field accesses, e.g. (call .x).y
return treeContainsDisallowedNode(n.Node)
case *parse.PipeNode: case *parse.PipeNode:
if n == nil { if n == nil {
return false return false
@@ -184,3 +223,18 @@ func treeContainsDisallowedNode(node parse.Node) bool {
} }
return false return false
} }
// templatePrintf is the template builtin printf, guarded against memory amplification: fmt
// allows widths and precisions up to 1e6 per verb, so a small template like
// {{printf "%999999d%999999d..." ...}} can allocate gigabytes inside a single fmt call -- and the
// executor's cancellation context is only checked between template nodes, never inside one.
// Widths and precisions of 1000 or more are therefore rejected, as is the star (*) form, which
// takes the width from an argument. Combined with the template size limit, this bounds a single
// render to a few MB. Registered via Funcs, which takes precedence over the builtin, and checked
// at call time so a format string assembled during execution is covered too.
func templatePrintf(format string, args ...any) (string, error) {
if templatePrintfLargeSizeRegex.MatchString(strings.ReplaceAll(format, "%%", "")) { // Strip escaped percent signs, they take no width
return "", errors.New("printf width or precision too large")
}
return fmt.Sprintf(format, args...), nil
}
+131
View File
@@ -0,0 +1,131 @@
package server
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestServer_MessageTemplate_TooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", `{"foo":"bar"}`, map[string]string{
"X-Message": "{{.foo}}" + strings.Repeat("x", 33*1024),
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40056, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_MessageTemplate_PrintfWidthTooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// A handful of 1MB-wide verbs would allocate several MB inside a single fmt call, where
// the executor's context is never checked; the printf guard must reject the call before
// fmt runs, not after the limit writer sees the output
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{printf "%1000000d%1000000d%1000000d" .n .n .n}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfWidthTooLarge_DynamicFormat(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// The format string is assembled at execution time, so the guard must inspect the actual
// argument, not the template source
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{$f := print "%" "999999" "d" "%" "999999" "d"}}{{printf $f .n .n}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfStarWidthTooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// Star widths take the width from an argument; sprig's math functions (int64 results)
// make large integer arguments reachable from a template
response := request(t, s, "PUT", "/mytopic", `{"n":1}`, map[string]string{
"X-Message": `{{printf "%*d" (mul 1000 2000) 1}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Contains(t, response.Body.String(), "printf width or precision too large")
})
}
func TestServer_MessageTemplate_PrintfSmallWidthStillWorks(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
response := request(t, s, "PUT", "/mytopic", `{"n":7}`, map[string]string{
"X-Message": `{{printf "%05d" 7}}`,
"X-Template": "1",
})
require.Equal(t, 200, response.Code)
require.Equal(t, "00007", toMessage(t, response.Body.String()).Message)
})
}
func Test_templatePrintf(t *testing.T) {
tests := []struct {
format string
args []any
want string // Empty means the call must be rejected
}{
{"%d", []any{5}, "5"},
{"%05d", []any{5}, "00005"},
{"%-8.3f|", []any{1.5}, "1.500 |"},
{"%1000d", []any{1}, ""}, // Rejected: four digits
{"%.1000s", []any{"x"}, ""},
{"%*d", []any{500, 1}, ""}, // Rejected: star width
{"%.*s", []any{400, "x"}, ""}, // Rejected: star precision
{"%[1]1000000d", []any{1}, ""}, // Rejected: explicit arg index does not hide the width
{"%[2]*[1]d", []any{6, 12}, ""}, // Rejected: star width behind an arg index
{"100%% of 2024 values", nil, "100% of 2024 values"}, // Literal digits are not a width
}
for _, test := range tests {
out, err := templatePrintf(test.format, test.args...)
if test.want == "" {
require.Error(t, err, "format %q must be rejected", test.format)
require.Contains(t, err.Error(), "too large")
} else {
require.Nil(t, err, "format %q", test.format)
require.Equal(t, test.want, out)
}
}
// The largest allowed width still produces bounded output
out, err := templatePrintf("%999d", 1)
require.Nil(t, err)
require.Len(t, out, 999)
}
func TestServer_MessageTemplate_DisallowedCallInChain(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel()
s := newTestServer(t, newTestConfig(t, databaseURL))
// {{call}} behind a field access parses into a ChainNode. JSON data cannot produce a
// function value, so this cannot be exploited today, but the ban must catch every
// syntactic form rather than relying on the call failing at runtime.
response := request(t, s, "PUT", "/mytopic", `{"fn":1}`, map[string]string{
"X-Message": `{{(call .fn).x}}`,
"X-Template": "1",
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40044, toHTTPError(t, response.Body.String()).Code)
})
}
+320 -10
View File
@@ -16,12 +16,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"runtime/debug" "runtime/debug"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
dbtest "heckel.io/ntfy/v2/db/test" dbtest "heckel.io/ntfy/v2/db/test"
@@ -323,6 +325,40 @@ func TestServer_WebEnabled(t *testing.T) {
require.Equal(t, 200, rr.Code) require.Equal(t, 200, rr.Code)
}) })
} }
// TestServer_MetricsEnabled ensures that the /metrics endpoint serves the registered ntfy metrics
// once the metrics handler is set (as Serve does when enable-metrics is configured).
func TestServer_MetricsEnabled(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
s := newTestServer(t, newTestConfig(t, databaseURL))
s.metricsHandler = promhttp.Handler() // Serve sets this when enable-metrics is configured
// Count at least one request first: Prometheus only reports a CounterVec such as
// ntfy_http_requests_total once it has children
request(t, s, "GET", "/v1/health", "", nil)
rr := request(t, s, "GET", "/metrics", "", nil)
require.Equal(t, 200, rr.Code)
require.Contains(t, rr.Body.String(), "ntfy_messages_published_success")
require.Contains(t, rr.Body.String(), "ntfy_http_requests_total")
})
}
// TestServer_MetricsDisabled ensures that the ntfy metrics are not exposed when the metrics handler
// is unset (the default). The collectors are always registered with the Prometheus registry, so a
// nil metrics handler is the only thing keeping them off the wire.
func TestServer_MetricsDisabled(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
conf := newTestConfig(t, databaseURL)
conf.WebRoot = "" // Disable the web app, so its catch-all does not mask the /metrics route
s := newTestServer(t, conf)
rr := request(t, s, "GET", "/metrics", "", nil)
require.Equal(t, 404, rr.Code)
require.NotContains(t, rr.Body.String(), "ntfy_messages_published_success")
})
}
func TestServer_PublishLargeMessage(t *testing.T) { func TestServer_PublishLargeMessage(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
c := newTestConfig(t, databaseURL) c := newTestConfig(t, databaseURL)
@@ -1684,6 +1720,7 @@ func TestServer_PublishEmailVerify_BoolValueUsesPrimary(t *testing.T) {
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
}) })
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
require.Equal(t, "zzz@example.com", mailer.LastTo()) require.Equal(t, "zzz@example.com", mailer.LastTo())
}) })
} }
@@ -1710,6 +1747,7 @@ func TestServer_PublishEmailVerify_BoolValueNoVerifyUsesPrimary(t *testing.T) {
"Authorization": util.BasicAuth("phil", "phil"), "Authorization": util.BasicAuth("phil", "phil"),
}) })
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
require.Equal(t, "zzz@example.com", mailer.LastTo()) require.Equal(t, "zzz@example.com", mailer.LastTo())
}) })
} }
@@ -1751,6 +1789,7 @@ func TestServer_PublishEmailVerify_BoolValueProvisionedUsesPrimary(t *testing.T)
"Authorization": util.BasicAuth("prov", "provpass"), "Authorization": util.BasicAuth("prov", "provpass"),
}) })
require.Equal(t, 200, response.Code) require.Equal(t, 200, response.Code)
waitFor(t, func() bool { return mailer.LastTo() != "" }) // E-Mail publishing happens in a Go routine
require.Equal(t, "zzz@example.com", mailer.LastTo()) require.Equal(t, "zzz@example.com", mailer.LastTo())
}) })
} }
@@ -2772,6 +2811,201 @@ func TestServer_PublishAttachmentBandwidthLimit(t *testing.T) {
}) })
} }
func TestServer_PollOrderAcrossTopics(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Replaying several topics at once concatenates each topic's messages and then sorts the
// lot by Time, which has second granularity. That sort must not reorder messages that
// share a timestamp, or a topic's own messages come back out of publish order. See #1297.
//
// The messages have to straddle a second boundary: if every timestamp is identical the
// concatenation is already sorted and Go's pdqsort leaves it alone, hiding the bug.
s := newTestServer(t, newTestConfig(t, databaseURL))
const perBatch = 10
publish := func(batch int) {
for _, topic := range []string{"topicA", "topicB"} {
for i := 0; i < perBatch; i++ {
body := fmt.Sprintf("%s-%02d", topic, batch*perBatch+i)
require.Equal(t, 200, request(t, s, "PUT", "/"+topic, body, nil).Code)
}
}
}
publish(0)
time.Sleep(1100 * time.Millisecond) // Cross a second boundary, so Time is not all-equal
publish(1)
response := request(t, s, "GET", "/topicA,topicB/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
messages := toMessages(t, response.Body.String())
require.Equal(t, 4*perBatch, len(messages))
// Each topic's own messages must appear in publish order, whatever the interleaving
lastSeen := map[string]int{"topicA": -1, "topicB": -1}
for _, m := range messages {
topic, seqStr, found := strings.Cut(m.Message, "-")
require.True(t, found)
seq, err := strconv.Atoi(seqStr)
require.Nil(t, err)
require.Greater(t, seq, lastSeen[topic], "%s came back out of publish order", m.Message)
lastSeen[topic] = seq
}
})
}
func TestServer_PublishTitleTooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Title has no length limit of its own, unlike the body, so it is capped here. Prod p999
// is 212 bytes and only 16 of ~3M cached messages exceed 1 KB.
s := newTestServer(t, newTestConfig(t, databaseURL))
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "x", map[string]string{
"Title": strings.Repeat("t", messageTitleSizeLimit),
}).Code)
response := request(t, s, "PUT", "/mytopic", "x", map[string]string{
"Title": strings.Repeat("t", messageTitleSizeLimit+1),
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40057, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PublishTagsTooLarge(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Same for tags, measured across all of them: prod p999 is 244 bytes and only 197 of ~3M
// cached messages exceed 512.
s := newTestServer(t, newTestConfig(t, databaseURL))
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", "x", map[string]string{
"Tags": strings.Repeat("g", messageTagsSizeLimit),
}).Code)
response := request(t, s, "PUT", "/mytopic", "x", map[string]string{
"Tags": strings.Repeat("g", messageTagsSizeLimit+1),
})
require.Equal(t, 400, response.Code)
require.Equal(t, 40058, toHTTPError(t, response.Body.String()).Code)
})
}
func TestServer_PollSizeLimit(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// A poll without "since" replays the entire cache, which is unbounded in size. The cap is
// a byte budget rather than a message count, because message sizes vary ~20x in practice:
// a count cap truncates cheap high-volume topics while barely touching the expensive
// large-message ones it is meant to catch. The newest messages are kept.
c := newTestConfig(t, databaseURL)
c.MessagePollSizeLimit = 3500 // Fits three 1000-byte messages, not four
s := newTestServer(t, c)
for i := 0; i < 6; i++ {
body := fmt.Sprintf("%04d%s", i, strings.Repeat("x", 996)) // 1000 bytes, ordered prefix
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", body, nil).Code)
}
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, "1", response.Header().Get("X-Messages-Truncated"))
messages := toMessages(t, response.Body.String())
require.Equal(t, 3, len(messages))
require.Equal(t, "0003", messages[0].Message[:4]) // newest three, oldest first
require.Equal(t, "0004", messages[1].Message[:4])
require.Equal(t, "0005", messages[2].Message[:4])
// A topic under the budget is served whole, with no truncation header
require.Equal(t, 200, request(t, s, "PUT", "/othertopic", "small", nil).Code)
response = request(t, s, "GET", "/othertopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
require.Empty(t, response.Header().Get("X-Messages-Truncated"))
require.Equal(t, 1, len(toMessages(t, response.Body.String())))
})
}
func TestServer_PollSizeLimitCountsTitle(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Title is user-controlled and has no length limit of its own, so it has to count against
// the replay budget too; otherwise a topic of title-heavy messages sails past the cap.
c := newTestConfig(t, databaseURL)
c.MessagePollSizeLimit = 1600 // Fits one 500-byte title + 500-byte body, not two
s := newTestServer(t, c)
for i := 0; i < 4; i++ {
body := fmt.Sprintf("%04d%s", i, strings.Repeat("b", 496)) // 500 bytes
title := fmt.Sprintf("%04d%s", i, strings.Repeat("t", 496)) // 500 bytes
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", body, map[string]string{"Title": title}).Code)
}
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, "1", response.Header().Get("X-Messages-Truncated"))
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages)) // 3 if the title were not counted
require.Equal(t, "0003", messages[0].Message[:4])
})
}
func TestServer_PollSizeLimitCountsEveryField(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// Every field a publisher can grow has to count against the replay budget, not just the
// body and title: tags, click, icon and actions are all user-controlled, so anything left
// out is a hole the budget can be walked through.
c := newTestConfig(t, databaseURL)
c.MessagePollSizeLimit = 900 // Two messages fit if only body+title count; one if all fields do
s := newTestServer(t, c)
tags := make([]string, 5)
for i := range tags {
tags[i] = strings.Repeat("g", 79) // 395 bytes of tags
}
for i := 0; i < 3; i++ {
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", fmt.Sprintf("%04d%s", i, strings.Repeat("b", 196)), map[string]string{
"Title": strings.Repeat("t", 200),
"Tags": strings.Join(tags, ","),
"Click": "https://example.com/" + strings.Repeat("c", 180),
}).Code)
}
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, "1", response.Header().Get("X-Messages-Truncated"))
messages := toMessages(t, response.Body.String())
require.Equal(t, 1, len(messages)) // 2 if only body+title were counted
require.Equal(t, "0002", messages[0].Message[:4])
})
}
func TestServer_PollBandwidthLimit(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
// A poll without "since" replays the entire cache, so a topic that is cheap to fill is
// expensive to read over and over. Replayed bytes are charged against the same daily
// budget as attachment traffic. One message per poll keeps the accounting coarse: any
// shortfall hits the very first message, so the request is rejected before anything is
// written rather than truncated mid-stream.
c := newTestConfig(t, databaseURL)
c.VisitorAttachmentDailyBandwidthLimit = 9000 // Enough for two replays of the ~4 KB topic below, not three
s := newTestServer(t, c)
require.Equal(t, 200, request(t, s, "PUT", "/mytopic", util.RandomString(4000), nil).Code)
// Two full replays fit in the budget
for i := 1; i <= 2; i++ {
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
require.Equal(t, 1, len(toMessages(t, response.Body.String())))
}
// The third is rejected before a single byte is written
response := request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 429, response.Code)
require.Equal(t, 42905, toHTTPError(t, response.Body.String()).Code)
// A subscription that replays nothing is not charged against the budget
response = request(t, s, "GET", "/mytopic/json?poll=1&since=none", "", nil)
require.Equal(t, 200, response.Code)
require.Empty(t, strings.TrimSpace(response.Body.String()))
})
}
func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) { func TestServer_PublishAttachmentBandwidthLimitUploadOnly(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
content := util.RandomString(5000) // > 4096 content := util.RandomString(5000) // > 4096
@@ -2851,7 +3085,7 @@ func TestServer_Visitor_XForwardedFor_None(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty! r.Header.Set("X-Forwarded-For", " ") // Spaces, not empty!
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "8.9.10.11", v.ip.String()) require.Equal(t, "8.9.10.11", v.ip.String())
}) })
@@ -2865,7 +3099,7 @@ func TestServer_Visitor_XForwardedFor_Single(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", "1.1.1.1") r.Header.Set("X-Forwarded-For", "1.1.1.1")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "1.1.1.1", v.ip.String()) require.Equal(t, "1.1.1.1", v.ip.String())
}) })
@@ -2879,7 +3113,7 @@ func TestServer_Visitor_XForwardedFor_Multiple(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ") r.Header.Set("X-Forwarded-For", "1.2.3.4 , 2.4.4.2,234.5.2.1 ")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "234.5.2.1", v.ip.String()) require.Equal(t, "234.5.2.1", v.ip.String())
}) })
@@ -2894,7 +3128,7 @@ func TestServer_Visitor_Custom_ClientIP_Header(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("X-Client-IP", "1.2.3.4") r.Header.Set("X-Client-IP", "1.2.3.4")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "1.2.3.4", v.ip.String()) require.Equal(t, "1.2.3.4", v.ip.String())
}) })
@@ -2909,7 +3143,7 @@ func TestServer_Visitor_Custom_ClientIP_Header_IPv6(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:9999::1]:1234" r.RemoteAddr = "[2001:db8:9999::1]:1234"
r.Header.Set("X-Client-IP", "2001:db8:7777::1") r.Header.Set("X-Client-IP", "2001:db8:7777::1")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "2001:db8:7777::1", v.ip.String()) require.Equal(t, "2001:db8:7777::1", v.ip.String())
}) })
@@ -2925,7 +3159,7 @@ func TestServer_Visitor_Custom_Forwarded_Header(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "8.9.10.11:1234" r.RemoteAddr = "8.9.10.11:1234"
r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4") r.Header.Set("Forwarded", " for=5.6.7.8, by=example.com;for=1.2.3.4")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "5.6.7.8", v.ip.String()) require.Equal(t, "5.6.7.8", v.ip.String())
}) })
@@ -2941,7 +3175,7 @@ func TestServer_Visitor_Custom_Forwarded_Header_IPv6(t *testing.T) {
r, _ := http.NewRequest("GET", "/bla", nil) r, _ := http.NewRequest("GET", "/bla", nil)
r.RemoteAddr = "[2001:db8:2222::1]:1234" r.RemoteAddr = "[2001:db8:2222::1]:1234"
r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]") r.Header.Set("Forwarded", " for=[2001:db8:1111::1], by=example.com;for=[2001:db8:3333::1]")
v, err := s.maybeAuthenticate(r) _, v, err := s.maybeAuthenticate(r)
require.Nil(t, err) require.Nil(t, err)
require.Equal(t, "2001:db8:3333::1", v.ip.String()) require.Equal(t, "2001:db8:3333::1", v.ip.String())
}) })
@@ -3764,9 +3998,8 @@ func (b *slowBody) Close() error { return nil }
func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) { func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.T) {
s := newTestServer(t, newTestConfig(t, "")) s := newTestServer(t, newTestConfig(t, ""))
start := time.Now() start := time.Now()
// The loop makes the template execute enough nodes (>256) to actually hit the deadline check, // The template runs in ~1ms, far under the deadline, so on correct code it renders fine; the
// so this test distinguishes correct behavior from a deadline that includes upload time -- yet // point is that the deadline starts at execution, not when the (slow) upload began.
// it runs in ~1ms, far under the deadline, so on correct code it renders fine.
response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{ response := request(t, s, "POST", "/mytopic", `{"foo":"bar"}`, map[string]string{
"Template": "yes", "Template": "yes",
"X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`, "X-Message": `{{range until 5000}}{{$x := .}}{{end}}hello {{.foo}}`,
@@ -3780,6 +4013,40 @@ func TestServer_MessageTemplate_SlowUpload_NotCountedAgainstDeadline(t *testing.
require.Equal(t, "hello bar", m.Message) require.Equal(t, "hello bar", m.Message)
} }
// TestServer_MessageTemplate_ClientDisconnect_CancelsRender verifies that canceling the request
// context (e.g. the client disconnecting) aborts an in-progress template render. The execution
// deadline is raised well above the cancel delay for this test so that cancellation -- not the
// deadline -- is what stops the render: a runaway template is canceled 500ms in and must abort
// shortly after (well under the raised deadline), yielding the generic execute-failed code (40045),
// not the timeout code (40055).
//
// Not parallel: it temporarily raises the package-global templateMaxExecutionTime. Non-parallel
// tests run in their own phase (parallel tests are paused), so the override is race-free.
func TestServer_MessageTemplate_ClientDisconnect_CancelsRender(t *testing.T) {
origDeadline := templateMaxExecutionTime
templateMaxExecutionTime = 30 * time.Second // large enough that only the cancel can stop the render
defer func() { templateMaxExecutionTime = origDeadline }()
s := newTestServer(t, newTestConfig(t, ""))
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(500 * time.Millisecond)
cancel()
}()
start := time.Now()
response := request(t, s, "POST", "/mytopic", `{}`, map[string]string{
"X-Message": `{{$x := until 10000}}{{range $x}}{{range $x}}{{end}}{{end}}done`,
"X-Template": "1",
}, func(r *http.Request) {
*r = *r.WithContext(ctx)
})
elapsed := time.Since(start)
require.Equal(t, 400, response.Code)
require.Equal(t, 40045, toHTTPError(t, response.Body.String()).Code, "a canceled render should map to execute-failed, not the timeout code 40055")
require.Greater(t, elapsed, 500*time.Millisecond, "render must still be running when the cancel fires (took %s)", elapsed)
require.Less(t, elapsed, 700*time.Millisecond, "request-context cancel should abort the render promptly after firing (took %s)", elapsed)
}
func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) { func TestServer_MessageTemplate_ExceedMessageSize_TemplatedMessageOK(t *testing.T) {
forEachBackend(t, func(t *testing.T, databaseURL string) { forEachBackend(t, func(t *testing.T, databaseURL string) {
t.Parallel() t.Parallel()
@@ -5147,3 +5414,46 @@ func TestServer_Publish_InvalidUTF8WithFirebase(t *testing.T) {
require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"]) require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"])
require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"]) require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"])
} }
func TestServer_BanFeed_RateLimitedIPBanned(t *testing.T) {
// Real requests: exhaust the visitor request limit so ntfy returns 429s, and confirm the
// client IP is written to the ban file after it breaches the per-status ban limit.
banFile := filepath.Join(t.TempDir(), "ntfy-ban.log")
c := newTestConfig(t, "")
c.BanFile = banFile
c.BanWindow = time.Minute
c.BanThreshold = 2 // Ban after the weighted budget of 2 is exhausted
c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike
c.VisitorRequestLimitBurst = 2 // 429 quickly
s := newTestServer(t, c)
got429 := 0
for i := 0; i < 10; i++ {
rr := request(t, s, "PUT", "/mytopic", "x", nil)
if rr.Code == 429 {
got429++
}
}
require.Greater(t, got429, 2)
s.ban.Close() // Writes are async (runWriteLoop); Close flushes the buffer before we read
data, err := os.ReadFile(banFile)
require.NoError(t, err)
require.Contains(t, string(data), "9.9.9.9 9.9.9.9/32 429 42901") // <ip> <prefix> <http> <ntfy-code>
}
func TestServer_BanFeed_SuccessfulRequestsNotBanned(t *testing.T) {
// Real requests that all succeed (200) must never trigger a ban, even with a low "*" fallback.
banFile := filepath.Join(t.TempDir(), "ntfy-ban.log")
c := newTestConfig(t, "")
c.BanFile = banFile
c.BanWindow = time.Minute
c.BanThreshold = 3 // Low threshold that would catch 200s if 2xx were not skipped
c.BanWeights = map[string]int{"*": 1} // Every rejection costs 1 strike
c.VisitorRequestLimitBurst = 100 // Stay under the request limit so every request is 200
s := newTestServer(t, c)
for i := 0; i < 10; i++ {
rr := request(t, s, "PUT", "/mytopic", fmt.Sprintf("m%d", i), nil)
require.Equal(t, 200, rr.Code)
}
s.ban.Close() // Flush any buffered bans (there should be none) before asserting no file
require.NoFileExists(t, banFile)
}
+17 -188
View File
@@ -1,77 +1,21 @@
package server package server
import ( import (
"bytes" "heckel.io/ntfy/v2/metrics"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"text/template"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/twilio"
"heckel.io/ntfy/v2/user" "heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
// twilioClient talks to the Twilio API to make phone calls (for the "Call" feature) and to verify
// phone numbers. It holds the Twilio configuration and the user manager (used to look up a user's
// verified phone numbers), so that this functionality is decoupled from the main Server.
type twilioClient struct {
config *Config
userManager *user.Manager // May be nil!
}
func newTwilioClient(conf *Config, userManager *user.Manager) *twilioClient {
return &twilioClient{
config: conf,
userManager: userManager,
}
}
// 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 {{.Topic}}. Message:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
End of message.
<break time="1s"/>
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>`))
// 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 // 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. // phone number. It also converts a boolean string ("yes", "1", "true") to the first verified phone number.
// If the user is anonymous, it will return an error. // If the user is anonymous, it will return an error.
func (c *twilioClient) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) { func (s *Server) convertPhoneNumber(u *user.User, phoneNumber string) (string, *errHTTP) {
if u == nil { if u == nil {
return "", errHTTPBadRequestAnonymousCallsNotAllowed return "", errHTTPBadRequestAnonymousCallsNotAllowed
} }
phoneNumbers, err := c.userManager.PhoneNumbers(u.ID) phoneNumbers, err := s.userManager.PhoneNumbers(u.ID)
if err != nil { if err != nil {
return "", errHTTPInternalError return "", errHTTPInternalError
} else if len(phoneNumbers) == 0 { } else if len(phoneNumbers) == 0 {
@@ -87,139 +31,24 @@ func (c *twilioClient) convertPhoneNumber(u *user.User, phoneNumber string) (str
// callPhone calls the Twilio API to make a phone call to the given phone number, using the given message. // callPhone calls the Twilio API to make a phone call to the given phone number, using the given message.
// Failures will be logged, but not returned to the caller. // Failures will be logged, but not returned to the caller.
func (c *twilioClient) callPhone(v *visitor, r *http.Request, m *model.Message, to string) { func (s *Server) callPhone(v *visitor, m *model.Message, to string) {
u, sender := v.User(), m.Sender.String() u, sender := v.User(), m.Sender.String()
if u != nil { if u != nil {
sender = u.Name sender = u.Name
} }
tmpl := defaultTwilioCallFormatTemplate logvm(v, m).Tag(tagTwilio).Field("twilio_to", to).Info("Making phone call to %s", to)
if c.config.TwilioCallFormat != nil { err := s.twilio.Call(to, &twilio.CallData{
tmpl = c.config.TwilioCallFormat Topic: m.Topic,
} Title: m.Title,
tags := make([]string, len(m.Tags)) Message: m.Message,
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, Priority: m.Priority,
Tags: tags, Tags: m.Tags,
Sender: xmlEscapeText(sender), Sender: sender,
} })
var bodyBuf bytes.Buffer if err != nil {
if err := tmpl.Execute(&bodyBuf, templateData); err != nil { logvm(v, m).Tag(tagTwilio).Field("twilio_to", to).Err(err).Warn("Unable to call phone %s: %v", to, err.Error())
logvrm(v, r, m).Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template") metrics.CallsMadeFailure.Inc()
minc(metricCallsMadeFailure)
return return
} }
body := bodyBuf.String() metrics.CallsMadeSuccess.Inc()
data := url.Values{}
data.Set("From", c.config.TwilioPhoneNumber)
data.Set("To", to)
data.Set("Twiml", body)
ev := logvrm(v, r, m).Tag(tagTwilio).Field("twilio_to", to).FieldIf("twilio_body", body, log.TraceLevel).Debug("Sending Twilio request")
response, err := c.callPhoneInternal(data)
if err != nil {
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
minc(metricCallsMadeFailure)
return
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
minc(metricCallsMadeSuccess)
}
func (c *twilioClient) callPhoneInternal(data url.Values) (string, error) {
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.TwilioCallsBaseURL, c.config.TwilioAccount)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(response), nil
}
func (c *twilioClient) verifyPhoneNumber(v *visitor, r *http.Request, phoneNumber, channel string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Channel", channel)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.TwilioVerifyBaseURL, c.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
response, err := io.ReadAll(resp.Body)
if err != nil {
ev.Err(err).Warn("Error sending Twilio phone verification request")
return err
}
ev.FieldIf("twilio_response", string(response), log.TraceLevel).Debug("Received Twilio phone verification response")
return nil
}
func (c *twilioClient) verifyPhoneNumberCheck(v *visitor, r *http.Request, phoneNumber, code string) error {
ev := logvr(v, r).Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
data := url.Values{}
data.Set("To", phoneNumber)
data.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.TwilioVerifyBaseURL, c.config.TwilioVerifyService)
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.TwilioAccount, c.config.TwilioAuthToken))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
} else if resp.StatusCode != http.StatusOK {
if ev.IsTrace() {
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ev.Field("twilio_response", string(response))
}
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
if resp.StatusCode == http.StatusNotFound {
return errHTTPGonePhoneVerificationExpired
}
return errHTTPInternalError
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if ev.IsTrace() {
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
} else if ev.IsDebug() {
ev.Debug("Received successful Twilio phone verification response")
}
return nil
}
func xmlEscapeText(text string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(text))
return buf.String()
} }
+3 -2
View File
@@ -19,6 +19,7 @@ import (
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"heckel.io/ntfy/v2/metrics"
"heckel.io/ntfy/v2/model" "heckel.io/ntfy/v2/model"
) )
@@ -180,7 +181,7 @@ func (s *smtpSession) Data(r io.Reader) error {
s.backend.mu.Lock() s.backend.mu.Lock()
s.backend.success++ s.backend.success++
s.backend.mu.Unlock() s.backend.mu.Unlock()
minc(metricEmailsReceivedSuccess) metrics.EmailsReceivedSuccess.Inc()
return nil return nil
}) })
} }
@@ -238,7 +239,7 @@ func (s *smtpSession) withFailCount(fn func() error) error {
// We do not want to spam the log with WARN messages. // We do not want to spam the log with WARN messages.
logem(s.conn).Err(err).Debug("Incoming mail error") logem(s.conn).Err(err).Debug("Incoming mail error")
s.backend.failure++ s.backend.failure++
minc(metricEmailsReceivedFailure) metrics.EmailsReceivedFailure.Inc()
} }
return err return err
} }
+19
View File
@@ -29,6 +29,17 @@ type publishMessage struct {
Delay string `json:"delay"` Delay string `json:"delay"`
} }
// dispatchOpts selects which delivery targets fire for a published message, beyond delivery
// to local subscribers (see Server.dispatch)
type dispatchOpts struct {
firebase bool // Send to Firebase (if configured)
email string // Send an email to this address (if a mailer is configured)
call string // Call this phone number (if Twilio is configured)
upstream bool // Forward a poll request to the upstream server (if configured)
webPush bool // Publish to web push endpoints (if configured)
async bool // Deliver to local subscribers in a goroutine, logging errors instead of returning them
}
// messageEncoder is a function that knows how to encode a message // messageEncoder is a function that knows how to encode a message
type messageEncoder func(msg *model.Message) (string, error) type messageEncoder func(msg *model.Message) (string, error)
@@ -217,6 +228,14 @@ type apiAccountTokenResponse struct {
Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config Provisioned bool `json:"provisioned,omitempty"` // True if this token was provisioned by the server config
} }
// apiAccountLoginResponse is the body of POST /v1/account/login: it authenticates a
// username-or-email + password, mints a session token, and returns the token together with the
// canonical username (which may differ from the identifier the user typed, e.g. a primary email).
type apiAccountLoginResponse struct {
Token string `json:"token"`
Username string `json:"username"`
}
type apiAccountPhoneNumberVerifyRequest struct { type apiAccountPhoneNumberVerifyRequest struct {
Number string `json:"number"` Number string `json:"number"`
Channel string `json:"channel"` Channel string `json:"channel"`
+1 -1
View File
@@ -65,7 +65,7 @@ type visitor struct {
callsLimiter *util.FixedLimiter // Rate limiter for calls callsLimiter *util.FixedLimiter // Rate limiter for calls
subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections) subscriptionLimiter *util.FixedLimiter // Fixed limiter for active subscriptions (ongoing connections)
topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map topicCreationLimiter *rate.Limiter // Rate limiter for inserting new topics into the in-memory topic map
bandwidthLimiter *util.RateLimiter // Limiter for attachment bandwidth downloads bandwidthLimiter *util.RateLimiter // Limiter for attachment downloads and cached-message replay (polls)
accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil accountLimiter *rate.Limiter // Rate limiter for account actions (signup, password-reset requests), may be nil
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
firebase time.Time // Next allowed Firebase message firebase time.Time // Next allowed Firebase message
+1 -1
View File
@@ -1 +1 @@
go1.26.5 go1.27.0
+30 -18
View File
@@ -1,8 +1,8 @@
# `template/gotext/` -- vendored `text/template` with an execution deadline # `template/gotext/` -- vendored `text/template` with context cancellation
This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one This directory is a **verbatim copy of Go's standard-library `text/template` package**, plus one
small patch that adds a wall-clock execution deadline. It exists for exactly one reason: to stop small patch that adds context-aware execution (`ExecuteContext`). It exists for exactly one reason:
**user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating)) to stop **user-supplied** message templates (`Template: yes`, see the [templating docs](https://ntfy.sh/docs/publish/#message-templating))
from burning CPU. from burning CPU.
- **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src` - **Source:** Go stdlib `text/template` (+ `internal/fmtsort`), `$(go env GOROOT)/src`
@@ -14,7 +14,8 @@ from burning CPU.
ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template` ntfy lets users send a Go template that is rendered against a JSON body. Go's `text/template`
**cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation **cannot be interrupted mid-execution** -- there is no context, no deadline, no cancellation
([golang/go#31107](https://github.com/golang/go/issues/31107) was declined). So a crafted template ([golang/go#31107](https://github.com/golang/go/issues/31107) proposed `ExecuteContext` but was
declined, over a bundled context-*values* feature, not cancellation itself). So a crafted template
with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that with a tight or nested `{{range}}` (e.g. ranging over a large JSON array with a big loop body that
writes no output) can run for tens of seconds on a single request. That is a CPU denial of service writes no output) can run for tens of seconds on a single request. That is a CPU denial of service
(GHSA-rhwf-xgc9-m9fp). (GHSA-rhwf-xgc9-m9fp).
@@ -22,13 +23,17 @@ writes no output) can run for tens of seconds on a single request. That is a CPU
There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is There is no way to add an interrupt from the outside -- the executor's per-node `walk` loop is
unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile unexported. The only robust fix is to patch the executor itself. Rather than reach for fragile
heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add heuristics (guessing iteration counts, wrapping every function, etc.), we vendor the package and add
a **single check inside `walk`**: every ~256 nodes it checks a wall-clock deadline and aborts (via the cancellation half of #31107 as a patch: `ExecuteContext(ctx, ...)` that aborts with `ctx.Err()`
the normal `ExecError` path) if it has passed. This bounds CPU for *any* template shape -- cheap when `ctx` is canceled or its deadline passes. The check is a **single poll inside `walk`** of an
loops and expensive functions alike -- by construction. atomic flag that a `context.AfterFunc` watcher flips -- so it bounds CPU for *any* template shape
(cheap loops and expensive functions alike), it is exact (observed within one node), and it adds no
measurable overhead. If #31107's cancellation half ever lands upstream, this fork can be deleted and
the call site keeps compiling unchanged.
The one user-facing execution site (`server/server_template.go` `renderTemplate`) sets the deadline The one user-facing execution site (`server/server_template.go` `renderTemplate`) wraps execution in
with `SetExecutionDeadline` and maps the resulting error to a `400`. Trusted templates (operator `context.WithTimeout` and calls `ExecuteContext`, mapping `context.DeadlineExceeded` to a `400`.
config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not user-supplied. Trusted templates (operator config: Twilio, `cmd/serve.go`) keep using the standard library -- they
are not user-supplied.
## What's here ## What's here
@@ -36,7 +41,7 @@ config: Twilio, `cmd/serve.go`) keep using the standard library -- they are not
|------|--------| |------|--------|
| `*.go` (`exec.go`, `funcs.go`, `template.go`, `option.go`, `helper.go`, `doc.go`) | verbatim from `$(go env GOROOT)/src/text/template/`, enumerated with `go list` so files added/removed upstream are picked up automatically | | `*.go` (`exec.go`, `funcs.go`, `template.go`, `option.go`, `helper.go`, `doc.go`) | verbatim from `$(go env GOROOT)/src/text/template/`, enumerated with `go list` so files added/removed upstream are picked up automatically |
| `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along | | `fmtsort/sort.go` | verbatim from `$(go env GOROOT)/src/internal/fmtsort/` -- `exec.go` needs it, and `internal/...` packages can't be imported from outside GOROOT, so it comes along |
| `patches/0001-exec-deadline.patch` | our only real change (see below) | | `patches/0001-exec-context.patch` | our only real change (see below) |
| `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target | | `GENERATED_FROM` | the exact Go version `make update-template` last regenerated this copy from; provenance, written by that target |
The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version) The Go toolchain version this copy is pinned to lives in the repo-root [`.go-version`](../../.go-version)
@@ -49,19 +54,26 @@ plain import.
## The patch ## The patch
`patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just `patches/` is a quilt-style ordered series (apply `0001-*`, then `0002-*`, ...). Today there is just
`0001-exec-deadline.patch` -- small, purely additive, and touching only `exec.go`/`template.go`: `0001-exec-context.patch` -- small, purely additive, and touching only `exec.go`:
- adds `deadline`/`steps` fields to the executor `state` and a `deadline` field + a - adds `ctx context.Context` and a shared `cancelled *atomic.Bool` to the executor `state`
`SetExecutionDeadline(time.Time)` method on `Template` - adds `ExecuteContext` / `ExecuteTemplateContext`; `Execute` / `ExecuteTemplate` become
- adds the amortized deadline check at the top of `state.walk` `context.Background()` wrappers, so their behavior and cost are unchanged
- adds the exported sentinel `ErrExecutionInterrupted` (detect with `errors.Is`) - when `ctx.Done() != nil`, arms one `context.AfterFunc` watcher that flips the flag; `walk` polls it
per node and aborts via a `cancelError` that `errRecover` strips to the bare `ctx.Err()`
(`errors.Is(err, context.DeadlineExceeded)`)
The flag is a `*atomic.Bool` (not a value) because `walkTemplate` copies `state` for nested
`{{template}}` invocations; a shared pointer keeps one flag across all copies and avoids `go vet`
copylocks. `template.go` is unchanged -- the context is per-call, not stored on the `Template`.
Two *mechanical* transforms are applied by `make update-template` with `sed`, **not** the patch -- Two *mechanical* transforms are applied by `make update-template` with `sed`, **not** the patch --
renaming the package to `gotext`, and rewriting the `internal/fmtsort` import to renaming the package to `gotext`, and rewriting the `internal/fmtsort` import to
`heckel.io/ntfy/v2/template/gotext/fmtsort`. Keeping them out of the patch means they apply to `heckel.io/ntfy/v2/template/gotext/fmtsort`. Keeping them out of the patch means they apply to
whatever files `go list` returns, so they survive upstream files being added or removed. whatever files `go list` returns, so they survive upstream files being added or removed. (These two
transforms are also the only difference between our patch and the upstream `text/template` diff.)
Keeping the patch tiny (deadline logic only, on two stable files) is deliberate: it makes re-basing Keeping the patch tiny (cancellation only, on one stable file) is deliberate: it makes re-basing
onto a new Go release cheap. onto a new Go release cheap.
## Updating (when bumping the Go toolchain) ## Updating (when bumping the Go toolchain)
+66 -23
View File
@@ -5,14 +5,15 @@
package gotext package gotext
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"reflect" "reflect"
"runtime" "runtime"
"strings" "strings"
"sync/atomic"
"text/template/parse" "text/template/parse"
"time"
"heckel.io/ntfy/v2/template/gotext/fmtsort" "heckel.io/ntfy/v2/template/gotext/fmtsort"
) )
@@ -34,13 +35,13 @@ func initMaxExecDepth() int {
// template so that multiple executions of the same template // template so that multiple executions of the same template
// can execute in parallel. // can execute in parallel.
type state struct { type state struct {
tmpl *Template tmpl *Template
wr io.Writer ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
node parse.Node // current node, for errors wr io.Writer
vars []variable // push-down stack of variable values. node parse.Node // current node, for errors
depth int // the height of the stack of executing templates. vars []variable // push-down stack of variable values.
deadline time.Time // ntfy: wall-clock bail-out; zero means no limit depth int // the height of the stack of executing templates.
steps int64 // ntfy: node counter for amortized deadline checks cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
} }
// variable holds the dynamic value of a variable such as $, $x etc. // variable holds the dynamic value of a variable such as $, $x etc.
@@ -135,10 +136,6 @@ func (e ExecError) Unwrap() error {
return e.Err return e.Err
} }
// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the
// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition)
var ErrExecutionInterrupted = errors.New("template execution interrupted")
// errorf records an ExecError and terminates processing. // errorf records an ExecError and terminates processing.
func (s *state) errorf(format string, args ...any) { func (s *state) errorf(format string, args ...any) {
name := doublePercent(s.tmpl.Name()) name := doublePercent(s.tmpl.Name())
@@ -168,6 +165,14 @@ func (s *state) writeError(err error) {
}) })
} }
// cancelError is the wrapper type used internally when execution is aborted
// because the context is done. Like writeError, it is stripped in errRecover
// so the caller receives the original ctx.Err(). It is not an implementation
// of error, so it cannot escape from the package as an error value.
type cancelError struct {
Err error // Original context error.
}
// errRecover is the handler that turns panics into returns from the top // errRecover is the handler that turns panics into returns from the top
// level of Parse. // level of Parse.
func errRecover(errp *error) { func errRecover(errp *error) {
@@ -178,6 +183,8 @@ func errRecover(errp *error) {
panic(e) panic(e)
case writeError: case writeError:
*errp = err.Err // Strip the wrapper. *errp = err.Err // Strip the wrapper.
case cancelError:
*errp = err.Err // Strip the wrapper; return the context error.
case ExecError: case ExecError:
*errp = err // Keep the wrapper. *errp = err // Keep the wrapper.
default: default:
@@ -194,11 +201,19 @@ func errRecover(errp *error) {
// A template may be executed safely in parallel, although if parallel // A template may be executed safely in parallel, although if parallel
// executions share a Writer the output may be interleaved. // executions share a Writer the output may be interleaved.
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error { func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
return t.ExecuteTemplateContext(context.Background(), wr, name, data)
}
// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
// execution completes. See [Template.ExecuteContext] for the cancellation
// semantics.
func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
tmpl := t.Lookup(name) tmpl := t.Lookup(name)
if tmpl == nil { if tmpl == nil {
return fmt.Errorf("template: no template %q associated with template %q", name, t.name) return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
} }
return tmpl.Execute(wr, data) return tmpl.ExecuteContext(ctx, wr, data)
} }
// Execute applies a parsed template to the specified data object, // Execute applies a parsed template to the specified data object,
@@ -212,20 +227,47 @@ func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
// If data is a [reflect.Value], the template applies to the concrete // If data is a [reflect.Value], the template applies to the concrete
// value that the reflect.Value holds, as in [fmt.Print]. // value that the reflect.Value holds, as in [fmt.Print].
func (t *Template) Execute(wr io.Writer, data any) error { func (t *Template) Execute(wr io.Writer, data any) error {
return t.execute(wr, data) return t.executeContext(context.Background(), wr, data)
} }
func (t *Template) execute(wr io.Writer, data any) (err error) { // ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
// completes.
//
// Cancellation is observed between node evaluations as the template is walked,
// so long-running renders -- including tight or nested {{range}} loops that
// write no output -- are aborted promptly. A template blocked inside a single
// function call is not interrupted until that call returns. Partial results may
// already have been written to wr.
func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
if err := ctx.Err(); err != nil {
return err
}
return t.executeContext(ctx, wr, data)
}
func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
defer errRecover(&err) defer errRecover(&err)
value, ok := data.(reflect.Value) value, ok := data.(reflect.Value)
if !ok { if !ok {
value = reflect.ValueOf(data) value = reflect.ValueOf(data)
} }
state := &state{ state := &state{
tmpl: t, tmpl: t,
wr: wr, ctx: ctx,
vars: []variable{{"$", value}}, wr: wr,
deadline: t.deadline, // ntfy: wall-clock execution bail-out vars: []variable{{"$", value}},
}
// If the context can be canceled, watch it with a single context.AfterFunc
// callback that flips an atomic flag; walk polls that flag per node (a cheap
// monomorphic atomic load) instead of calling ctx.Err() every node.
// Contexts that can never be canceled (Background, TODO) have a nil Done
// channel, so the default Execute path installs nothing and pays nothing.
if ctx.Done() != nil {
state.cancelled = new(atomic.Bool)
stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
defer stop()
} }
if t.Tree == nil || t.Root == nil { if t.Tree == nil || t.Root == nil {
state.errorf("%q is an incomplete or empty template", t.Name()) state.errorf("%q is an incomplete or empty template", t.Name())
@@ -269,10 +311,11 @@ var (
// generating output as they go. // generating output as they go.
func (s *state) walk(dot reflect.Value, node parse.Node) { func (s *state) walk(dot reflect.Value, node parse.Node) {
s.at(node) s.at(node)
// ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates // Abort if the context has been canceled or its deadline has passed. The
// (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp. // flag is set by the watcher installed in executeContext; observing it here
if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) { // interrupts any template shape, including loops that write no output.
s.errorf("execution interrupted: %w", ErrExecutionInterrupted) if s.cancelled != nil && s.cancelled.Load() {
panic(cancelError{s.ctx.Err()})
} }
switch node := node.(type) { switch node := node.(type) {
case *parse.ActionNode: case *parse.ActionNode:
@@ -0,0 +1,149 @@
--- a/exec.go 2026-07-10 01:31:35.188129862 +0200
+++ b/exec.go 2026-07-10 01:31:35.189129894 +0200
@@ -5,14 +5,17 @@
package gotext
import (
+ "context"
"errors"
"fmt"
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
"io"
"reflect"
"runtime"
"strings"
+ "sync/atomic"
"text/template/parse"
+
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
)
// maxExecDepth specifies the maximum stack depth of templates within
@@ -32,11 +35,13 @@
// template so that multiple executions of the same template
// can execute in parallel.
type state struct {
- tmpl *Template
- wr io.Writer
- node parse.Node // current node, for errors
- vars []variable // push-down stack of variable values.
- depth int // the height of the stack of executing templates.
+ tmpl *Template
+ ctx context.Context // ctx-ex: execution context; Execute uses context.Background.
+ wr io.Writer
+ node parse.Node // current node, for errors
+ vars []variable // push-down stack of variable values.
+ depth int // the height of the stack of executing templates.
+ cancelled *atomic.Bool // ctx-ex: shared flag set by the context.AfterFunc watcher; nil if ctx cannot be canceled
}
// variable holds the dynamic value of a variable such as $, $x etc.
@@ -160,6 +165,14 @@
})
}
+// cancelError is the wrapper type used internally when execution is aborted
+// because the context is done. Like writeError, it is stripped in errRecover
+// so the caller receives the original ctx.Err(). It is not an implementation
+// of error, so it cannot escape from the package as an error value.
+type cancelError struct {
+ Err error // Original context error.
+}
+
// errRecover is the handler that turns panics into returns from the top
// level of Parse.
func errRecover(errp *error) {
@@ -170,6 +183,8 @@
panic(e)
case writeError:
*errp = err.Err // Strip the wrapper.
+ case cancelError:
+ *errp = err.Err // Strip the wrapper; return the context error.
case ExecError:
*errp = err // Keep the wrapper.
default:
@@ -186,11 +201,19 @@
// A template may be executed safely in parallel, although if parallel
// executions share a Writer the output may be interleaved.
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error {
+ return t.ExecuteTemplateContext(context.Background(), wr, name, data)
+}
+
+// ExecuteTemplateContext is like [Template.ExecuteTemplate], but aborts and
+// returns ctx.Err() if ctx is canceled or its deadline is exceeded before
+// execution completes. See [Template.ExecuteContext] for the cancellation
+// semantics.
+func (t *Template) ExecuteTemplateContext(ctx context.Context, wr io.Writer, name string, data any) error {
tmpl := t.Lookup(name)
if tmpl == nil {
return fmt.Errorf("template: no template %q associated with template %q", name, t.name)
}
- return tmpl.Execute(wr, data)
+ return tmpl.ExecuteContext(ctx, wr, data)
}
// Execute applies a parsed template to the specified data object,
@@ -204,10 +227,27 @@
// If data is a [reflect.Value], the template applies to the concrete
// value that the reflect.Value holds, as in [fmt.Print].
func (t *Template) Execute(wr io.Writer, data any) error {
- return t.execute(wr, data)
+ return t.executeContext(context.Background(), wr, data)
}
-func (t *Template) execute(wr io.Writer, data any) (err error) {
+// ExecuteContext is like [Template.Execute], but aborts and returns ctx.Err()
+// (either [context.Canceled] or [context.DeadlineExceeded], retrievable with
+// [errors.Is]) if ctx is canceled or its deadline is exceeded before execution
+// completes.
+//
+// Cancellation is observed between node evaluations as the template is walked,
+// so long-running renders -- including tight or nested {{range}} loops that
+// write no output -- are aborted promptly. A template blocked inside a single
+// function call is not interrupted until that call returns. Partial results may
+// already have been written to wr.
+func (t *Template) ExecuteContext(ctx context.Context, wr io.Writer, data any) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ return t.executeContext(ctx, wr, data)
+}
+
+func (t *Template) executeContext(ctx context.Context, wr io.Writer, data any) (err error) {
defer errRecover(&err)
value, ok := data.(reflect.Value)
if !ok {
@@ -215,9 +255,20 @@
}
state := &state{
tmpl: t,
+ ctx: ctx,
wr: wr,
vars: []variable{{"$", value}},
}
+ // If the context can be canceled, watch it with a single context.AfterFunc
+ // callback that flips an atomic flag; walk polls that flag per node (a cheap
+ // monomorphic atomic load) instead of calling ctx.Err() every node.
+ // Contexts that can never be canceled (Background, TODO) have a nil Done
+ // channel, so the default Execute path installs nothing and pays nothing.
+ if ctx.Done() != nil {
+ state.cancelled = new(atomic.Bool)
+ stop := context.AfterFunc(ctx, func() { state.cancelled.Store(true) })
+ defer stop()
+ }
if t.Tree == nil || t.Root == nil {
state.errorf("%q is an incomplete or empty template", t.Name())
}
@@ -260,6 +311,12 @@
// generating output as they go.
func (s *state) walk(dot reflect.Value, node parse.Node) {
s.at(node)
+ // Abort if the context has been canceled or its deadline has passed. The
+ // flag is set by the watcher installed in executeContext; observing it here
+ // interrupts any template shape, including loops that write no output.
+ if s.cancelled != nil && s.cancelled.Load() {
+ panic(cancelError{s.ctx.Err()})
+ }
switch node := node.(type) {
case *parse.ActionNode:
// Do not pop variables so they persist until next end.
@@ -1,113 +0,0 @@
diff -ruN a/exec.go b/exec.go
--- a/exec.go 2026-07-08 21:46:30.952555712 +0200
+++ b/exec.go 2026-07-08 21:46:30.953912265 +0200
@@ -7,12 +7,14 @@
import (
"errors"
"fmt"
- "heckel.io/ntfy/v2/template/gotext/fmtsort"
"io"
"reflect"
"runtime"
"strings"
"text/template/parse"
+ "time"
+
+ "heckel.io/ntfy/v2/template/gotext/fmtsort"
)
// maxExecDepth specifies the maximum stack depth of templates within
@@ -32,11 +34,13 @@
// template so that multiple executions of the same template
// can execute in parallel.
type state struct {
- tmpl *Template
- wr io.Writer
- node parse.Node // current node, for errors
- vars []variable // push-down stack of variable values.
- depth int // the height of the stack of executing templates.
+ tmpl *Template
+ wr io.Writer
+ node parse.Node // current node, for errors
+ vars []variable // push-down stack of variable values.
+ depth int // the height of the stack of executing templates.
+ deadline time.Time // ntfy: wall-clock bail-out; zero means no limit
+ steps int64 // ntfy: node counter for amortized deadline checks
}
// variable holds the dynamic value of a variable such as $, $x etc.
@@ -131,6 +135,10 @@
return e.Err
}
+// ErrExecutionInterrupted is wrapped into the error returned by Execute when a template exceeds the
+// deadline set via Template.SetExecutionDeadline. Detect it with errors.Is. (ntfy addition)
+var ErrExecutionInterrupted = errors.New("template execution interrupted")
+
// errorf records an ExecError and terminates processing.
func (s *state) errorf(format string, args ...any) {
name := doublePercent(s.tmpl.Name())
@@ -214,9 +222,10 @@
value = reflect.ValueOf(data)
}
state := &state{
- tmpl: t,
- wr: wr,
- vars: []variable{{"$", value}},
+ tmpl: t,
+ wr: wr,
+ vars: []variable{{"$", value}},
+ deadline: t.deadline, // ntfy: wall-clock execution bail-out
}
if t.Tree == nil || t.Root == nil {
state.errorf("%q is an incomplete or empty template", t.Name())
@@ -260,6 +269,11 @@
// generating output as they go.
func (s *state) walk(dot reflect.Value, node parse.Node) {
s.at(node)
+ // ntfy: amortized wall-clock bail-out to prevent CPU DoS from user-supplied templates
+ // (tight/nested ranges that never write output). See GHSA-rhwf-xgc9-m9fp.
+ if s.steps++; s.steps&0xff == 0 && !s.deadline.IsZero() && time.Now().After(s.deadline) {
+ s.errorf("execution interrupted: %w", ErrExecutionInterrupted)
+ }
switch node := node.(type) {
case *parse.ActionNode:
// Do not pop variables so they persist until next end.
diff -ruN a/template.go b/template.go
--- a/template.go 2026-07-08 21:46:30.952848382 +0200
+++ b/template.go 2026-07-08 21:46:30.953952891 +0200
@@ -9,13 +9,15 @@
"reflect"
"sync"
"text/template/parse"
+ "time"
)
// common holds the information shared by related templates.
type common struct {
- tmpl map[string]*Template // Map from name to defined templates.
- muTmpl sync.RWMutex // protects tmpl
- option option
+ tmpl map[string]*Template // Map from name to defined templates.
+ muTmpl sync.RWMutex // protects tmpl
+ option option
+ deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
// We use two maps, one for parsing and one for execution.
// This separation makes the API cleaner since it doesn't
// expose reflection to the client.
@@ -49,6 +51,15 @@
return t.name
}
+// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping
+// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates
+// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.)
+func (t *Template) SetExecutionDeadline(deadline time.Time) *Template {
+ t.init()
+ t.deadline = deadline
+ return t
+}
+
// New allocates a new, undefined template associated with the given one and with the same
// delimiters. The association, which is transitive, allows one template to
// invoke another with a {{template}} action.
+8 -17
View File
@@ -9,15 +9,13 @@ import (
"reflect" "reflect"
"sync" "sync"
"text/template/parse" "text/template/parse"
"time"
) )
// common holds the information shared by related templates. // common holds the information shared by related templates.
type common struct { type common struct {
tmpl map[string]*Template // Map from name to defined templates. tmpl map[string]*Template // Map from name to defined templates.
muTmpl sync.RWMutex // protects tmpl muTmpl sync.RWMutex // protects tmpl
option option option option
deadline time.Time // ntfy: wall-clock execution deadline (zero = none)
// We use two maps, one for parsing and one for execution. // We use two maps, one for parsing and one for execution.
// This separation makes the API cleaner since it doesn't // This separation makes the API cleaner since it doesn't
// expose reflection to the client. // expose reflection to the client.
@@ -51,15 +49,6 @@ func (t *Template) Name() string {
return t.name return t.name
} }
// SetExecutionDeadline sets a wall-clock deadline after which Execute aborts with an error wrapping
// ErrExecutionInterrupted. A zero deadline disables the limit. It bounds CPU for untrusted templates
// that text/template cannot otherwise interrupt. (ntfy addition, see GHSA-rhwf-xgc9-m9fp.)
func (t *Template) SetExecutionDeadline(deadline time.Time) *Template {
t.init()
t.deadline = deadline
return t
}
// New allocates a new, undefined template associated with the given one and with the same // New allocates a new, undefined template associated with the given one and with the same
// delimiters. The association, which is transitive, allows one template to // delimiters. The association, which is transitive, allows one template to
// invoke another with a {{template}} action. // invoke another with a {{template}} action.
@@ -178,11 +167,13 @@ func (t *Template) Delims(left, right string) *Template {
} }
// Funcs adds the elements of the argument map to the template's function map. // Funcs adds the elements of the argument map to the template's function map.
// It must be called before the template is parsed. // Any function used in the template must be added before the template is
// parsed. Funcs may be called more than once, including after parsing (for
// example, after [Template.Clone]), to replace a function of the same name;
// the replacement is used when the template is executed.
// It panics if a value in the map is not a function with appropriate return // It panics if a value in the map is not a function with appropriate return
// type or if the name cannot be used syntactically as a function in a template. // type or if the name cannot be used syntactically as a function in a template.
// It is legal to overwrite elements of the map. The return value is the template, // The return value is the template, so calls can be chained.
// so calls can be chained.
func (t *Template) Funcs(funcMap FuncMap) *Template { func (t *Template) Funcs(funcMap FuncMap) *Template {
t.init() t.init()
t.muFuncs.Lock() t.muFuncs.Lock()
+169
View File
@@ -0,0 +1,169 @@
// Package twilio talks to the Twilio API to make phone calls (for the "Call" feature) and to
// verify phone numbers. It holds the Twilio configuration, so that this functionality is
// decoupled from the ntfy server.
package twilio
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util"
)
const (
tagTwilio = "twilio"
)
// Client is the Twilio API client
type Client struct {
config *Config
}
// NewClient creates a new Twilio Client with the given config
func NewClient(config *Config) *Client {
return &Client{config: config}
}
// Call calls the Twilio API to make a phone call to the given phone number, using the given data
func (c *Client) Call(to string, data *CallData) error {
tmpl := defaultCallFormatTemplate
if c.config.CallFormat != nil {
tmpl = c.config.CallFormat
}
var bodyBuf bytes.Buffer
if err := tmpl.Execute(&bodyBuf, data.escaped()); err != nil {
log.Tag(tagTwilio).Err(err).Warn("Error executing Twilio call format template")
return err
}
body := bodyBuf.String()
form := url.Values{}
form.Set("From", c.config.PhoneNumber)
form.Set("To", to)
form.Set("Twiml", body)
ev := log.Tag(tagTwilio).
Field("twilio_to", to).
FieldIf("twilio_body", body, log.TraceLevel).
Debug("Sending Twilio request")
requestURL := fmt.Sprintf("%s/2010-04-01/Accounts/%s/Calls.json", c.config.CallsBaseURL, c.config.Account)
response, code, err := c.request(requestURL, form)
if err != nil {
ev.Field("twilio_response", response).Err(err).Warn("Error sending Twilio request")
return err
} else if !success(code) {
// Twilio rejects calls with a 4xx, e.g. for an invalid phone number, or if the account
// is out of funds. Without this check, a rejected call would be counted as a success.
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio call failed with status code %d", code)
return fmt.Errorf("twilio call failed with status code %d", code)
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received successful Twilio response")
return nil
}
// Verify calls the Twilio Verify API to send a verification code to the given phone
// number, via the given channel ("sms" or "call")
func (c *Client) Verify(phoneNumber, channel string) error {
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Field("twilio_channel", channel).Debug("Sending phone verification")
form := url.Values{}
form.Set("To", phoneNumber)
form.Set("Channel", channel)
requestURL := fmt.Sprintf("%s/v2/Services/%s/Verifications", c.config.VerifyBaseURL, c.config.VerifyService)
response, code, err := c.request(requestURL, form)
if err != nil {
ev.Err(err).Warn("Error sending Twilio phone verification request")
return err
} else if !success(code) {
// Without this check, a rejected verification would look like a success to the caller,
// and the user would be told to wait for an SMS that was never sent.
ev.Field("twilio_status", code).Field("twilio_response", response).Warn("Twilio phone verification request failed with status code %d", code)
return fmt.Errorf("twilio phone verification request failed with status code %d", code)
}
ev.FieldIf("twilio_response", response, log.TraceLevel).Debug("Received Twilio phone verification response")
return nil
}
// CheckVerify calls the Twilio Verify API to check the verification code for the given
// phone number. It returns ErrVerificationExpired if the code has expired or never existed.
func (c *Client) CheckVerify(phoneNumber, code string) error {
ev := log.Tag(tagTwilio).Field("twilio_to", phoneNumber).Debug("Checking phone verification")
form := url.Values{}
form.Set("To", phoneNumber)
form.Set("Code", code)
requestURL := fmt.Sprintf("%s/v2/Services/%s/VerificationCheck", c.config.VerifyBaseURL, c.config.VerifyService)
req, err := c.newRequest(requestURL, form)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if ev.IsTrace() {
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
ev.Field("twilio_response", string(response))
}
ev.Warn("Twilio phone verification failed with status code %d", resp.StatusCode)
if resp.StatusCode == http.StatusNotFound {
return ErrVerificationExpired
}
return fmt.Errorf("twilio phone verification failed with status code %d", resp.StatusCode)
}
response, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if ev.IsTrace() {
ev.Field("twilio_response", string(response)).Trace("Received successful Twilio phone verification response")
} else if ev.IsDebug() {
ev.Debug("Received successful Twilio phone verification response")
}
return nil
}
// request POSTs the given form to the given Twilio API URL, and returns the raw response body
// and status code. It does not treat a non-2xx status code as an error; that is up to the
// caller. The response body is returned even if the request failed, so that it can be logged.
func (c *Client) request(requestURL string, form url.Values) (string, int, error) {
req, err := c.newRequest(requestURL, form)
if err != nil {
return "", 0, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
response, err := io.ReadAll(resp.Body)
if err != nil {
return "", resp.StatusCode, err
}
return string(response), resp.StatusCode, nil
}
// success reports whether the given HTTP status code indicates success. Note that the Twilio
// Calls API returns 201 Created (not 200 OK) for a successfully queued call.
func success(code int) bool {
return code >= 200 && code <= 299
}
// newRequest creates a form-encoded POST request against the Twilio API, with the auth and
// User-Agent headers set
func (c *Client) newRequest(requestURL string, form url.Values) (*http.Request, error) {
req, err := http.NewRequest(http.MethodPost, requestURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "ntfy/"+c.config.BuildVersion)
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", util.BasicAuth(c.config.Account, c.config.AuthToken))
return req, nil
}
+301
View File
@@ -0,0 +1,301 @@
package twilio
import (
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"text/template"
"github.com/stretchr/testify/require"
)
func TestClient_Call_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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, "application/x-www-form-urlencoded", r.Header.Get("Content-Type"))
require.Equal(t, "ntfy/1.2.3", r.Header.Get("User-Agent"))
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "+1234567890", form.Get("From"))
require.Equal(t, "+11122233344", form.Get("To"))
require.Contains(t, form.Get("Twiml"), "You have a message from notify on topic mytopic. Message:")
require.Contains(t, form.Get("Twiml"), "hi there")
require.Contains(t, form.Get("Twiml"), "This message was sent by user phil.")
}
// TestClient_Call_EscapesXML ensures that user-controlled fields cannot break out of the
// TwiML document, i.e. that a message containing XML is escaped rather than interpreted
func TestClient_Call_EscapesXML(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
data := &CallData{
Topic: "mytopic",
Message: `</Say><Say>evil</Say>`,
Tags: []string{"<tag>"},
Sender: `phil & "friends"`,
}
require.Nil(t, c.Call("+11122233344", data))
form, err := url.ParseQuery(body)
require.Nil(t, err)
twiml := form.Get("Twiml")
require.NotContains(t, twiml, "<Say>evil</Say>")
require.Contains(t, twiml, "&lt;/Say&gt;&lt;Say&gt;evil&lt;/Say&gt;")
require.Contains(t, twiml, "phil &amp; &#34;friends&#34;")
// The caller's data must not be modified by the escaping
require.Equal(t, `</Say><Say>evil</Say>`, data.Message)
require.Equal(t, []string{"<tag>"}, data.Tags)
}
func TestClient_Call_CustomCallFormat(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
conf := testConfig(server.URL)
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Message}} von {{.Sender}}</Say></Response>`))
c := NewClient(conf)
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there", Sender: "phil"}))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "<Response><Say>hi there von phil</Say></Response>", form.Get("Twiml"))
}
// TestClient_Call_RendersAllFields covers the fields that the default TwiML template does not
// use, i.e. Title, Priority and Tags, including the escaping of every tag
func TestClient_Call_RendersAllFields(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
conf := testConfig(server.URL)
conf.CallFormat = template.Must(template.New("twiml").Parse(`<Response><Say>{{.Title}}/{{.Priority}}{{range .Tags}}/{{.}}{{end}}</Say></Response>`))
c := NewClient(conf)
data := &CallData{
Topic: "mytopic",
Title: "a <title>",
Priority: 5,
Tags: []string{"<one>", "two & three"},
}
require.Nil(t, c.Call("+11122233344", data))
form, err := url.ParseQuery(body)
require.Nil(t, err)
require.Equal(t, "<Response><Say>a &lt;title&gt;/5/&lt;one&gt;/two &amp; three</Say></Response>", form.Get("Twiml"))
}
func TestClient_Call_TemplateError(t *testing.T) {
conf := testConfig("http://dummy.invalid")
conf.CallFormat = template.Must(template.New("twiml").Parse(`{{.DoesNotExist}}`))
c := NewClient(conf)
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic"}))
}
// TestClient_Call_Created ensures that a 201 Created is treated as a success. The Twilio Calls
// API returns 201 (not 200) for a successfully queued call, so this must not be an error.
func TestClient_Call_Created(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"status":"queued"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Call_TwilioError ensures that a non-2xx response from Twilio is returned as an
// error, so that the server counts it as a failure instead of a success. Twilio rejects calls
// with a 4xx, e.g. for an invalid "To" number, or when the account is out of funds.
func TestClient_Call_TwilioError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"code":21211,"message":"Invalid 'To' Phone Number: +invalid"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.Call("+invalid", &CallData{Topic: "mytopic", Message: "hi there"})
require.Error(t, err)
require.Contains(t, err.Error(), "400")
}
func TestClient_Call_TwilioServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Call_TransportError ensures that a call to an unreachable Twilio API returns an
// error, so that the server can count it as a failure
func TestClient_Call_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
func TestClient_Call_InvalidBaseURL(t *testing.T) {
c := NewClient(testConfig("://invalid"))
require.Error(t, c.Call("+11122233344", &CallData{Topic: "mytopic", Message: "hi there"}))
}
// TestClient_Verify_Created ensures that a 201 Created is treated as a success. The Twilio
// Verify API returns 201 (not 200) when it creates a verification, so this must not be an error.
func TestClient_Verify_Created(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"status":"pending"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Verify("+12223334444", "sms"))
}
// TestClient_Verify_TwilioError ensures that a non-2xx response from Twilio is returned as an
// error. Without this, no SMS is sent, but the user is still told to check their phone.
func TestClient_Verify_TwilioError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"code":60200,"message":"Invalid parameter"}`))
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.Verify("+12223334444", "sms")
require.Error(t, err)
require.Contains(t, err.Error(), "400")
}
func TestClient_Verify_Unauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Error(t, c.Verify("+12223334444", "sms"))
}
func TestClient_Verify_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
require.Error(t, c.Verify("+12223334444", "sms"))
}
func TestClient_CheckVerify_TransportError(t *testing.T) {
c := NewClient(testConfig(closedServerURL(t)))
err := c.CheckVerify("+12223334444", "123456")
require.Error(t, err)
require.False(t, errors.Is(err, ErrVerificationExpired))
}
func TestClient_Verify_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v2/Services/VA1234567890/Verifications", r.URL.Path)
require.Equal(t, "Basic QUMxMjM0NTY3ODkwOkFBRUFBMTIzNDU2Nzg5MA==", r.Header.Get("Authorization"))
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.Verify("+12223334444", "sms"))
require.Equal(t, "Channel=sms&To=%2B12223334444", body)
}
func TestClient_CheckVerify_Success(t *testing.T) {
var body string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/v2/Services/VA1234567890/VerificationCheck", r.URL.Path)
b, err := io.ReadAll(r.Body)
require.Nil(t, err)
body = string(b)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
require.Nil(t, c.CheckVerify("+12223334444", "123456"))
require.Equal(t, "Code=123456&To=%2B12223334444", body)
}
// TestClient_CheckVerify_Expired ensures that a 404 from the Twilio Verify API is
// mapped to ErrVerificationExpired, which the server turns into an HTTP 410
func TestClient_CheckVerify_Expired(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.CheckVerify("+12223334444", "123456")
require.True(t, errors.Is(err, ErrVerificationExpired))
}
func TestClient_CheckVerify_OtherError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
c := NewClient(testConfig(server.URL))
err := c.CheckVerify("+12223334444", "123456")
require.Error(t, err)
require.False(t, errors.Is(err, ErrVerificationExpired))
}
// closedServerURL returns the URL of a server that is not listening anymore, to simulate an
// unreachable Twilio API
func closedServerURL(t *testing.T) string {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatal("Should not be called")
}))
server.Close()
return server.URL
}
func testConfig(baseURL string) *Config {
return &Config{
Account: "AC1234567890",
AuthToken: "AAEAA1234567890",
PhoneNumber: "+1234567890",
CallsBaseURL: baseURL,
VerifyBaseURL: baseURL,
VerifyService: "VA1234567890",
BuildVersion: "1.2.3",
}
}
+80
View File
@@ -0,0 +1,80 @@
package twilio
import (
"bytes"
"encoding/xml"
"errors"
"text/template"
)
// ErrVerificationExpired is returned by CheckVerify if the verification code has
// expired, or if it never existed in the first place
var ErrVerificationExpired = errors.New("phone number verification expired or does not exist")
// Config holds the Twilio configuration for the client
type Config struct {
Account string // Twilio account SID, e.g. AC123...
AuthToken string // Twilio auth token
PhoneNumber string // Twilio number to use for outgoing calls
CallsBaseURL string // Base URL of the Twilio Calls API
VerifyBaseURL string // Base URL of the Twilio Verify API
VerifyService string // Twilio Verify service ID, e.g. VA123...
CallFormat *template.Template // TwiML template for calls; if nil, defaultCallFormatTemplate is used
BuildVersion string // ntfy version, used for the User-Agent header
}
// defaultCallFormatTemplate 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 defaultCallFormatTemplate = template.Must(template.New("twiml").Parse(`
<Response>
<Pause length="1"/>
<Say loop="3">
You have a message from notify on topic {{.Topic}}. Message:
<break time="1s"/>
{{.Message}}
<break time="1s"/>
End of message.
<break time="1s"/>
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>`))
// CallData holds the data passed to the Twilio call format template. String fields are
// XML-escaped before the template is executed, so callers pass them unescaped.
type CallData struct {
Topic string
Title string
Message string
Priority int
Tags []string
Sender string
}
// escaped returns a copy of the call data with all string fields XML-escaped
func (d *CallData) escaped() *CallData {
tags := make([]string, len(d.Tags))
for i, tag := range d.Tags {
tags[i] = xmlEscapeText(tag)
}
return &CallData{
Topic: xmlEscapeText(d.Topic),
Title: xmlEscapeText(d.Title),
Message: xmlEscapeText(d.Message),
Priority: d.Priority,
Tags: tags,
Sender: xmlEscapeText(d.Sender),
}
}
func xmlEscapeText(text string) string {
var buf bytes.Buffer
_ = xml.EscapeText(&buf, []byte(text))
return buf.String()
}
+27 -9
View File
@@ -33,6 +33,7 @@ const (
tokenLength = 32 tokenLength = 32
tokenMaxCount = 60 // Only keep this many tokens in the table per user tokenMaxCount = 60 // Only keep this many tokens in the table per user
tag = "user_manager" tag = "user_manager"
schemaStore = "user" // Store name in the schema_version table (see db/schema)
) )
// Default constants that may be overridden by configs // Default constants that may be overridden by configs
@@ -153,24 +154,26 @@ func (a *Manager) asyncExpiredMagicLinkReapLoop(interval time.Duration) {
} }
} }
// Authenticate checks username and password and returns a User if correct, and the user has not been // Authenticate checks a login identifier (a username or a verified primary email) and password, and
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or // returns a User if correct and not marked as deleted. The identifier is resolved in a single query
// the password is correct or incorrect. // via userByNameOrEmail, so a user can log in with either their username or their primary
func (a *Manager) Authenticate(username, password string) (*User, error) { // email. The method returns in constant-ish time (one query, one bcrypt compare), regardless of
if username == Everyone { // whether the identifier exists or the password is correct or incorrect.
func (a *Manager) Authenticate(identifier, password string) (*User, error) {
if identifier == Everyone {
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
user, err := a.User(username) user, err := a.userByNameOrEmail(identifier)
if err != nil { if err != nil {
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (1)") log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (1)")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} else if user.Deleted { } else if user.Deleted {
log.Tag(tag).Field("user_name", username).Trace("Authentication of user failed (2): user marked deleted") log.Tag(tag).Field("user_name", identifier).Trace("Authentication of user failed (2): user marked deleted")
bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks")) bcrypt.CompareHashAndPassword([]byte(userAuthIntentionalSlowDownHash), []byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil { } else if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
log.Tag(tag).Field("user_name", username).Err(err).Trace("Authentication of user failed (3)") log.Tag(tag).Field("user_name", identifier).Err(err).Trace("Authentication of user failed (3)")
return nil, ErrUnauthenticated return nil, ErrUnauthenticated
} }
return user, nil return user, nil
@@ -532,6 +535,21 @@ func (a *Manager) UserByEmailOrUsername(identifier string) (*User, error) {
return a.User(identifier) return a.User(identifier)
} }
// userByNameOrEmail resolves a login identifier to a single user in one query, matching it
// against the username first and a verified primary email address second. This is the INVERSE
// precedence of UserByEmailOrUsername (used by password reset): at login a freely-chosen username
// must win over a look-alike primary email, so a user whose username happens to equal another
// account's email is not locked out of their own account. Because Authenticate still gates the match
// on a password check, returning the username owner here never grants access to the email owner's
// account. Returns ErrUserNotFound if neither matches.
func (a *Manager) userByNameOrEmail(identifier string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByNameOrPrimaryEmail, identifier, identifier, identifier)
if err != nil {
return nil, err
}
return a.readUser(rows)
}
// userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise // userByToken returns the user with the given token if it exists and is not expired, or ErrUserNotFound otherwise
func (a *Manager) userByToken(token string) (*User, error) { func (a *Manager) userByToken(token string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix()) rows, err := a.db.Query(a.queries.selectUserByToken, token, time.Now().Unix())
+86 -75
View File
@@ -5,6 +5,7 @@ import (
"strings" "strings"
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
) )
// PostgreSQL queries // PostgreSQL queries
@@ -33,6 +34,15 @@ const (
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE user_name = $1 WHERE user_name = $1
` `
postgresSelectUserByNameOrPrimaryEmailQuery = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.user_name = $1
OR u.id = (SELECT user_id FROM user_email WHERE email = $2 AND is_primary)
ORDER BY CASE WHEN u.user_name = $3 THEN 0 ELSE 1 END
LIMIT 1
`
postgresSelectUserByTokenQuery = ` postgresSelectUserByTokenQuery = `
SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user_name, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, u.deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM "user" u FROM "user" u
@@ -260,85 +270,86 @@ func postgresSelectAccessCacheUsersQuery(n int) string {
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool. // NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
var postgresQueries = queries{ var postgresQueries = queries{
selectUserByID: postgresSelectUserByIDQuery, selectUserByID: postgresSelectUserByIDQuery,
selectUserByName: postgresSelectUserByNameQuery, selectUserByName: postgresSelectUserByNameQuery,
selectUserByToken: postgresSelectUserByTokenQuery, selectUserByNameOrPrimaryEmail: postgresSelectUserByNameOrPrimaryEmailQuery,
selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery, selectUserByToken: postgresSelectUserByTokenQuery,
selectUsernames: postgresSelectUsernamesQuery, selectUserByStripeCustomerID: postgresSelectUserByStripeCustomerIDQuery,
selectUsers: postgresSelectUsersQuery, selectUsernames: postgresSelectUsernamesQuery,
selectUserCount: postgresSelectUserCountQuery, selectUsers: postgresSelectUsersQuery,
selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery, selectUserCount: postgresSelectUserCountQuery,
insertUser: postgresInsertUserQuery, selectUserIDFromUsername: postgresSelectUserIDFromUsernameQuery,
updateUserPass: postgresUpdateUserPassQuery, insertUser: postgresInsertUserQuery,
updateUserRole: postgresUpdateUserRoleQuery, updateUserPass: postgresUpdateUserPassQuery,
updateUserProvisioned: postgresUpdateUserProvisionedQuery, updateUserRole: postgresUpdateUserRoleQuery,
updateUserPrefs: postgresUpdateUserPrefsQuery, updateUserProvisioned: postgresUpdateUserProvisionedQuery,
updateUserStats: postgresUpdateUserStatsQuery, updateUserPrefs: postgresUpdateUserPrefsQuery,
updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery, updateUserStats: postgresUpdateUserStatsQuery,
updateUserTier: postgresUpdateUserTierQuery, updateUserStatsResetAll: postgresUpdateUserStatsResetAllQuery,
updateUserDeleted: postgresUpdateUserDeletedQuery, updateUserTier: postgresUpdateUserTierQuery,
deleteUser: postgresDeleteUserQuery, updateUserDeleted: postgresUpdateUserDeletedQuery,
deleteUserTier: postgresDeleteUserTierQuery, deleteUser: postgresDeleteUserQuery,
deleteUsersMarked: postgresDeleteUsersMarkedQuery, deleteUserTier: postgresDeleteUserTierQuery,
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery, deleteUsersMarked: postgresDeleteUsersMarkedQuery,
selectTopicPerms: postgresSelectTopicPermsQuery, deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
selectAccessCacheAll: postgresSelectAccessCacheAllQuery, selectTopicPerms: postgresSelectTopicPermsQuery,
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery, selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
selectUserAllAccess: postgresSelectUserAllAccessQuery, selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
selectUserAccess: postgresSelectUserAccessQuery, selectUserAllAccess: postgresSelectUserAllAccessQuery,
selectUserReservations: postgresSelectUserReservationsQuery, selectUserAccess: postgresSelectUserAccessQuery,
selectUserReservationsCount: postgresSelectUserReservationsCountQuery, selectUserReservations: postgresSelectUserReservationsQuery,
selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery, selectUserReservationsCount: postgresSelectUserReservationsCountQuery,
selectUserHasReservation: postgresSelectUserHasReservationQuery, selectUserReservationsOwner: postgresSelectUserReservationsOwnerQuery,
selectOtherAccessCount: postgresSelectOtherAccessCountQuery, selectUserHasReservation: postgresSelectUserHasReservationQuery,
upsertUserAccess: postgresUpsertUserAccessQuery, selectOtherAccessCount: postgresSelectOtherAccessCountQuery,
deleteUserAccess: postgresDeleteUserAccessQuery, upsertUserAccess: postgresUpsertUserAccessQuery,
deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery, deleteUserAccess: postgresDeleteUserAccessQuery,
deleteTopicAccess: postgresDeleteTopicAccessQuery, deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery,
deleteAllAccess: postgresDeleteAllAccessQuery, deleteTopicAccess: postgresDeleteTopicAccessQuery,
selectToken: postgresSelectTokenQuery, deleteAllAccess: postgresDeleteAllAccessQuery,
selectTokens: postgresSelectTokensQuery, selectToken: postgresSelectTokenQuery,
selectTokenCount: postgresSelectTokenCountQuery, selectTokens: postgresSelectTokensQuery,
selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, selectTokenCount: postgresSelectTokenCountQuery,
upsertToken: postgresUpsertTokenQuery, selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery,
updateToken: postgresUpdateTokenQuery, upsertToken: postgresUpsertTokenQuery,
updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, updateToken: postgresUpdateTokenQuery,
deleteToken: postgresDeleteTokenQuery, updateTokenLastAccess: postgresUpdateTokenLastAccessQuery,
deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, deleteToken: postgresDeleteTokenQuery,
deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery, deleteProvisionedToken: postgresDeleteProvisionedTokenQuery,
deleteAllToken: postgresDeleteAllTokenQuery, deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery,
deleteExpiredTokens: postgresDeleteExpiredTokensQuery, deleteAllToken: postgresDeleteAllTokenQuery,
deleteExcessTokens: postgresDeleteExcessTokensQuery, deleteExpiredTokens: postgresDeleteExpiredTokensQuery,
insertTier: postgresInsertTierQuery, deleteExcessTokens: postgresDeleteExcessTokensQuery,
selectTiers: postgresSelectTiersQuery, insertTier: postgresInsertTierQuery,
selectTierByCode: postgresSelectTierByCodeQuery, selectTiers: postgresSelectTiersQuery,
selectTierByPriceID: postgresSelectTierByPriceIDQuery, selectTierByCode: postgresSelectTierByCodeQuery,
updateTier: postgresUpdateTierQuery, selectTierByPriceID: postgresSelectTierByPriceIDQuery,
deleteTier: postgresDeleteTierQuery, updateTier: postgresUpdateTierQuery,
selectPhoneNumbers: postgresSelectPhoneNumbersQuery, deleteTier: postgresDeleteTierQuery,
insertPhoneNumber: postgresInsertPhoneNumberQuery, selectPhoneNumbers: postgresSelectPhoneNumbersQuery,
deletePhoneNumber: postgresDeletePhoneNumberQuery, insertPhoneNumber: postgresInsertPhoneNumberQuery,
selectEmails: postgresSelectEmailsQuery, deletePhoneNumber: postgresDeletePhoneNumberQuery,
insertEmail: postgresInsertEmailQuery, selectEmails: postgresSelectEmailsQuery,
insertEmailIgnore: postgresInsertEmailIgnoreQuery, insertEmail: postgresInsertEmailQuery,
deleteEmail: postgresDeleteEmailQuery, insertEmailIgnore: postgresInsertEmailIgnoreQuery,
selectPrimaryEmail: postgresSelectPrimaryEmailQuery, deleteEmail: postgresDeleteEmailQuery,
selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery, selectPrimaryEmail: postgresSelectPrimaryEmailQuery,
updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery, selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery,
updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery, updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery,
insertMagicLink: postgresInsertMagicLinkQuery, updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery,
selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery, insertMagicLink: postgresInsertMagicLinkQuery,
deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery, selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery, deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery,
deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery, deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery,
selectPendingEmails: postgresSelectPendingEmailsQuery, deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery,
deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery, selectPendingEmails: postgresSelectPendingEmailsQuery,
updateBilling: postgresUpdateBillingQuery, deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery,
updateBilling: postgresUpdateBillingQuery,
} }
// NewPostgresManager creates a new Manager backed by a PostgreSQL database // NewPostgresManager creates a new Manager backed by a PostgreSQL database
func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) { func NewPostgresManager(d *db.DB, config *Config) (*Manager, error) {
if err := setupPostgres(d.Primary()); err != nil { if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, postgresMigrations); err != nil {
return nil, err return nil, err
} }
return newManager(d, postgresQueries, config) return newManager(d, postgresQueries, config)
+11 -65
View File
@@ -1,8 +1,7 @@
package user package user
import ( import (
"database/sql" "heckel.io/ntfy/v2/db/schema"
"fmt"
) )
// Initial PostgreSQL schema // Initial PostgreSQL schema
@@ -90,21 +89,14 @@ const (
PRIMARY KEY (token_hash) PRIMARY KEY (token_hash)
); );
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created) INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT) VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
` `
) )
// Schema table management queries for Postgres
const ( const (
postgresCurrentSchemaVersion = 8 postgresCurrentSchemaVersion = 9
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'user'`
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('user', $1)`
) )
const ( const (
@@ -133,62 +125,16 @@ const (
); );
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
` `
postgresUpdateSchemaVersionQuery = `UPDATE schema_version SET version = $1 WHERE store = 'user'`
) )
var postgresMigrations = map[int]func(db *sql.DB) error{ var (
6: postgresMigrateFrom6, postgresCreateTables = schema.AsMigrateFunc(postgresCreateTablesQueries)
7: postgresMigrateFrom7,
}
func setupPostgres(db *sql.DB) error { // postgresMigrations maps a schema version to the migration upgrading it to the next
var schemaVersion int // version. Always append migrations at the end, never insert in the middle.
err := db.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion) postgresMigrations = map[int]schema.MigrateFunc{
if err != nil { 6: schema.AsMigrateFunc(postgresMigrate6To7UpdateQueries),
return setupNewPostgres(db) 7: schema.AsMigrateFunc(postgresMigrate7To8UpdateQueries),
8: schema.NopMigrateFunc, // 8 -> 9 repairs a SQLite-only foreign key defect; nothing to do on Postgres
} }
if schemaVersion == postgresCurrentSchemaVersion { )
return nil
} else if schemaVersion > postgresCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, postgresCurrentSchemaVersion)
}
for i := schemaVersion; i < postgresCurrentSchemaVersion; i++ {
fn, ok := postgresMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func postgresMigrateFrom6(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
}
func postgresMigrateFrom7(db *sql.DB) error {
if _, err := db.Exec(postgresMigrate7To8UpdateQueries); err != nil {
return err
}
if _, err := db.Exec(postgresUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
}
func setupNewPostgres(db *sql.DB) error {
if _, err := db.Exec(postgresCreateTablesQueries); err != nil {
return err
}
if _, err := db.Exec(postgresInsertSchemaVersionQuery, postgresCurrentSchemaVersion); err != nil {
return err
}
return nil
}
+90 -76
View File
@@ -9,6 +9,7 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -37,6 +38,15 @@ const (
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ? WHERE user = ?
` `
sqliteSelectUserByNameOrPrimaryEmailQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u
LEFT JOIN tier t on t.id = u.tier_id
WHERE u.user = ?
OR u.id = (SELECT user_id FROM user_email WHERE email = ? AND is_primary = 1)
ORDER BY CASE WHEN u.user = ? THEN 0 ELSE 1 END
LIMIT 1
`
sqliteSelectUserByTokenQuery = ` sqliteSelectUserByTokenQuery = `
SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id SELECT u.id, u.user, u.pass, u.role, u.prefs, u.sync_topic, u.provisioned, u.stats_messages, u.stats_emails, u.stats_calls, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_interval, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, deleted, t.id, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.calls_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.attachment_bandwidth_limit, t.stripe_monthly_price_id, t.stripe_yearly_price_id
FROM user u FROM user u
@@ -256,80 +266,81 @@ func sqliteSelectAccessCacheUsersQuery(n int) string {
} }
var sqliteQueries = queries{ var sqliteQueries = queries{
selectUserByID: sqliteSelectUserByIDQuery, selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery, selectUserByName: sqliteSelectUserByNameQuery,
selectUserByToken: sqliteSelectUserByTokenQuery, selectUserByNameOrPrimaryEmail: sqliteSelectUserByNameOrPrimaryEmailQuery,
selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery, selectUserByToken: sqliteSelectUserByTokenQuery,
selectUsernames: sqliteSelectUsernamesQuery, selectUserByStripeCustomerID: sqliteSelectUserByStripeCustomerIDQuery,
selectUsers: sqliteSelectUsersQuery, selectUsernames: sqliteSelectUsernamesQuery,
selectUserCount: sqliteSelectUserCountQuery, selectUsers: sqliteSelectUsersQuery,
selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery, selectUserCount: sqliteSelectUserCountQuery,
insertUser: sqliteInsertUserQuery, selectUserIDFromUsername: sqliteSelectUserIDFromUsernameQuery,
updateUserPass: sqliteUpdateUserPassQuery, insertUser: sqliteInsertUserQuery,
updateUserRole: sqliteUpdateUserRoleQuery, updateUserPass: sqliteUpdateUserPassQuery,
updateUserProvisioned: sqliteUpdateUserProvisionedQuery, updateUserRole: sqliteUpdateUserRoleQuery,
updateUserPrefs: sqliteUpdateUserPrefsQuery, updateUserProvisioned: sqliteUpdateUserProvisionedQuery,
updateUserStats: sqliteUpdateUserStatsQuery, updateUserPrefs: sqliteUpdateUserPrefsQuery,
updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery, updateUserStats: sqliteUpdateUserStatsQuery,
updateUserTier: sqliteUpdateUserTierQuery, updateUserStatsResetAll: sqliteUpdateUserStatsResetAllQuery,
updateUserDeleted: sqliteUpdateUserDeletedQuery, updateUserTier: sqliteUpdateUserTierQuery,
deleteUser: sqliteDeleteUserQuery, updateUserDeleted: sqliteUpdateUserDeletedQuery,
deleteUserTier: sqliteDeleteUserTierQuery, deleteUser: sqliteDeleteUserQuery,
deleteUsersMarked: sqliteDeleteUsersMarkedQuery, deleteUserTier: sqliteDeleteUserTierQuery,
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery, deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
selectTopicPerms: sqliteSelectTopicPermsQuery, deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery, selectTopicPerms: sqliteSelectTopicPermsQuery,
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery, selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
selectUserAllAccess: sqliteSelectUserAllAccessQuery, selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
selectUserAccess: sqliteSelectUserAccessQuery, selectUserAllAccess: sqliteSelectUserAllAccessQuery,
selectUserReservations: sqliteSelectUserReservationsQuery, selectUserAccess: sqliteSelectUserAccessQuery,
selectUserReservationsCount: sqliteSelectUserReservationsCountQuery, selectUserReservations: sqliteSelectUserReservationsQuery,
selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery, selectUserReservationsCount: sqliteSelectUserReservationsCountQuery,
selectUserHasReservation: sqliteSelectUserHasReservationQuery, selectUserReservationsOwner: sqliteSelectUserReservationsOwnerQuery,
selectOtherAccessCount: sqliteSelectOtherAccessCountQuery, selectUserHasReservation: sqliteSelectUserHasReservationQuery,
upsertUserAccess: sqliteUpsertUserAccessQuery, selectOtherAccessCount: sqliteSelectOtherAccessCountQuery,
deleteUserAccess: sqliteDeleteUserAccessQuery, upsertUserAccess: sqliteUpsertUserAccessQuery,
deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery, deleteUserAccess: sqliteDeleteUserAccessQuery,
deleteTopicAccess: sqliteDeleteTopicAccessQuery, deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery,
deleteAllAccess: sqliteDeleteAllAccessQuery, deleteTopicAccess: sqliteDeleteTopicAccessQuery,
selectToken: sqliteSelectTokenQuery, deleteAllAccess: sqliteDeleteAllAccessQuery,
selectTokens: sqliteSelectTokensQuery, selectToken: sqliteSelectTokenQuery,
selectTokenCount: sqliteSelectTokenCountQuery, selectTokens: sqliteSelectTokensQuery,
selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, selectTokenCount: sqliteSelectTokenCountQuery,
upsertToken: sqliteUpsertTokenQuery, selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery,
updateToken: sqliteUpdateTokenQuery, upsertToken: sqliteUpsertTokenQuery,
updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, updateToken: sqliteUpdateTokenQuery,
deleteToken: sqliteDeleteTokenQuery, updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery,
deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, deleteToken: sqliteDeleteTokenQuery,
deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery, deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery,
deleteAllToken: sqliteDeleteAllTokenQuery, deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery,
deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, deleteAllToken: sqliteDeleteAllTokenQuery,
deleteExcessTokens: sqliteDeleteExcessTokensQuery, deleteExpiredTokens: sqliteDeleteExpiredTokensQuery,
insertTier: sqliteInsertTierQuery, deleteExcessTokens: sqliteDeleteExcessTokensQuery,
selectTiers: sqliteSelectTiersQuery, insertTier: sqliteInsertTierQuery,
selectTierByCode: sqliteSelectTierByCodeQuery, selectTiers: sqliteSelectTiersQuery,
selectTierByPriceID: sqliteSelectTierByPriceIDQuery, selectTierByCode: sqliteSelectTierByCodeQuery,
updateTier: sqliteUpdateTierQuery, selectTierByPriceID: sqliteSelectTierByPriceIDQuery,
deleteTier: sqliteDeleteTierQuery, updateTier: sqliteUpdateTierQuery,
selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, deleteTier: sqliteDeleteTierQuery,
insertPhoneNumber: sqliteInsertPhoneNumberQuery, selectPhoneNumbers: sqliteSelectPhoneNumbersQuery,
deletePhoneNumber: sqliteDeletePhoneNumberQuery, insertPhoneNumber: sqliteInsertPhoneNumberQuery,
selectEmails: sqliteSelectEmailsQuery, deletePhoneNumber: sqliteDeletePhoneNumberQuery,
insertEmail: sqliteInsertEmailQuery, selectEmails: sqliteSelectEmailsQuery,
insertEmailIgnore: sqliteInsertEmailIgnoreQuery, insertEmail: sqliteInsertEmailQuery,
deleteEmail: sqliteDeleteEmailQuery, insertEmailIgnore: sqliteInsertEmailIgnoreQuery,
selectPrimaryEmail: sqliteSelectPrimaryEmailQuery, deleteEmail: sqliteDeleteEmailQuery,
selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery, selectPrimaryEmail: sqliteSelectPrimaryEmailQuery,
updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery, selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery,
updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery, updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery,
insertMagicLink: sqliteInsertMagicLinkQuery, updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery,
selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery, insertMagicLink: sqliteInsertMagicLinkQuery,
deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery, selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery,
deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery, deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery,
deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery, deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery,
selectPendingEmails: sqliteSelectPendingEmailsQuery, deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery,
deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery, selectPendingEmails: sqliteSelectPendingEmailsQuery,
updateBilling: sqliteUpdateBillingQuery, deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery,
updateBilling: sqliteUpdateBillingQuery,
} }
// NewSQLiteManager creates a new Manager backed by a SQLite database // NewSQLiteManager creates a new Manager backed by a SQLite database
@@ -341,14 +352,17 @@ func NewSQLiteManager(filename, startupQueries string, config *Config) (*Manager
// Open with case-sensitive LIKE. ACL topic matching is done via LIKE (see // Open with case-sensitive LIKE. ACL topic matching is done via LIKE (see
// selectTopicPerms), and SQLite's LIKE is case-insensitive for ASCII by // selectTopicPerms), and SQLite's LIKE is case-insensitive for ASCII by
// default -- without this, an ACL rule for "secret" would also match a // default -- without this, an ACL rule for "secret" would also match a
// request for "SECRET", which is a security iisue. PostgreSQL's LIKE is // request for "SECRET", which is a security issue. PostgreSQL's LIKE is
// already case-sensitive, so this only affects SQLite. The pragma is // already case-sensitive, so this only affects SQLite. The pragma is
// applied to every pooled connection by the driver. // applied to every pooled connection by the driver.
d, err := sql.Open("sqlite3", fmt.Sprintf("%s?_case_sensitive_like=on", filename)) d, err := sql.Open("sqlite3", fmt.Sprintf("%s?_case_sensitive_like=on", filename))
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := setupSQLite(d); err != nil { // Migrations must run before the startup queries: the 5 -> 6 table rebuilds rely on
// foreign keys being OFF, which is only guaranteed on fresh connections (the foreign_keys
// pragma is enabled as part of the builtin startup queries below)
if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations); err != nil {
return nil, err return nil, err
} }
if err := runSQLiteStartupQueries(d, startupQueries); err != nil { if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
+64 -175
View File
@@ -2,10 +2,8 @@ package user
import ( import (
"database/sql" "database/sql"
"fmt"
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db/schema"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -105,10 +103,6 @@ const (
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
); );
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created) INSERT INTO user (id, user, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH()) VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, UNIXEPOCH())
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
@@ -119,12 +113,8 @@ const (
sqliteBuiltinStartupQueries = `PRAGMA foreign_keys = ON;` sqliteBuiltinStartupQueries = `PRAGMA foreign_keys = ON;`
) )
// Schema version table management for SQLite
const ( const (
sqliteCurrentSchemaVersion = 8 sqliteCurrentSchemaVersion = 9
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)`
sqliteUpdateSchemaVersionQuery = `UPDATE schemaVersion SET version = ? WHERE id = 1`
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
) )
// Schema migrations for SQLite // Schema migrations for SQLite
@@ -190,10 +180,6 @@ const (
PRIMARY KEY (user_id, token), PRIMARY KEY (user_id, token),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO user (id, user, pass, role, sync_topic, created) INSERT INTO user (id, user, pass, role, sync_topic, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH()) VALUES ('u_everyone', '*', '', 'anonymous', '', UNIXEPOCH())
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
@@ -270,10 +256,11 @@ const (
CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind);
` `
// 5 -> 6 // 5 -> 6. The table rebuilds below rely on foreign keys being OFF (otherwise RENAME
// would rewrite the childrens' REFERENCES clauses to point at the _old tables). This is
// guaranteed because migrations run on fresh connections, before the startup queries
// enable the foreign_keys pragma; see NewSQLiteManager.
sqliteMigrate5To6UpdateQueries = ` sqliteMigrate5To6UpdateQueries = `
PRAGMA foreign_keys=off;
-- Alter user table: Add provisioned column -- Alter user table: Add provisioned column
ALTER TABLE user RENAME TO user_old; ALTER TABLE user RENAME TO user_old;
CREATE TABLE IF NOT EXISTS user ( CREATE TABLE IF NOT EXISTS user (
@@ -359,57 +346,45 @@ const (
CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id); CREATE UNIQUE INDEX idx_user_stripe_customer_id ON user (stripe_customer_id);
CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id); CREATE UNIQUE INDEX idx_user_stripe_subscription_id ON user (stripe_subscription_id);
CREATE UNIQUE INDEX idx_user_token ON user_token (token); CREATE UNIQUE INDEX idx_user_token ON user_token (token);
`
-- Re-enable foreign keys // 8 -> 9: Repair the user_phone foreign key. The 5 -> 6 migration renamed user to
PRAGMA foreign_keys=on; // user_old, which rewrote user_phone's REFERENCES clause to user_old -- a table that was
// then dropped (the rebuilt tables got correct fresh foreign keys; user_phone was the only
// child table not rebuilt). Rebuilding user_phone re-points the foreign key at user; on
// healthy databases the rebuild is a harmless no-op schema-wise.
sqliteMigrate8To9UpdateQueries = `
ALTER TABLE user_phone RENAME TO user_phone_old;
CREATE TABLE user_phone (
user_id TEXT NOT NULL,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number),
FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE
);
INSERT INTO user_phone (user_id, phone_number)
SELECT user_id, phone_number FROM user_phone_old
WHERE user_id IN (SELECT id FROM user); -- Drop orphaned rows that the broken foreign key failed to cascade-delete
DROP TABLE user_phone_old;
` `
) )
var ( var (
sqliteMigrations = map[int]func(db *sql.DB) error{ sqliteCreateTables = schema.AsMigrateFunc(sqliteCreateTablesQueries)
// sqliteMigrations maps a schema version to the migration upgrading it to the next
// version. Always append migrations at the end, never insert in the middle.
sqliteMigrations = map[int]schema.MigrateFunc{
1: sqliteMigrateFrom1, 1: sqliteMigrateFrom1,
2: sqliteMigrateFrom2, 2: schema.AsMigrateFunc(sqliteMigrate2To3UpdateQueries),
3: sqliteMigrateFrom3, 3: schema.AsMigrateFunc(sqliteMigrate3To4UpdateQueries),
4: sqliteMigrateFrom4, 4: schema.AsMigrateFunc(sqliteMigrate4To5UpdateQueries),
5: sqliteMigrateFrom5, 5: schema.AsMigrateFunc(sqliteMigrate5To6UpdateQueries),
6: sqliteMigrateFrom6, 6: schema.AsMigrateFunc(sqliteMigrate6To7UpdateQueries),
7: sqliteMigrateFrom7, 7: schema.AsMigrateFunc(sqliteMigrate7To8UpdateQueries),
8: schema.AsMigrateFunc(sqliteMigrate8To9UpdateQueries),
} }
) )
func setupSQLite(db *sql.DB) error {
var schemaVersion int
if err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
return setupNewSQLite(db)
}
if schemaVersion == sqliteCurrentSchemaVersion {
return nil
} else if schemaVersion > sqliteCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
}
for i := schemaVersion; i < sqliteCurrentSchemaVersion; i++ {
fn, ok := sqliteMigrations[i]
if !ok {
return fmt.Errorf("cannot find migration step from schema version %d to %d", i, i+1)
} else if err := fn(db); err != nil {
return err
}
}
return nil
}
func setupNewSQLite(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteCreateTablesQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil { if _, err := db.Exec(sqliteBuiltinStartupQueries); err != nil {
return err return err
@@ -422,122 +397,36 @@ func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
return nil return nil
} }
func sqliteMigrateFrom1(sqlDB *sql.DB) error { func sqliteMigrateFrom1(tx *sql.Tx) error {
log.Tag(tag).Info("Migrating user database schema: from 1 to 2") // Rename user -> user_old, and create new tables
return db.ExecTx(sqlDB, func(tx *sql.Tx) error { if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil {
// Rename user -> user_old, and create new tables return err
if _, err := tx.Exec(sqliteMigrate1To2CreateTablesQueries); err != nil { }
// Insert users from user_old into new user table, with ID and sync_topic
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery)
if err != nil {
return err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return err return err
} }
// Insert users from user_old into new user table, with ID and sync_topic usernames = append(usernames, username)
rows, err := tx.Query(sqliteMigrate1To2SelectAllOldUsernamesNoTxQuery) }
if err != nil { if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {
return err return err
} }
defer rows.Close() }
usernames := make([]string, 0) // Migrate old "access" table to "user_access" and drop "access" and "user_old"
for rows.Next() { _, err = tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery)
var username string return err
if err := rows.Scan(&username); err != nil {
return err
}
usernames = append(usernames, username)
}
if err := rows.Close(); err != nil {
return err
}
for _, username := range usernames {
userID := util.RandomStringPrefix(userIDPrefix, userIDLength)
syncTopic := util.RandomStringPrefix(syncTopicPrefix, syncTopicLength)
if _, err := tx.Exec(sqliteMigrate1To2InsertUserNoTxQuery, userID, syncTopic, username); err != nil {
return err
}
}
// Migrate old "access" table to "user_access" and drop "access" and "user_old"
if _, err := tx.Exec(sqliteMigrate1To2InsertFromOldTablesAndDropNoTxQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 2); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom2(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 2 to 3")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate2To3UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 3); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom3(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 3 to 4")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate3To4UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 4); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom4(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 4 to 5")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate4To5UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 5); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom5(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 5 to 6")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate5To6UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 6); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom6(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 6 to 7")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate6To7UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 7); err != nil {
return err
}
return nil
})
}
func sqliteMigrateFrom7(sqlDB *sql.DB) error {
log.Tag(tag).Info("Migrating user database schema: from 7 to 8")
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteMigrate7To8UpdateQueries); err != nil {
return err
}
if _, err := tx.Exec(sqliteUpdateSchemaVersionQuery, 8); err != nil {
return err
}
return nil
})
} }
+249 -2
View File
@@ -14,6 +14,7 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/pg" "heckel.io/ntfy/v2/db/pg"
"heckel.io/ntfy/v2/db/schema"
dbtest "heckel.io/ntfy/v2/db/test" dbtest "heckel.io/ntfy/v2/db/test"
"heckel.io/ntfy/v2/util" "heckel.io/ntfy/v2/util"
) )
@@ -1566,6 +1567,83 @@ func TestToFromSQLWildcard(t *testing.T) {
require.Equal(t, "foo", fromSQLWildcard(toSQLWildcard("foo"))) require.Equal(t, "foo", fromSQLWildcard(toSQLWildcard("foo")))
} }
// testPostgresV6Schema is the PostgreSQL schema exactly as created by the version that first
// shipped Postgres support (schema version 6), taken from the code at that time; used to
// verify the migration chain from its oldest supported version.
const testPostgresV6Schema = `
CREATE TABLE IF NOT EXISTS tier (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
messages_limit BIGINT NOT NULL,
messages_expiry_duration BIGINT NOT NULL,
emails_limit BIGINT NOT NULL,
calls_limit BIGINT NOT NULL,
reservations_limit BIGINT NOT NULL,
attachment_file_size_limit BIGINT NOT NULL,
attachment_total_size_limit BIGINT NOT NULL,
attachment_expiry_duration BIGINT NOT NULL,
attachment_bandwidth_limit BIGINT NOT NULL,
stripe_monthly_price_id TEXT,
stripe_yearly_price_id TEXT,
UNIQUE(code),
UNIQUE(stripe_monthly_price_id),
UNIQUE(stripe_yearly_price_id)
);
CREATE TABLE IF NOT EXISTS "user" (
id TEXT PRIMARY KEY,
tier_id TEXT REFERENCES tier(id),
user_name TEXT NOT NULL UNIQUE,
pass TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('anonymous', 'admin', 'user')),
prefs JSONB NOT NULL DEFAULT '{}',
sync_topic TEXT NOT NULL,
provisioned BOOLEAN NOT NULL,
stats_messages BIGINT NOT NULL DEFAULT 0,
stats_emails BIGINT NOT NULL DEFAULT 0,
stats_calls BIGINT NOT NULL DEFAULT 0,
stripe_customer_id TEXT UNIQUE,
stripe_subscription_id TEXT UNIQUE,
stripe_subscription_status TEXT,
stripe_subscription_interval TEXT,
stripe_subscription_paid_until BIGINT,
stripe_subscription_cancel_at BIGINT,
created BIGINT NOT NULL,
deleted BIGINT
);
CREATE TABLE IF NOT EXISTS user_access (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
topic TEXT NOT NULL,
read BOOLEAN NOT NULL,
write BOOLEAN NOT NULL,
owner_user_id TEXT REFERENCES "user"(id) ON DELETE CASCADE,
provisioned BOOLEAN NOT NULL,
PRIMARY KEY (user_id, topic)
);
CREATE TABLE IF NOT EXISTS user_token (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
label TEXT NOT NULL,
last_access BIGINT NOT NULL,
last_origin TEXT NOT NULL,
expires BIGINT NOT NULL,
provisioned BOOLEAN NOT NULL,
PRIMARY KEY (user_id, token)
);
CREATE TABLE IF NOT EXISTS user_phone (
user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
phone_number TEXT NOT NULL,
PRIMARY KEY (user_id, phone_number)
);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('u_everyone', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING;
`
func TestMigrationFrom1(t *testing.T) { func TestMigrationFrom1(t *testing.T) {
filename := filepath.Join(t.TempDir(), "user.db") filename := filepath.Join(t.TempDir(), "user.db")
db, err := sql.Open("sqlite3", filename) db, err := sql.Open("sqlite3", filename)
@@ -1649,6 +1727,8 @@ func TestMigrationFrom1(t *testing.T) {
require.Equal(t, 1, len(everyoneGrants)) require.Equal(t, 1, len(everyoneGrants))
require.Equal(t, "stats", everyoneGrants[0].TopicPattern) require.Equal(t, "stats", everyoneGrants[0].TopicPattern)
require.Equal(t, PermissionRead, everyoneGrants[0].Permission) require.Equal(t, PermissionRead, everyoneGrants[0].Permission)
checkMigratedSqliteSchema(t, filename)
} }
func TestMigrationFrom4(t *testing.T) { func TestMigrationFrom4(t *testing.T) {
@@ -1740,12 +1820,15 @@ func TestMigrationFrom4(t *testing.T) {
`) `)
require.Nil(t, err) require.Nil(t, err)
// Insert a few ACL entries // Insert a few ACL entries, and phone numbers: one for a live user, one orphaned (its user
// is gone; the broken pre-v9 foreign key never cascade-deleted it)
_, err = db.Exec(` _, err = db.Exec(`
BEGIN; BEGIN;
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'mytopic_', 1, 1); INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'mytopic_', 1, 1);
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'up%', 1, 1); INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'up%', 1, 1);
INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'down_%', 1, 1); INSERT INTO user_access (user_id, topic, read, write) values ('u_everyone', 'down_%', 1, 1);
INSERT INTO user_phone (user_id, phone_number) VALUES ('u_everyone', '+12223334444');
INSERT INTO user_phone (user_id, phone_number) VALUES ('u_gone', '+15556667777');
COMMIT; COMMIT;
`) `)
require.Nil(t, err) require.Nil(t, err)
@@ -1795,6 +1878,68 @@ func TestMigrationFrom4(t *testing.T) {
require.Nil(t, a.Authorize(nil, "up123", PermissionRead)) require.Nil(t, a.Authorize(nil, "up123", PermissionRead))
require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters require.Nil(t, a.Authorize(nil, "up", PermissionRead)) // % matches 0 or more characters
// The 8 -> 9 repair kept the live user's phone number and dropped the orphaned row
phoneNumbers := make([]string, 0)
rows, err = db.Query(`SELECT phone_number FROM user_phone ORDER BY phone_number`)
require.Nil(t, err)
for rows.Next() {
var phoneNumber string
require.Nil(t, rows.Scan(&phoneNumber))
phoneNumbers = append(phoneNumbers, phoneNumber)
}
require.Nil(t, rows.Close())
require.Equal(t, []string{"+12223334444"}, phoneNumbers)
checkMigratedSqliteSchema(t, filename)
}
// TestMigrationFrom6Postgres tests the Postgres migration chain from its oldest supported
// version (6, the version PostgreSQL support first shipped with).
func TestMigrationFrom6Postgres(t *testing.T) {
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(testPostgresV6Schema)
require.Nil(t, err)
_, err = testDB.Exec(`INSERT INTO schema_version (store, version) VALUES ('user', 6)`)
require.Nil(t, err)
// Create manager to trigger migration
a, err := NewPostgresManager(testDB, &Config{DefaultAccess: PermissionDenyAll, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval})
require.Nil(t, err)
var version int
require.Nil(t, testDB.QueryRow(`SELECT version FROM schema_version WHERE store = 'user'`).Scan(&version))
require.Equal(t, postgresCurrentSchemaVersion, version)
// The manager works against the migrated schema
require.Nil(t, a.AddUser("phil", "mypass", RoleUser, false))
u, err := a.User("phil")
require.Nil(t, err)
require.Nil(t, a.AddEmail(u.ID, "phil@example.com"))
// The migrated database must be structurally identical to a freshly created one
freshDB := dbtest.CreateTestPostgres(t)
_, err = NewPostgresManager(freshDB, &Config{DefaultAccess: PermissionDenyAll, BcryptCost: bcrypt.MinCost, QueueWriterInterval: DefaultUserStatsQueueWriterInterval})
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, testDB))
}
// checkMigratedSqliteSchema verifies that a migrated database is structurally identical to a
// freshly created one (this pins, among other things, that the foreign keys of the tables
// rebuilt in migration 5 -> 6 still point at "user", not at a dropped "user_old"), and that
// its data passes SQLite's foreign key consistency check.
func checkMigratedSqliteSchema(t *testing.T, filename string) {
t.Helper()
freshFile := filepath.Join(t.TempDir(), "fresh.db")
fresh := newTestManagerFromFile(t, freshFile, "", PermissionDenyAll, bcrypt.MinCost, DefaultUserStatsQueueWriterInterval)
defer fresh.Close()
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
rows, err := migratedDB.Query(`PRAGMA foreign_key_check`)
require.Nil(t, err)
defer rows.Close()
require.False(t, rows.Next(), "foreign_key_check reported violations in the migrated database")
} }
func checkSchemaVersion(t *testing.T, d *db.DB) { func checkSchemaVersion(t *testing.T, d *db.DB) {
@@ -2947,6 +3092,108 @@ func TestUser_MagicLink_PrimaryGlobalUniqueness(t *testing.T) {
}) })
} }
func TestManager_Authenticate_ByPrimaryEmail(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
// phil verifies phil@example.com -> becomes his primary (recovery) email
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "phil@example.com", 24*time.Hour))
require.Nil(t, err)
// Login by username still works
u, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// Login by primary email works and resolves to the same account
u, err = a.Authenticate("phil@example.com", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// Login by primary email with the wrong password fails
u, err = a.Authenticate("phil@example.com", "wrong")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
// An unknown email fails
u, err = a.Authenticate("nobody@example.com", "phil")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
})
}
func TestManager_Authenticate_BySecondaryEmailDenied(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
phil, err := a.User("phil")
require.Nil(t, err)
ben, err := a.User("ben")
require.Nil(t, err)
// phil verifies shared@ first -> his primary; ben verifies it too -> only secondary for ben
_, err = a.VerifyEmail(addVerifyLink(t, a, phil.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "shared@example.com", 24*time.Hour))
require.Nil(t, err)
// Login by the shared address resolves to the primary owner (phil), never the secondary (ben)
u, err := a.Authenticate("shared@example.com", "phil")
require.Nil(t, err)
require.Equal(t, "phil", u.Name)
// ben's password must not authenticate via the shared address (it is not his primary)
u, err = a.Authenticate("shared@example.com", "ben")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
})
}
func TestManager_Authenticate_UsernameLookalikeEmailPrecedence(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll)
// The collision: a squatter whose USERNAME is literally "phil@example.com" (usernames may
// contain '@' and '.'), and a different account (ben) that owns "phil@example.com" as its
// verified primary email. Both are reachable; nothing links usernames to email addresses.
require.Nil(t, a.AddUser("phil@example.com", "squatterpass", RoleUser, false))
require.Nil(t, a.AddUser("ben", "benpass", RoleUser, false))
ben, err := a.User("ben")
require.Nil(t, err)
_, err = a.VerifyEmail(addVerifyLink(t, a, ben.ID, "phil@example.com", 24*time.Hour))
require.Nil(t, err)
// Login resolves the ambiguous identifier username-FIRST (the ORDER BY CASE in the query):
// the squatter owns the login, and returns deterministically even though both rows match.
squatter, err := a.Authenticate("phil@example.com", "squatterpass")
require.Nil(t, err)
require.Equal(t, "phil@example.com", squatter.Name)
// Consequently the email owner's password does NOT authenticate via the colliding identifier
// at login, but the owner is not locked out: their real username still works.
u, err := a.Authenticate("phil@example.com", "benpass")
require.Nil(t, u)
require.Equal(t, ErrUnauthenticated, err)
u, err = a.Authenticate("ben", "benpass")
require.Nil(t, err)
require.Equal(t, "ben", u.Name)
// The inverse: password reset (UserByEmailOrUsername) resolves the SAME identifier email-FIRST,
// so the reset link goes to the verified email owner (ben), never the look-alike username. The
// two flows deliberately use opposite precedence.
loginUser, err := a.userByNameOrEmail("phil@example.com")
require.Nil(t, err)
require.Equal(t, "phil@example.com", loginUser.Name) // username owner (squatter)
resetUser, err := a.UserByEmailOrUsername("phil@example.com")
require.Nil(t, err)
require.Equal(t, "ben", resetUser.Name) // email owner
})
}
func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) { func TestUser_MagicLink_SetPrimary_NotVerified(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
a := newTestManager(t, newManager, PermissionDenyAll) a := newTestManager(t, newManager, PermissionDenyAll)
@@ -3250,7 +3497,7 @@ func TestManager_Emails_PrimaryFlagAndHelpers(t *testing.T) {
func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB { func openReplicaTestSQLite(t *testing.T, filename string) *sql.DB {
d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on") d, err := sql.Open("sqlite3", filename+"?_case_sensitive_like=on")
require.Nil(t, err) require.Nil(t, err)
require.Nil(t, setupSQLite(d)) require.Nil(t, schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, sqliteMigrations))
return d return d
} }
+26 -25
View File
@@ -47,10 +47,10 @@ func (u *User) IsUser() bool {
// Auther is an interface for authentication and authorization // Auther is an interface for authentication and authorization
type Auther interface { type Auther interface {
// Authenticate checks username and password and returns a user if correct. The method // Authenticate checks a login identifier (username or verified primary email) and password
// returns in constant-ish time, regardless of whether the user exists or the password is // and returns a user if correct. The method returns in constant-ish time, regardless of
// correct or incorrect. // whether the identifier exists or the password is correct or incorrect.
Authenticate(username, password string) (*User, error) Authenticate(identifier, password string) (*User, error)
// Authorize returns nil if the given user has access to the given topic using the desired // Authorize returns nil if the given user has access to the given topic using the desired
// permission. The user param may be nil to signal an anonymous user. // permission. The user param may be nil to signal an anonymous user.
@@ -338,27 +338,28 @@ var (
// queries holds the database-specific SQL queries // queries holds the database-specific SQL queries
type queries struct { type queries struct {
// User queries // User queries
selectUserByID string selectUserByID string
selectUserByName string selectUserByName string
selectUserByToken string selectUserByNameOrPrimaryEmail string
selectUserByStripeCustomerID string selectUserByToken string
selectUsernames string selectUserByStripeCustomerID string
selectUsers string selectUsernames string
selectUserCount string selectUsers string
selectUserIDFromUsername string selectUserCount string
insertUser string selectUserIDFromUsername string
updateUserPass string insertUser string
updateUserRole string updateUserPass string
updateUserProvisioned string updateUserRole string
updateUserPrefs string updateUserProvisioned string
updateUserStats string updateUserPrefs string
updateUserStatsResetAll string updateUserStats string
updateUserTier string updateUserStatsResetAll string
updateUserDeleted string updateUserTier string
deleteUser string updateUserDeleted string
deleteUserTier string deleteUser string
deleteUsersMarked string deleteUserTier string
deleteUsersProvisioned string deleteUsersMarked string
deleteUsersProvisioned string
// Access queries // Access queries
selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled
+1
View File
@@ -12,6 +12,7 @@ const (
loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long loopExecutionLimit = 10_000 // Limit the number of loop executions to prevent execution from taking too long
stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues stringLengthLimit = 100_000 // Limit the length of strings to prevent memory issues
sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues sliceSizeLimit = 10_000 // Limit the size of slices to prevent memory issues
indentSpacesLimit = 100 // Limit indentation width to prevent memory issues; indent allocates spaces*lines bytes
) )
// TxtFuncMap produces the function map. // TxtFuncMap produces the function map.
+7
View File
@@ -116,6 +116,7 @@ func cat(v ...any) string {
} }
// indent adds a specified number of spaces at the beginning of each line in a string. // indent adds a specified number of spaces at the beginning of each line in a string.
// It has a safety limit to prevent excessive memory usage.
// //
// Parameters: // Parameters:
// - spaces: The number of spaces to add // - spaces: The number of spaces to add
@@ -123,7 +124,13 @@ func cat(v ...any) string {
// //
// Returns: // Returns:
// - string: The indented string // - string: The indented string
//
// Panics:
// - If spaces exceeds indentSpacesLimit
func indent(spaces int, v string) string { func indent(spaces int, v string) string {
if spaces > indentSpacesLimit {
panic(fmt.Sprintf("indent %d exceeds limit of %d", spaces, indentSpacesLimit))
}
pad := strings.Repeat(" ", spaces) pad := strings.Repeat(" ", spaces)
return pad + strings.Replace(v, "\n", "\n"+pad, -1) return pad + strings.Replace(v, "\n", "\n"+pad, -1)
} }
+20
View File
@@ -4,6 +4,7 @@ import (
"encoding/base32" "encoding/base32"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -214,6 +215,25 @@ func TestNindent(t *testing.T) {
} }
} }
func TestIndentLimit(t *testing.T) {
// Indentation beyond a sane width is an amplification attempt: indent allocates
// spaces * lines bytes in a single uninterruptible call
if err := runt(`{{indent 100 "a"}}`, strings.Repeat(" ", 100)+"a"); err != nil {
t.Error(err)
}
for _, tpl := range []string{
`{{indent 101 "a"}}`,
`{{nindent 101 "a"}}`,
`{{indent 1000000000 "a"}}`,
} {
if _, err := runRaw(tpl, nil); err == nil {
t.Errorf("expected %s to be rejected", tpl)
} else if !strings.Contains(err.Error(), "exceeds limit") {
t.Errorf("expected limit error for %s, got: %v", tpl, err)
}
}
}
func TestReplace(t *testing.T) { func TestReplace(t *testing.T) {
tpl := `{{"I Am Henry VIII" | replace " " "-"}}` tpl := `{{"I Am Henry VIII" | replace " " "-"}}`
if err := runt(tpl, "I-Am-Henry-VIII"); err != nil { if err := runt(tpl, "I-Am-Henry-VIII"); err != nil {
+551 -693
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -26,6 +26,7 @@
"signup_error_username_taken": "Username {{username}} is already taken", "signup_error_username_taken": "Username {{username}} is already taken",
"signup_error_creation_limit_reached": "Account creation limit reached", "signup_error_creation_limit_reached": "Account creation limit reached",
"login_title": "Sign in to your ntfy account", "login_title": "Sign in to your ntfy account",
"login_form_username_label": "Username or email",
"login_form_button_submit": "Sign in", "login_form_button_submit": "Sign in",
"login_link_signup": "Sign up", "login_link_signup": "Sign up",
"login_link_forgot_password": "Forgot password", "login_link_forgot_password": "Forgot password",
+38 -37
View File
@@ -9,44 +9,44 @@
"action_bar_account": "Konts", "action_bar_account": "Konts",
"action_bar_profile_title": "Profils", "action_bar_profile_title": "Profils",
"action_bar_profile_settings": "Iestatījumi", "action_bar_profile_settings": "Iestatījumi",
"action_bar_profile_logout": "Iziet", "action_bar_profile_logout": "Atteikties",
"nav_button_account": "Konts", "nav_button_account": "Konts",
"nav_button_settings": "Iestatījumi", "nav_button_settings": "Iestatījumi",
"nav_button_documentation": "Dokumentācija", "nav_button_documentation": "Dokumentācija",
"nav_button_connecting": "savienojas", "nav_button_connecting": "savienojas",
"notifications_list_item": "Paziņojums", "notifications_list_item": "Paziņojums",
"notifications_delete": "Dzēst", "notifications_delete": "Izdzēst",
"notifications_tags": "Birkas", "notifications_tags": "Birkas",
"notifications_example": "Piemērs", "notifications_example": "Piemērs",
"publish_dialog_title_label": "Virsraksts", "publish_dialog_title_label": "Virsraksts",
"publish_dialog_message_label": "Ziņojums", "publish_dialog_message_label": "Ziņojums",
"publish_dialog_tags_label": "Birkas", "publish_dialog_tags_label": "Birkas",
"publish_dialog_priority_label": "Prioritāte", "publish_dialog_priority_label": "Svarīgums",
"publish_dialog_email_label": "E-pasta adrese", "publish_dialog_email_label": "E-pasta adrese",
"publish_dialog_filename_label": "Datnes nosaukums", "publish_dialog_filename_label": "Datnes nosaukums",
"publish_dialog_delay_label": "Aizkave", "publish_dialog_delay_label": "Aizkave",
"publish_dialog_button_cancel": "Atcelt", "publish_dialog_button_cancel": "Atcelt",
"publish_dialog_button_send": "Sūtīt", "publish_dialog_button_send": "Nosūtīt",
"subscribe_dialog_subscribe_button_cancel": "Atcelt", "subscribe_dialog_subscribe_button_cancel": "Atcelt",
"subscribe_dialog_subscribe_button_subscribe": "Abonēt", "subscribe_dialog_subscribe_button_subscribe": "Abonēt",
"subscribe_dialog_login_password_label": "Parole", "subscribe_dialog_login_password_label": "Parole",
"subscribe_dialog_error_user_anonymous": "anonīms lietotājs", "subscribe_dialog_error_user_anonymous": "nezināms lietotājs",
"account_basics_title": "Konts", "account_basics_title": "Konts",
"account_basics_username_title": "Lietotājvārds", "account_basics_username_title": "Lietotājvārds",
"account_basics_password_title": "Parole", "account_basics_password_title": "Parole",
"account_basics_phone_numbers_dialog_channel_sms": "Nosūtīt īsziņu", "account_basics_phone_numbers_dialog_channel_sms": "Īsziņa",
"account_basics_phone_numbers_dialog_channel_call": "Zvanīt", "account_basics_phone_numbers_dialog_channel_call": "Zvans",
"account_usage_title": "Lietojums", "account_usage_title": "Lietojums",
"account_usage_unlimited": "Neierobežots", "account_usage_unlimited": "Neierobežots",
"account_basics_tier_admin": "Administrators", "account_basics_tier_admin": "Pārvaldītājs",
"account_basics_tier_basic": "Pamata", "account_basics_tier_basic": "Pamata",
"account_basics_tier_free": "Bezmaksas", "account_basics_tier_free": "Bezmaksas",
"account_basics_tier_interval_monthly": "ikmēnesi", "account_basics_tier_interval_monthly": "ik mēnesi",
"account_basics_tier_interval_yearly": "katru gadu", "account_basics_tier_interval_yearly": "katru gadu",
"account_basics_tier_change_button": "Mainīt", "account_basics_tier_change_button": "Mainīt",
"account_delete_dialog_label": "Parole", "account_delete_dialog_label": "Parole",
"account_delete_dialog_button_cancel": "Atcelt", "account_delete_dialog_button_cancel": "Atcelt",
"account_upgrade_dialog_interval_monthly": "Ikmēnesi", "account_upgrade_dialog_interval_monthly": "Ik mēnesi",
"account_upgrade_dialog_interval_yearly": "Katru gadu", "account_upgrade_dialog_interval_yearly": "Katru gadu",
"account_upgrade_dialog_tier_price_per_month": "mēnesī", "account_upgrade_dialog_tier_price_per_month": "mēnesī",
"account_upgrade_dialog_tier_selected_label": "Atlasīts", "account_upgrade_dialog_tier_selected_label": "Atlasīts",
@@ -62,25 +62,25 @@
"prefs_users_dialog_password_label": "Parole", "prefs_users_dialog_password_label": "Parole",
"prefs_appearance_title": "Izskats", "prefs_appearance_title": "Izskats",
"prefs_appearance_language_title": "Valoda", "prefs_appearance_language_title": "Valoda",
"prefs_appearance_theme_title": "Motīvs", "prefs_appearance_theme_title": "Krāsu palete",
"prefs_reservations_table_topic_header": "Tēma", "prefs_reservations_table_topic_header": "Tēma",
"prefs_reservations_table_access_header": "Piekļuve", "prefs_reservations_table_access_header": "Piekļuve",
"prefs_reservations_dialog_topic_label": "Tēma", "prefs_reservations_dialog_topic_label": "Tēma",
"prefs_reservations_dialog_access_label": "Piekļuve", "prefs_reservations_dialog_access_label": "Piekļuve",
"priority_min": "minimālā", "priority_min": "viszemākais",
"priority_low": "zema", "priority_low": "zems",
"priority_default": "noklusējuma", "priority_default": "noklusējuma",
"priority_high": "augsta", "priority_high": "augsts",
"priority_max": "maksimālā", "priority_max": "visaugstākais",
"signup_form_confirm_password": "Atkārtot paroli", "signup_form_confirm_password": "Apstiprināt paroli",
"signup_form_button_submit": "Izveidot kontu", "signup_form_button_submit": "Izveidot kontu",
"login_link_signup": "Izveidot kontu", "login_link_signup": "Izveidot kontu",
"action_bar_show_menu": "Rādīt izvēlni", "action_bar_show_menu": "Rādīt izvēlni",
"action_bar_logo_alt": "ntfy logotips", "action_bar_logo_alt": "ntfy logotips",
"action_bar_reservation_add": "Rezervēt tēmu", "action_bar_reservation_add": "Aizņemt tēmu",
"action_bar_reservation_edit": "Mainīt rezervāciju", "action_bar_reservation_edit": "Mainīt aizņemšanu",
"action_bar_reservation_delete": "Noņemt rezervāciju", "action_bar_reservation_delete": "Noņemt aizņemšanu",
"action_bar_reservation_limit_reached": "Sasniegts limits", "action_bar_reservation_limit_reached": "Sasniegts ierobežojums",
"action_bar_mute_notifications": "Apklusināt paziņojumus", "action_bar_mute_notifications": "Apklusināt paziņojumus",
"action_bar_sign_up": "Izveidot kontu", "action_bar_sign_up": "Izveidot kontu",
"message_bar_publish": "Publicēt ziņojumu", "message_bar_publish": "Publicēt ziņojumu",
@@ -88,38 +88,39 @@
"nav_button_all_notifications": "Visi paziņojumi", "nav_button_all_notifications": "Visi paziņojumi",
"nav_button_publish_message": "Publicēt paziņojumu", "nav_button_publish_message": "Publicēt paziņojumu",
"nav_button_muted": "Paziņojumi apklusināti", "nav_button_muted": "Paziņojumi apklusināti",
"alert_notification_permission_required_button": "Dot tagad", "alert_notification_permission_required_button": "Piešķirt tagad",
"notifications_list": "Paziņojumu saraksts", "notifications_list": "Paziņojumu saraksts",
"notifications_priority_x": "{{priority}} prioritāte", "notifications_priority_x": "{{priority}} svarīgums",
"notifications_new_indicator": "Jauns paziņojums", "notifications_new_indicator": "Jauns paziņojums",
"notifications_attachment_image": "Pielikuma attēls", "notifications_attachment_image": "Pielikuma attēls",
"notifications_attachment_copy_url_button": "Kopēt URL adresi", "notifications_attachment_copy_url_button": "Ievietot URL starpliktuvē",
"notifications_attachment_open_button": "Atvērt pielikumu", "notifications_attachment_open_button": "Atvērt pielikumu",
"notifications_attachment_file_image": "attēla datne", "notifications_attachment_file_image": "attēla datne",
"notifications_attachment_file_video": "video datne", "notifications_attachment_file_video": "video datne",
"notifications_attachment_file_audio": "audio datne", "notifications_attachment_file_audio": "skaņas datne",
"notifications_attachment_file_document": "cits datnes tips", "notifications_attachment_file_document": "cits datnes veids",
"notifications_click_copy_url_button": "Kopēt saiti", "notifications_click_copy_url_button": "Ievietot saiti starpliktuvē",
"notifications_click_open_button": "Atvērt saiti", "notifications_click_open_button": "Atvērt saiti",
"notifications_actions_failed_notification": "Neveiksmīga darbība", "notifications_actions_failed_notification": "Nesekmīga darbība",
"publish_dialog_title_no_topic": "Publicēt paziņojumu", "publish_dialog_title_no_topic": "Publicēt paziņojumu",
"publish_dialog_progress_uploading": "Augšupielādē …", "publish_dialog_progress_uploading": "Augšupielādē …",
"publish_dialog_message_published": "Paziņojums publicēts", "publish_dialog_message_published": "Paziņojums izdots",
"publish_dialog_emoji_picker_show": "Atlasīt emocijzīmi", "publish_dialog_emoji_picker_show": "Atlasīt emocijzīmi",
"publish_dialog_priority_min": "Minimāla prioritāte", "publish_dialog_priority_min": "Viszemākais svarīgums",
"publish_dialog_priority_low": "Zema prioritāte", "publish_dialog_priority_low": "Zems svarīgums",
"publish_dialog_priority_default": "Noklusējuma prioritāte", "publish_dialog_priority_default": "Noklusējuma svarīgums",
"publish_dialog_priority_high": "Augsta prioritāte", "publish_dialog_priority_high": "Augsts svarīgums",
"publish_dialog_priority_max": "Maksimāla prioritāte", "publish_dialog_priority_max": "Visaugstākais svarīgums",
"publish_dialog_base_url_label": "Pakalpojuma URL adrese", "publish_dialog_base_url_label": "Pakalpojuma URL",
"publish_dialog_topic_label": "Tēmas nosaukums", "publish_dialog_topic_label": "Tēmas nosaukums",
"publish_dialog_topic_reset": "Atiestatīt tēmu", "publish_dialog_topic_reset": "Atiestatīt tēmu",
"publish_dialog_click_label": "Klikšķināma URL adrese", "publish_dialog_click_label": "Klikšķināma URL adrese",
"publish_dialog_call_label": "Tālruņa zvans", "publish_dialog_call_label": "Tālruņa zvans",
"publish_dialog_attach_label": "Pielikuma URL adrese", "publish_dialog_attach_label": "Pielikuma URL",
"publish_dialog_filename_placeholder": "Pielikuma datnes nosaukums", "publish_dialog_filename_placeholder": "Pielikuma datnes nosaukums",
"publish_dialog_other_features": "Citas funkcijas:", "publish_dialog_other_features": "Citas iespējas:",
"publish_dialog_chip_call_label": "Tālruņa zvans", "publish_dialog_chip_call_label": "Tālruņa zvans",
"publish_dialog_chip_delay_label": "Aizkavēt piegādi", "publish_dialog_chip_delay_label": "Aizkavēt piegādi",
"publish_dialog_chip_topic_label": "Mainīt tēmu" "publish_dialog_chip_topic_label": "Mainīt tēmu",
"common_close": "Aizvērt"
} }
+57 -3
View File
@@ -232,7 +232,7 @@
"account_usage_messages_title": "Mensagens publicadas", "account_usage_messages_title": "Mensagens publicadas",
"account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS", "account_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS",
"account_basics_tier_change_button": "Mudar", "account_basics_tier_change_button": "Mudar",
"account_basics_tier_admin_suffix_with_tier": "(com nível {{tier}})", "account_basics_tier_admin_suffix_with_tier": "(com tier {{tier}})",
"account_basics_title": "Conta", "account_basics_title": "Conta",
"account_basics_phone_numbers_no_phone_numbers_yet": "Ainda não há números de telefone", "account_basics_phone_numbers_no_phone_numbers_yet": "Ainda não há números de telefone",
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome", "subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome",
@@ -244,7 +244,7 @@
"account_usage_title": "Uso", "account_usage_title": "Uso",
"account_basics_tier_upgrade_button": "Atualizar para Pro", "account_basics_tier_upgrade_button": "Atualizar para Pro",
"subscribe_dialog_error_topic_already_reserved": "Tópico já reservado", "subscribe_dialog_error_topic_already_reserved": "Tópico já reservado",
"account_basics_tier_admin_suffix_no_tier": "(sem nível)", "account_basics_tier_admin_suffix_no_tier": "(sem tier)",
"account_basics_tier_payment_overdue": "O teu pagamento está atrasado. Por favor, atualize seu método de pagamento, ou sua conta será rebaixada em breve.", "account_basics_tier_payment_overdue": "O teu pagamento está atrasado. Por favor, atualize seu método de pagamento, ou sua conta será rebaixada em breve.",
"account_basics_tier_description": "Nível de poder da sua conta", "account_basics_tier_description": "Nível de poder da sua conta",
"account_basics_tier_free": "Grátis", "account_basics_tier_free": "Grátis",
@@ -406,5 +406,59 @@
"web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor", "web_push_unknown_notification_title": "Notificação desconhecida recebida do servidor",
"web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web", "web_push_unknown_notification_body": "Talvez seja necessário atualizar o ntfy abrindo o aplicativo da Web",
"account_basics_cannot_edit_or_delete_provisioned_user": "Um usuário provisionado não pode ser editado ou apagado", "account_basics_cannot_edit_or_delete_provisioned_user": "Um usuário provisionado não pode ser editado ou apagado",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado" "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Não é possível editar ou apagar o token provisionado",
"common_refresh": "Atualizar",
"email_verify_progress_title": "Verificando seu email...",
"email_verify_success_title": "E-mail verificado",
"email_verify_success_description": "Seu endereço de e-mail foi verificado e adicionado à sua conta.",
"email_verify_error_title": "A verificação falhou",
"email_verify_error_description": "Esse link de verificação está inválido ou expirou. Você pode solicitar um novo link nas configurações da sua conta.",
"email_verify_button_account": "Ir para conta",
"version_update_available_title": "Nova versão disponível",
"version_update_available_description": "O servidor do ntfy foi atualizado. Por favor, atualize a página.",
"signup_form_email": "Email (opcional, para recuperação de conta)",
"login_link_forgot_password": "Esqueci minha senha",
"reset_password_request_description": "Insira seu nome de usuário ou endereço de e-mail. Se sua conta existir, um link para redefinir sua senha será enviado pelo e-mail.",
"reset_password_request_primary_required": "Isso só funciona se você já tiver adicionado e verificado um endereço de e-mail primário.",
"reset_password_request_identifier_label": "Nome de usuário ou e-mail",
"reset_password_request_button_submit": "Enviar link de redefinição",
"reset_password_sent_title": "Verifique sua caixa de entrada",
"reset_password_sent_description": "Se uma conta existir, um link para redefinir sua senha será enviado por e-mail.",
"reset_password_back_to_login": "Voltar para login",
"reset_password_disabled": "A redefinição de senha está desativada",
"reset_password_title": "Definir nova senha",
"reset_password_form_password": "Nova senha",
"reset_password_form_confirm": "Confirmar nova senha",
"reset_password_form_button_submit": "Definir senha",
"reset_password_form_error_invalid": "Esse link de redefinição está inválido ou expirou. Por favor, peça um novo.",
"reset_password_success_title": "Senha atualizada",
"reset_password_success_description": "Sua senha mudou. Agora você pode entrar com sua nova senha.",
"action_bar_reload": "Atualizar app",
"account_basics_emails_title": "Endereços de e-mail",
"account_basics_emails_description": "Para notificações de e-mail e redefinição de senha",
"account_basics_emails_no_emails_yet": "Nenhum e-mail por enquanto",
"account_basics_emails_copied_to_clipboard": "Endereço de e-mail copiado pra área de transferência",
"account_basics_emails_chip_actions_verified": "Pode ser usado para notificações. Clique para ver ações.",
"account_basics_emails_chip_actions_unverified": "E-mail não verificado, confira sua caixa de entrada para verificar. Clique para ver ações.",
"account_basics_emails_unverified": "não verificado",
"account_basics_emails_set_primary": "Definir como e-mail primário",
"account_basics_emails_delete": "Remover e-mail",
"account_basics_emails_resend": "Reenviar e-mail de verificação",
"account_basics_emails_resent": "E-mail de verificação enviado, confira sua caixa de entrada",
"account_basics_emails_primary_elsewhere": "Esse endereço de e-mail é usado como e-mail primário em outra conta",
"account_basics_emails_no_recovery_warning": "Adicione pelo menos um endereço de e-mail para garantir que você possa recuperar sua conta caso perca sua senha.",
"account_basics_emails_no_primary_warning": "Adicione um endereço de email primário para garantir que você possa recuperar sua conta caso perca sua senha.",
"account_basics_emails_dialog_title": "Adicionar endereço de e-mail",
"account_basics_emails_dialog_description": "Insira um endereço de e-mail para adicionar à sua conta. Um link de verificação será enviado pra confirmar seu e-mail.",
"account_basics_emails_dialog_email_label": "Endereço de e-mail",
"account_basics_emails_dialog_email_placeholder": "exemplo: usuário@exemplo.com",
"account_basics_emails_dialog_verify_button": "Enviar link de verificação",
"account_basics_emails_dialog_check_inbox": "Verifique sua caixa de entrada e clique no link de verificação para confirmar esse endereço de e-mail. Ele aparecerá como não verificado até que você o verifique.",
"account_usage_emails_none": "Nenhuma notificação de e-mail pode ser enviada com essa conta",
"prefs_users_dialog_base_url_invalid": "Formato de URL inválido. Deve começar com http:// ou https://",
"prefs_users_dialog_base_url_exists": "Um usuário para esse serviço de URL já existe",
"common_close": "Fechar",
"reset_password_request_title": "Redefinir senha",
"account_basics_emails_chip_actions_primary": "E-mail primário, usado como seu endereço de e-mail padrão. Clique para ver ações.",
"account_basics_tier_provisioned": "Provisionado"
} }
+2 -1
View File
@@ -405,5 +405,6 @@
"prefs_appearance_theme_dark": "Тёмная", "prefs_appearance_theme_dark": "Тёмная",
"prefs_appearance_theme_light": "Светлая", "prefs_appearance_theme_light": "Светлая",
"account_basics_cannot_edit_or_delete_provisioned_user": "Пользователя, созданного автоматически, нельзя изменить или удалить", "account_basics_cannot_edit_or_delete_provisioned_user": "Пользователя, созданного автоматически, нельзя изменить или удалить",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить" "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматически созданный токен нельзя изменить или удалить",
"common_refresh": "Обновить"
} }
+57 -3
View File
@@ -236,8 +236,8 @@
"account_basics_tier_upgrade_button": "Pro'ya yükselt", "account_basics_tier_upgrade_button": "Pro'ya yükselt",
"account_basics_tier_change_button": "Değiştir", "account_basics_tier_change_button": "Değiştir",
"account_basics_tier_paid_until": "Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek", "account_basics_tier_paid_until": "Abonelik {{date}} tarihine kadar ödendi ve otomatik olarak yenilenecek",
"account_basics_tier_admin_suffix_with_tier": "({{tier}} seviyesiyle)", "account_basics_tier_admin_suffix_with_tier": "{{tier}} seviyesiyle",
"account_basics_tier_admin_suffix_no_tier": "(seviye yok)", "account_basics_tier_admin_suffix_no_tier": "seviye yok",
"account_basics_tier_manage_billing_button": "Faturalandırmayı yönet", "account_basics_tier_manage_billing_button": "Faturalandırmayı yönet",
"account_usage_reservations_title": "Ayırtılan konular", "account_usage_reservations_title": "Ayırtılan konular",
"account_usage_reservations_none": "Bu hesap için ayırtılan konu yok", "account_usage_reservations_none": "Bu hesap için ayırtılan konu yok",
@@ -405,5 +405,59 @@
"web_push_unknown_notification_body": "Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir", "web_push_unknown_notification_body": "Web uygulamasını açarak ntfy'yi güncellemeniz gerekebilir",
"subscribe_dialog_subscribe_use_another_background_info": "Web uygulaması açık değilken diğer sunuculardan gelen bildirimler alınmayacaktır", "subscribe_dialog_subscribe_use_another_background_info": "Web uygulaması açık değilken diğer sunuculardan gelen bildirimler alınmayacaktır",
"account_basics_cannot_edit_or_delete_provisioned_user": "Yetkilendirilmiş kullanıcı düzenlenemez veya silinemez", "account_basics_cannot_edit_or_delete_provisioned_user": "Yetkilendirilmiş kullanıcı düzenlenemez veya silinemez",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Sağlanmış belirteci düzenleyemez veya silemezsiniz" "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Sağlanmış belirteci düzenleyemez veya silemezsiniz",
"common_close": "Kapat",
"common_refresh": "Yenile",
"email_verify_progress_title": "E-posta adresiniz doğrulanıyor...",
"email_verify_success_title": "E-posta adresi doğrulandı",
"email_verify_success_description": "E-posta adresiniz doğrulandı ve hesabınıza eklendi.",
"email_verify_error_title": "Doğrulama başarısız",
"email_verify_error_description": "Bu doğrulama bağlantısı geçersiz veya süresi doldu. Hesap ayarlarınızdan yeni bir bağlantı isteyebilirsiniz.",
"email_verify_button_account": "Hesaba git",
"version_update_available_title": "Yeni sürüm var",
"version_update_available_description": "ntfy sunucusu güncellendi. Lütfen sayfayı yenileyin.",
"signup_form_email": "E-posta adresi (isteğe bağlı, hesap kurtarma için)",
"login_link_forgot_password": "Parolayı unuttum",
"reset_password_request_title": "Parola sıfırla",
"reset_password_request_description": "Kullanıcı adınızı veya e-posta adresinizi girin. Hesabınız varsa, parolanızı sıfırlamanız için e-posta ile bir bağlantı gönderilecek.",
"reset_password_request_primary_required": "Bu, yalnızca birincil e-posta adresinizi eklediyseniz ve doğruladıysanız çalışır.",
"reset_password_request_identifier_label": "Kullanıcı adı veya e-posta adresi",
"reset_password_request_button_submit": "Sıfırlama bağlantısı gönder",
"reset_password_sent_title": "Gelen kutunuza bakın",
"reset_password_sent_description": "Hesabınız varsa, parolanızı sıfırlamanız için e-posta ile bir bağlantı gönderildi.",
"reset_password_back_to_login": "Oturum açmaya dön",
"reset_password_disabled": "Parola sıfırlama devre dışı",
"reset_password_title": "Yeni parola belirle",
"reset_password_form_password": "Yeni parola",
"reset_password_form_confirm": "Yeni parolayı onayla",
"reset_password_form_button_submit": "Parola belirle",
"reset_password_form_error_invalid": "Bu sıfırlama bağlantısı geçersiz veya süresi doldu. Lütfen yeni bir bağlantı isteyin.",
"reset_password_success_title": "Parola değiştirildi",
"reset_password_success_description": "Parolanız değiştirildi. Artık yeni parolanızla oturum açabilirsiniz.",
"action_bar_reload": "Uygulamayı yeniden yükle",
"account_basics_emails_title": "E-posta adresleri",
"account_basics_emails_description": "E-posta bildirimleri ve parola sıfırlama için",
"account_basics_emails_no_emails_yet": "Henüz e-posta yok",
"account_basics_emails_copied_to_clipboard": "E-posta adresi panoya kopyalandı",
"account_basics_emails_chip_actions_primary": "Öntanımlı e-posta adresiniz olarak kullanılan birincil adres. Eylemler için tıklayın.",
"account_basics_emails_chip_actions_verified": "Bildirimler için kullanılabilir. Eylemler için tıklayın.",
"account_basics_emails_chip_actions_unverified": "Adres doğrulanmadı, doğrulamak için gelen kutunuza bakın. Eylemler için tıklayın.",
"account_basics_emails_unverified": "doğrulanmadı",
"account_basics_emails_set_primary": "Birincil e-posta adresi olarak ayarla",
"account_basics_emails_delete": "Adresi kaldır",
"account_basics_emails_resend": "Doğrulama e-postasını yeniden gönder",
"account_basics_emails_resent": "Doğrulama e-postası gönderildi, gelen kutunuza bakın",
"account_basics_emails_primary_elsewhere": "Bu e-posta adresi başka bir hesapta birincil adres olarak kullanılıyor",
"account_basics_emails_no_recovery_warning": "Parolanızı kaybettiğinizde hesabınızı kurtarabilmek için en az bir e-posta adresi ekleyin.",
"account_basics_emails_no_primary_warning": "Parolanızı kaybettiğinizde hesabınızı kurtarabilmek için birincil e-posta adresi ekleyin.",
"account_basics_emails_dialog_title": "E-posta adresi ekle",
"account_basics_emails_dialog_description": "Hesabınıza eklemek için bir e-posta adresi girin. Adresin size ait olduğunu doğrulamak için bir doğrulama bağlantısı gönderilecek.",
"account_basics_emails_dialog_email_label": "E-posta adresi",
"account_basics_emails_dialog_email_placeholder": "örn. user@example.com",
"account_basics_emails_dialog_verify_button": "Doğrulama bağlantısı gönder",
"account_basics_emails_dialog_check_inbox": "Gelen kutunuza bakın ve bu e-posta adresini onaylamak için doğrulama bağlantısına tıklayın. Onaylayana kadar adresiniz doğrulanmadı olarak görünecek.",
"account_basics_tier_provisioned": "Yetkilendirildi",
"account_usage_emails_none": "Bu hesapla e-posta bildirimi gönderilemez",
"prefs_users_dialog_base_url_invalid": "Geçersiz URL biçimi. http:// veya https:// ile başlamalıdır",
"prefs_users_dialog_base_url_exists": "Bu hizmet URL'si için zaten bir kullanıcı var"
} }
+60 -6
View File
@@ -27,14 +27,14 @@
"publish_dialog_title_topic": "Опублікувати в {{topic}}", "publish_dialog_title_topic": "Опублікувати в {{topic}}",
"publish_dialog_title_no_topic": "Опублікувати сповіщення", "publish_dialog_title_no_topic": "Опублікувати сповіщення",
"publish_dialog_progress_uploading": "Завантаження…", "publish_dialog_progress_uploading": "Завантаження…",
"publish_dialog_message_published": "Сповіщення опубліковано", "publish_dialog_message_published": "Сповіщення опубліковане",
"publish_dialog_attachment_limits_quota_reached": "перевищує квоту, залишилося {{remainingBytes}}", "publish_dialog_attachment_limits_quota_reached": "перевищує квоту, залишилося {{remainingBytes}}",
"publish_dialog_priority_low": "Низький пріоритет", "publish_dialog_priority_low": "Низький пріоритет",
"publish_dialog_topic_label": "Назва теми", "publish_dialog_topic_label": "Назва теми",
"publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts", "publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts",
"publish_dialog_topic_reset": "Скинути тему", "publish_dialog_topic_reset": "Скинути тему",
"publish_dialog_title_label": "Заголовок", "publish_dialog_title_label": "Заголовок",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостатньо місця на диску", "publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостачу місця на диску",
"publish_dialog_message_label": "Повідомлення", "publish_dialog_message_label": "Повідомлення",
"publish_dialog_message_placeholder": "Введіть повідомлення", "publish_dialog_message_placeholder": "Введіть повідомлення",
"publish_dialog_tags_label": "Теги", "publish_dialog_tags_label": "Теги",
@@ -276,15 +276,15 @@
"publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"", "publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"",
"publish_dialog_chip_call_label": "Телефонний дзвінок", "publish_dialog_chip_call_label": "Телефонний дзвінок",
"publish_dialog_call_reset": "Видалити телефонний дзвінок", "publish_dialog_call_reset": "Видалити телефонний дзвінок",
"account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.", "account_basics_phone_numbers_dialog_description": "Щоб скористатися функцією сповіщень про дзвінки, вам потрібно додати та підтвердити принаймні один номер телефону. Підтвердження можна здійснити за допомогою SMS або телефонного дзвінка.",
"account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.", "account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.",
"account_basics_tier_upgrade_button": "Оновлення до Pro", "account_basics_tier_upgrade_button": "Оновлення до Pro",
"account_basics_password_description": "Зміна пароля облікового запису", "account_basics_password_description": "Зміна пароля облікового запису",
"account_usage_of_limit": "з {{limit}}", "account_usage_of_limit": "з {{limit}}",
"account_usage_unlimited": "Без обмежень", "account_usage_unlimited": "Без обмежень",
"account_basics_tier_description": "Рівень потужності вашого облікового запису", "account_basics_tier_description": "Рівень потужності вашого облікового запису",
"account_basics_tier_admin_suffix_with_tier": "(з рівнем {{tier}})", "account_basics_tier_admin_suffix_with_tier": "з рівнем {{tier}}",
"account_basics_tier_admin_suffix_no_tier": "(без рівня)", "account_basics_tier_admin_suffix_no_tier": "без рівня",
"account_basics_tier_basic": "Базовий", "account_basics_tier_basic": "Базовий",
"account_basics_tier_free": "Безкоштовний", "account_basics_tier_free": "Безкоштовний",
"account_basics_tier_change_button": "Змінити", "account_basics_tier_change_button": "Змінити",
@@ -406,5 +406,59 @@
"web_push_unknown_notification_body": "Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку", "web_push_unknown_notification_body": "Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку",
"alert_notification_ios_install_required_title": "Необхідне встановлення на iOS", "alert_notification_ios_install_required_title": "Необхідне встановлення на iOS",
"account_basics_cannot_edit_or_delete_provisioned_user": "Автоматично створеного користувача не можна редагувати чи видалити", "account_basics_cannot_edit_or_delete_provisioned_user": "Автоматично створеного користувача не можна редагувати чи видалити",
"account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити" "account_tokens_table_cannot_delete_or_edit_provisioned_token": "Автоматично створений токен не можна редагувати чи видалити",
"common_close": "Закрити",
"common_refresh": "Оновити",
"login_link_forgot_password": "Забув пароль",
"reset_password_request_title": "Скинути пароль",
"prefs_users_dialog_base_url_invalid": "Неправильний формат URL-адреси. Потрібно, щоб починалось із http:// або https://",
"email_verify_progress_title": "Підтвердження вашої електронної пошти...",
"email_verify_success_title": "Електронну пошту підтверджено",
"email_verify_success_description": "Адресу електронної пошти варифіковано та додано до вашого облікового запису.",
"email_verify_error_title": "Не вдалося перевірити",
"email_verify_error_description": "Це посилання для підтвердження невірне або його термін дії минув. Ви можете запитати нове у налаштуваннях вашого облікового запису.",
"email_verify_button_account": "Перейти до облікового запису",
"version_update_available_title": "Доступна нова версія",
"version_update_available_description": "Сервер ntfy оновлено. Будь ласка, перезавантажте сторінку.",
"signup_form_email": "Адреса електронної пошти (опціонально, для відновлення облікового запису)",
"reset_password_form_confirm": "Підтвердити новий пароль",
"reset_password_form_button_submit": "Встановити пароль",
"reset_password_form_error_invalid": "Це посилання скидання пароля невірне, або його термін дії минув. Будь ласка, запитайте нове.",
"reset_password_success_title": "Пароль змінено",
"reset_password_success_description": "Ваш пароль змінився. Ви тепер можете зайти до облікового запису із новим паролем.",
"action_bar_reload": "Перезавантажити додаток",
"account_basics_emails_title": "Електронні поштові адреси",
"account_basics_emails_description": "Для сповіщень через пошту та скидання пароля",
"prefs_users_dialog_base_url_exists": "Користувач для цієї сервісної URL-адреси вже існує",
"account_usage_emails_none": "З цього облікового запису не можна відправляти сповіщення по електронній пошті",
"account_basics_emails_dialog_title": "Додати адресу електронної пошти",
"reset_password_sent_description": "Якщо цей обліковий запис існує, посилання зі скиданням пароля буде надіслане на адресу електронної пошти.",
"reset_password_back_to_login": "Повернутися до форми входу",
"reset_password_disabled": "Скидади пароль заборонено",
"reset_password_title": "Установити новий пароль",
"reset_password_form_password": "Новий пароль",
"account_basics_emails_no_emails_yet": "Листів ще немає",
"account_basics_emails_copied_to_clipboard": "Адресу електронної пошти скопійовано до буфера обміну",
"account_basics_emails_delete": "Видалити адресу",
"account_basics_emails_dialog_email_label": "Адреса електронної пошти",
"account_basics_emails_dialog_email_placeholder": "наприклад, user@example.com",
"account_basics_emails_dialog_verify_button": "Надіслати посилання для підтвердження",
"reset_password_request_button_submit": "Надіслати посилання для скидання",
"reset_password_sent_title": "Перевірте вхідні листи",
"account_basics_emails_dialog_check_inbox": "Перевірте вхідні електронні листи та перейдіть за посиланням верифікації для підтвердження цієї адреси електронної пошти. До цього моменту вона буде у вас відображена як неперевірена.",
"account_basics_emails_primary_elsewhere": "Ця адреса електронної пошти вже використовуєтсья як основна у іншому обліковому записі",
"account_basics_emails_set_primary": "Зробити основною поштовою адресою",
"account_basics_emails_resend": "Повторно надіслати електронний лист для підтвердження",
"account_basics_emails_resent": "Листа підтвердження надіслано, перевірте свою пошту",
"account_basics_emails_no_recovery_warning": "Додайте принаймні одну адресу електронної пошти, щоб мати змогу відновити свій обліковий запис, якщо ви втратите пароль.",
"account_basics_emails_no_primary_warning": "Додайте основну адресу електронної пошти, щоб мати змогу відновити свій обліковий запис, якщо ви втратите пароль.",
"account_basics_emails_dialog_description": "Введіть адресу електронної пошти, щоб додати її до свого облікового запису. Вам буде надіслано посилання для підтвердження того, що вона ваша.",
"reset_password_request_description": "Введіть ім’я користувача або адресу електронної пошти. Якщо обліковий запис існує, на нього буде надіслано посилання для скидання пароля.",
"reset_password_request_identifier_label": "Ім’я користувача або адреса електронної пошти",
"reset_password_request_primary_required": "Це працює, лише якщо ви вже додали основну адресу електронної пошти та підтвердили її.",
"account_basics_emails_chip_actions_primary": "Основна адреса, яка використовується як ваша адреса електронної пошти за замовчуванням. Натисніть, щоб переглянути дії.",
"account_basics_emails_chip_actions_verified": "Можна використовувати для сповіщень. Натисніть, щоб переглянути дії.",
"account_basics_emails_chip_actions_unverified": "Непідтверджена адреса, перевірте свою поштову скриньку для підтвердження. Натисніть, щоб переглянути дії.",
"account_basics_emails_unverified": "неперевірена",
"account_basics_tier_provisioned": "Створений конфігурацією"
} }
+7 -3
View File
@@ -6,6 +6,7 @@ import {
accountEmailVerifyUrl, accountEmailVerifyUrl,
accountEmailPrimaryUrl, accountEmailPrimaryUrl,
accountEmailResendUrl, accountEmailResendUrl,
accountLoginUrl,
accountPasswordResetRequestUrl, accountPasswordResetRequestUrl,
accountPasswordResetUrl, accountPasswordResetUrl,
accountPasswordUrl, accountPasswordUrl,
@@ -47,8 +48,8 @@ class AccountApi {
} }
async login(user) { async login(user) {
const url = accountTokenUrl(config.base_url); const url = accountLoginUrl(config.base_url);
console.log(`[AccountApi] Checking auth for ${url}`); console.log(`[AccountApi] Logging in at ${url}`);
const response = await fetchOrThrow(url, { const response = await fetchOrThrow(url, {
method: "POST", method: "POST",
headers: withBasicAuth({}, user.username, user.password), headers: withBasicAuth({}, user.username, user.password),
@@ -57,7 +58,10 @@ class AccountApi {
if (!json.token) { if (!json.token) {
throw new Error(`Unexpected server response: Cannot find token`); throw new Error(`Unexpected server response: Cannot find token`);
} }
return json.token; // The identifier the user typed may be a primary email; login returns the canonical username
// so callers can store it and show the real username rather than whatever was typed. Fall back
// to the typed identifier if an older server omits the username, so the session still stores one.
return { token: json.token, username: json.username || user.username };
} }
async logout() { async logout() {
+10 -6
View File
@@ -42,15 +42,19 @@ afterEach(() => {
}); });
describe("AccountApi.login", () => { describe("AccountApi.login", () => {
it("POSTs basic auth to the token URL and returns the token", async () => { it("POSTs basic auth to the login URL and returns the token and canonical username", async () => {
fetchMock.mockResolvedValue(ok({ token: "tk_returned" })); // The typed identifier is an email; the login endpoint returns the canonical username in one
const token = await accountApi.login({ username: "phil", password: "secret" }); // request, so login() surfaces it (callers store it) rather than echoing what was typed.
fetchMock.mockResolvedValue(ok({ token: "tk_returned", username: "phil" }));
const result = await accountApi.login({ username: "phil@example.com", password: "secret" });
expect(result).toEqual({ token: "tk_returned", username: "phil" });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(token).toBe("tk_returned");
const [url, options] = fetchMock.mock.calls[0]; const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe("https://ntfy.sh/v1/account/token"); expect(url).toBe("https://ntfy.sh/v1/account/login");
expect(options.method).toBe("POST"); expect(options.method).toBe("POST");
expect(options.headers.Authorization).toBe(`Basic ${btoa("phil:secret")}`); expect(options.headers.Authorization).toBe(`Basic ${btoa("phil@example.com:secret")}`);
}); });
it("throws when the server response has no token", async () => { it("throws when the server response has no token", async () => {
+1
View File
@@ -23,6 +23,7 @@ export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/aut
export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
export const webPushUrl = (baseUrl) => `${baseUrl}/v1/webpush`; export const webPushUrl = (baseUrl) => `${baseUrl}/v1/webpush`;
export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`; export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
export const accountLoginUrl = (baseUrl) => `${baseUrl}/v1/account/login`;
export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`; export const accountPasswordUrl = (baseUrl) => `${baseUrl}/v1/account/password`;
export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`; export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`; export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
+5 -5
View File
@@ -24,14 +24,14 @@ const Login = () => {
event.preventDefault(); event.preventDefault();
const user = { username, password }; const user = { username, password };
try { try {
const token = await accountApi.login(user); const { token, username: canonicalUsername } = await accountApi.login(user);
console.log(`[Login] User auth for user ${user.username} successful, token is ${token}`); console.log(`[Login] User auth for user ${user.username} successful, logged in as ${canonicalUsername}`);
await session.store(user.username, token); await session.store(canonicalUsername, token);
fadeReload(routes.app); fadeReload(routes.app);
} catch (e) { } catch (e) {
console.log(`[Login] User auth for user ${user.username} failed`, e); console.log(`[Login] User auth for user ${user.username} failed`, e);
if (e instanceof UnauthorizedError) { if (e instanceof UnauthorizedError) {
setError(t("Login failed: Invalid username or password")); setError(t("Login failed: Invalid username/email or password"));
} else { } else {
setError(e.message); setError(e.message);
} }
@@ -53,7 +53,7 @@ const Login = () => {
required required
fullWidth fullWidth
id="username" id="username"
label={t("signup_form_username")} label={t("login_form_username_label")}
name="username" name="username"
value={username} value={username}
onChange={(ev) => setUsername(ev.target.value.trim())} onChange={(ev) => setUsername(ev.target.value.trim())}
+3 -3
View File
@@ -28,9 +28,9 @@ const Signup = () => {
const user = { username, password }; const user = { username, password };
try { try {
await accountApi.create(user.username, user.password, email); await accountApi.create(user.username, user.password, email);
const token = await accountApi.login(user); const { token, username: canonicalUsername } = await accountApi.login(user);
console.log(`[Signup] User signup for user ${user.username} successful, token is ${token}`); console.log(`[Signup] User signup for user ${user.username} successful, logged in as ${canonicalUsername}`);
await session.store(user.username, token); await session.store(canonicalUsername, token);
fadeReload(routes.app); fadeReload(routes.app);
} catch (e) { } catch (e) {
console.log(`[Signup] Signup for user ${user.username} failed`, e); console.log(`[Signup] Signup for user ${user.username} failed`, e);
+2
View File
@@ -14,6 +14,8 @@ const (
subscriptionIDPrefix = "wps_" subscriptionIDPrefix = "wps_"
subscriptionIDLength = 10 subscriptionIDLength = 10
subscriptionEndpointLimitPerSubscriberIP = 10 subscriptionEndpointLimitPerSubscriberIP = 10
schemaStore = "webpush"
) )
// Errors returned by the store // Errors returned by the store
+28 -58
View File
@@ -1,39 +1,11 @@
package webpush package webpush
import ( import (
"database/sql"
"fmt"
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
) )
const ( const (
postgresCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS webpush_subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at BIGINT NOT NULL,
warned_at BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);
CREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription (updated_at);
CREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription (user_id);
CREATE TABLE IF NOT EXISTS webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
);
CREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
`
postgresSelectSubscriptionIDByEndpointQuery = `SELECT id FROM webpush_subscription WHERE endpoint = $1` postgresSelectSubscriptionIDByEndpointQuery = `SELECT id FROM webpush_subscription WHERE endpoint = $1`
postgresSelectSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM webpush_subscription WHERE subscriber_ip = $1` postgresSelectSubscriptionCountBySubscriberIPQuery = `SELECT COUNT(*) FROM webpush_subscription WHERE subscriber_ip = $1`
postgresSelectSubscriptionsForTopicQuery = ` postgresSelectSubscriptionsForTopicQuery = `
@@ -66,16 +38,38 @@ const (
postgresDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id NOT IN (SELECT id FROM webpush_subscription)` postgresDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM webpush_subscription_topic WHERE subscription_id NOT IN (SELECT id FROM webpush_subscription)`
) )
// PostgreSQL schema management queries // Schema version and queries
const ( const (
pgCurrentSchemaVersion = 1 postgresCurrentSchemaVersion = 1
postgresInsertSchemaVersionQuery = `INSERT INTO schema_version (store, version) VALUES ('webpush', $1)` )
postgresSelectSchemaVersionQuery = `SELECT version FROM schema_version WHERE store = 'webpush'`
var (
postgresCreateTables = schema.AsMigrateFunc(`
CREATE TABLE IF NOT EXISTS webpush_subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at BIGINT NOT NULL,
warned_at BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);
CREATE INDEX IF NOT EXISTS idx_webpush_updated_at ON webpush_subscription (updated_at);
CREATE INDEX IF NOT EXISTS idx_webpush_user_id ON webpush_subscription (user_id);
CREATE TABLE IF NOT EXISTS webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
);
CREATE INDEX IF NOT EXISTS idx_webpush_topic ON webpush_subscription_topic (topic);
`)
) )
// NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool. // NewPostgresStore creates a new PostgreSQL-backed web push store using an existing database connection pool.
func NewPostgresStore(d *db.DB) (*Store, error) { func NewPostgresStore(d *db.DB) (*Store, error) {
if err := setupPostgres(d.Primary()); err != nil { if err := schema.Migrate(d.Primary(), schema.Postgres, schemaStore, postgresCurrentSchemaVersion, postgresCreateTables, nil); err != nil {
return nil, err return nil, err
} }
return &Store{ return &Store{
@@ -97,27 +91,3 @@ func NewPostgresStore(d *db.DB) (*Store, error) {
}, },
}, nil }, nil
} }
func setupPostgres(d *sql.DB) error {
var schemaVersion int
err := d.QueryRow(postgresSelectSchemaVersionQuery).Scan(&schemaVersion)
if err != nil {
return setupNewPostgres(d)
}
if schemaVersion > pgCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, pgCurrentSchemaVersion)
}
return nil
}
func setupNewPostgres(d *sql.DB) error {
return db.ExecTx(d, func(tx *sql.Tx) error {
if _, err := tx.Exec(postgresCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(postgresInsertSchemaVersionQuery, pgCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
+28 -54
View File
@@ -2,39 +2,13 @@ package webpush
import ( import (
"database/sql" "database/sql"
"fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"heckel.io/ntfy/v2/db" "heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/db/schema"
) )
const ( const (
sqliteCreateTablesQuery = `
CREATE TABLE IF NOT EXISTS subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
`
sqliteBuiltinStartupQueries = ` sqliteBuiltinStartupQueries = `
PRAGMA foreign_keys = ON; PRAGMA foreign_keys = ON;
` `
@@ -71,11 +45,33 @@ const (
sqliteDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)` sqliteDeleteSubscriptionTopicWithoutSubscriptionQuery = `DELETE FROM subscription_topic WHERE subscription_id NOT IN (SELECT id FROM subscription)`
) )
// SQLite schema management queries // Schema version and queries
const ( const (
sqliteCurrentSchemaVersion = 1 sqliteCurrentSchemaVersion = 1
sqliteInsertSchemaVersionQuery = `INSERT INTO schemaVersion VALUES (1, ?)` )
sqliteSelectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
var (
sqliteCreateTables = schema.AsMigrateFunc(`
CREATE TABLE IF NOT EXISTS subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_endpoint ON subscription (endpoint);
CREATE INDEX IF NOT EXISTS idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE IF NOT EXISTS subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_topic ON subscription_topic (topic);
`)
) )
// NewSQLiteStore creates a new SQLite-backed web push store. // NewSQLiteStore creates a new SQLite-backed web push store.
@@ -84,7 +80,7 @@ func NewSQLiteStore(filename, startupQueries string) (*Store, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := setupSQLite(d); err != nil { if err := schema.Migrate(d, schema.SQLite, schemaStore, sqliteCurrentSchemaVersion, sqliteCreateTables, nil); err != nil {
return nil, err return nil, err
} }
if err := runSQLiteStartupQueries(d, startupQueries); err != nil { if err := runSQLiteStartupQueries(d, startupQueries); err != nil {
@@ -110,28 +106,6 @@ func NewSQLiteStore(filename, startupQueries string) (*Store, error) {
}, nil }, nil
} }
func setupSQLite(db *sql.DB) error {
var schemaVersion int
if err := db.QueryRow(sqliteSelectSchemaVersionQuery).Scan(&schemaVersion); err != nil {
return setupNewSQLite(db)
} else if schemaVersion > sqliteCurrentSchemaVersion {
return fmt.Errorf("unexpected schema version: version %d is higher than current version %d", schemaVersion, sqliteCurrentSchemaVersion)
}
return nil
}
func setupNewSQLite(sqlDB *sql.DB) error {
return db.ExecTx(sqlDB, func(tx *sql.Tx) error {
if _, err := tx.Exec(sqliteCreateTablesQuery); err != nil {
return err
}
if _, err := tx.Exec(sqliteInsertSchemaVersionQuery, sqliteCurrentSchemaVersion); err != nil {
return err
}
return nil
})
}
func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error { func runSQLiteStartupQueries(db *sql.DB, startupQueries string) error {
if _, err := db.Exec(startupQueries); err != nil { if _, err := db.Exec(startupQueries); err != nil {
return err return err
+122
View File
@@ -1,6 +1,7 @@
package webpush_test package webpush_test
import ( import (
"database/sql"
"fmt" "fmt"
"net/netip" "net/netip"
"path/filepath" "path/filepath"
@@ -14,6 +15,127 @@ import (
const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF" const testWebPushEndpoint = "https://updates.push.services.mozilla.com/wpush/v1/AAABBCCCDDEEEFFF"
// Schema layout as written by ntfy releases before the db/schema framework; used to verify
// that existing databases open cleanly without an adoption step
const (
testPreFrameworkSQLiteSchema = `
CREATE TABLE subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at INT NOT NULL,
warned_at INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX idx_endpoint ON subscription (endpoint);
CREATE INDEX idx_subscriber_ip ON subscription (subscriber_ip);
CREATE TABLE subscription_topic (
subscription_id TEXT NOT NULL,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic),
FOREIGN KEY (subscription_id) REFERENCES subscription (id) ON DELETE CASCADE
);
CREATE INDEX idx_topic ON subscription_topic (topic);
CREATE TABLE schemaVersion (id INT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schemaVersion VALUES (1, 1);
`
testPreFrameworkPostgresSchema = `
CREATE TABLE webpush_subscription (
id TEXT PRIMARY KEY,
endpoint TEXT NOT NULL UNIQUE,
key_auth TEXT NOT NULL,
key_p256dh TEXT NOT NULL,
user_id TEXT NOT NULL,
subscriber_ip TEXT NOT NULL,
updated_at BIGINT NOT NULL,
warned_at BIGINT NOT NULL DEFAULT 0
);
CREATE INDEX idx_webpush_subscriber_ip ON webpush_subscription (subscriber_ip);
CREATE INDEX idx_webpush_updated_at ON webpush_subscription (updated_at);
CREATE INDEX idx_webpush_user_id ON webpush_subscription (user_id);
CREATE TABLE webpush_subscription_topic (
subscription_id TEXT NOT NULL REFERENCES webpush_subscription (id) ON DELETE CASCADE,
topic TEXT NOT NULL,
PRIMARY KEY (subscription_id, topic)
);
CREATE INDEX idx_webpush_topic ON webpush_subscription_topic (topic);
CREATE TABLE schema_version (store TEXT PRIMARY KEY, version INT NOT NULL);
INSERT INTO schema_version (store, version) VALUES ('webpush', 1);
`
)
// TestStoreSchemaEquivalence verifies that a database adopted from the pre-framework layout is
// structurally identical to a freshly created one: same tables, columns, indexes and keys.
func TestStoreSchemaEquivalence(t *testing.T) {
t.Run("sqlite", func(t *testing.T) {
freshFile := filepath.Join(t.TempDir(), "fresh.db")
fresh, err := webpush.NewSQLiteStore(freshFile, "")
require.Nil(t, err)
defer fresh.Close()
migratedFile := filepath.Join(t.TempDir(), "migrated.db")
d, err := sql.Open("sqlite3", migratedFile)
require.Nil(t, err)
_, err = d.Exec(testPreFrameworkSQLiteSchema)
require.Nil(t, err)
require.Nil(t, d.Close())
migrated, err := webpush.NewSQLiteStore(migratedFile, "")
require.Nil(t, err)
defer migrated.Close()
freshDB, err := sql.Open("sqlite3", freshFile)
require.Nil(t, err)
defer freshDB.Close()
migratedDB, err := sql.Open("sqlite3", migratedFile)
require.Nil(t, err)
defer migratedDB.Close()
require.Equal(t, dbtest.SQLiteSchema(t, freshDB), dbtest.SQLiteSchema(t, migratedDB))
})
t.Run("postgres", func(t *testing.T) {
freshDB := dbtest.CreateTestPostgres(t)
_, err := webpush.NewPostgresStore(freshDB)
require.Nil(t, err)
migratedDB := dbtest.CreateTestPostgres(t)
_, err = migratedDB.Exec(testPreFrameworkPostgresSchema)
require.Nil(t, err)
_, err = webpush.NewPostgresStore(migratedDB)
require.Nil(t, err)
require.Equal(t, dbtest.PostgresSchema(t, freshDB), dbtest.PostgresSchema(t, migratedDB))
})
}
func TestStoreSQLiteOpensExistingDatabase(t *testing.T) {
filename := filepath.Join(t.TempDir(), "webpush.db")
d, err := sql.Open("sqlite3", filename)
require.Nil(t, err)
_, err = d.Exec(testPreFrameworkSQLiteSchema)
require.Nil(t, err)
require.Nil(t, d.Close())
store, err := webpush.NewSQLiteStore(filename, "")
require.Nil(t, err)
defer store.Close()
requireStoreUsable(t, store)
}
func TestStorePostgresOpensExistingDatabase(t *testing.T) {
testDB := dbtest.CreateTestPostgres(t)
_, err := testDB.Exec(testPreFrameworkPostgresSchema)
require.Nil(t, err)
store, err := webpush.NewPostgresStore(testDB)
require.Nil(t, err)
requireStoreUsable(t, store)
}
func requireStoreUsable(t *testing.T, store *webpush.Store) {
t.Helper()
err := store.UpsertSubscription(testWebPushEndpoint, "auth-key", "p256dh-key", "u_1234", netip.MustParseAddr("1.2.3.4"), []string{"mytopic"})
require.Nil(t, err)
subs, err := store.SubscriptionsForTopic("mytopic")
require.Nil(t, err)
require.Len(t, subs, 1)
require.Equal(t, testWebPushEndpoint, subs[0].Endpoint)
}
func forEachBackend(t *testing.T, f func(t *testing.T, store *webpush.Store)) { func forEachBackend(t *testing.T, f func(t *testing.T, store *webpush.Store)) {
t.Run("sqlite", func(t *testing.T) { t.Run("sqlite", func(t *testing.T) {
store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "") store, err := webpush.NewSQLiteStore(filepath.Join(t.TempDir(), "webpush.db"), "")