Compare commits

...
Author SHA1 Message Date
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
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
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
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
Holden Oullette b15213e531 Add ex_ntfy (Elixir library) to integrations 2026-07-05 22:32:25 -06:00
40 changed files with 1687 additions and 1054 deletions
+3 -3
View File
@@ -8,13 +8,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
-
name: Checkout ntfy code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-
name: Checkout docs pages code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: binwiederhier/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 }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Docker login
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
+3 -3
View File
@@ -25,13 +25,13 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: '.go-version'
- name: Install node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
cache: 'npm'
+1 -1
View File
@@ -1 +1 @@
1.26.5
1.27.0
+3
View File
@@ -48,6 +48,9 @@ ADD ./webpush ./webpush
ADD ./attachment ./attachment
ADD ./mail ./mail
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
FROM alpine
+1 -1
View File
@@ -89,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.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-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.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"}),
+5 -3
View File
@@ -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.
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.
* `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
most cloud providers. This defaults to 500M.
@@ -2382,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-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-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-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. |
@@ -2498,7 +2500,7 @@ OPTIONS:
--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-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-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]
+38 -38
View File
@@ -34,37 +34,37 @@ as a service starting at boot time.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_amd64.tar.gz
tar zxvf ntfy_2.26.3_linux_amd64.tar.gz
sudo cp -a ntfy_2.26.3_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_amd64.tar.gz
tar zxvf ntfy_2.28.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.28.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv6.tar.gz
tar zxvf ntfy_2.26.3_linux_armv6.tar.gz
sudo cp -a ntfy_2.26.3_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv6.tar.gz
tar zxvf ntfy_2.28.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.28.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_armv7.tar.gz
tar zxvf ntfy_2.26.3_linux_armv7.tar.gz
sudo cp -a ntfy_2.26.3_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_armv7.tar.gz
tar zxvf ntfy_2.28.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.28.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_linux_arm64.tar.gz
tar zxvf ntfy_2.26.3_linux_arm64.tar.gz
sudo cp -a ntfy_2.26.3_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.26.3_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.28.0/ntfy_2.28.0_linux_arm64.tar.gz
tar zxvf ntfy_2.28.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.28.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.28.0_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -84,25 +84,25 @@ Install the ntfy server unit file (which contains parameters to start the servic
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.26.3_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
```
=== "armv6"
```bash
sudo mv ntfy_2.26.3_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
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.26.3_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
```
=== "arm64"
```bash
sudo mv ntfy_2.26.3_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
```
@@ -118,25 +118,25 @@ Install the ntfy server service script:
=== "x86_64/amd64"
```bash
sudo mv ntfy_2.26.3_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
```
=== "armv6"
```bash
sudo mv ntfy_2.26.3_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
```
=== "armv7/armhf"
```bash
sudo mv ntfy_2.26.3_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
```
=== "arm64"
```bash
sudo mv ntfy_2.26.3_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
```
@@ -204,7 +204,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -212,7 +212,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -220,7 +220,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -228,7 +228,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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 systemctl enable ntfy
sudo systemctl start ntfy
@@ -238,28 +238,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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 start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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 start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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 start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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 start ntfy
```
@@ -301,18 +301,18 @@ pkg install go-ntfy
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_darwin_all.tar.gz > ntfy_2.26.3_darwin_all.tar.gz
tar zxvf ntfy_2.26.3_darwin_all.tar.gz
sudo cp -a ntfy_2.26.3_darwin_all/ntfy /usr/local/bin/ntfy
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.28.0_darwin_all.tar.gz
sudo cp -a ntfy_2.28.0_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.26.3_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
```
@@ -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.
To install, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.26.3/ntfy_2.26.3_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%`.
* 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)
- [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)
- [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
- [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://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#)
- [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
+6 -2
View File
@@ -3227,7 +3227,10 @@ your templates there first ([example for Grafana alert](https://repeatit.io/#/sh
!!! info
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 --
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
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. |
| **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 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. |
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
+43 -18
View File
@@ -6,12 +6,54 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date |
|------------------|---------|---------------|
| ntfy server | v2.26.3 | Jul 20, 2026 |
| ntfy server | v2.28.0 | Aug 27, 2026 |
| ntfy Android app | v1.25.2 | July 23, 2026 |
| ntfy iOS app | v1.7.0 | May 30, 2026 |
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
@@ -2051,23 +2093,6 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.27.0 (UNRELEASED)
**Security:**
* Exclude secrets (Stripe/Twilio/web push keys, SMTP password, provisioned users and tokens) from the config hash served to the web app
**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 iOS app v1.8.0 (UNRELEASED)
**Features:**
+31 -1
View File
@@ -245,6 +245,9 @@ combined with `since=` (defaults to `since=all`).
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
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
@@ -275,6 +278,28 @@ parameter (makes most sense with the `poll=1` parameter):
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
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
@@ -308,6 +333,11 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
{"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
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.
@@ -427,7 +457,7 @@ and can be passed as **HTTP headers** or **query parameters in the URL**. They a
| 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 |
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
| `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
require (
cloud.google.com/go/firestore v1.22.0 // indirect
cloud.google.com/go/storage v1.63.0 // indirect
cloud.google.com/go/firestore v1.25.0 // indirect
cloud.google.com/go/storage v1.65.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.24.0
github.com/gabriel-vasile/mimetype v1.4.13
github.com/emersion/go-smtp v0.25.0
github.com/gabriel-vasile/mimetype v1.4.15
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/stretchr/testify v1.11.1
github.com/stretchr/testify v1.12.1
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/sync v0.21.0
golang.org/x/term v0.44.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.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
)
@@ -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 (
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/jackc/pgx/v5 v5.10.0
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
golang.org/x/sys v0.46.0
golang.org/x/text v0.39.0
golang.org/x/sys v0.47.0
golang.org/x/text v0.41.0
)
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/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/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.11.0 // indirect
cloud.google.com/go/longrunning v1.1.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
cloud.google.com/go/iam v1.13.0 // indirect
cloud.google.com/go/longrunning v1.2.0 // indirect
cloud.google.com/go/monitoring v1.30.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/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // 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/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/felixge/httpsnoop v1.1.0 // 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/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.17 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.21 // indirect
github.com/googleapis/gax-go/v2 v2.24.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // 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
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/net v0.56.0 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.46.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.71.0 // indirect
go.opentelemetry.io/otel v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/otel/sdk v1.46.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.46.0 // indirect
go.opentelemetry.io/otel/trace v1.46.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/genproto v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/grpc v1.82.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.12 // 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.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
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/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
cloud.google.com/go/auth v0.23.2 h1:pxSCpfiji41hpzpPdMCftEUCezpgpqmmDdYiAjCKXxo=
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/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/firestore v1.22.0 h1:avooeboIq37vKXobrbPUFhFBxS/c3FqmWoX0xs8dO6E=
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/logging v1.18.0 h1:KhzZq+1cSkPH9YUaKLLhLtQxIHitVayBmk0sGfoM9+k=
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
cloud.google.com/go/longrunning v1.1.0 h1:qJ0R0IA8ONaRCNWTRPAS0iAmt1bj3TVgJ40z7ZGRslE=
cloud.google.com/go/longrunning v1.1.0/go.mod h1:tH+A/6UvNypiPJWAQaKCsh+xiGbB23wUO8egwUXlD2E=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.63.0 h1:hvXF2xfg9I32bjujggxgkEZn/Ej6sJ9pieFgeueBLrQ=
cloud.google.com/go/storage v1.63.0/go.mod h1:tirWVptrFNo5GEX2DQ47JooF7yaweJdAJ1hYAVMvKzE=
cloud.google.com/go/firestore v1.25.0 h1:yY3rQKyQXNhnhETdseNayF6W1p4x0bdg9ZYS4hKJfOw=
cloud.google.com/go/firestore v1.25.0/go.mod h1:0PU6hj+r/QlhB6BLsRX+Kt/SYefTXrpYrBeHbYaSis8=
cloud.google.com/go/iam v1.13.0 h1:ufT3FPT5rFFXu6UtLkNoxaOaV5EuA1dsSkmemCSTo6U=
cloud.google.com/go/iam v1.13.0/go.mod h1:gHXdDEiPDvqd1q1KwBDGQlgZY/BwY760zU2LhOZS5w0=
cloud.google.com/go/logging v1.19.1 h1:7SsLhyTDBDrJw+Ll6Ns3I2mByqHXvJUc3rGjSlwiWgU=
cloud.google.com/go/logging v1.19.1/go.mod h1:2IkQ/d8jVJqV2qW8ZUGUiMjdZG1gkLD2JReGbZ8isqg=
cloud.google.com/go/longrunning v1.2.0 h1:WjYH3YHBGCxGJP9M4dWGHBfXr/cFIjMkNgWcJj7/iMM=
cloud.google.com/go/longrunning v1.2.0/go.mod h1:5KMQALFGOCtFoi2xSOA1u3H7WKlhmckgiyFw7+LGQp0=
cloud.google.com/go/monitoring v1.30.0 h1:r/d+JUbyKmJ8b07iznuKfzVzrIXTWxHQ3lBRm3x2LlY=
cloud.google.com/go/monitoring v1.30.0/go.mod h1:htlUR0QWVMrjFzZmN4LGnMAve9xB/eduwjmINxVZ8RM=
cloud.google.com/go/storage v1.65.1 h1:LRRpBJUTf+OXDPX9jZUKZ3mSLIsz3htG+qUpeNZovyA=
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/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
firebase.google.com/go/v4 v4.20.0 h1:ighpjeAC45rY/95cUQ+ojIKlKcTnz2YC0ldam56z2YU=
firebase.google.com/go/v4 v4.20.0/go.mod h1:hqhkQtZkThGH42TnaYi7A8EFR1E0FEuB5oHvJ1Q57t8=
firebase.google.com/go/v4 v4.21.0 h1:HBZV4jrLtFYj8EwWyqEZOuRLfkfkV2bpnfyyXHOhPxY=
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/go.mod h1:gZGfd3dpW4vEc/UlyfKKi1roIqcCgwOIvb0tSNSBle0=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
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.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
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.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0 h1:cSjUzZ7KU8hicTgzaSv9NmSyM9fTVK3y5lsBUl3wOis=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.57.0/go.mod h1:dzcEjy1WJ0Q4u9twNR3LcLhNoYMRCrMCMafpxa0TjPQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.36.0 h1:3SdxXLkgAfiHRWcGTq6fneq9jgoJzneiY0yPQnjoT2E=
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.60.0 h1:HldzheTs05E3ybqSitI/wHaof6+XERRudgZLjYbs3eE=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0/go.mod h1:evkqaSczW9g2BQm1veCtgNhJ4wCCsRrOsSgNIn9LHQk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0 h1:Fx8NtDCmKH4ML2hUkPz4Dq250903vRDojMjVCDKwQuc=
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.60.0 h1:Oblia1QXBJlM/wOY9ARRUtsXdDYiMCzk3eCMikqoLbI=
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/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw=
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/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
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.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-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
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/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/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
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 h1:1uwRDYPYG8BIBU9Mj1sUAebNmlM6beu/ZKKweSLDxk8=
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/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/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
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.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
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/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
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.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
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/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.17 h1:73NfMHdiqo9JFU9+7a5ExpVa10/R29pXfZIaW559nrg=
github.com/googleapis/enterprise-certificate-proxy v0.3.17/go.mod h1:rSEsBUemEBZEexP2y6jPp16LUmUbjmSbcPMQizR0o4k=
github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4=
github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY=
github.com/googleapis/enterprise-certificate-proxy v0.3.21 h1:OFdQ3tnCX/zaQ0Cedur3D3z7kI6HiLX9g3TiAN4/DFU=
github.com/googleapis/enterprise-certificate-proxy v0.3.21/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w=
github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnwsS76wlg=
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/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
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/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/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
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/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-sqlite3 v1.14.47 h1:jOBI62gS7nKeZv+as1oGEy0+1qISgXwH/QBlR6KbfIo=
github.com/mattn/go-sqlite3 v1.14.47/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
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/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
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/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.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
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/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
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/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
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.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
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/go.mod h1:f9L6LvaXa35ja7eyvP6GQswoaIPaBRvGAimAO+udbBw=
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=
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/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
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.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/contrib/detectors/gcp v1.46.0 h1:PI8dGkqDaQkwJ8kOopqMhDTbrnK3UIeG/RCHH4HErbo=
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.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s=
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.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
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/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
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/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/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-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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
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.8.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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
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.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
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/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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.7.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.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
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-20201119102817-f84b799fce68/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.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.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
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/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=
@@ -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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
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.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
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.3/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.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.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
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=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
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.287.0/go.mod h1:pPW85yt3Iuc3unkpaMhFtMmOqnTdCwCqEOaUlnuxRlQ=
google.golang.org/api v0.294.0 h1:8gASjJxdtcIieB3OqbkLcF0FfbXVNqKtU5iozD1ssvA=
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/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7Z1JKf3J3wLI=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7 h1:lQG76ePMKmtujel4VIVMiFoHVWVNtJdawbCZJtWlVXU=
google.golang.org/genproto v0.0.0-20260630182238-925bb5da69e7/go.mod h1:LwlOWYBU335L+sR55UuR5fbbU8KmEX+3tUHf3SwMmhM=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
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-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5 h1:jPP56YzdY899KJ5W7efXHt/CkjlVfAaoFOwdi/IEAFA=
google.golang.org/genproto v0.0.0-20260825221802-da73d73af1c5/go.mod h1:gutZdP0DwAHp4vu5WaXgEK7tjsJ77ZEqzlOFWGZGziE=
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-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0=
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-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
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.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.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+65 -15
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"net/netip"
"slices"
"strings"
"sync"
"time"
@@ -18,6 +19,9 @@ import (
const (
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")
@@ -186,19 +190,29 @@ func (c *Cache) addMessages(ms []*model.Message) error {
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) {
if since.IsNone() {
return make([]*model.Message, 0), nil
} else if since.IsLatest() {
return c.messagesLatest(topic)
} else if since.IsID() {
return c.messagesSinceID(topic, since, scheduled)
}
return c.messagesSinceTime(topic, since, scheduled)
messages, _, err := c.MessagesCapped(topic, since, scheduled, NoLimit)
return messages, err
}
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 err error
rdb := c.db.ReadOnly()
@@ -208,12 +222,12 @@ func (c *Cache) messagesSinceTime(topic string, since model.SinceMarker, schedul
rows, err = rdb.Query(c.queries.selectMessagesSinceTime, topic, since.Time().Unix())
}
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 err error
rdb := c.db.ReadOnly()
@@ -223,9 +237,9 @@ func (c *Cache) messagesSinceID(topic string, since model.SinceMarker, scheduled
rows, err = rdb.Query(c.queries.selectMessagesSinceID, topic, since.ID())
}
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) {
@@ -458,6 +472,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) {
defer rows.Close()
messages := make([]*model.Message, 0)
+4 -4
View File
@@ -25,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
FROM message
WHERE topic = $1 AND time >= $2 AND published = TRUE
ORDER BY time, id
ORDER BY time DESC, id DESC
`
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
FROM message
WHERE topic = $1 AND time >= $2
ORDER BY time, id
ORDER BY time DESC, id DESC
`
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
@@ -39,14 +39,14 @@ const (
WHERE topic = $1
AND id > COALESCE((SELECT id FROM message WHERE mid = $2), 0)
AND published = TRUE
ORDER BY time, id
ORDER BY time DESC, id DESC
`
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
FROM message
WHERE topic = $1
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 = `
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
+4 -4
View File
@@ -31,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
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time, id
ORDER BY time DESC, id DESC
`
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
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time, id
ORDER BY time DESC, id DESC
`
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
FROM messages
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 = `
SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, sender, user, content_type, encoding
FROM messages
WHERE topic = ? AND (id > COALESCE((SELECT id FROM messages WHERE mid = ?), 0) OR published = 0)
ORDER BY time, id
ORDER BY time DESC, id DESC
`
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
+30
View File
@@ -101,6 +101,24 @@ func (m *Message) ForJSON() *Message {
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
type Attachment struct {
Name string `json:"name"`
@@ -125,6 +143,18 @@ type Action struct {
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
func NewAction() *Action {
return &Action{
+12
View File
@@ -73,6 +73,16 @@ const (
DefaultAttachmentExpiryDuration = 3 * time.Hour
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
@@ -174,6 +184,7 @@ type Config struct {
MessageDelayMin time.Duration
MessageDelayMax time.Duration
MessageSizeLimit int
MessagePollSizeLimit int64
TotalTopicLimit int
TotalAttachmentSizeLimit int64
VisitorSubscriptionLimit int
@@ -282,6 +293,7 @@ func NewConfig() *Config {
TwilioVerifyService: "",
TwilioCallFormat: nil,
MessageSizeLimit: DefaultMessageSizeLimit,
MessagePollSizeLimit: DefaultMessagePollSizeLimit,
MessageDelayMin: DefaultMessageDelayMin,
MessageDelayMax: DefaultMessageDelayMax,
TotalTopicLimit: DefaultTotalTopicLimit,
+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}
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}
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}
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}
+96 -65
View File
@@ -150,17 +150,6 @@ var (
//go:embed docs
docsStaticFs embed.FS
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]+$`)
// 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 (
@@ -174,8 +163,6 @@ const (
unifiedPushTopicPrefix = "up" // Temporarily, we rate limit all "up*" topics based on the subscriber
unifiedPushTopicLength = 14 // Length of UnifiedPush topics, including the "up" part
messagesHistoryMax = 10 // Number of message count values to keep in memory
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
@@ -833,6 +820,41 @@ func (s *Server) handleMatrixDiscovery(w http.ResponseWriter) error {
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) {
start := time.Now()
t, err := fromContext[*topic](r, contextTopic)
@@ -911,24 +933,16 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
ev.Debug("Received message")
}
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
}
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.callPhone(v, 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 {
logvrm(v, r, m).Tag(tagPublish).Debug("Message delayed, will process later")
}
@@ -1027,18 +1041,10 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v *
m.Sender = v.IP()
m.User = v.MaybeUserID()
m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix()
// Publish to subscribers
if err := t.Publish(v, m); err != nil {
// Publish to subscribers, Firebase (for Android clients), and web push endpoints
if err := s.dispatch(v, t, m, dispatchOpts{firebase: true, webPush: true}); err != nil {
return err
}
// Send to Firebase for Android clients
if s.firebaseClient != nil {
go s.sendToFirebase(v, m)
}
// Send to web push endpoints
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
}
if event == model.MessageDeleteEvent {
// Delete any existing scheduled message with the same sequence ID
deletedIDs, err := s.messageCache.DeleteScheduledBySequenceID(t.ID, sequenceID)
@@ -1141,6 +1147,9 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
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")
icon := readParam(r, "x-icon", "icon")
filename := readParam(r, "x-filename", "filename", "file", "f")
@@ -1207,6 +1216,14 @@ func (s *Server) parsePublishParams(r *http.Request, m *model.Message) (cache bo
priorityStr = "" // Clear since it's already parsed
}
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")
if delayStr != "" {
if !cache {
@@ -1418,6 +1435,10 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
}
var wlock sync.Mutex
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() {
// 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
@@ -1432,16 +1453,22 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
if !filters.Pass(msg) {
return nil
}
m, err := encoder(msg)
encoded, err := encoder(msg)
if err != nil {
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()
defer wlock.Unlock()
if closed {
return nil
}
if _, err := w.Write([]byte(m)); err != nil {
if _, err := w.Write([]byte(encoded)); err != nil {
return err
}
if fl, ok := w.(http.Flusher); ok {
@@ -1458,7 +1485,8 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *
for _, t := range topics {
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())
defer cancel()
@@ -1474,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
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
}
for {
@@ -1609,7 +1637,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi
for _, t := range topics {
t.Keepalive()
}
return s.sendOldMessages(topics, since, scheduled, v, sub)
return s.sendOldMessages(w, topics, since, scheduled, v, sub)
}
subscriberIDs := make([]int, 0)
for _, t := range topics {
@@ -1623,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
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
}
err = g.Wait()
@@ -1718,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
// 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() {
return nil
}
messages := make([]*model.Message, 0)
truncated := false
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 {
return err
}
truncated = truncated || topicTruncated
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
})
// 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 {
if err := sub(v, m); err != nil {
return err
@@ -1987,24 +2024,18 @@ func (s *Server) sendDelayedMessages() error {
func (s *Server) sendDelayedMessage(v *visitor, m *model.Message) error {
logvm(v, m).Debug("Sending delayed message")
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()
if ok {
go func() {
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler
if err := t.Publish(v, m); err != nil {
logvm(v, m).Err(err).Warn("Unable to publish message")
}
}()
}
if s.firebaseClient != nil { // Firebase subscribers may not show up in topics map
go s.sendToFirebase(v, m)
}
if s.config.UpstreamBaseURL != "" {
go s.forwardPollRequest(v, m)
}
if s.config.WebPushPublicKey != "" {
go s.publishToWebPushEndpoints(v, m)
// We do not rate-limit messages here, since we've rate limited them in the PUT/POST handler.
// Firebase subscribers may not show up in the topics map, so side effects fire regardless.
err := s.dispatch(v, t, m, dispatchOpts{
firebase: true,
upstream: true,
webPush: true,
async: true,
})
if err != nil {
return err
}
if err := s.messageCache.MarkPublished(m); err != nil {
return err
+4 -1
View File
@@ -417,7 +417,10 @@
# 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-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-daily-bandwidth-limit: "500M"
+1 -1
View File
@@ -985,7 +985,7 @@ func (s *Server) publishSyncEventForUser(v *visitor, u *user.User) error {
return err
}
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 nil
+51 -1
View File
@@ -3,12 +3,16 @@ package server
import (
"bytes"
"context"
"embed"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"text/template/parse"
"time"
"gopkg.in/yaml.v2"
"heckel.io/ntfy/v2/model"
@@ -17,6 +21,32 @@ import (
"heckel.io/ntfy/v2/util/sprig"
)
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))
if err != nil {
@@ -106,11 +136,14 @@ func (s *Server) renderTemplateFromParams(ctx context.Context, m *model.Message,
// renderTemplate renders a template with the given JSON source data.
func (s *Server) renderTemplate(ctx context.Context, name, tpl, source string) (string, error) {
if len(tpl) > templateMaxTemplateBytes {
return "", errHTTPBadRequestTemplateTooLarge
}
var data any
if err := json.Unmarshal([]byte(source), &data); err != nil {
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 {
return "", errHTTPBadRequestTemplateInvalid.Wrap("%s", err.Error())
}
@@ -168,6 +201,8 @@ func treeContainsDisallowedNode(node parse.Node) bool {
return treeContainsDisallowedNode(n.Pipe) || treeContainsDisallowedNode(n.List) || treeContainsDisallowedNode(n.ElseList)
case *parse.TemplateNode: // {{template}} or {{block}} invocation
return true
case *parse.ChainNode: // A term followed by field accesses, e.g. (call .x).y
return treeContainsDisallowedNode(n.Node)
case *parse.PipeNode:
if n == nil {
return false
@@ -188,3 +223,18 @@ func treeContainsDisallowedNode(node parse.Node) bool {
}
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)
})
}
+196
View File
@@ -16,6 +16,7 @@ import (
"os"
"path/filepath"
"runtime/debug"
"strconv"
"strings"
"sync"
"sync/atomic"
@@ -2810,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) {
forEachBackend(t, func(t *testing.T, databaseURL string) {
content := util.RandomString(5000) // > 4096
+11
View File
@@ -29,6 +29,17 @@ type publishMessage struct {
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
type messageEncoder func(msg *model.Message) (string, error)
+1 -1
View File
@@ -65,7 +65,7 @@ type visitor struct {
callsLimiter *util.FixedLimiter // Rate limiter for calls
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
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
authLimiter *rate.Limiter // Limiter for incorrect login attempts, may be nil
firebase time.Time // Next allowed Firebase message
+1 -1
View File
@@ -1 +1 @@
go1.26.5
go1.27.0
+5 -3
View File
@@ -167,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.
// 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
// 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,
// so calls can be chained.
// The return value is the template, so calls can be chained.
func (t *Template) Funcs(funcMap FuncMap) *Template {
t.init()
t.muFuncs.Lock()
+1
View File
@@ -12,6 +12,7 @@ const (
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
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.
+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.
// It has a safety limit to prevent excessive memory usage.
//
// Parameters:
// - spaces: The number of spaces to add
@@ -123,7 +124,13 @@ func cat(v ...any) string {
//
// Returns:
// - string: The indented string
//
// Panics:
// - If spaces exceeds indentSpacesLimit
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)
return pad + strings.Replace(v, "\n", "\n"+pad, -1)
}
+20
View File
@@ -4,6 +4,7 @@ import (
"encoding/base32"
"encoding/base64"
"fmt"
"strings"
"testing"
"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) {
tpl := `{{"I Am Henry VIII" | replace " " "-"}}`
if err := runt(tpl, "I-Am-Henry-VIII"); err != nil {
+551 -693
View File
File diff suppressed because it is too large Load Diff
+38 -37
View File
@@ -9,44 +9,44 @@
"action_bar_account": "Konts",
"action_bar_profile_title": "Profils",
"action_bar_profile_settings": "Iestatījumi",
"action_bar_profile_logout": "Iziet",
"action_bar_profile_logout": "Atteikties",
"nav_button_account": "Konts",
"nav_button_settings": "Iestatījumi",
"nav_button_documentation": "Dokumentācija",
"nav_button_connecting": "savienojas",
"notifications_list_item": "Paziņojums",
"notifications_delete": "Dzēst",
"notifications_delete": "Izdzēst",
"notifications_tags": "Birkas",
"notifications_example": "Piemērs",
"publish_dialog_title_label": "Virsraksts",
"publish_dialog_message_label": "Ziņojums",
"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_filename_label": "Datnes nosaukums",
"publish_dialog_delay_label": "Aizkave",
"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_subscribe": "Abonēt",
"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_username_title": "Lietotājvārds",
"account_basics_password_title": "Parole",
"account_basics_phone_numbers_dialog_channel_sms": "Nosūtīt īsziņu",
"account_basics_phone_numbers_dialog_channel_call": "Zvanīt",
"account_basics_phone_numbers_dialog_channel_sms": "Īsziņa",
"account_basics_phone_numbers_dialog_channel_call": "Zvans",
"account_usage_title": "Lietojums",
"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_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_change_button": "Mainīt",
"account_delete_dialog_label": "Parole",
"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_tier_price_per_month": "mēnesī",
"account_upgrade_dialog_tier_selected_label": "Atlasīts",
@@ -62,25 +62,25 @@
"prefs_users_dialog_password_label": "Parole",
"prefs_appearance_title": "Izskats",
"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_access_header": "Piekļuve",
"prefs_reservations_dialog_topic_label": "Tēma",
"prefs_reservations_dialog_access_label": "Piekļuve",
"priority_min": "minimālā",
"priority_low": "zema",
"priority_min": "viszemākais",
"priority_low": "zems",
"priority_default": "noklusējuma",
"priority_high": "augsta",
"priority_max": "maksimālā",
"signup_form_confirm_password": "Atkārtot paroli",
"priority_high": "augsts",
"priority_max": "visaugstākais",
"signup_form_confirm_password": "Apstiprināt paroli",
"signup_form_button_submit": "Izveidot kontu",
"login_link_signup": "Izveidot kontu",
"action_bar_show_menu": "Rādīt izvēlni",
"action_bar_logo_alt": "ntfy logotips",
"action_bar_reservation_add": "Rezervēt tēmu",
"action_bar_reservation_edit": "Mainīt rezervāciju",
"action_bar_reservation_delete": "Noņemt rezervāciju",
"action_bar_reservation_limit_reached": "Sasniegts limits",
"action_bar_reservation_add": "Aizņemt tēmu",
"action_bar_reservation_edit": "Mainīt aizņemšanu",
"action_bar_reservation_delete": "Noņemt aizņemšanu",
"action_bar_reservation_limit_reached": "Sasniegts ierobežojums",
"action_bar_mute_notifications": "Apklusināt paziņojumus",
"action_bar_sign_up": "Izveidot kontu",
"message_bar_publish": "Publicēt ziņojumu",
@@ -88,38 +88,39 @@
"nav_button_all_notifications": "Visi paziņojumi",
"nav_button_publish_message": "Publicēt paziņojumu",
"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_priority_x": "{{priority}} prioritāte",
"notifications_priority_x": "{{priority}} svarīgums",
"notifications_new_indicator": "Jauns paziņojums",
"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_file_image": "attēla datne",
"notifications_attachment_file_video": "video datne",
"notifications_attachment_file_audio": "audio datne",
"notifications_attachment_file_document": "cits datnes tips",
"notifications_click_copy_url_button": "Kopēt saiti",
"notifications_attachment_file_audio": "skaņas datne",
"notifications_attachment_file_document": "cits datnes veids",
"notifications_click_copy_url_button": "Ievietot saiti starpliktuvē",
"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_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_priority_min": "Minimāla prioritāte",
"publish_dialog_priority_low": "Zema prioritāte",
"publish_dialog_priority_default": "Noklusējuma prioritāte",
"publish_dialog_priority_high": "Augsta prioritāte",
"publish_dialog_priority_max": "Maksimāla prioritāte",
"publish_dialog_base_url_label": "Pakalpojuma URL adrese",
"publish_dialog_priority_min": "Viszemākais svarīgums",
"publish_dialog_priority_low": "Zems svarīgums",
"publish_dialog_priority_default": "Noklusējuma svarīgums",
"publish_dialog_priority_high": "Augsts svarīgums",
"publish_dialog_priority_max": "Visaugstākais svarīgums",
"publish_dialog_base_url_label": "Pakalpojuma URL",
"publish_dialog_topic_label": "Tēmas nosaukums",
"publish_dialog_topic_reset": "Atiestatīt tēmu",
"publish_dialog_click_label": "Klikšķināma URL adrese",
"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_other_features": "Citas funkcijas:",
"publish_dialog_other_features": "Citas iespējas:",
"publish_dialog_chip_call_label": "Tālruņa zvans",
"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_basics_phone_numbers_dialog_verify_button_sms": "Enviar SMS",
"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_phone_numbers_no_phone_numbers_yet": "Ainda não há números de telefone",
"subscribe_dialog_subscribe_button_generate_topic_name": "Gerar nome",
@@ -244,7 +244,7 @@
"account_usage_title": "Uso",
"account_basics_tier_upgrade_button": "Atualizar para Pro",
"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_description": "Nível de poder da sua conta",
"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_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_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_light": "Светлая",
"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_change_button": "Değiştir",
"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_no_tier": "(seviye yok)",
"account_basics_tier_admin_suffix_with_tier": "{{tier}} seviyesiyle",
"account_basics_tier_admin_suffix_no_tier": "seviye yok",
"account_basics_tier_manage_billing_button": "Faturalandırmayı yönet",
"account_usage_reservations_title": "Ayırtılan konular",
"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",
"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_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_no_topic": "Опублікувати сповіщення",
"publish_dialog_progress_uploading": "Завантаження…",
"publish_dialog_message_published": "Сповіщення опубліковано",
"publish_dialog_message_published": "Сповіщення опубліковане",
"publish_dialog_attachment_limits_quota_reached": "перевищує квоту, залишилося {{remainingBytes}}",
"publish_dialog_priority_low": "Низький пріоритет",
"publish_dialog_topic_label": "Назва теми",
"publish_dialog_topic_placeholder": "Назва теми, наприклад phil_alerts",
"publish_dialog_topic_reset": "Скинути тему",
"publish_dialog_title_label": "Заголовок",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостатньо місця на диску",
"publish_dialog_title_placeholder": "Заголовок сповіщення, наприклад, Попередження про недостачу місця на диску",
"publish_dialog_message_label": "Повідомлення",
"publish_dialog_message_placeholder": "Введіть повідомлення",
"publish_dialog_tags_label": "Теги",
@@ -276,15 +276,15 @@
"publish_dialog_call_placeholder": "Номер телефону, на який потрібно зателефонувати з повідомленням, наприклад, +12223334444 або \"yes\"",
"publish_dialog_chip_call_label": "Телефонний дзвінок",
"publish_dialog_call_reset": "Видалити телефонний дзвінок",
"account_basics_phone_numbers_dialog_description": "Щоб користуватися функцією сповіщення про дзвінки, потрібно додати та верифікувати принаймні один телефонний номер. Верифікацію можна здійснити за допомогою SMS або телефонного дзвінка.",
"account_basics_phone_numbers_dialog_description": "Щоб скористатися функцією сповіщень про дзвінки, вам потрібно додати та підтвердити принаймні один номер телефону. Підтвердження можна здійснити за допомогою SMS або телефонного дзвінка.",
"account_delete_dialog_description": "Це призведе до остаточного видалення вашого облікового запису, включаючи всі дані, які зберігаються на сервері. Після видалення ваше ім'я користувача буде недоступне протягом 7 днів. Якщо ви дійсно хочете продовжити, будь ласка, підтвердьте свій пароль у полі нижче.",
"account_basics_tier_upgrade_button": "Оновлення до Pro",
"account_basics_password_description": "Зміна пароля облікового запису",
"account_usage_of_limit": "з {{limit}}",
"account_usage_unlimited": "Без обмежень",
"account_basics_tier_description": "Рівень потужності вашого облікового запису",
"account_basics_tier_admin_suffix_with_tier": "(з рівнем {{tier}})",
"account_basics_tier_admin_suffix_no_tier": "(без рівня)",
"account_basics_tier_admin_suffix_with_tier": "з рівнем {{tier}}",
"account_basics_tier_admin_suffix_no_tier": "без рівня",
"account_basics_tier_basic": "Базовий",
"account_basics_tier_free": "Безкоштовний",
"account_basics_tier_change_button": "Змінити",
@@ -406,5 +406,59 @@
"web_push_unknown_notification_body": "Можливо вам потрібно оновити ntfy шляхом відкриття вебзастосунку",
"alert_notification_ios_install_required_title": "Необхідне встановлення на iOS",
"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": "Створений конфігурацією"
}