Compare commits

..
Author SHA1 Message Date
binwiederhier 869f005136 Review 2026-06-01 21:15:43 -04:00
binwiederhier d2507dbeed Again: Manual review 2026-06-01 21:09:56 -04:00
binwiederhier ae27172f8d Add trace logs 2026-06-01 20:30:05 -04:00
binwiederhier e18329a17e Log changes 2026-06-01 14:08:33 -04:00
binwiederhier 104182a8be Log reload 2026-06-01 12:47:21 -04:00
binwiederhier 7614405332 Wire up server.yml 2026-05-31 21:50:57 -04:00
binwiederhier 4196e6444c Stringbuilder 2026-05-31 21:31:23 -04:00
binwiederhier a2dc290f31 Review 2026-05-31 21:25:53 -04:00
binwiederhier 0e2c459d6b Further review 2026-05-31 15:42:12 -04:00
binwiederhier 2f4afbdae5 Manual refinements 2026-05-31 15:26:28 -04:00
binwiederhier 204723f3c0 Make opt-in flag 2026-05-31 14:28:04 -04:00
binwiederhier 301be79f0a Per-user reload 2026-05-31 14:11:55 -04:00
binwiederhier 03d405ed80 Rename acl cahce 2026-05-31 11:42:54 -04:00
binwiederhier 4b87a27326 Docblock, more tests 2026-05-31 11:22:30 -04:00
binwiederhier d987796243 Rename 2026-05-31 11:08:30 -04:00
binwiederhier c841caa3b3 Review, rename to pattern 2026-05-31 11:05:27 -04:00
binwiederhier 6310e3a96f Replace atomic.Pointer for readbility 2026-05-31 10:45:10 -04:00
binwiederhier 62a812b742 Merge branch 'main' of github.com:binwiederhier/ntfy into access-cache-1 2026-05-31 10:19:44 -04:00
Philipp C. Heckel 5c5766b031 Merge pull request #1768 from nihalgonsalves/ng/pinact
security: pin GitHub Actions
2026-05-30 18:25:38 -04:00
Nihal Gonsalves 2d2b1635fe chore: group github action updates 2026-05-30 23:34:18 +02:00
Nihal Gonsalves c51a3c0cb1 security: pin GitHub Actions 2026-05-30 23:32:41 +02:00
binwiederhier 6481713626 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-05-30 13:23:17 -04:00
binwiederhier 561a44b29b Docs 2026-05-30 13:23:06 -04:00
Philipp C. Heckel edc504b47a Merge pull request #1720 from dmitrylyzo/fix-noreferrer
Fix opening links with noreferrer
2026-05-30 13:22:31 -04:00
Philipp C. Heckel 0635e1efdb Merge pull request #1759 from binwiederhier/dependabot/github_actions/actions/setup-go-6
Bump actions/setup-go from 4 to 6
2026-05-30 13:14:43 -04:00
Philipp C. Heckel 8831d2f87f Merge pull request #1741 from nexus-uw/patch-1
Add directories for attachment, mail, and s3 to Dockerfile-build
2026-05-30 13:14:09 -04:00
dependabot[bot] 2215479294 Bump actions/setup-go from 4 to 6
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 4 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 17:11:55 +00:00
Philipp C. Heckel aab944fc39 Merge pull request #1763 from binwiederhier/dependabot/github_actions/actions/setup-node-6
Bump actions/setup-node from 3 to 6
2026-05-30 13:11:47 -04:00
Philipp C. Heckel a829d1a4e1 Merge pull request #1760 from binwiederhier/dependabot/github_actions/docker/login-action-4
Bump docker/login-action from 2 to 4
2026-05-30 13:10:51 -04:00
Philipp C. Heckel 965942844a Merge pull request #1761 from binwiederhier/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 3 to 6
2026-05-30 13:10:01 -04:00
dependabot[bot] 4dd7d1cd62 Bump actions/setup-node from 3 to 6
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 17:09:50 +00:00
Philipp C. Heckel f22f913590 Merge pull request #1754 from rubixvi/patch-1
Add new blog and forum posts for ntfy integration
2026-05-30 13:08:39 -04:00
binwiederhier f55886e3cc Pipeline fixes 2026-05-30 13:07:41 -04:00
dependabot[bot] b908213f02 Bump actions/checkout from 3 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 16:56:24 +00:00
dependabot[bot] 6596551bc1 Bump docker/login-action from 2 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-30 16:56:19 +00:00
binwiederhier 5d6b864130 Merge branch 'main' of github.com:binwiederhier/ntfy 2026-05-30 12:55:34 -04:00
binwiederhier 36ab5b3a8b Fmt 2026-05-30 12:55:22 -04:00
binwiederhier 9f217d9d40 Bump 2026-05-30 12:55:14 -04:00
Philipp C. Heckel 360b40ec07 Merge pull request #1758 from simoneferrari/patch-1
docs: add ntfy Desktop (Windows) to CLIs + GUIs
2026-05-30 12:51:31 -04:00
binwiederhier a3f0f6cfa0 Dependabot protection 2026-05-30 12:44:38 -04:00
binwiederhier 0c819a003d Release notes 2026-05-30 12:35:51 -04:00
binwiederhier ef0dde8aa4 PWA token extend 2026-05-28 21:58:01 -04:00
binwiederhier 85abd40658 Merge branch 'main' of github.com:binwiederhier/ntfy into ng/pwa-token-extend 2026-05-28 20:51:14 -04:00
s1m0 fda636fe34 Update integrations.md with addtional ntfy GUI 2026-05-29 00:24:43 +03:00
binwiederhier 25520c4505 WIP: Access cache 2026-05-28 17:13:14 -04:00
Vincent Vu 6796f6147b Fix date for WHM push alerts blog post
Updated the date for the blog post on WHM push alerts.
2026-05-26 17:44:29 +10:00
Vincent Vu 134c4dd079 Add new blog and forum posts for ntfy integration 2026-05-26 17:30:58 +10:00
binwiederhier 47044c632e iOS release notes 2026-05-20 15:26:15 -04:00
Simon Ramsay 9ccad9da2e Add directories for attachment, mail, and s3 to Dockerfile-build
Added new directories for attachment, mail, and s3 to the build process.

build was failing with 

>16.61     │ server/server.go:39:2: no required module provides package heckel.io/ntfy/v2/mail; to add it:
2026-05-15 23:09:56 -07:00
Dmitry Lyzo 6ba3b7c8be Fix opening links with noreferrer
rel="noreferrer" has the same effect as rel="noopener", but also
prevents the Referer header from being sent to the new page.
[https://mui.com/material-ui/react-link/#security]

If this feature is set, the browser will omit the Referer header,
as well as set noopener to true.
[https://developer.mozilla.org/en-US/docs/Web/API/Window/open#noreferrer]
2026-04-28 11:38:56 +03:00
Nihal Gonsalves 6a7c1c47aa fix: token extension for PWAs 2026-03-22 12:27:39 +01:00
35 changed files with 1268 additions and 171 deletions
+26
View File
@@ -0,0 +1,26 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/web"
schedule:
interval: "weekly"
cooldown:
default-days: 7
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
groups:
all:
patterns:
- "*"
+4 -4
View File
@@ -8,13 +8,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.25.x'
go-version: '1.26.x'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
+2 -2
View File
@@ -9,10 +9,10 @@ jobs:
steps:
-
name: Checkout ntfy code
uses: actions/checkout@v3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
name: Checkout docs pages code
uses: actions/checkout@v3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: binwiederhier/ntfy-docs.github.io
path: build/ntfy-docs.github.io
+5 -5
View File
@@ -25,19 +25,19 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.25.x'
go-version: '1.26.x'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: './web/package-lock.json'
- name: Docker login
uses: docker/login-action@v2
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ github.repository_owner }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
+4 -4
View File
@@ -25,13 +25,13 @@ jobs:
NTFY_TEST_S3_URL: ${{ secrets.NTFY_TEST_S3_URL }}
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: '1.25.x'
go-version: '1.26.x'
- name: Install node
uses: actions/setup-node@v3
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
cache: 'npm'
+3
View File
@@ -45,6 +45,9 @@ ADD ./db ./db
ADD ./message ./message
ADD ./model ./model
ADD ./webpush ./webpush
ADD ./attachment ./attachment
ADD ./mail ./mail
ADD ./s3 ./s3
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
@@ -151,7 +151,7 @@ web-deps:
# If this fails for .svg files, optimize them with svgo
web-deps-update:
cd web && $(NPM) update
cd web && $(NPM) update --before="$(shell date -d '7 days ago' +%Y-%m-%d)"
cd web && $(NPM) install
web-fmt:
+3
View File
@@ -52,6 +52,7 @@ var flagsServe = append(
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-users", Aliases: []string{"auth_users"}, EnvVars: []string{"NTFY_AUTH_USERS"}, Usage: "pre-provisioned declarative users"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-access", Aliases: []string{"auth_access"}, EnvVars: []string{"NTFY_AUTH_ACCESS"}, Usage: "pre-provisioned declarative access control entries"}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{Name: "auth-tokens", Aliases: []string{"auth_tokens"}, EnvVars: []string{"NTFY_AUTH_TOKENS"}, Usage: "pre-provisioned declarative access tokens"}),
altsrc.NewBoolFlag(&cli.BoolFlag{Name: "auth-access-cache", Aliases: []string{"auth_access_cache"}, EnvVars: []string{"NTFY_AUTH_ACCESS_CACHE"}, Value: user.DefaultAccessCacheEnabled, Usage: "enables the in-memory ACL cache (high-volume servers only)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", Aliases: []string{"attachment_cache_dir"}, EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT])"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"attachment_total_size_limit", "A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentTotalSizeLimit), Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"attachment_file_size_limit", "Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, Value: util.FormatSize(server.DefaultAttachmentFileSizeLimit), Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
@@ -168,6 +169,7 @@ func execServe(c *cli.Context) error {
authUsersRaw := c.StringSlice("auth-users")
authAccessRaw := c.StringSlice("auth-access")
authTokensRaw := c.StringSlice("auth-tokens")
authAccessCacheEnabled := c.Bool("auth-access-cache")
attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
@@ -468,6 +470,7 @@ func execServe(c *cli.Context) error {
conf.AuthUsers = authUsers
conf.AuthAccess = authAccess
conf.AuthTokens = authTokens
conf.AuthAccessCacheEnabled = authAccessCacheEnabled
conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit
+1
View File
@@ -378,6 +378,7 @@ func createUserManager(c *cli.Context) (*user.Manager, error) {
ProvisionEnabled: false, // Hack: Do not re-provision users on manager initialization
BcryptCost: user.DefaultUserPasswordBcryptCost,
QueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
AccessCacheEnabled: false, // Do not cache for CLI commands
}
if databaseURL != "" {
host, dbErr := pg.Open(databaseURL)
+5 -3
View File
@@ -1,14 +1,15 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/v2/server"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/user"
"os"
"path/filepath"
"testing"
)
func TestCLI_User_Add(t *testing.T) {
@@ -128,6 +129,7 @@ func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config,
conf.File = configFile
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthDefault = user.PermissionDenyAll
conf.AuthAccessCacheEnabled = false
s, port = test.StartServerWithConfig(t, conf)
return
}
+3 -1
View File
@@ -2284,6 +2284,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `cache-batch-timeout` | `NTFY_CACHE_BATCH_TIMEOUT` | *duration* | 0s | Timeout for batched async writes to the message cache (if zero, writes are synchronous) |
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control (SQLite). If set, enables authentication and access control. Not required if `database-url` is set. See [access control](#access-control). |
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | `read-write` | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `auth-access-cache` | `NTFY_AUTH_ACCESS_CACHE` | *bool* | false | Enables an in-memory ACL cache so authorization checks no longer hit the database. Only worth enabling on high-volume servers. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, use forwarded header (e.g. X-Forwarded-For, X-Client-IP) to determine visitor IP address (for rate limiting) |
| `proxy-forwarded-header` | `NTFY_PROXY_FORWARDED_HEADER` | *string* | `X-Forwarded-For` | Use specified header to determine visitor IP address (for rate limiting) |
| `proxy-trusted-hosts` | `NTFY_PROXY_TRUSTED_HOSTS` | *comma-separated host/IP/CIDR list* | - | Comma-separated list of trusted IP addresses, hosts, or CIDRs to remove from forwarded header |
@@ -2295,7 +2296,7 @@ variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-sender-verify` | `NTFY_SMTP_SENDER_VERIFY` | *bool* | `false` | If true, require verified email addresses for email notifications; anonymous email sending is disabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | *string* | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
@@ -2392,6 +2393,7 @@ OPTIONS:
--auth-file value, --auth_file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--auth-startup-queries value, --auth_startup_queries value queries run when the auth database is initialized [$NTFY_AUTH_STARTUP_QUERIES]
--auth-default-access value, --auth_default_access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--auth-access-cache, --auth_access_cache enables the in-memory ACL cache (high-volume servers only) (default: false) [$NTFY_AUTH_ACCESS_CACHE]
--attachment-cache-dir value, --attachment_cache_dir value cache directory for attached files, or S3 URL (s3://ACCESS_KEY:SECRET_KEY@BUCKET[/PREFIX]?region=REGION[&endpoint=ENDPOINT][&disable_http2=true]) [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-total-size-limit value, --attachment_total_size_limit value, -A value limit of the on-disk attachment cache (default: "5G") [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, --attachment_file_size_limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: "15M") [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
+1
View File
@@ -255,6 +255,7 @@ Reference: <https://stackoverflow.com/questions/34160509/options-for-testing-ser
go run main.go \
--log-level debug \
serve \
--base-url http://localhost \
--web-push-public-key KEY \
--web-push-private-key KEY \
--web-push-email-address <email> \
+2
View File
@@ -89,6 +89,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
- [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
- [ntfyr](https://github.com/haxwithaxe/ntfyr) - A simple commandline tool to send notifications to ntfy
@@ -191,6 +192,7 @@ I've added a ⭐ to projects or posts that have a significant following, or had
## Blog + forum posts
- [Push alerts for WHM using ntfy](https://rubixstudios.com.au/insights/push-alerts-for-whm-using-ntfy) - rubixstudios.com.au - 5/2026
- [Device notifications via HTTP with ntfy](https://alistairshepherd.uk/writing/ntfy/) - alistairshepherd.uk - 6/2025
- [Notifications about (almost) anything with ntfy.sh](https://hamatti.org/posts/notifications-about-almost-anything-with-ntfy-sh/) - hamatti.org - 6/2025
- [I set up a self-hosted notification service for everything, and I'll never look back](https://www.xda-developers.com/set-up-self-hosted-notification-service/) ⭐ - xda-developers.com - 5/2025
+1 -1
View File
@@ -932,7 +932,7 @@ Here's an example of how it will look on Android:
</figure>
## Attachments
_Supported on:_ :material-android: :material-firefox:
_Supported on:_ :material-android: :material-apple: :material-firefox:
You can **send images and other files to your phone** as attachments to a notification. The attachments are then downloaded
onto your phone (depending on size and setting automatically), and can be used from the Downloads folder.
+32 -2
View File
@@ -8,10 +8,29 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
|------------------|---------|--------------|
| ntfy server | v2.23.0 | May 17, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.6.0 | May 12, 2026 |
| ntfy iOS app | v1.7.0 | May 30, 2026 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
## ntfy iOS app v1.7.0
Released May 30, 2026
This release brings **image and attachment support** to the iOS app, finally closing one of the longest-standing iOS
feature gaps. Images sent via the `Attach` header (or as a PUT body) are now previewed inline in the notification banner
and inside the app, and other attachments can be downloaded, previewed via Quick Look, and shared from the notification
row. There's also a new "Download attachments" setting to control auto-download by size.
**Features:**
* Show image previews in notifications and inline in the notification list, with tap-to-zoom Quick Look preview and share sheet ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), [#276](https://github.com/binwiederhier/ntfy/issues/276), [#1226](https://github.com/binwiederhier/ntfy/issues/1226), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Download non-image attachments on demand with progress indication, persist them locally, and reuse files already fetched by the notification service extension ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Add "Download attachments" setting with size thresholds (Never, Under 100 KB / 500 KB / 1 MB / 5 MB / 10 MB / 50 MB, Always) to control automatic attachment downloads ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
**Bug fixes + maintenance:**
* Improve background download reliability so attachments continue downloading when the app is suspended ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
* Reorganize notification and subscription views into their own folders and split out `NotificationRowView` for readability ([ntfy-ios#40](https://github.com/binwiederhier/ntfy-ios/pull/40), thanks to [@am7590](https://github.com/am7590) for the contribution)
## ntfy server v2.23.0
Released May 17, 2026
@@ -1905,7 +1924,18 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
## ntfy Android v1.25.x (UNRELEASED)
### ntfy server v2.24.0 (UNRELEASED)
**Features:**
* Add opt-in in-memory ACL cache (`auth-access-cache`) that serves topic authorization without a database round-trip; off by default, intended for high-volume servers
**Bug fixes + maintenance:**
* Extend account token automatically from the PWA service worker, so installed PWAs don't get logged out ([#1669](https://github.com/binwiederhier/ntfy/pull/1669), [#1203](https://github.com/binwiederhier/ntfy/issues/1203), [#1533](https://github.com/binwiederhier/ntfy/issues/1533), thanks to [@nihalgonsalves](https://github.com/nihalgonsalves) for the contribution)
* Fix `rel` attribute on auto-linked notification URLs so `noreferrer`/`noopener` are actually applied ([#1720](https://github.com/binwiederhier/ntfy/pull/1720), thanks to [@dmitrylyzo](https://github.com/dmitrylyzo) for the contribution)
### ntfy Android v1.25.x (UNRELEASED)
This release makes the "connection lost" alert configurable and turns it off by default. Folks did not like it and many reached out
or even gave ntfy bad reviews. I heard you! You can re-enable the alert in the advanced settings.
+19 -20
View File
@@ -1,10 +1,10 @@
module heckel.io/ntfy/v2
go 1.25.0
go 1.25.8
require (
cloud.google.com/go/firestore v1.22.0 // indirect
cloud.google.com/go/storage v1.62.1 // indirect
cloud.google.com/go/storage v1.62.2 // 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
@@ -14,12 +14,12 @@ require (
github.com/olebedev/when v1.1.0
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
golang.org/x/crypto v0.51.0
golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0
golang.org/x/term v0.43.0
golang.org/x/time v0.15.0
google.golang.org/api v0.279.0
google.golang.org/api v0.282.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -34,7 +34,7 @@ require (
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/stripe/stripe-go/v74 v74.30.0
golang.org/x/sys v0.44.0
golang.org/x/sys v0.45.0
golang.org/x/text v0.37.0
)
@@ -69,7 +69,7 @@ require (
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.16 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -79,27 +79,26 @@ require (
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.68.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stretchr/objx v0.5.2 // 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.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/net v0.54.0 // 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.55.0 // indirect
google.golang.org/appengine/v2 v2.0.6 // indirect
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+38 -36
View File
@@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v1.0.0 h1:lwzWEYD8+NkYV7dhexOz6kmlvajZA70+bW/xMh
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
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.62.1 h1:Os0G3XbUbjZumkpDUf2Y0rLoXJTCF1kU2kWUujKYXD8=
cloud.google.com/go/storage v1.62.1/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
cloud.google.com/go/storage v1.62.2 h1:WgR4U9n7bIzXkkVnwPKKE8bkaKUNsHG+0MAAlh9DGU4=
cloud.google.com/go/storage v1.62.2/go.mod h1:cpYz/kRVZ+UQAF1uHeea10/9ewcRbxGoGNKsS9daSXA=
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=
@@ -96,8 +96,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas=
github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/enterprise-certificate-proxy v0.3.16 h1:F/VPrx0YPBdksZJQdCAp0WUsqnNmZpUZszzfYt0M5Dw=
github.com/googleapis/enterprise-certificate-proxy v0.3.16/go.mod h1:9Yb0eAkH/Xqhvv3zbeKf/+wMJqCeocWc6KIhDvEAuYE=
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/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -139,8 +139,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/common v0.68.0 h1:8rQJvQmYltsR2L7h8Zw0Iyj8WYNNmpwikoQTZXwfVeA=
github.com/prometheus/common v0.68.0/go.mod h1:4soH+U8yJSROk7OJ//hmTiWKsxapv6zRGgTt3keN8gQ=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -165,24 +165,26 @@ 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.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
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/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
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.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=
@@ -193,8 +195,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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=
@@ -209,8 +211,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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
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=
@@ -234,8 +236,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.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.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=
@@ -272,16 +274,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74=
google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ=
google.golang.org/api v0.282.0 h1:WmJiSVqUnKqJCpJOx7YADbXaC+9DDsnGSfllFSj7R2I=
google.golang.org/api v0.282.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM=
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-20260511170946-3700d4141b60 h1:rhBdfmsOlOZIvz3Y5/BdUzPg2CkO8L7QQPKj96B8554=
google.golang.org/genproto v0.0.0-20260511170946-3700d4141b60/go.mod h1:8xo2Pj1b20ZOCpzlU3B9qieMwVIAXx1QVZWLMlPL6sM=
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa h1:mfj8IS4EA4VAR9a6QDVxTQkLY64iBybb5QI1B4pXrpE=
google.golang.org/genproto v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:fuT7yonGw1Iq2oa+YC0fyqPPQJkgo/54gPNC6VitOkI=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
+4
View File
@@ -116,6 +116,8 @@ type Config struct {
AuthTokens map[string][]*user.Token
AuthBcryptCost int
AuthStatsQueueWriterInterval time.Duration
AuthAccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
AuthAccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
AttachmentCacheDir string
AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64
@@ -223,6 +225,8 @@ func NewConfig() *Config {
AuthDefault: user.PermissionReadWrite,
AuthBcryptCost: user.DefaultUserPasswordBcryptCost,
AuthStatsQueueWriterInterval: user.DefaultUserStatsQueueWriterInterval,
AuthAccessCacheEnabled: user.DefaultAccessCacheEnabled,
AuthAccessCacheReloadInterval: user.DefaultAccessCacheReloadInterval,
AttachmentCacheDir: "",
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,
+12 -10
View File
@@ -247,16 +247,18 @@ func New(conf *Config) (*Server, error) {
var userManager *user.Manager
if conf.AuthFile != "" || pool != nil {
authConfig := &user.Config{
Filename: conf.AuthFile,
DatabaseURL: conf.DatabaseURL,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
Users: conf.AuthUsers,
Access: conf.AuthAccess,
Tokens: conf.AuthTokens,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
Filename: conf.AuthFile,
DatabaseURL: conf.DatabaseURL,
StartupQueries: conf.AuthStartupQueries,
DefaultAccess: conf.AuthDefault,
ProvisionEnabled: true, // Enable provisioning of users and access
Users: conf.AuthUsers,
Access: conf.AuthAccess,
Tokens: conf.AuthTokens,
BcryptCost: conf.AuthBcryptCost,
QueueWriterInterval: conf.AuthStatsQueueWriterInterval,
AccessCacheEnabled: conf.AuthAccessCacheEnabled,
AccessCacheReloadInterval: conf.AuthAccessCacheReloadInterval,
}
if pool != nil {
userManager, err = user.NewPostgresManager(pool, authConfig)
+3
View File
@@ -116,6 +116,8 @@
# - auth-tokens is a list of access tokens that are automatically created when the server starts.
# Each entry is in the format "<username>:<token>[:<label>]", e.g. "phil:tk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef:My token".
# Use 'ntfy token generate' to generate a new access token.
# - auth-access-cache enables an in-memory snapshot of the access control table that authorizes every
# request without a database round-trip.
#
# Debian/RPM package users:
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
@@ -131,6 +133,7 @@
# auth-users:
# auth-access:
# auth-tokens:
# auth-access-cache: false
# If set, the X-Forwarded-For header (or whatever is configured in proxy-forwarded-header) is used to determine
# the visitor IP address instead of the remote address of the connection.
+252
View File
@@ -0,0 +1,252 @@
package user
import (
"regexp"
"strings"
"sync"
"time"
"heckel.io/ntfy/v2/db"
"heckel.io/ntfy/v2/log"
)
// accessCache is an in-memory index over the entire user_access table.
//
// exact[username][escapedTopic] returns the matching entry in O(1) for the common
// case where the requested topic appears verbatim in some rule. The key is the
// stored form of the topic (i.e. with \_ escapes), so Lookup escapes incoming
// topics through escapeUnderscore before probing.
//
// pattern[username] is the linear-scan list of %-bearing rules for that user.
// Walked per request; trivially small in practice. Wildcards are NOT u_everyone-
// only -- any user can create them.
type accessCache struct {
exact map[string]map[string]aclEntry
pattern map[string][]aclEntry
mu sync.RWMutex // Protect exact and pattern
}
// aclEntry mirrors one user_access row. length feeds better()'s "longer
// pattern wins" tie-break; the stored topic/pattern string itself is not kept
// on the entry (the exact map already keys on it; surfacing wildcard "topics"
// like "up%" alongside real ones would invite misuse). pattern is the
// compiled regex form of the LIKE pattern; nil for exact entries.
type aclEntry struct {
length int
pattern *regexp.Regexp
read bool
write bool
}
func newAccessCache() *accessCache {
return &accessCache{
exact: make(map[string]map[string]aclEntry),
pattern: make(map[string][]aclEntry),
}
}
// Lookup returns the effective (read, write, found) permission for the given
// (username, topic), preserving the priority ordering of the original SQL query:
// 1. specific user beats Everyone
// 2. longer pattern beats shorter (more specific wins)
// 3. write beats read at equal length (write is "stronger")
func (c *accessCache) Lookup(username, topic string) (read, write, found bool) {
escapedTopic := escapeUnderscore(topic)
c.mu.RLock()
if username != Everyone {
if entry, found := c.lookupNoLock(username, topic, escapedTopic); found {
c.mu.RUnlock()
maybeLogACLDecision(username, username, topic, entry.read, entry.write)
return entry.read, entry.write, true
}
}
if entry, found := c.lookupNoLock(Everyone, topic, escapedTopic); found {
c.mu.RUnlock()
maybeLogACLDecision(username, Everyone, topic, entry.read, entry.write)
return entry.read, entry.write, true
}
c.mu.RUnlock()
maybeLogACLDecision(username, "", topic, false, false)
return false, false, false
}
// Reload scans (user_name, topic, read, write) rows and merges them into the
// cache. With no usernames the cache is replaced wholesale; otherwise the
// query is invoked with those usernames as positional args and only the
// listed users' slices are touched (a username absent from the result drops
// them from both maps). Runs against the primary so a reload after a
// mutation sees the just-written rows.
func (c *accessCache) Reload(d *db.DB, query string, usernames ...string) error {
started := time.Now()
scope := "full"
if len(usernames) > 0 {
scope = "users=" + strings.Join(usernames, ",")
}
args := make([]any, len(usernames))
for i, u := range usernames {
args[i] = u
}
// Query the database for all ACL entries
rows, err := d.Query(query, args...)
if err != nil {
return err
}
defer rows.Close()
exacts := make(map[string]map[string]aclEntry)
patterns := make(map[string][]aclEntry)
updatedEntries := 0
for rows.Next() {
var username, escapedTopic string
var read, write bool
if err := rows.Scan(&username, &escapedTopic, &read, &write); err != nil {
return err
}
entry, hasWildcard, err := toACLEntry(escapedTopic, read, write)
if err != nil {
return err
}
if hasWildcard {
patterns[username] = append(patterns[username], entry)
} else {
if exacts[username] == nil {
exacts[username] = make(map[string]aclEntry)
}
exacts[username][escapedTopic] = entry
}
updatedEntries++
}
if err := rows.Err(); err != nil {
return err
}
// Replace or update the internal maps
c.mu.Lock()
if len(usernames) == 0 {
c.exact = exacts
c.pattern = patterns
} else {
for _, u := range usernames {
if e, ok := exacts[u]; ok {
c.exact[u] = e
} else {
delete(c.exact, u)
}
if p, ok := patterns[u]; ok {
c.pattern[u] = p
} else {
delete(c.pattern, u)
}
}
}
c.mu.Unlock()
log.Tag(tag).
Field("reload_scope", scope).
Field("updated_entries", updatedEntries).
Field("duration_ms", time.Since(started).Milliseconds()).
Debug("Reloaded ACL cache")
return nil
}
// lookupNoLock returns the highest-priority entry for a single user. When
// more than one of that user's rules matches the requested topic, the winner
// is chosen by:
//
// 1. longer stored pattern beats shorter (a more specific rule wins over a
// more general one)
// 2. at equal length, write beats read (a stronger permission wins the tie)
//
// Exact and wildcard rules are ranked together under the same criteria, so
// an exact "foo" (length 3) beats a wildcard "f%" (length 2), but a wildcard
// "foo%" (length 4) beats an exact "foo" (length 3).
func (c *accessCache) lookupNoLock(username, topic, escapedTopic string) (*aclEntry, bool) {
var best aclEntry
var found bool
if exact, exists := c.exact[username]; exists {
if entry, exists := exact[escapedTopic]; exists {
best, found = entry, true
}
}
for _, pattern := range c.pattern[username] {
if !pattern.pattern.MatchString(topic) {
continue
} else if !found || better(pattern, best) {
best, found = pattern, true
}
}
return &best, found
}
// toACLEntry builds an aclEntry from one user_access row's values. The
// isWildcard return tells the caller which storage slot the entry belongs in:
// the per-user wildcard slice if true, the per-user exact map if false.
// Wildcards have their LIKE pattern pre-compiled into entry.pattern; exact
// entries leave entry.pattern nil.
func toACLEntry(escapedTopic string, read, write bool) (entry aclEntry, hasWildcard bool, err error) {
entry = aclEntry{
length: len(escapedTopic),
read: read,
write: write,
}
if !strings.Contains(escapedTopic, "%") {
return entry, false, nil
}
pattern, err := compileLikeToRegex(escapedTopic)
if err != nil {
return entry, true, err
}
entry.pattern = pattern
return entry, true, nil
}
// better implements the (length DESC, write DESC) tie-break used by the original
// query's ORDER BY for entries owned by the same user.
func better(a, b aclEntry) bool {
if a.length != b.length {
return a.length > b.length
} else if a.write != b.write {
return a.write
}
return false
}
// compileLikeToRegex converts a stored ntfy LIKE pattern into an equivalent Go
// regexp. In ntfy's stored form, % is the only wildcard (translated from *) and
// \_ is a literal underscore; no other backslashes occur. Topics themselves are
// restricted to [A-Za-z0-9_-] (see AllowedTopic), so neither % nor stray
// backslashes appear in user-supplied input.
func compileLikeToRegex(pattern string) (*regexp.Regexp, error) {
var sb strings.Builder
sb.WriteString("^")
i := 0
for i < len(pattern) {
switch {
case pattern[i] == '\\' && i+1 < len(pattern) && pattern[i+1] == '_':
sb.WriteString(regexp.QuoteMeta("_"))
i += 2
case pattern[i] == '%':
sb.WriteString(".*")
i++
default:
sb.WriteString(regexp.QuoteMeta(string(pattern[i])))
i++
}
}
sb.WriteString("$")
return regexp.Compile(sb.String())
}
// maybeLogACLDecision logs an ACL lookup result
func maybeLogACLDecision(requestUser, matchedUser, topic string, read, write bool) {
ev := log.Tag(tag).
Field("user_name", requestUser).
Field("topic", topic).
Field("read", read).
Field("write", write)
if !ev.IsTrace() {
return
}
if matchedUser == "" {
ev.Trace("ACL no match")
return
}
ev.Field("matched_user", matchedUser).Trace("ACL match")
}
+312
View File
@@ -0,0 +1,312 @@
package user
import (
"regexp"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
// Cache-only unit tests. Integration with the Manager (loading from the DB,
// reload-after-mutation, end-to-end Authorize behavior) is covered by the
// existing TestStoreAuthorizeTopicAccess* tests in manager_test.go via
// forEachStoreBackend.
func TestCompileLikeToRegex_Exact(t *testing.T) {
r := mustCompileLikeToRegex(t, "foo")
require.True(t, r.MatchString("foo"))
require.False(t, r.MatchString("foox"))
require.False(t, r.MatchString("xfoo"))
}
func TestCompileLikeToRegex_TrailingPercent(t *testing.T) {
r := mustCompileLikeToRegex(t, "up%")
require.True(t, r.MatchString("up"))
require.True(t, r.MatchString("up123"))
require.False(t, r.MatchString("xup"))
}
func TestCompileLikeToRegex_LeadingAndEmbeddedPercent(t *testing.T) {
r := mustCompileLikeToRegex(t, "%test%")
require.True(t, r.MatchString("test"))
require.True(t, r.MatchString("mytest"))
require.True(t, r.MatchString("testxxx"))
require.True(t, r.MatchString("xtestx"))
require.False(t, r.MatchString("nope"))
}
func TestCompileLikeToRegex_EscapedUnderscore(t *testing.T) {
// "my\_topic" is the stored form of a literal "my_topic" -- the underscore
// must match itself, NOT act as a SQL one-character wildcard.
r := mustCompileLikeToRegex(t, `my\_topic`)
require.True(t, r.MatchString("my_topic"))
require.False(t, r.MatchString("myXtopic"))
require.False(t, r.MatchString("mytopic"))
}
func TestCompileLikeToRegex_EscapedUnderscoreAdjacentToPercent(t *testing.T) {
// "nz\_vip\_%" is the stored form of "nz_vip_*" -- literal "nz_vip_" prefix
// followed by any suffix.
r := mustCompileLikeToRegex(t, `nz\_vip\_%`)
require.True(t, r.MatchString("nz_vip_"))
require.True(t, r.MatchString("nz_vip_alpha"))
require.False(t, r.MatchString("nz_vipX"))
require.False(t, r.MatchString("nzvip_alpha"))
}
func TestCompileLikeToRegex_RegexMetaCharsInTopic(t *testing.T) {
// Topics in ntfy can include '-', which is benign, but make sure
// regex metacharacters in the pattern are escaped properly anyway.
r := mustCompileLikeToRegex(t, "foo-bar")
require.True(t, r.MatchString("foo-bar"))
require.False(t, r.MatchString("foo.bar")) // would match if '-' leaked into a character class
}
func TestACLCache_LookupBeforeReload(t *testing.T) {
// A freshly-constructed cache has empty exact and wildcards maps. The
// cache treats this as "no rule found", which the caller resolves via
// DefaultAccess.
c := newAccessCache()
read, write, found := c.Lookup("phil", "mytopic")
require.False(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_ExactMatchHit(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "mytopic", read: true, write: true},
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_ExactMatchMiss(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "mytopic", read: true, write: true},
})
_, _, found := c.Lookup("phil", "othertopic")
require.False(t, found)
}
func TestACLCache_LiteralUnderscoreExactMatch(t *testing.T) {
// Stored as "my\_topic" (toSQLWildcard of "my_topic"). A literal underscore
// in the requested topic must match, while any other single char must not.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: `my\_topic`, read: true, write: false},
})
read, write, found := c.Lookup("phil", "my_topic")
require.True(t, found)
require.True(t, read)
require.False(t, write)
_, _, found = c.Lookup("phil", "myXtopic")
require.False(t, found)
}
func TestACLCache_WildcardMatch(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "up%", read: false, write: true},
})
read, write, found := c.Lookup("phil", "up42")
require.True(t, found)
require.False(t, read)
require.True(t, write)
}
func TestACLCache_SpecificUserBeatsEveryone(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: false},
{user: "phil", topic: "mytopic", read: false, write: false}, // deny-all for phil
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_SpecificUserBeatsEveryoneEvenWhenShorter(t *testing.T) {
// The SQL's "user_name DESC" sort key takes precedence over LENGTH(topic).
// Concretely: a specific user with a shorter matching rule still wins over
// Everyone with a longer matching rule.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "foo", read: true, write: true}, // exact, length 3
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.False(t, read)
require.False(t, write)
}
func TestACLCache_SpecificUserBeatsEveryoneRegardlessOfWrite(t *testing.T) {
// Same-length rules but conflicting permissions across user boundary: the
// specific user always wins, even if its permission set is weaker (or
// stronger, in either direction).
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: true}, // wide-open
{user: "phil", topic: "mytopic", read: true, write: false}, // read-only for phil
})
read, write, found := c.Lookup("phil", "mytopic")
require.True(t, found)
require.True(t, read)
require.False(t, write)
}
func TestACLCache_AnonymousReadsEveryone(t *testing.T) {
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "announcements", read: true, write: false},
})
read, write, found := c.Lookup(Everyone, "announcements")
require.True(t, found)
require.True(t, read)
require.False(t, write)
}
func TestACLCache_LongerPatternWinsForSameUser(t *testing.T) {
// Both rules belong to the same user (Everyone). The more specific (longer)
// "mytopic%" should beat the catch-all "%".
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "%", read: true, write: false},
{user: Everyone, topic: "mytopic%", read: true, write: true},
})
read, write, found := c.Lookup(Everyone, "mytopicX")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_ExactBeatsShorterWildcardSameUser(t *testing.T) {
// Same user, two matching rules: exact "foo" (length 3) and wildcard "f%"
// (length 2). The longer one wins, which is the exact rule -- mirroring
// the SQL's "LENGTH(topic) DESC" tie-break. Crucially, the cache must seed
// "best" from the exact map probe before walking wildcards, otherwise a
// shorter wildcard could overwrite a longer exact.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "foo", read: true, write: true}, // exact, length 3
{user: "phil", topic: "f%", read: false, write: false}, // wildcard, length 2, deny-all
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_LongerWildcardBeatsExactSameUser(t *testing.T) {
// Same user, two matching rules: exact "foo" (length 3) and wildcard "foo%"
// (length 4). The wildcard wins on length DESC. Exercises the "swap best
// to wildcard when better() returns true" path.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: "phil", topic: "foo", read: false, write: false}, // exact, length 3, deny-all
{user: "phil", topic: "foo%", read: true, write: true}, // wildcard, length 4
})
read, write, found := c.Lookup("phil", "foo")
require.True(t, found)
require.True(t, read)
require.True(t, write)
}
func TestACLCache_WriteBeatsReadAtEqualLength(t *testing.T) {
// Two wildcard rules of identical length for the same user. The write rule
// should win the tie-break. The two-rows-with-same-topic shape is
// impossible via real upsert (pkey would conflict), so we inject the entries
// directly into the cache's wildcard slice.
c := newAccessCache()
c.mu.Lock()
c.exact = map[string]map[string]aclEntry{}
c.pattern = map[string][]aclEntry{
Everyone: {
{length: len("ab%"), read: true, write: false, pattern: mustCompileLikeToRegex(t, "ab%")},
{length: len("ab%"), read: false, write: true, pattern: mustCompileLikeToRegex(t, "ab%")},
},
}
c.mu.Unlock()
_, write, found := c.Lookup(Everyone, "abc")
require.True(t, found)
require.True(t, write)
}
func TestACLCache_ConcurrentLookupAndReload(t *testing.T) {
// Lock-based swap must be safe under concurrent reads. The race detector
// catches any unsafe shared mutation.
c := newAccessCache()
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: true, write: true},
})
var stop atomic.Bool
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for !stop.Load() {
_, _, _ = c.Lookup(Everyone, "mytopic")
}
}()
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
loadCache(t, c, []rawACLRow{
{user: Everyone, topic: "mytopic", read: i%2 == 0, write: i%2 == 1},
})
}
stop.Store(true)
}()
wg.Wait()
}
// rawACLRow models the rows that reload would Scan from the DB but avoids
// actually opening a DB for these unit tests.
type rawACLRow struct {
user string
topic string
read bool
write bool
}
// loadCache writes the given rows into the cache under its write lock,
// preserving the same exact/wildcard partitioning that reload would produce.
func loadCache(t *testing.T, c *accessCache, rows []rawACLRow) {
t.Helper()
exact := make(map[string]map[string]aclEntry)
wildcards := make(map[string][]aclEntry)
for _, r := range rows {
e := aclEntry{length: len(r.topic), read: r.read, write: r.write}
if strings.Contains(r.topic, "%") {
e.pattern = mustCompileLikeToRegex(t, r.topic)
wildcards[r.user] = append(wildcards[r.user], e)
} else {
if exact[r.user] == nil {
exact[r.user] = make(map[string]aclEntry)
}
exact[r.user][r.topic] = e
}
}
c.mu.Lock()
c.exact = exact
c.pattern = wildcards
c.mu.Unlock()
}
func mustCompileLikeToRegex(t *testing.T, pattern string) *regexp.Regexp {
t.Helper()
r, err := compileLikeToRegex(pattern)
require.NoError(t, err)
return r
}
+148 -27
View File
@@ -38,6 +38,8 @@ const (
const (
DefaultUserStatsQueueWriterInterval = 33 * time.Second
DefaultUserPasswordBcryptCost = 10
DefaultAccessCacheEnabled = false
DefaultAccessCacheReloadInterval = 87 * time.Second
)
var (
@@ -48,12 +50,14 @@ var (
// Manager handles user authentication, authorization, and management
type Manager struct {
config *Config
db *db.DB
queries queries
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
mu sync.Mutex
config *Config
db *db.DB
queries queries
statsQueue map[string]*Stats // "Queue" to asynchronously write user stats to the database (UserID -> Stats)
tokenQueue map[string]*TokenUpdate // "Queue" to asynchronously write token access stats to the database (Token ID -> TokenUpdate)
accessCache *accessCache // In-memory snapshot of user_access; refreshed by maybeReloadAccessCache after every ACL mutation
quit chan struct{} // Closed by Close() to signal background goroutines to stop
mu sync.Mutex
}
var _ Auther = (*Manager)(nil)
@@ -65,20 +69,65 @@ func newManager(d *db.DB, queries queries, config *Config) (*Manager, error) {
if config.QueueWriterInterval.Seconds() <= 0 {
config.QueueWriterInterval = DefaultUserStatsQueueWriterInterval
}
if config.AccessCacheReloadInterval <= 0 {
config.AccessCacheReloadInterval = DefaultAccessCacheReloadInterval
}
manager := &Manager{
config: config,
db: d,
statsQueue: make(map[string]*Stats),
tokenQueue: make(map[string]*TokenUpdate),
quit: make(chan struct{}),
queries: queries,
}
if err := manager.maybeProvisionUsersAccessAndTokens(); err != nil {
return nil, err
}
go manager.asyncQueueWriter(manager.config.QueueWriterInterval)
if config.AccessCacheEnabled {
manager.accessCache = newAccessCache()
if err := manager.maybeReloadAccessCache(); err != nil {
return nil, err
}
go manager.asyncAccessCacheReloadLoop(manager.config.AccessCacheReloadInterval)
}
go manager.asyncQueueWriteLoop(manager.config.QueueWriterInterval)
return manager, nil
}
// maybeReloadAccessCache refreshes the in-memory access cache from the
// primary database. No-op when the cache is disabled. With no usernames it
// does a full bulk reload; with one or more it refreshes only those users'
// slices in a single DB round-trip via an IN clause.
func (a *Manager) maybeReloadAccessCache(usernames ...string) error {
if a.accessCache == nil {
return nil
}
if len(usernames) == 0 {
return a.accessCache.Reload(a.db, a.queries.selectAccessCacheAll)
}
return a.accessCache.Reload(a.db, a.queries.selectAccessCacheUsers(len(usernames)), usernames...)
}
// asyncAccessCacheReloadLoop periodically bulk-reloads the access cache so that
// writes made by other processes against the same database (most notably the
// `ntfy access` CLI subcommand running while a server holds the cache) become
// visible within the configured interval. This Manager's own mutations do
// not depend on the poller -- they refresh affected users synchronously.
func (a *Manager) asyncAccessCacheReloadLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-a.quit:
return
case <-ticker.C:
if err := a.maybeReloadAccessCache(); err != nil {
log.Tag(tag).Err(err).Warn("Reloading ACL cache failed")
}
}
}
}
// Authenticate checks username and password and returns a User if correct, and the user has not been
// marked as deleted. The method returns in constant-ish time, regardless of whether the user exists or
// the password is correct or incorrect.
@@ -151,9 +200,14 @@ func (a *Manager) RemoveUser(username string) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.removeUserTx(tx, username)
})
if err != nil {
return err
}
// Reload user-specific parts of the access cache
return a.maybeReloadAccessCache(username, Everyone)
}
// removeUserTx deletes the user with the given username
@@ -174,7 +228,7 @@ func (a *Manager) MarkUserRemoved(user *User) error {
if !AllowedUsername(user.Name) {
return ErrInvalidArgument
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
if err := a.resetUserAccessTx(tx, user.Name); err != nil {
return err
}
@@ -187,14 +241,27 @@ func (a *Manager) MarkUserRemoved(user *User) error {
}
return nil
})
if err != nil {
return err
}
// Reload user-specific parts of the access cache
return a.maybeReloadAccessCache(user.Name, Everyone)
}
// RemoveDeletedUsers deletes all users that have been marked deleted
func (a *Manager) RemoveDeletedUsers() error {
if _, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix()); err != nil {
res, err := a.db.Exec(a.queries.deleteUsersMarked, time.Now().Unix())
if err != nil {
return err
}
return nil
affected, err := res.RowsAffected()
if err != nil {
return err
} else if affected == 0 {
return nil
}
// Full cache reload, because we don't know which users were affected.
return a.maybeReloadAccessCache()
}
// ChangePassword changes a user's password
@@ -225,9 +292,14 @@ func (a *Manager) ChangeRole(username string, role Role) error {
if err := a.CanChangeUser(username); err != nil {
return err
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.changeRoleTx(tx, username, role)
})
if err != nil {
return err
}
// Full cache reload: Role changes are extremely rare.
return a.maybeReloadAccessCache()
}
// changeRoleTx changes a user's role
@@ -351,14 +423,20 @@ func (a *Manager) EnqueueUserStats(userID string, stats *Stats) {
a.statsQueue[userID] = stats
}
func (a *Manager) asyncQueueWriter(interval time.Duration) {
func (a *Manager) asyncQueueWriteLoop(interval time.Duration) {
ticker := time.NewTicker(interval)
for range ticker.C {
if err := a.writeUserStatsQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
}
if err := a.writeTokenUpdateQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing token update queue failed")
defer ticker.Stop()
for {
select {
case <-a.quit:
return
case <-ticker.C:
if err := a.writeUserStatsQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing user stats queue failed")
}
if err := a.writeTokenUpdateQueue(); err != nil {
log.Tag(tag).Err(err).Warn("Writing token update queue failed")
}
}
}
}
@@ -588,9 +666,14 @@ func (a *Manager) resolvePerms(base, perm Permission) error {
// read/write access to a topic. The parameter topicPattern may include wildcards (*). The ACL entry
// owner may either be a user (username), or the system (empty).
func (a *Manager) AllowAccess(username string, topicPattern string, permission Permission) error {
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.allowAccessTx(tx, username, topicPattern, permission, false)
})
if err != nil {
return err
}
// Only this user's row set changed; refresh their slice only.
return a.maybeReloadAccessCache(username)
}
func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string, permission Permission, provisioned bool) error {
@@ -606,9 +689,20 @@ func (a *Manager) allowAccessTx(tx *sql.Tx, username string, topicPattern string
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
func (a *Manager) ResetAccess(username string, topicPattern string) error {
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
return a.resetAccessTx(tx, username, topicPattern)
})
if err != nil {
return err
}
// Empty username -> deleteAllAccess affected every user, bulk reload.
// Otherwise refresh the named user plus Everyone, since resetUserAccessTx
// and deleteTopicAccess both touch rows owned by the user (typically the
// Everyone row from their reservations).
if username == "" {
return a.maybeReloadAccessCache()
}
return a.maybeReloadAccessCache(username, Everyone)
}
func (a *Manager) resetAccessTx(tx *sql.Tx, username string, topicPattern string) error {
@@ -650,10 +744,20 @@ func (a *Manager) AllowReservation(username string, topic string) error {
// authorizeTopicAccess returns the read/write permissions for the given username and topic.
// The found return value indicates whether an ACL entry was found at all.
//
// - The query may return two rows (one for everyone, and one for the user), but prioritizes the user.
// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*"
// - It also prioritizes write permissions over read permissions
// Priority:
// - Specific user beats Everyone
// - Longer pattern beats shorter (a more specific rule beats a more general one,
// e.g. "test*" > "*")
// - Write beats read at equal length
//
// When AccessCacheEnabled is true (config), the lookup is served entirely from
// the in-memory snapshot maintained by accessCache. Otherwise the original SQL
// query is executed against the database on every call.
func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) {
if a.accessCache != nil {
read, write, found = a.accessCache.Lookup(usernameOrEveryone, topic)
return read, write, found, nil
}
rows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)
if err != nil {
return false, false, false, err
@@ -731,7 +835,7 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) {
return ErrInvalidArgument
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
if limit > 0 {
hasReservation, err := a.hasReservationTx(tx, username, topic)
if err != nil {
@@ -755,6 +859,11 @@ func (a *Manager) AddReservation(username string, topic string, everyone Permiss
}
return nil
})
if err != nil {
return err
}
// Both user's and Everyone's rows changed.
return a.maybeReloadAccessCache(username, Everyone)
}
// RemoveReservations deletes the access control entries associated with the given username/topic,
@@ -769,7 +878,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
return ErrInvalidArgument
}
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
err := db.ExecTx(a.db, func(tx *sql.Tx) error {
for _, topic := range topics {
if err := a.removeReservationAccessTx(tx, username, topic); err != nil {
return err
@@ -777,6 +886,12 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
}
return nil
})
if err != nil {
return err
}
// Mirror the DB: rows for this user and any Everyone rows owned by this
// user are gone. Refresh both slices.
return a.maybeReloadAccessCache(username, Everyone)
}
// Reservations returns all user-owned topics, and the associated everyone-access
@@ -1515,8 +1630,14 @@ func (a *Manager) maybeProvisionTokens(tx *sql.Tx, provisionUsernames []string,
return nil
}
// Close closes the underlying database
// Close stops background goroutines and closes the underlying database.
func (a *Manager) Close() error {
select {
case <-a.quit:
// Already closed
default:
close(a.quit)
}
return a.db.Close()
}
+25
View File
@@ -1,6 +1,9 @@
package user
import (
"fmt"
"strings"
"heckel.io/ntfy/v2/db"
)
@@ -77,6 +80,11 @@ const (
WHERE (u.user_name = $1 OR u.user_name = $2) AND $3 LIKE a.topic ESCAPE '\'
ORDER BY u.user_name DESC, LENGTH(a.topic) DESC, CASE WHEN a.write THEN 1 ELSE 0 END DESC
`
postgresSelectAccessCacheAllQuery = `
SELECT u.user_name, a.topic, a.read, a.write
FROM user_access a
JOIN "user" u ON u.id = a.user_id
`
postgresSelectUserAllAccessQuery = `
SELECT user_id, topic, read, write, provisioned
FROM user_access
@@ -221,6 +229,21 @@ const (
`
)
// postgresSelectAccessCacheUsersQuery builds the per-users cache-load query
// with a "$1, $2, ..." IN clause sized for n usernames.
func postgresSelectAccessCacheUsersQuery(n int) string {
var sb strings.Builder
sb.WriteString(`SELECT u.user_name, a.topic, a.read, a.write FROM user_access a JOIN "user" u ON u.id = a.user_id WHERE u.user_name IN (`)
for i := 0; i < n; i++ {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, "$%d", i+1)
}
sb.WriteString(")")
return sb.String()
}
// NewPostgresManager creates a new Manager backed by a PostgreSQL database using an existing connection pool.
var postgresQueries = queries{
selectUserByID: postgresSelectUserByIDQuery,
@@ -245,6 +268,8 @@ var postgresQueries = queries{
deleteUsersMarked: postgresDeleteUsersMarkedQuery,
deleteUsersProvisioned: postgresDeleteUsersProvisionedQuery,
selectTopicPerms: postgresSelectTopicPermsQuery,
selectAccessCacheAll: postgresSelectAccessCacheAllQuery,
selectAccessCacheUsers: postgresSelectAccessCacheUsersQuery,
selectUserAllAccess: postgresSelectUserAllAccessQuery,
selectUserAccess: postgresSelectUserAccessQuery,
selectUserReservations: postgresSelectUserReservationsQuery,
+23
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"fmt"
"path/filepath"
"strings"
_ "github.com/mattn/go-sqlite3" // SQLite driver
@@ -83,6 +84,11 @@ const (
WHERE (u.user = ? OR u.user = ?) AND ? LIKE a.topic ESCAPE '\'
ORDER BY u.user DESC, LENGTH(a.topic) DESC, a.write DESC
`
sqliteSelectAccessCacheAllQuery = `
SELECT u.user, a.topic, a.read, a.write
FROM user_access a
JOIN user u ON u.id = a.user_id
`
sqliteSelectUserAllAccessQuery = `
SELECT user_id, topic, read, write, provisioned
FROM user_access
@@ -220,6 +226,21 @@ const (
`
)
// sqliteSelectAccessCacheUsersQuery builds the per-users cache-load query
// with a "?, ?, ..." IN clause sized for n usernames.
func sqliteSelectAccessCacheUsersQuery(n int) string {
var sb strings.Builder
sb.WriteString(`SELECT u.user, a.topic, a.read, a.write FROM user_access a JOIN user u ON u.id = a.user_id WHERE u.user IN (`)
for i := 0; i < n; i++ {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString("?")
}
sb.WriteString(")")
return sb.String()
}
var sqliteQueries = queries{
selectUserByID: sqliteSelectUserByIDQuery,
selectUserByName: sqliteSelectUserByNameQuery,
@@ -243,6 +264,8 @@ var sqliteQueries = queries{
deleteUsersMarked: sqliteDeleteUsersMarkedQuery,
deleteUsersProvisioned: sqliteDeleteUsersProvisionedQuery,
selectTopicPerms: sqliteSelectTopicPermsQuery,
selectAccessCacheAll: sqliteSelectAccessCacheAllQuery,
selectAccessCacheUsers: sqliteSelectAccessCacheUsersQuery,
selectUserAllAccess: sqliteSelectUserAllAccessQuery,
selectUserAccess: sqliteSelectUserAccessQuery,
selectUserReservations: sqliteSelectUserReservationsQuery,
+142
View File
@@ -2169,6 +2169,148 @@ func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
})
}
// TestAuthorizeTopicAccess_CacheAndDirectDBAgree wires up two Managers on the
// same backend storage -- one with AccessCacheEnabled=true (in-memory cache
// path) and one with AccessCacheEnabled=false (direct SQL path) -- then runs
// an identical battery of authorizeTopicAccess queries against both and
// asserts byte-identical (read, write, found) responses for every query.
// This protects the in-memory implementation from drifting away from the
// SQL behavior it is meant to mirror.
func TestAuthorizeTopicAccess_CacheAndDirectDBAgree(t *testing.T) {
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// Seed via a Manager with the cache enabled. Writes go to the shared
// backend; both Managers will see them after the writes commit.
writer := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: true,
})
t.Cleanup(func() { writer.Close() })
require.Nil(t, writer.AddUser("phil", "mypass", RoleAdmin, false))
require.Nil(t, writer.AddUser("ben", "mypass", RoleUser, false))
require.Nil(t, writer.AddUser("alice", "mypass", RoleUser, false))
// A mix that exercises every branch of the priority logic:
// - exact and wildcard rules for the same user
// - exact and wildcard rules under Everyone
// - Everyone rules that are longer than the matching user rule
// - literal underscores (stored as "\_")
// - deny-all permissions
require.Nil(t, writer.AllowAccess("ben", "mytopic", PermissionReadWrite))
require.Nil(t, writer.AllowAccess("ben", "readme", PermissionRead))
require.Nil(t, writer.AllowAccess("ben", "writeme", PermissionWrite))
require.Nil(t, writer.AllowAccess("ben", "ben_topic", PermissionReadWrite))
require.Nil(t, writer.AllowAccess("ben", "mytopic*", PermissionRead))
require.Nil(t, writer.AllowAccess("alice", "alice_*", PermissionWrite))
require.Nil(t, writer.AllowAccess("alice", "secret", PermissionDenyAll))
require.Nil(t, writer.AllowAccess(Everyone, "announcements", PermissionRead))
require.Nil(t, writer.AllowAccess(Everyone, "up*", PermissionWrite))
require.Nil(t, writer.AllowAccess(Everyone, "mytopic", PermissionDenyAll))
// Build a reader Manager with the cache OFF, pointing at the same backend.
reader := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: false,
})
t.Cleanup(func() { reader.Close() })
// Probe matrix: every (user, topic) pair that exercises some branch.
cases := []struct {
user, topic string
}{
// Anonymous reads.
{Everyone, "announcements"},
{Everyone, "up42"},
{Everyone, "up"},
{Everyone, "downstream"},
{Everyone, "mytopic"},
{Everyone, "nope"},
// Specific user, only-user rules.
{"ben", "mytopic"},
{"ben", "readme"},
{"ben", "writeme"},
{"ben", "ben_topic"},
{"ben", "benXtopic"}, // underscore in rule means "X" must NOT match
// Specific user falls through to Everyone.
{"ben", "announcements"},
{"ben", "up5"},
{"alice", "announcements"},
// Wildcards with literal underscores.
{"alice", "alice_anything"},
{"alice", "alice_"},
{"alice", "aliceX"}, // does NOT match alice_*
// Exact-vs-wildcard overlap for the same user (ben has both
// "mytopic" exact and "mytopic*" wildcard).
{"ben", "mytopic"}, // exact wins on length
{"ben", "mytopicX"}, // only wildcard matches
{"ben", "mytopicYZ"}, // only wildcard matches
// Deny-all override.
{"alice", "secret"},
// No matching rule anywhere.
{"ben", "completely_unmatched"},
{"alice", "completely_unmatched"},
{Everyone, "completely_unmatched"},
}
// Sanity: the two Managers must agree on every probe.
for _, tc := range cases {
cRead, cWrite, cFound, cErr := writer.authorizeTopicAccess(tc.user, tc.topic)
dRead, dWrite, dFound, dErr := reader.authorizeTopicAccess(tc.user, tc.topic)
require.Nil(t, cErr, "cache path errored for (%s, %s)", tc.user, tc.topic)
require.Nil(t, dErr, "direct-DB path errored for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dFound, cFound, "found mismatch for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dRead, cRead, "read mismatch for (%s, %s)", tc.user, tc.topic)
require.Equal(t, dWrite, cWrite, "write mismatch for (%s, %s)", tc.user, tc.topic)
}
})
}
// TestAccessCacheReloadInterval_PicksUpExternalWrite proves that the
// background reloader actually closes the cross-process coherence gap: a
// write made through a *different* Manager on the same backend becomes
// visible to a cache-enabled Manager within roughly one reload interval,
// without that Manager being told about the write.
func TestAccessCacheReloadInterval_PicksUpExternalWrite(t *testing.T) {
const interval = 25 * time.Millisecond
forEachBackend(t, func(t *testing.T, newManager newManagerFunc) {
// reader holds the cache and polls; writer plays the role of an
// out-of-band process (e.g. `ntfy access` CLI) writing to the same
// backend.
reader := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: true,
AccessCacheReloadInterval: interval,
})
t.Cleanup(func() { reader.Close() })
writer := newManager(&Config{
DefaultAccess: PermissionDenyAll,
BcryptCost: bcrypt.MinCost,
AccessCacheEnabled: false,
})
t.Cleanup(func() { writer.Close() })
require.Nil(t, writer.AddUser("phil", "mypass", RoleUser, false))
// Sanity: before the write, the reader sees no rule for this topic.
_, _, found, err := reader.authorizeTopicAccess("phil", "via-poller")
require.Nil(t, err)
require.False(t, found)
// Write through the second Manager. reader's cache is unaware.
require.Nil(t, writer.AllowAccess("phil", "via-poller", PermissionReadWrite))
// Wait for the poller to catch up. The interval is 25ms; allow a
// generous multiple to keep this test from flaking on slow CI.
require.Eventually(t, func() bool {
read, write, found, err := reader.authorizeTopicAccess("phil", "via-poller")
return err == nil && found && read && write
}, 2*time.Second, 10*time.Millisecond, "reader's cache never observed the external write")
})
}
func TestStoreReservations(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
+15 -11
View File
@@ -245,16 +245,18 @@ const (
// Config holds the configuration for the user Manager
type Config struct {
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
DatabaseURL string // Database connection string (PostgreSQL)
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
Users []*User // Predefined users to create on startup
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
Filename string // Database filename, e.g. "/var/lib/ntfy/user.db" (SQLite)
DatabaseURL string // Database connection string (PostgreSQL)
StartupQueries string // Queries to run on startup, e.g. to create initial users or tiers (SQLite only)
DefaultAccess Permission // Default permission if no ACL matches
ProvisionEnabled bool // Hack: Enable auto-provisioning of users and access grants, disabled for "ntfy user" commands
Users []*User // Predefined users to create on startup
Access map[string][]*Grant // Predefined access grants to create on startup (username -> []*Grant)
Tokens map[string][]*Token // Predefined users to create on startup (username -> []*Token)
QueueWriterInterval time.Duration // Interval for the async queue writer to flush stats and token updates to the database
BcryptCost int // Cost of generated passwords; lowering makes testing faster
AccessCacheEnabled bool // Enables the in-memory ACL cache (high volume servers only)
AccessCacheReloadInterval time.Duration // Reload interval for access cache, relevant for ACL writes from CLI
}
// Error constants used by the package
@@ -303,7 +305,9 @@ type queries struct {
deleteUsersProvisioned string
// Access queries
selectTopicPerms string
selectTopicPerms string // Direct-DB authorizeTopicAccess query; used when the in-memory cache is disabled
selectAccessCacheAll string // Bulk load: (user_name, topic, read, write) for the in-memory ACL cache
selectAccessCacheUsers func(n int) string // Returns a per-users load query whose IN clause is sized for n usernames
selectUserAllAccess string
selectUserAccess string
selectUserReservations string
+29 -26
View File
@@ -3293,9 +3293,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"version": "19.2.15",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz",
"integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3747,9 +3747,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.30",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz",
"integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==",
"version": "2.10.32",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4308,9 +4308,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.357",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz",
"integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==",
"version": "1.5.361",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
"integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==",
"dev": true,
"license": "ISC"
},
@@ -4447,9 +4447,9 @@
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6830,11 +6830,14 @@
}
},
"node_modules/node-releases": {
"version": "2.0.44",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"version": "2.0.46",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
@@ -7145,9 +7148,9 @@
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"version": "11.5.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz",
"integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -7193,9 +7196,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -7213,7 +7216,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -8326,9 +8329,9 @@
}
},
"node_modules/terser": {
"version": "5.47.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.47.1.tgz",
"integrity": "sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==",
"version": "5.48.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz",
"integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
+85 -13
View File
@@ -4,6 +4,7 @@ import { NavigationRoute, registerRoute } from "workbox-routing";
import { NetworkFirst } from "workbox-strategies";
import { clientsClaim } from "workbox-core";
import { dbAsync } from "../src/app/db";
import session from "../src/app/Session";
import { ACTION_HTTP, ACTION_VIEW } from "../src/app/actions";
import { badge, icon, messageWithSequenceId, notificationTag, toNotificationParams } from "../src/app/notificationUtils";
import initI18n from "../src/app/i18n";
@@ -11,8 +12,9 @@ import {
EVENT_MESSAGE,
EVENT_MESSAGE_CLEAR,
EVENT_MESSAGE_DELETE,
WEBPUSH_EVENT_MESSAGE,
WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG,
SW_WEBPUSH_EVENT_MESSAGE,
SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING,
} from "../src/app/events";
/**
@@ -35,6 +37,7 @@ const broadcastChannel = new BroadcastChannel("web-push-broadcast");
*/
const handlePushMessage = async (data) => {
const { subscription_id: subscriptionId, message } = data;
const db = await dbAsync();
console.log("[ServiceWorker] Message received", data);
@@ -43,9 +46,24 @@ const handlePushMessage = async (data) => {
const subscription = await db.subscriptions.get(subscriptionId);
if (!subscription) {
console.log("[ServiceWorker] Subscription not found", subscriptionId);
handlePushUnknown(data);
return;
}
// NOTE: As soon as possible, to avoid this Safari error:
// > Push event handling completed without showing any notification via
// > ServiceWorkerRegistration.showNotification(). This may trigger removal of
// > the push subscription.
await self.registration.showNotification(
...toNotificationParams({
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
baseUrl: subscription.baseUrl,
topic: subscription.topic,
})
);
// Delete existing notification with same sequence ID (if any)
const sequenceId = message.sequence_id || message.id;
if (sequenceId) {
@@ -71,17 +89,71 @@ const handlePushMessage = async (data) => {
// Broadcast the message to potentially play a sound
broadcastChannel.postMessage(message);
await self.registration.showNotification(
...toNotificationParams({
message,
defaultTitle: message.topic,
topicRoute: new URL(message.topic, self.location.origin).toString(),
baseUrl: subscription.baseUrl,
topic: subscription.topic,
})
);
await maybeExtendToken();
};
const refreshTokenThreshold = 1000 * 60 * 60; // 1 hour
const maybeExtendToken = async () => {
if (import.meta.env.DEV) {
console.warn("[ServiceWorker] Skipping token extension in development since no config.base_url exists");
return;
}
const token = await session.tokenAsync();
if (!token) {
console.debug("[ServiceWorker] No session token, skipping token extension");
return;
}
const lastExtendedAt = await session.lastExtendedAtAsync();
const now = Date.now();
if (lastExtendedAt && now - lastExtendedAt < refreshTokenThreshold) {
console.debug(`[ServiceWorker] Token extended ${Math.floor((now - lastExtendedAt) / 1000 / 60)} minutes ago, skipping`);
return;
}
console.log("[ServiceWorker] Extending user access token");
// duplicated from utils.js#accountTokenUrl since we can't import that here
// as long as there's mp3 and other incompatible imports there
const tokenUrl = `${config.base_url}/v1/account/token`;
try {
const response = await fetch(tokenUrl, {
method: "PATCH",
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
await session.setLastExtendedAtAsync();
console.log(`[ServiceWorker] Token extended successfully`);
} else {
console.error(`[ServiceWorker] Failed to extend token: ${response.status} ${response.statusText}`);
}
} catch (e) {
console.error("[ServiceWorker] Failed to extend token", e);
}
};
/**
* Registers a periodic-sync listener for `extend_token` (see hooks.js).
* This extends the token regardless of whether the browser is open.
*
* CAVEATS:
* - Chromium-only
* - Only when the PWA is _installed_ (not just running in a browser tab)
* - Only when notifications are granted
*/
self.addEventListener("periodicsync", (event) => {
if (event.tag === SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG) {
console.log(`[ServiceWorker] Received periodicsync event "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`);
event.waitUntil(maybeExtendToken());
}
});
/**
* Handle a message_delete event: delete the notification from the database.
*/
@@ -192,7 +264,7 @@ const handlePush = async (data) => {
// - Web app: hooks.js:handleNotification()
// - Web app: sw.js:handleMessage(), sw.js:handleMessageClear(), ...
if (data.event === WEBPUSH_EVENT_MESSAGE) {
if (data.event === SW_WEBPUSH_EVENT_MESSAGE) {
const { message } = data;
if (message.event === EVENT_MESSAGE) {
return await handlePushMessage(data);
@@ -201,7 +273,7 @@ const handlePush = async (data) => {
} else if (message.event === EVENT_MESSAGE_CLEAR) {
return await handlePushMessageClear(data);
}
} else if (data.event === WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
} else if (data.event === SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING) {
return await handlePushSubscriptionExpiring(data);
}
+1
View File
@@ -155,6 +155,7 @@ class AccountApi {
method: "PATCH",
headers: withBearerAuth({}, session.token()),
});
await session.setLastExtendedAtAsync();
}
async deleteToken(token) {
+13
View File
@@ -36,6 +36,7 @@ class Session {
await this.db.kv.bulkPut([
{ key: "user", value: username },
{ key: "token", value: token },
{ key: "lastExtendedAt", value: Date.now() },
]);
localStorage.setItem("user", username);
localStorage.setItem("token", token);
@@ -52,6 +53,18 @@ class Session {
return (await this.db.kv.get({ key: "user" }))?.value;
}
async tokenAsync() {
return (await this.db.kv.get({ key: "token" }))?.value;
}
async lastExtendedAtAsync() {
return (await this.db.kv.get({ key: "lastExtendedAt" }))?.value;
}
async setLastExtendedAtAsync() {
await this.db.kv.put({ key: "lastExtendedAt", value: Date.now() });
}
exists() {
return this.username() && this.token();
}
+4 -2
View File
@@ -8,8 +8,10 @@ export const EVENT_MESSAGE_DELETE = "message_delete";
export const EVENT_MESSAGE_CLEAR = "message_clear";
export const EVENT_POLL_REQUEST = "poll_request";
export const WEBPUSH_EVENT_MESSAGE = "message";
export const WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
export const SW_WEBPUSH_EVENT_MESSAGE = "message";
export const SW_WEBPUSH_EVENT_SUBSCRIPTION_EXPIRING = "subscription_expiring";
export const SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG = "extend_token";
// Check if an event is a notification event (message, delete, or read)
export const isNotificationEvent = (event) => event === EVENT_MESSAGE || event === EVENT_MESSAGE_DELETE || event === EVENT_MESSAGE_CLEAR;
+1 -1
View File
@@ -209,7 +209,7 @@ export const formatPrice = (n) => {
};
export const openUrl = (url) => {
window.open(url, "_blank", "noopener,noreferrer");
window.open(url, "_blank", "noreferrer");
};
export const sounds = {
+1 -1
View File
@@ -164,7 +164,7 @@ const autolink = (s) => {
const parts = s.split(/(\bhttps?:\/\/[-A-Z0-9+\u0026\u2019@#/%?=()~_|!:,.;]*[-A-Z0-9+\u0026@#/%=~()_|]\b)/gi);
for (let i = 1; i < parts.length; i += 2) {
parts[i] = (
<Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer,noopener">
<Link key={i} href={parts[i]} underline="hover" target="_blank" rel="noreferrer">
{shortUrl(parts[i])}
</Link>
);
+48 -1
View File
@@ -13,7 +13,7 @@ import versionChecker from "../app/VersionChecker";
import { UnauthorizedError } from "../app/errors";
import notifier from "../app/Notifier";
import prefs from "../app/Prefs";
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR } from "../app/events";
import { EVENT_MESSAGE_DELETE, EVENT_MESSAGE_CLEAR, SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG } from "../app/events";
/**
* Wire connectionManager and subscriptionManager so that subscriptions are updated when the connection
@@ -283,6 +283,52 @@ export const useStandaloneWebPushAutoSubscribe = () => {
}, [isLaunchedPWA]);
};
/**
* Registers a periodicsync listener for `extend_token` (see sw.js).
* This extends the token regardless of whether the browser is open.
*
* CAVEATS:
* - Chromium-only
* - Only when the PWA is _installed_ (not just running in a browser tab)
* - Only when notifications are granted
*
* This is an experimental feature:
* https://developer.mozilla.org/en-US/docs/Web/API/Web_Periodic_Background_Synchronization_API
*/
const usePeriodicTokenExtend = () => {
const isLaunchedPWA = useIsLaunchedPWA();
const pushPossible = useNotificationPermissionListener(() => notifier.pushPossible());
useEffect(() => {
(async () => {
if (!isLaunchedPWA) {
console.debug("[usePeriodicTokenExtend] Skipping: Not running as PWA");
return;
}
if (!pushPossible) {
console.debug("[usePeriodicTokenExtend] Skipping: Web push not possible or granted");
return;
}
try {
const registration = await navigator.serviceWorker.ready;
if (!registration.periodicSync) {
console.debug("[usePeriodicTokenExtend] Skipping: Periodic Sync not supported");
return;
}
console.log(`[usePeriodicTokenExtend] Turning on periodicsync "${SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG}"`);
await registration.periodicSync.register(SW_PERIODIC_SYNC_EXTEND_TOKEN_TAG, {
minInterval: 12 * 60 * 60 * 1000, // 12 hours
});
} catch (error) {
console.log("[usePeriodicTokenExtend] Periodic Sync could not be registered", error);
}
})();
}, [isLaunchedPWA, pushPossible]);
};
/**
* Start the poller and the pruner. This is done in a side effect as opposed to just in Pruner.js
* and Poller.js, because side effect imports are not a thing in JS, and "Optimize imports" cleans
@@ -305,6 +351,7 @@ const stopWorkers = () => {
export const useBackgroundProcesses = () => {
useStandaloneWebPushAutoSubscribe();
usePeriodicTokenExtend();
useEffect(() => {
console.log("[useBackgroundProcesses] mounting");