Compare commits

...

32 Commits

Author SHA1 Message Date
binwiederhier
2ad78edca1 Release notes 2026-03-16 20:13:39 -04:00
binwiederhier
9b1a32ec56 Refine 2026-03-16 15:52:57 -04:00
binwiederhier
3d9ce69042 PG races 2026-03-16 15:48:36 -04:00
binwiederhier
59ce581ba2 Fix postgres primary/replica races 2026-03-16 11:21:21 -04:00
binwiederhier
df82fdf44c Add HTTP 413 to normal errors to not log 2026-03-16 10:27:23 -04:00
binwiederhier
3a37ea32f7 Webpush: Fix FK issue with Postgres 2026-03-16 10:24:16 -04:00
binwiederhier
6b38acb23a Route authorization query to read-only database replica to reduce primary database load 2026-03-15 22:01:19 -04:00
binwiederhier
f5c255c53c Grr 2026-03-15 21:17:58 -04:00
binwiederhier
fd0a49244e Disable test temporarily 2026-03-15 21:13:12 -04:00
binwiederhier
4699ed3ffd Fix UTF-8 insert failures in Postgres 2026-03-15 21:03:18 -04:00
Philipp C. Heckel
1afb99db67 Merge pull request #1658 from BradStaton/1657-postgresql-urls
Support `postgresql://` and `postgres://` URLs
2026-03-15 20:45:08 -04:00
binwiederhier
66208e6f88 Pre-import 2026-03-15 20:25:22 -04:00
BradStaton
ce24594c32 Update serve.go
Support multiple postgres connection URL formats
2026-03-15 16:22:22 -04:00
binwiederhier
888850d8bc Add blurp 2026-03-15 10:29:07 -04:00
binwiederhier
be09acd411 Bump 2026-03-15 10:26:03 -04:00
binwiederhier
bf19a5be2d Merge branch 'main' of https://hosted.weblate.org/git/ntfy/web 2026-03-15 10:12:54 -04:00
BonifacioCalindoro
fd8f356d1f Translated using Weblate (Spanish)
Currently translated at 100.0% (407 of 407 strings)

Translation: ntfy/Web app
Translate-URL: https://hosted.weblate.org/projects/ntfy/web/es/
2026-03-15 01:09:47 +01:00
binwiederhier
3296d158c5 Release notes 2026-03-14 15:07:11 -04:00
binwiederhier
45f045a5a4 Make config generator work on mobile 2026-03-14 14:39:51 -04:00
binwiederhier
f7b6e9bbe3 Merge branch 'config-generator' 2026-03-14 14:21:18 -04:00
binwiederhier
22868f4742 Derp 2026-03-14 14:21:09 -04:00
Philipp C. Heckel
3801a28958 Merge pull request #1654 from binwiederhier/config-generator
Config generator
2026-03-14 14:16:51 -04:00
binwiederhier
2bf8f6271b Review 2026-03-14 14:15:46 -04:00
binwiederhier
13be9747e4 Security reivew 2026-03-14 13:56:43 -04:00
binwiederhier
26dd017401 Merge branch 'main' of github.com:binwiederhier/ntfy into config-generator 2026-03-14 13:47:42 -04:00
binwiederhier
d00cd64220 Add admin user 2026-03-14 13:03:36 -04:00
binwiederhier
fab08e862d More refining for config generator 2026-03-14 12:56:26 -04:00
binwiederhier
143935b917 More refining 2026-03-14 08:42:07 -04:00
binwiederhier
3402510b47 More config generator 2026-03-10 21:11:27 -04:00
binwiederhier
19d1618bb8 Continued 2026-03-08 22:00:08 -04:00
binwiederhier
612afb1435 Configurator 2026-03-08 19:32:54 -04:00
binwiederhier
2b36ad9eb9 config generator 2026-03-08 18:59:17 -04:00
31 changed files with 4331 additions and 172 deletions

View File

@@ -2,9 +2,6 @@ package cmd
import (
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/util"
"net/http"
"net/http/httptest"
"os"
@@ -14,9 +11,14 @@ import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/v2/test"
"heckel.io/ntfy/v2/util"
)
func TestCLI_Publish_Subscribe_Poll_Real_Server(t *testing.T) {
t.Skip("temporarily disabled") // FIXME
testMessage := util.RandomString(10)
app, _, _, _ := newTestApp()
require.Nil(t, app.Run([]string{"ntfy", "publish", "ntfytest", "ntfy unit test " + testMessage}))

View File

@@ -284,10 +284,12 @@ func execServe(c *cli.Context) error {
}
// Check values
if len(databaseReplicaURLs) > 0 && databaseURL == "" {
return errors.New("database-replica-urls can only be used if database-url is also set")
if databaseURL != "" && !strings.HasPrefix(databaseURL, "postgres://") && !strings.HasPrefix(databaseURL, "postgresql://") {
return errors.New("if database-url is set, it must start with postgres:// or postgresql://")
} else if databaseURL != "" && (authFile != "" || cacheFile != "" || webPushFile != "") {
return errors.New("if database-url is set, auth-file, cache-file, and web-push-file must not be set")
} else if len(databaseReplicaURLs) > 0 && databaseURL == "" {
return errors.New("database-replica-urls can only be used if database-url is also set")
} else if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
return errors.New("if set, FCM key file must exist")
} else if firebaseKeyFile != "" && !server.FirebaseAvailable {

View File

@@ -11,6 +11,12 @@ type Beginner interface {
Begin() (*sql.Tx, error)
}
// Querier is an interface for types that can execute SQL queries.
// *sql.DB, *sql.Tx, and *DB all implement this.
type Querier interface {
Query(query string, args ...any) (*sql.Rows, error)
}
// Host pairs a *sql.DB with the host:port it was opened against.
type Host struct {
Addr string // "host:port"

View File

@@ -135,6 +135,268 @@ using Docker Compose (i.e. `docker-compose.yml`):
command: serve
```
## Config generator
This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point. Please refer to the relevant sections in the doc for more details.
<div style="text-align: center;">
<button type="button" id="cg-open-btn" class="cg-open-btn">Open config generator</button>
</div>
<figure markdown style="padding-left: 50px; padding-right: 50px; cursor: pointer;" onclick="document.getElementById('cg-open-btn').click();">
<img src="../../static/img/config-generator.png"/>
<figcaption>The config generator helps you create a custom config for your self-hosted ntfy instance. Click to open.</figcaption>
</figure>
<div id="cg-modal" class="cg-modal" style="display:none">
<div class="cg-modal-backdrop"></div>
<div class="cg-modal-dialog">
<div class="cg-modal-header">
<div class="cg-modal-header-left">
<span class="cg-modal-title">Config generator</span><span class="cg-badge-beta">BETA</span>
<span class="cg-modal-desc">This generator helps you configure your self-hosted ntfy instance. It's not fully featured, but it is a good starting point.</span>
</div>
<div class="cg-modal-header-actions">
<button type="button" id="cg-reset-btn" class="cg-modal-reset" title="Reset all values">Reset</button>
<button type="button" id="cg-close-btn" class="cg-modal-close" title="Close">&times;</button>
</div>
</div>
<div class="cg-modal-body">
<div class="cg-mobile-toggle">
<button class="cg-mobile-toggle-btn active" data-show="left">Edit</button>
<button class="cg-mobile-toggle-btn" data-show="right">Preview</button>
</div>
<div id="cg-left">
<div class="cg-nav">
<div class="cg-nav-tab active" data-panel="cg-panel-general">General</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-database" id="cg-nav-database">Database</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-auth" id="cg-nav-auth">Users</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-cache" id="cg-nav-cache">Message Cache</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-attach" id="cg-nav-attach">Attachments</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-webpush" id="cg-nav-webpush">Web Push</div>
<div class="cg-nav-tab cg-hidden" data-panel="cg-panel-email" id="cg-nav-email">Email</div>
</div>
<div class="cg-panels">
<div class="cg-panel active" id="cg-panel-general">
<div class="cg-field cg-inline-field">
<label>What URL will ntfy be reachable on?</label>
<input type="text" data-key="base-url" placeholder="https://ntfy.example.com">
</div>
<div class="cg-field cg-inline-field">
<label>Will ntfy run behind a proxy (e.g. nginx, Caddy)? <a href="/config/#behind-a-proxy-tls-etc" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-proxy" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-proxy" value="yes"><span>Yes</span></label>
</div>
</div>
<div class="cg-field cg-inline-field">
<label>Will this ntfy server be open or private? <a href="/config/#access-control" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-server-type" value="open" checked><span>Open</span></label>
<label><input type="radio" name="cg-server-type" value="private"><span>Private</span></label>
<label><input type="radio" name="cg-server-type" value="custom"><span>Custom</span></label>
</div>
</div>
<div class="cg-field cg-inline-field">
<label>Will iOS/iPhone users use this server? <a href="/config/#ios-instant-notifications" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-ios" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-ios" value="yes"><span>Yes</span></label>
</div>
</div>
<div class="cg-field cg-inline-field">
<label>Do you want to use ntfy as a UnifiedPush distributor? <a href="/config/#example-unifiedpush" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-unifiedpush" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-unifiedpush" value="yes"><span>Yes</span></label>
</div>
</div>
<div class="cg-field">
<label>Which features do you want to enable?</label>
<div class="cg-feature-grid">
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-auth"> User management and access control</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-auth">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-cache"> Persistent message cache</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-cache">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-attach"> Attachments</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-attach">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-webpush"> Web push</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-webpush">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-smtp-out"> Email notifications</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-email">Configure</button></div>
<div class="cg-feature-row"><label><input type="checkbox" id="cg-feat-smtp-in"> Email publishing</label><button type="button" class="cg-btn-configure cg-hidden" data-panel="cg-panel-email">Configure</button></div>
</div>
</div>
<div class="cg-field cg-inline-field cg-hidden" id="cg-wizard-db">
<label>Which database backend would you like to use? <a href="/config/#database-options" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-db-type" value="sqlite" checked><span>SQLite</span></label>
<label><input type="radio" name="cg-db-type" value="postgres"><span>PostgreSQL</span></label>
</div>
</div>
</div>
<div class="cg-panel" id="cg-panel-auth">
<div class="cg-panel-desc">Configure user management, access control, and provisioned users/ACLs. See <a href="/config/#access-control" target="_blank">access control</a> for details.</div>
<div class="cg-field cg-inline-field">
<label>Where should the user database be stored?</label>
<input type="text" data-key="auth-file" placeholder="/var/lib/ntfy/auth.db">
</div>
<div class="cg-field cg-inline-field">
<label>What should the default access policy be? <a href="/config/#access-control" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<select id="cg-default-access-select">
<option value="read-write" selected>Read &amp; Write</option>
<option value="read-only">Read Only</option>
<option value="write-only">Write Only</option>
<option value="deny-all">Deny All</option>
</select>
</div>
<div class="cg-field cg-inline-field">
<label>Should login to the web app be enabled?</label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-login-mode" value="disabled" checked><span>Disabled</span></label>
<label><input type="radio" name="cg-login-mode" value="enabled"><span>Enabled</span></label>
<label><input type="radio" name="cg-login-mode" value="required"><span>Required</span></label>
</div>
</div>
<div class="cg-field cg-inline-field">
<label>Should it be possible to sign up via the web app?</label>
<div class="cg-btn-group">
<label><input type="radio" name="cg-enable-signup" value="no" checked><span>No</span></label>
<label><input type="radio" name="cg-enable-signup" value="yes"><span>Yes</span></label>
</div>
</div>
<input type="hidden" data-key="auth-default-access">
<input type="checkbox" data-key="enable-login" id="cg-enable-login-hidden" style="display:none">
<input type="checkbox" data-key="require-login" id="cg-require-login-hidden" style="display:none">
<input type="checkbox" data-key="enable-signup" id="cg-enable-signup-hidden" style="display:none">
<div class="cg-field">
<label>Provisioned users <a href="/config/#users-and-roles" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-repeatable-container" id="cg-auth-users-container"></div>
<button type="button" class="cg-btn-add" data-add-type="user">+ Add user</button>
</div>
<div class="cg-field">
<label>Provisioned ACLs <a href="/config/#access-control-list-acl" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-repeatable-container" id="cg-auth-acls-container"></div>
<button type="button" class="cg-btn-add" data-add-type="acl">+ Add ACL</button>
</div>
<div class="cg-field">
<label>Provisioned tokens <a href="/config/#access-tokens" target="_blank" class="cg-help"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16"><path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M5.496 6.033h.825c.138 0 .248-.113.266-.25.09-.656.54-1.134 1.342-1.134.686 0 1.314.343 1.314 1.168 0 .635-.374.927-.965 1.371-.673.489-1.206 1.06-1.168 1.987l.003.217a.25.25 0 0 0 .25.246h.811a.25.25 0 0 0 .25-.25v-.105c0-.718.273-.927 1.01-1.486.609-.463 1.244-.977 1.244-2.056 0-1.511-1.276-2.241-2.673-2.241-1.267 0-2.655.59-2.75 2.286a.237.237 0 0 0 .241.247m2.325 6.443c.61 0 1.029-.394 1.029-.927 0-.552-.42-.94-1.029-.94-.584 0-1.009.388-1.009.94 0 .533.425.927 1.01.927z"/></svg></a></label>
<div class="cg-repeatable-container" id="cg-auth-tokens-container"></div>
<button type="button" class="cg-btn-add" data-add-type="token">+ Add token</button>
</div>
</div>
<div class="cg-panel" id="cg-panel-cache">
<div class="cg-panel-desc">Configure the message cache to allow devices to retrieve missed notifications. See <a href="/config/#message-cache" target="_blank">message cache</a> for details.</div>
<div class="cg-field cg-inline-field" id="cg-cache-file-field">
<label>Where should the cache be stored?</label>
<input type="text" data-key="cache-file" placeholder="/var/cache/ntfy/cache.db">
</div>
<div class="cg-field cg-inline-field">
<label>How long should messages be cached?</label>
<input type="text" data-key="cache-duration" placeholder="12h">
</div>
</div>
<div class="cg-panel" id="cg-panel-attach">
<div class="cg-panel-desc">Allow users to upload and attach files to notifications. See <a href="/config/#attachments" target="_blank">attachments</a> for details.</div>
<div class="cg-field cg-inline-field">
<label>Where should attachments be stored?</label>
<input type="text" data-key="attachment-cache-dir" placeholder="/var/cache/ntfy/attachments">
</div>
<div class="cg-field cg-inline-field">
<label>Max file size per attachment?</label>
<input type="text" data-key="attachment-file-size-limit" placeholder="15M">
</div>
<div class="cg-field cg-inline-field">
<label>Total attachment storage limit?</label>
<input type="text" data-key="attachment-total-size-limit" placeholder="5G">
</div>
<div class="cg-field cg-inline-field">
<label>How long before attachments expire?</label>
<input type="text" data-key="attachment-expiry-duration" placeholder="3h">
</div>
</div>
<div class="cg-panel" id="cg-panel-webpush">
<div class="cg-panel-desc">Enable browser push notifications via the Web Push API. VAPID keys are generated automatically. See <a href="/config/#web-push" target="_blank">web push</a> for details.</div>
<div class="cg-field cg-inline-field">
<label>Where should web push data be stored?</label>
<input type="text" data-key="web-push-file" placeholder="/var/lib/ntfy/webpush.db">
</div>
<div class="cg-field cg-inline-field">
<label>Contact email address</label>
<input type="text" data-key="web-push-email-address" placeholder="admin@example.com">
</div>
<div class="cg-field cg-inline-field">
<label>Private key</label>
<input type="text" data-key="web-push-private-key" placeholder="Auto-generated" readonly>
</div>
<div class="cg-field cg-inline-field">
<label>Public key</label>
<input type="text" data-key="web-push-public-key" placeholder="Auto-generated" readonly>
</div>
<div class="cg-field cg-inline-field">
<label></label>
<button type="button" id="cg-regen-keys" class="cg-btn-add">Regenerate keys</button>
</div>
</div>
<div class="cg-panel" id="cg-panel-email">
<div class="cg-panel-desc">Configure outgoing email notifications and/or incoming email publishing. See <a href="/config/#e-mail-notifications" target="_blank">email notifications</a> and <a href="/config/#e-mail-publishing" target="_blank">email publishing</a> for details.</div>
<div id="cg-email-out-section" class="cg-hidden">
<div class="cg-field"><label><strong>Outgoing (notifications)</strong></label></div>
<div class="cg-field cg-inline-field">
<label>SMTP server address</label>
<input type="text" data-key="smtp-sender-addr" placeholder="smtp.example.com:587">
</div>
<div class="cg-field cg-inline-field">
<label>Sender email</label>
<input type="text" data-key="smtp-sender-from" placeholder="ntfy@example.com">
</div>
<div class="cg-field cg-inline-field">
<label>SMTP username</label>
<input type="text" data-key="smtp-sender-user" placeholder="Username">
</div>
<div class="cg-field cg-inline-field">
<label>SMTP password</label>
<input type="password" data-key="smtp-sender-pass" placeholder="Password">
</div>
</div>
<div id="cg-email-in-section" class="cg-hidden">
<div class="cg-field"><label><strong>Incoming (publishing)</strong></label></div>
<div class="cg-field cg-inline-field">
<label>Listen address</label>
<input type="text" data-key="smtp-server-listen" placeholder=":25">
</div>
<div class="cg-field cg-inline-field">
<label>Domain</label>
<input type="text" data-key="smtp-server-domain" placeholder="ntfy.example.com">
</div>
<div class="cg-field cg-inline-field">
<label>Address prefix</label>
<input type="text" data-key="smtp-server-addr-prefix" placeholder="ntfy-">
</div>
</div>
</div>
<div class="cg-panel" id="cg-panel-database">
<div class="cg-panel-desc">Configure the PostgreSQL connection. See <a href="/config/#postgresql-experimental" target="_blank">PostgreSQL</a> for details.</div>
<div class="cg-field">
<label>Database URL</label>
<input type="text" data-key="database-url" placeholder="postgres://user:pass@host:5432/ntfy">
</div>
</div>
<input type="hidden" data-key="upstream-base-url">
<input type="checkbox" data-key="behind-proxy" id="cg-behind-proxy" style="display:none">
</div>
</div>
<div id="cg-right">
<div class="cg-output-tabs">
<div class="cg-output-tab active" data-format="server-yml">server.yml</div>
<div class="cg-output-tab" data-format="docker-compose">docker-compose.yml</div>
<div class="cg-output-tab" data-format="env-vars">Env variables</div>
<button type="button" id="cg-copy-btn" class="cg-btn-copy" title="Copy to clipboard"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>
</div>
<div class="cg-output-wrap">
<pre><code id="cg-code"><span class="cg-empty-msg">Configure options on the left to generate your config...</span></code></pre>
<div id="cg-warnings" class="cg-hidden"></div>
</div>
</div>
</div>
</div>
</div>
## Database options
ntfy uses a database for storing messages ([message cache](#message-cache)), users and [access control](#access-control), and [web push](#web-push) subscriptions.
You can choose between **SQLite** and **PostgreSQL** as the database backend.

View File

@@ -30,37 +30,37 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.tar.gz
tar zxvf ntfy_2.18.0_linux_amd64.tar.gz
sudo cp -a ntfy_2.18.0_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_amd64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.tar.gz
tar zxvf ntfy_2.19.2_linux_amd64.tar.gz
sudo cp -a ntfy_2.19.2_linux_amd64/ntfy /usr/local/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_amd64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.tar.gz
tar zxvf ntfy_2.18.0_linux_armv6.tar.gz
sudo cp -a ntfy_2.18.0_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_armv6/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.tar.gz
tar zxvf ntfy_2.19.2_linux_armv6.tar.gz
sudo cp -a ntfy_2.19.2_linux_armv6/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv6/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.tar.gz
tar zxvf ntfy_2.18.0_linux_armv7.tar.gz
sudo cp -a ntfy_2.18.0_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_armv7/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.tar.gz
tar zxvf ntfy_2.19.2_linux_armv7.tar.gz
sudo cp -a ntfy_2.19.2_linux_armv7/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_armv7/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.tar.gz
tar zxvf ntfy_2.18.0_linux_arm64.tar.gz
sudo cp -a ntfy_2.18.0_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.18.0_linux_arm64/{client,server}/*.yml /etc/ntfy
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.tar.gz
tar zxvf ntfy_2.19.2_linux_arm64.tar.gz
sudo cp -a ntfy_2.19.2_linux_arm64/ntfy /usr/bin/ntfy
sudo mkdir /etc/ntfy && sudo cp ntfy_2.19.2_linux_arm64/{client,server}/*.yml /etc/ntfy
sudo ntfy serve
```
@@ -116,7 +116,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -124,7 +124,7 @@ Manually installing the .deb file:
=== "armv6"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -132,7 +132,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -140,7 +140,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -150,28 +150,28 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv6"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv6.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv6.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
@@ -213,18 +213,18 @@ pkg install go-ntfy
## macOS
The [ntfy CLI](subscribe/cli.md) (`ntfy publish` and `ntfy subscribe` only) is supported on macOS as well.
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_darwin_all.tar.gz),
To install, please [download the tarball](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz),
extract it and place it somewhere in your `PATH` (e.g. `/usr/local/bin/ntfy`).
If run as `root`, ntfy will look for its config at `/etc/ntfy/client.yml`. For all other users, it'll look for it at
`~/Library/Application Support/ntfy/client.yml` (sample included in the tarball).
```bash
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_darwin_all.tar.gz > ntfy_2.18.0_darwin_all.tar.gz
tar zxvf ntfy_2.18.0_darwin_all.tar.gz
sudo cp -a ntfy_2.18.0_darwin_all/ntfy /usr/local/bin/ntfy
curl -L https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_darwin_all.tar.gz > ntfy_2.19.2_darwin_all.tar.gz
tar zxvf ntfy_2.19.2_darwin_all.tar.gz
sudo cp -a ntfy_2.19.2_darwin_all/ntfy /usr/local/bin/ntfy
mkdir ~/Library/Application\ Support/ntfy
cp ntfy_2.18.0_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
cp ntfy_2.19.2_darwin_all/client/client.yml ~/Library/Application\ Support/ntfy/client.yml
ntfy --help
```
@@ -245,7 +245,7 @@ brew install ntfy
The ntfy server and CLI are fully supported on Windows. You can run the ntfy server directly or as a Windows service.
To install, you can either
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.18.0/ntfy_2.18.0_windows_amd64.zip),
* [Download the latest ZIP](https://github.com/binwiederhier/ntfy/releases/download/v2.19.2/ntfy_2.19.2_windows_amd64.zip),
extract it and place the `ntfy.exe` binary somewhere in your `%Path%`.
* Or install ntfy from the [Scoop](https://scoop.sh) main repository via `scoop install ntfy`

View File

@@ -6,12 +6,55 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
| Component | Version | Release date |
|------------------|---------|--------------|
| ntfy server | v2.18.0 | Mar 7, 2026 |
| ntfy server | v2.19.2 | Mar 16, 2026 |
| ntfy Android app | v1.24.0 | Mar 5, 2026 |
| ntfy iOS app | v1.3 | Nov 26, 2023 |
Please check out the release notes for [upcoming releases](#not-released-yet) below.
### ntfy server v2.19.2
Released March 16, 2026
This is another small bugfix release for PostgreSQL, avoiding races between primary and read replica, as well as to
further reduce primary load.
**Bug fixes + maintenance:**
* Fix race condition in web push subscription causing FK constraint violation when concurrent requests hit the same endpoint
* Route authorization query to read-only database replica to reduce primary database load
## ntfy server v2.19.1
Released March 15, 2026
This is a bugfix release to avoid PostgreSQL insert failures due to invalid UTF-8 messages. It also fixes `database-url`
validation incorrectly rejecting `postgresql://` connection strings.
**Bug fixes + maintenance:**
* Fix invalid UTF-8 in HTTP headers (e.g. Latin-1 encoded text) causing PostgreSQL insert failures and dropping entire message batches
* Fix `database-url` validation rejecting `postgresql://` connection strings ([#1657](https://github.com/binwiederhier/ntfy/issues/1657)/[#1658](https://github.com/binwiederhier/ntfy/pull/1658))
## ntfy server v2.19.0
Released March 15, 2026
This is a fast-follow release that enables Postgres read replica support.
To offload read-heavy queries from the primary database, you can optionally configure one or more read replicas
using the `database-replica-urls` option. When configured, non-critical read-only queries (e.g. fetching messages,
checking access permissions, etc) are distributed across the replicas using round-robin, while all writes and
correctness-critical reads continue to go to the primary. If a replica becomes unhealthy, ntfy automatically falls back
to the primary until the replica recovers.
**Features:**
* Support [PostgreSQL read replicas](config.md#postgresql-experimental) for offloading non-critical read queries via `database-replica-urls` config option ([#1648](https://github.com/binwiederhier/ntfy/pull/1648))
* Add interactive [config generator](config.md#config-generator) to the documentation to help create server configuration files ([#1654](https://github.com/binwiederhier/ntfy/pull/1654))
**Bug fixes + maintenance:**
* Web: Throttle notification sound in web app to play at most once every 2 seconds (similar to [#1550](https://github.com/binwiederhier/ntfy/issues/1550), thanks to [@jlaffaye](https://github.com/jlaffaye) for reporting)
* Web: Add hover tooltips to icon buttons in web app account and preferences pages ([#1565](https://github.com/binwiederhier/ntfy/issues/1565), thanks to [@jermanuts](https://github.com/jermanuts) for reporting)
## ntfy server v2.18.0
Released March 7, 2026
@@ -1755,13 +1798,4 @@ and the [ntfy Android app](https://github.com/binwiederhier/ntfy-android/release
## Not released yet
### ntfy server v2.19.x (UNRELEASED)
**Features:**
* Support PostgreSQL read replicas for offloading non-critical read queries via `database-replica-urls` config option
**Bug fixes + maintenance:**
* Web: Throttle notification sound in web app to play at most once every 2 seconds (similar to [#1550](https://github.com/binwiederhier/ntfy/issues/1550), thanks to [@jlaffaye](https://github.com/jlaffaye) for reporting)
* Web: Add hover tooltips to icon buttons in web app account and preferences pages ([#1565](https://github.com/binwiederhier/ntfy/issues/1565), thanks to [@jermanuts](https://github.com/jermanuts) for reporting)
Nothing.

853
docs/static/css/config-generator.css vendored Normal file
View File

@@ -0,0 +1,853 @@
/* Config Generator */
/* Hidden utility */
.cg-hidden {
display: none !important;
}
/* Open button */
.cg-open-btn {
display: inline-block;
padding: 8px 20px;
background: var(--md-primary-fg-color);
color: #fff;
border: none;
border-radius: 4px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.cg-open-btn:hover {
opacity: 0.85;
}
/* Modal overlay */
.cg-modal {
position: fixed;
inset: 0;
z-index: 1000;
}
.cg-modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.cg-modal-dialog {
position: absolute;
inset: 24px;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
overflow: hidden;
font-size: 0.78rem;
}
.cg-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
}
.cg-modal-header-left {
display: flex;
align-items: baseline;
gap: 12px;
min-width: 0;
}
.cg-modal-title {
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
}
.cg-badge-beta {
display: inline-block;
padding: 1px 8px;
margin-left: 8px;
background: var(--md-primary-fg-color);
color: #fff;
font-size: 0.6rem;
font-weight: 600;
border-radius: 10px;
letter-spacing: 0.5px;
vertical-align: middle;
}
.cg-modal-desc {
font-size: 0.75rem;
color: #888;
}
.cg-modal-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.cg-modal-reset {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.75rem;
color: #777;
cursor: pointer;
padding: 4px 12px;
font-family: inherit;
transition: color 0.15s, border-color 0.15s;
}
.cg-modal-reset:hover {
color: #333;
border-color: #999;
}
.cg-modal-close {
background: none;
border: none;
font-size: 1.4rem;
color: #999;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.cg-modal-close:hover {
color: #333;
}
/* Modal body: left + right */
.cg-modal-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Left panel */
#cg-left {
flex: 1;
display: flex;
flex-direction: column;
border-right: 1px solid #ddd;
min-width: 0;
}
.cg-nav {
display: flex;
flex-wrap: wrap;
gap: 0;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
padding: 0 16px;
}
.cg-nav-tab {
padding: 9px 14px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
color: #777;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
user-select: none;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.cg-nav-tab:hover {
color: #444;
}
.cg-nav-tab.active {
color: var(--md-primary-fg-color);
border-bottom-color: var(--md-primary-fg-color);
}
.cg-panels {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.cg-panel {
display: none;
}
.cg-panel.active {
display: block;
}
.cg-panel-desc {
font-size: 0.75rem;
color: #888;
margin-bottom: 12px;
line-height: 1.5;
}
.cg-panel-desc a {
color: var(--md-primary-fg-color);
}
.cg-help {
color: var(--md-primary-fg-color);
text-decoration: none;
margin-left: 4px;
vertical-align: middle;
flex-shrink: 0;
transition: color 0.15s;
}
.cg-help:hover {
color: var(--md-primary-fg-color--dark, #2a6e5f);
}
/* Right panel */
#cg-right {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.cg-output-tabs {
display: flex;
border-bottom: 1px solid #ddd;
flex-shrink: 0;
padding: 0 16px;
}
.cg-output-tab {
padding: 9px 14px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 500;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
color: #777;
transition: color 0.15s, border-color 0.15s;
user-select: none;
white-space: nowrap;
}
.cg-output-tab:hover {
color: #444;
}
.cg-output-tab.active {
color: var(--md-primary-fg-color);
border-bottom-color: var(--md-primary-fg-color);
}
.cg-btn-copy {
margin-left: auto;
background: none;
color: #777;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
padding: 9px 10px;
cursor: pointer;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
}
.cg-btn-copy:hover {
color: #333;
}
.cg-output-wrap {
flex: 1;
overflow: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.cg-output-wrap pre {
margin: 0;
padding: 8px 10px;
background: #f5f5f5;
color: #333;
border: 1px solid #ddd;
border-radius: 6px;
overflow-x: auto;
font-size: 0.76rem;
line-height: 1.5;
flex: 1;
white-space: pre;
}
.cg-empty-msg {
color: #888;
font-style: italic;
}
.cg-warning {
padding: 6px 10px;
margin-top: 8px;
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
border-radius: 4px;
font-size: 0.76rem;
}
/* Form fields */
.cg-field {
margin-bottom: 0;
padding: 8px 12px;
}
.cg-field:nth-child(odd) {
background: #f8f8f8;
}
.cg-field:nth-child(even) {
background: #fff;
}
.cg-field > label {
display: block;
font-weight: 500;
margin-bottom: 4px;
font-size: 0.78rem;
color: #555;
}
.cg-field input[type="text"],
.cg-field input[type="password"],
.cg-field select {
width: 100%;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.78rem;
font-family: inherit;
box-sizing: border-box;
background: #fff;
}
.cg-field input[type="text"]:focus,
.cg-field input[type="password"]:focus,
.cg-field select:focus {
border-color: var(--md-primary-fg-color);
outline: none;
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
}
.cg-checkbox {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
}
.cg-checkbox input[type="checkbox"] {
accent-color: var(--md-primary-fg-color);
}
.cg-checkbox label {
font-weight: 500;
font-size: 0.78rem;
margin: 0;
cursor: pointer;
}
.cg-radio-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.cg-radio-group label {
display: flex;
align-items: center;
gap: 4px;
font-weight: 400;
font-size: 0.78rem;
cursor: pointer;
}
.cg-radio-group input[type="radio"] {
accent-color: var(--md-primary-fg-color);
}
/* Inline field: label + control side by side */
.cg-inline-field {
display: flex;
align-items: center;
gap: 12px;
}
.cg-inline-field > label {
margin-bottom: 0;
width: 60%;
flex-shrink: 0;
}
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
width: 50%;
}
.cg-inline-field > input[type="text"],
.cg-inline-field > select {
padding: 4px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.75rem;
font-family: inherit;
box-sizing: border-box;
line-height: 1.4;
background: #fff;
}
.cg-inline-field > input[type="text"]:focus,
.cg-inline-field > select:focus {
border-color: var(--md-primary-fg-color);
outline: none;
box-shadow: 0 0 0 2px rgba(51, 133, 116, 0.15);
}
#cg-email-in-section {
margin-top: 20px;
}
.cg-pg-label {
font-size: 0.75rem;
color: #888;
font-style: italic;
}
/* Button group toggle */
.cg-btn-group {
display: flex;
flex-shrink: 0;
}
.cg-btn-group label {
cursor: pointer;
margin: 0;
}
.cg-btn-group input[type="radio"] {
display: none;
}
.cg-btn-group span {
display: block;
padding: 4px 14px;
font-size: 0.75rem;
font-weight: 500;
line-height: 1.4;
border: 1px solid #ccc;
color: #555;
background: #fff;
transition: background 0.15s, color 0.15s, border-color 0.15s;
user-select: none;
}
.cg-btn-group label:first-child span {
border-radius: 4px 0 0 4px;
}
.cg-btn-group label:last-child span {
border-radius: 0 4px 4px 0;
}
.cg-btn-group label + label span {
margin-left: -1px;
}
.cg-btn-group input[type="radio"]:checked + span {
background: var(--md-primary-fg-color);
color: #fff;
border-color: var(--md-primary-fg-color);
position: relative;
z-index: 1;
}
.cg-feature-grid {
display: flex;
flex-direction: column;
gap: 5px;
}
.cg-feature-row {
display: flex;
align-items: center;
gap: 8px;
}
.cg-feature-grid label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
cursor: pointer;
}
.cg-feature-grid input[type="checkbox"] {
accent-color: var(--md-primary-fg-color);
}
.cg-btn-configure {
background: none;
border: 1px solid var(--md-primary-fg-color);
border-radius: 10px;
color: var(--md-primary-fg-color);
font-size: 0.68rem;
font-family: inherit;
padding: 1px 10px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.cg-btn-configure:hover {
background: var(--md-primary-fg-color);
color: #fff;
}
/* Repeatable rows */
.cg-repeatable-row {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 6px;
flex-wrap: wrap;
}
.cg-repeatable-row input,
.cg-repeatable-row select {
flex: 1;
min-width: 80px;
padding: 5px 6px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.75rem;
font-family: inherit;
box-sizing: border-box;
}
.cg-repeatable-row input:focus,
.cg-repeatable-row select:focus {
border-color: var(--md-primary-fg-color);
outline: none;
}
.cg-repeatable-row input:disabled,
.cg-repeatable-row select:disabled {
background: #eee;
color: #999;
cursor: not-allowed;
}
.cg-btn-remove {
background: none;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
padding: 5px 8px;
color: #999;
line-height: 1;
}
.cg-btn-remove:hover {
background: #fee;
border-color: #c66;
color: #c33;
}
.cg-btn-add {
background: none;
border: 1px dashed #bbb;
border-radius: 4px;
cursor: pointer;
padding: 5px 10px;
font-size: 0.75rem;
color: #777;
margin-top: 2px;
}
.cg-btn-add:hover {
border-color: var(--md-primary-fg-color);
color: var(--md-primary-fg-color);
}
/* Dark mode */
body[data-md-color-scheme="slate"] .cg-modal-dialog {
background: #1e1e2e;
}
body[data-md-color-scheme="slate"] .cg-modal-header {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-modal-title {
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-modal-desc {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-modal-reset {
border-color: #555;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-modal-reset:hover {
border-color: #888;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-modal-close {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-modal-close:hover {
color: #ddd;
}
body[data-md-color-scheme="slate"] #cg-left {
border-right-color: #444;
}
body[data-md-color-scheme="slate"] .cg-nav {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-nav-tab {
color: #888;
}
body[data-md-color-scheme="slate"] .cg-nav-tab:hover {
color: #bbb;
}
body[data-md-color-scheme="slate"] .cg-output-tabs {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-output-tab {
color: #888;
}
body[data-md-color-scheme="slate"] .cg-output-tab:hover {
color: #bbb;
}
body[data-md-color-scheme="slate"] .cg-btn-copy {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-btn-copy:hover {
color: #bbb;
}
body[data-md-color-scheme="slate"] .cg-output-wrap pre {
background: #161620;
color: #ddd;
border-color: #444;
}
body[data-md-color-scheme="slate"] .cg-field:nth-child(odd) {
background: #232334;
}
body[data-md-color-scheme="slate"] .cg-field:nth-child(even) {
background: #1e1e2e;
}
body[data-md-color-scheme="slate"] .cg-panel-desc {
color: #777;
}
body[data-md-color-scheme="slate"] .cg-field > label {
color: #aaa;
}
body[data-md-color-scheme="slate"] .cg-field input[type="text"],
body[data-md-color-scheme="slate"] .cg-field input[type="password"],
body[data-md-color-scheme="slate"] .cg-field select {
background: #2a2a3a;
border-color: #555;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-btn-group span {
background: #2a2a3a;
border-color: #555;
color: #aaa;
}
body[data-md-color-scheme="slate"] .cg-btn-group input[type="radio"]:checked + span {
background: var(--md-primary-fg-color);
color: #fff;
border-color: var(--md-primary-fg-color);
}
body[data-md-color-scheme="slate"] .cg-checkbox label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-radio-group label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-feature-grid label {
color: #ccc;
}
body[data-md-color-scheme="slate"] .cg-repeatable-row input,
body[data-md-color-scheme="slate"] .cg-repeatable-row select {
background: #2a2a3a;
border-color: #555;
color: #ddd;
}
body[data-md-color-scheme="slate"] .cg-repeatable-row input:disabled,
body[data-md-color-scheme="slate"] .cg-repeatable-row select:disabled {
background: #1a1a28;
color: #666;
}
body[data-md-color-scheme="slate"] .cg-btn-remove {
border-color: #555;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-btn-add {
border-color: #555;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-warning {
background: #3a2e00;
color: #ffc107;
border-color: #665200;
}
/* Mobile toggle bar (hidden on desktop) */
.cg-mobile-toggle {
display: none;
}
/* Responsive */
@media (max-width: 900px) {
.cg-modal-dialog {
inset: 0;
border-radius: 0;
}
.cg-modal-header {
padding: 8px 16px;
}
.cg-modal-title {
font-size: 0.85rem;
}
.cg-modal-desc {
display: none;
}
.cg-modal-body {
flex-direction: column;
}
.cg-mobile-toggle {
display: flex;
flex-shrink: 0;
border-bottom: 1px solid #ddd;
}
.cg-mobile-toggle-btn {
flex: 1;
padding: 8px 0;
border: none;
background: #f5f5f5;
font-size: 0.78rem;
font-weight: 500;
font-family: inherit;
color: #777;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.cg-mobile-toggle-btn.active {
background: #fff;
color: var(--md-primary-fg-color);
box-shadow: inset 0 -2px 0 var(--md-primary-fg-color);
}
#cg-left {
border-right: none;
flex: 1;
min-height: 0;
}
#cg-right {
flex: 1;
display: none;
min-height: 0;
}
#cg-right.cg-mobile-active {
display: flex;
}
#cg-left.cg-mobile-hidden {
display: none;
}
.cg-nav {
overflow-x: auto;
flex-wrap: nowrap;
-webkit-overflow-scrolling: touch;
}
.cg-inline-field {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.cg-inline-field > label {
width: 100%;
}
.cg-panel:not(#cg-panel-general) .cg-inline-field > label {
width: 100%;
}
}
/* Dark mode mobile toggle */
body[data-md-color-scheme="slate"] .cg-mobile-toggle {
border-bottom-color: #444;
}
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn {
background: #2a2a3a;
color: #888;
}
body[data-md-color-scheme="slate"] .cg-mobile-toggle-btn.active {
background: #1e1e2e;
color: var(--md-primary-fg-color);
}

BIN
docs/static/img/config-generator.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

1220
docs/static/js/bcrypt.js vendored Normal file

File diff suppressed because it is too large Load Diff

1357
docs/static/js/config-generator.js vendored Normal file

File diff suppressed because it is too large Load Diff

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.25.0
require (
cloud.google.com/go/firestore v1.21.0 // indirect
cloud.google.com/go/storage v1.61.1 // indirect
cloud.google.com/go/storage v1.61.3 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/emersion/go-smtp v0.18.0

4
go.sum
View File

@@ -18,8 +18,8 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
cloud.google.com/go/storage v1.61.1 h1:VELCSvZKiSw0AS1k3so5mKGy3CB7bTCYD8EHhTF42bY=
cloud.google.com/go/storage v1.61.1/go.mod h1:k30/hwYfd0M8aULYbPkQLgNf+SFcdjlRHvLMXggw18E=
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
firebase.google.com/go/v4 v4.19.0 h1:f5NMlC2YHFsncz00c2+ecBr+ZYlRMhKIhj1z8Iz0lD8=

View File

@@ -125,16 +125,16 @@ func (c *Cache) addMessages(ms []*model.Message) error {
return model.ErrUnexpectedMessageType
}
published := m.Time <= time.Now().Unix()
tags := strings.Join(m.Tags, ",")
tags := util.SanitizeUTF8(strings.Join(m.Tags, ","))
var attachmentName, attachmentType, attachmentURL string
var attachmentSize, attachmentExpires int64
var attachmentDeleted bool
if m.Attachment != nil {
attachmentName = m.Attachment.Name
attachmentType = m.Attachment.Type
attachmentName = util.SanitizeUTF8(m.Attachment.Name)
attachmentType = util.SanitizeUTF8(m.Attachment.Type)
attachmentSize = m.Attachment.Size
attachmentExpires = m.Attachment.Expires
attachmentURL = m.Attachment.URL
attachmentURL = util.SanitizeUTF8(m.Attachment.URL)
}
var actionsStr string
if len(m.Actions) > 0 {
@@ -154,13 +154,13 @@ func (c *Cache) addMessages(ms []*model.Message) error {
m.Time,
m.Event,
m.Expires,
m.Topic,
m.Message,
m.Title,
util.SanitizeUTF8(m.Topic),
util.SanitizeUTF8(m.Message),
util.SanitizeUTF8(m.Title),
m.Priority,
tags,
m.Click,
m.Icon,
util.SanitizeUTF8(m.Click),
util.SanitizeUTF8(m.Icon),
actionsStr,
attachmentName,
attachmentType,
@@ -170,7 +170,7 @@ func (c *Cache) addMessages(ms []*model.Message) error {
attachmentDeleted, // Always zero
sender,
m.User,
m.ContentType,
util.SanitizeUTF8(m.ContentType),
m.Encoding,
published,
)

View File

@@ -827,3 +827,141 @@ func TestStore_MessageFieldRoundTrip(t *testing.T) {
require.Equal(t, `{"key":"value"}`, retrieved.Actions[1].Body)
})
}
func TestStore_AddMessage_InvalidUTF8(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// 0xc9 0x43: Latin-1 "ÉC" — 0xc9 starts a 2-byte UTF-8 sequence but 0x43 ('C') is not a continuation byte
m := model.NewDefaultMessage("mytopic", "\xc9Cas du serveur")
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "\uFFFDCas du serveur", messages[0].Message)
// 0xae: Latin-1 "®" — isolated byte above 0x7F, not a valid UTF-8 start for single byte
m2 := model.NewDefaultMessage("mytopic", "Product\xae Pro")
require.Nil(t, s.AddMessage(m2))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "Product\uFFFD Pro", messages[1].Message)
// 0xe8 0x6d 0x65: Latin-1 "ème" — 0xe8 starts a 3-byte UTF-8 sequence but 0x6d ('m') is not a continuation byte
m3 := model.NewDefaultMessage("mytopic", "probl\xe8me critique")
require.Nil(t, s.AddMessage(m3))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "probl\uFFFDme critique", messages[2].Message)
// 0xb2: Latin-1 "²" — isolated byte in 0x80-0xBF range (UTF-8 continuation byte without lead)
m4 := model.NewDefaultMessage("mytopic", "CO\xb2 level high")
require.Nil(t, s.AddMessage(m4))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "CO\uFFFD level high", messages[3].Message)
// 0xe9 0x6d 0x61: Latin-1 "éma" — 0xe9 starts a 3-byte UTF-8 sequence but 0x6d ('m') is not a continuation byte
m5 := model.NewDefaultMessage("mytopic", "th\xe9matique")
require.Nil(t, s.AddMessage(m5))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "th\uFFFDmatique", messages[4].Message)
// 0xed 0x64 0x65: Latin-1 "íde" — 0xed starts a 3-byte UTF-8 sequence but 0x64 ('d') is not a continuation byte
m6 := model.NewDefaultMessage("mytopic", "vid\xed\x64eo surveillance")
require.Nil(t, s.AddMessage(m6))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "vid\uFFFDdeo surveillance", messages[5].Message)
// 0xf3 0x6e 0x3a 0x20: Latin-1 "ón: " — 0xf3 starts a 4-byte UTF-8 sequence but 0x6e ('n') is not a continuation byte
m7 := model.NewDefaultMessage("mytopic", "notificaci\xf3n: alerta")
require.Nil(t, s.AddMessage(m7))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "notificaci\uFFFDn: alerta", messages[6].Message)
// 0xb7: Latin-1 "·" — isolated continuation byte
m8 := model.NewDefaultMessage("mytopic", "item\xb7value")
require.Nil(t, s.AddMessage(m8))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "item\uFFFDvalue", messages[7].Message)
// 0xa8: Latin-1 "¨" — isolated continuation byte
m9 := model.NewDefaultMessage("mytopic", "na\xa8ve")
require.Nil(t, s.AddMessage(m9))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "na\uFFFDve", messages[8].Message)
// 0xdf 0x64: Latin-1 "ßd" — 0xdf starts a 2-byte UTF-8 sequence but 0x64 ('d') is not a continuation byte
m10 := model.NewDefaultMessage("mytopic", "gro\xdf\x64ruck")
require.Nil(t, s.AddMessage(m10))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "gro\uFFFDdruck", messages[9].Message)
// 0xe4 0x67 0x74: Latin-1 "ägt" — 0xe4 starts a 3-byte UTF-8 sequence but 0x67 ('g') is not a continuation byte
m11 := model.NewDefaultMessage("mytopic", "tr\xe4gt Last")
require.Nil(t, s.AddMessage(m11))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "tr\uFFFDgt Last", messages[10].Message)
// 0xe9 0x65 0x20: Latin-1 "ée " — 0xe9 starts a 3-byte UTF-8 sequence but 0x65 ('e') is not a continuation byte
m12 := model.NewDefaultMessage("mytopic", "journ\xe9\x65 termin\xe9\x65")
require.Nil(t, s.AddMessage(m12))
messages, err = s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, "journ\uFFFDe termin\uFFFDe", messages[11].Message)
})
}
func TestStore_AddMessage_NullByte(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// 0x00: NUL byte — valid UTF-8 but rejected by PostgreSQL
m := model.NewDefaultMessage("mytopic", "hello\x00world")
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "helloworld", messages[0].Message)
})
}
func TestStore_AddMessage_InvalidUTF8InTitleAndTags(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Invalid UTF-8 can arrive via HTTP headers (Title, Tags) which bypass body validation
m := model.NewDefaultMessage("mytopic", "valid message")
m.Title = "\xc9clipse du syst\xe8me"
m.Tags = []string{"probl\xe8me", "syst\xe9me"}
m.Click = "https://example.com/\xae"
require.Nil(t, s.AddMessage(m))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 1, len(messages))
require.Equal(t, "\uFFFDclipse du syst\uFFFDme", messages[0].Title)
require.Equal(t, "probl\uFFFDme", messages[0].Tags[0])
require.Equal(t, "syst\uFFFDme", messages[0].Tags[1])
require.Equal(t, "https://example.com/\uFFFD", messages[0].Click)
})
}
func TestStore_AddMessage_InvalidUTF8BatchDoesNotDropValidMessages(t *testing.T) {
forEachBackend(t, func(t *testing.T, s *message.Cache) {
// Previously, a single invalid message would roll back the entire batch transaction.
// Sanitization ensures all messages in a batch are written successfully.
msgs := []*model.Message{
model.NewDefaultMessage("mytopic", "valid message 1"),
model.NewDefaultMessage("mytopic", "notificaci\xf3n: alerta"),
model.NewDefaultMessage("mytopic", "valid message 3"),
}
require.Nil(t, s.AddMessages(msgs))
messages, err := s.Messages("mytopic", model.SinceAllMessages, false)
require.Nil(t, err)
require.Equal(t, 3, len(messages))
})
}

View File

@@ -42,8 +42,11 @@ extra:
link: https://github.com/binwiederhier
extra_javascript:
- static/js/extra.js
- static/js/bcrypt.js
- static/js/config-generator.js
extra_css:
- static/css/extra.css
- static/css/config-generator.css
markdown_extensions:
- admonition

View File

@@ -70,6 +70,26 @@ func (m *Message) Context() log.Context {
return fields
}
// SanitizeUTF8 replaces invalid UTF-8 sequences and strips NUL bytes from all user-supplied
// string fields. This is called early in the publish path so that all downstream consumers
// (Firebase, WebPush, SMTP, cache) receive clean UTF-8 strings.
func (m *Message) SanitizeUTF8() {
m.Topic = util.SanitizeUTF8(m.Topic)
m.Message = util.SanitizeUTF8(m.Message)
m.Title = util.SanitizeUTF8(m.Title)
m.Click = util.SanitizeUTF8(m.Click)
m.Icon = util.SanitizeUTF8(m.Icon)
m.ContentType = util.SanitizeUTF8(m.ContentType)
for i, tag := range m.Tags {
m.Tags[i] = util.SanitizeUTF8(tag)
}
if m.Attachment != nil {
m.Attachment.Name = util.SanitizeUTF8(m.Attachment.Name)
m.Attachment.Type = util.SanitizeUTF8(m.Attachment.Type)
m.Attachment.URL = util.SanitizeUTF8(m.Attachment.URL)
}
}
// ForJSON returns a copy of the message suitable for JSON output.
// It clears the SequenceID if it equals the ID to reduce redundancy.
func (m *Message) ForJSON() *Message {

View File

@@ -36,7 +36,7 @@ const (
)
var (
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage}
normalErrorCodes = []int{http.StatusNotFound, http.StatusBadRequest, http.StatusTooManyRequests, http.StatusUnauthorized, http.StatusForbidden, http.StatusInsufficientStorage, http.StatusRequestEntityTooLarge}
rateLimitingErrorCodes = []int{http.StatusTooManyRequests, http.StatusRequestEntityTooLarge}
)

View File

@@ -880,6 +880,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess
if m.Message == "" {
m.Message = emptyMessageBody
}
m.SanitizeUTF8()
delayed := m.Time > time.Now().Unix()
ev := logvrm(v, r, m).
Tag(tagPublish).

View File

@@ -3,14 +3,15 @@ package server
import (
"encoding/json"
"errors"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
"net/http"
"net/netip"
"strings"
"time"
"heckel.io/ntfy/v2/log"
"heckel.io/ntfy/v2/model"
"heckel.io/ntfy/v2/user"
"heckel.io/ntfy/v2/util"
)
const (
@@ -455,21 +456,8 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
return errHTTPUnauthorized
} else if err := s.userManager.AllowReservation(u.Name, req.Topic); err != nil {
return errHTTPConflictTopicReserved
} else if u.IsUser() {
hasReservation, err := s.userManager.HasReservation(u.Name, req.Topic)
if err != nil {
return err
}
if !hasReservation {
reservations, err := s.userManager.ReservationsCount(u.Name)
if err != nil {
return err
} else if reservations >= u.Tier.ReservationLimit {
return errHTTPTooManyRequestsLimitReservations
}
}
}
// Actually add the reservation
// Actually add the reservation (with limit check inside the transaction to avoid races)
logvr(v, r).
Tag(tagAccount).
Fields(log.Context{
@@ -477,7 +465,14 @@ func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Requ
"everyone": everyone.String(),
}).
Debug("Adding topic reservation")
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone); err != nil {
var limit int64
if u.IsUser() && u.Tier != nil {
limit = u.Tier.ReservationLimit
}
if err := s.userManager.AddReservation(u.Name, req.Topic, everyone, limit); err != nil {
if errors.Is(err, user.ErrTooManyReservations) {
return errHTTPTooManyRequestsLimitReservations
}
return err
}
// Kill existing subscribers
@@ -530,22 +525,15 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R
// and marks associated messages for the topics as deleted. This also eventually deletes attachments.
// The process relies on the manager to perform the actual deletions (see runManager).
func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *visitor, u *user.User, reservationsLimit int64) error {
reservations, err := s.userManager.Reservations(u.Name)
removedTopics, err := s.userManager.RemoveExcessReservations(u.Name, reservationsLimit)
if err != nil {
return err
} else if int64(len(reservations)) <= reservationsLimit {
} else if len(removedTopics) == 0 {
logvr(v, r).Tag(tagAccount).Debug("No excess reservations to remove")
return nil
}
topics := make([]string, 0)
for i := int64(len(reservations)) - 1; i >= reservationsLimit; i-- {
topics = append(topics, reservations[i].Topic)
}
logvr(v, r).Tag(tagAccount).Info("Removing excess reservations for topics %s", strings.Join(topics, ", "))
if err := s.userManager.RemoveReservations(u.Name, topics...); err != nil {
return err
}
if err := s.messageCache.ExpireMessages(topics...); err != nil {
logvr(v, r).Tag(tagAccount).Info("Removed excess topic reservations, now removing messages for topics %s", strings.Join(removedTopics, ", "))
if err := s.messageCache.ExpireMessages(removedTopics...); err != nil {
return err
}
go s.pruneMessages()

View File

@@ -503,7 +503,7 @@ func TestAccount_Reservation_AddAdminSuccess(t *testing.T) {
}))
require.Nil(t, s.userManager.AddUser("noadmin1", "pass", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("noadmin1", "pro"))
require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll))
require.Nil(t, s.userManager.AddReservation("noadmin1", "mytopic", user.PermissionDenyAll, 0))
require.Nil(t, s.userManager.AddUser("noadmin2", "pass", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("noadmin2", "pro"))

View File

@@ -478,8 +478,8 @@ func TestPayments_Webhook_Subscription_Updated_Downgrade_From_PastDue_To_Active(
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll))
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll, 0))
require.Nil(t, s.userManager.AddReservation("phil", "ztopic", user.PermissionDenyAll, 0))
// Add billing details
u, err := s.userManager.User("phil")
@@ -589,7 +589,7 @@ func TestPayments_Webhook_Subscription_Deleted(t *testing.T) {
}))
require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleUser, false))
require.Nil(t, s.userManager.ChangeTier("phil", "pro"))
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll))
require.Nil(t, s.userManager.AddReservation("phil", "atopic", user.PermissionDenyAll, 0))
// Add billing details
u, err := s.userManager.User("phil")

View File

@@ -4441,3 +4441,88 @@ func TestServer_HandleError_SkipsWriteHeaderOnHijackedConnection(t *testing.T) {
}
})
}
func TestServer_Publish_InvalidUTF8InBody(t *testing.T) {
// All byte sequences from production logs, sent as message body
tests := []struct {
name string
body string
message string
}{
{"0xc9_0x43", "\xc9Cas du serveur", "\uFFFDCas du serveur"}, // Latin-1 "ÉC"
{"0xae", "Product\xae Pro", "Product\uFFFD Pro"}, // Latin-1 "®"
{"0xe8_0x6d_0x65", "probl\xe8me critique", "probl\uFFFDme critique"}, // Latin-1 "ème"
{"0xb2", "CO\xb2 level high", "CO\uFFFD level high"}, // Latin-1 "²"
{"0xe9_0x6d_0x61", "th\xe9matique", "th\uFFFDmatique"}, // Latin-1 "éma"
{"0xed_0x64_0x65", "vid\xed\x64eo surveillance", "vid\uFFFDdeo surveillance"}, // Latin-1 "íde"
{"0xf3_0x6e_0x3a_0x20", "notificaci\xf3n: alerta", "notificaci\uFFFDn: alerta"}, // Latin-1 "ón: "
{"0xb7", "item\xb7value", "item\uFFFDvalue"}, // Latin-1 "·"
{"0xa8", "na\xa8ve", "na\uFFFDve"}, // Latin-1 "¨"
{"0x00", "hello\x00world", "helloworld"}, // NUL byte
{"0xdf_0x64", "gro\xdf\x64ruck", "gro\uFFFDdruck"}, // Latin-1 "ßd"
{"0xe4_0x67_0x74", "tr\xe4gt Last", "tr\uFFFDgt Last"}, // Latin-1 "ägt"
{"0xe9_0x65_0x20", "journ\xe9\x65 termin\xe9\x65", "journ\uFFFDe termin\uFFFDe"}, // Latin-1 "ée"
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
// Publish via x-message header (the most common path for invalid UTF-8 from HTTP headers)
response := request(t, s, "PUT", "/mytopic", "", map[string]string{
"X-Message": tc.body,
})
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, tc.message, msg.Message)
// Verify it was stored in the cache correctly
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
msg = toMessage(t, response.Body.String())
require.Equal(t, tc.message, msg.Message)
})
}
}
func TestServer_Publish_InvalidUTF8InTitle(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
response := request(t, s, "PUT", "/mytopic", "valid body", map[string]string{
"Title": "\xc9clipse du syst\xe8me",
})
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "\uFFFDclipse du syst\uFFFDme", msg.Title)
require.Equal(t, "valid body", msg.Message)
}
func TestServer_Publish_InvalidUTF8InTags(t *testing.T) {
s := newTestServer(t, newTestConfig(t, ""))
response := request(t, s, "PUT", "/mytopic", "valid body", map[string]string{
"Tags": "probl\xe8me,syst\xe9me",
})
require.Equal(t, 200, response.Code)
msg := toMessage(t, response.Body.String())
require.Equal(t, "probl\uFFFDme", msg.Tags[0])
require.Equal(t, "syst\uFFFDme", msg.Tags[1])
}
func TestServer_Publish_InvalidUTF8WithFirebase(t *testing.T) {
// Verify that sanitization happens before Firebase dispatch, so Firebase
// receives clean UTF-8 strings rather than invalid byte sequences
sender := newTestFirebaseSender(10)
s := newTestServer(t, newTestConfig(t, ""))
s.firebaseClient = newFirebaseClient(sender, &testAuther{Allow: true})
response := request(t, s, "PUT", "/mytopic", "", map[string]string{
"X-Message": "notificaci\xf3n: alerta",
"Title": "\xc9clipse",
"Tags": "probl\xe8me",
})
require.Equal(t, 200, response.Code)
time.Sleep(100 * time.Millisecond) // Firebase publishing happens asynchronously
require.Equal(t, 1, len(sender.Messages()))
require.Equal(t, "notificaci\uFFFDn: alerta", sender.Messages()[0].Data["message"])
require.Equal(t, "\uFFFDclipse", sender.Messages()[0].Data["title"])
require.Equal(t, "probl\uFFFDme", sender.Messages()[0].Data["tags"])
}

View File

@@ -65,12 +65,12 @@ const (
key TEXT PRIMARY KEY,
value BIGINT
);
INSERT INTO message_stats (key, value) VALUES ('messages', 0);
INSERT INTO message_stats (key, value) VALUES ('messages', 0) ON CONFLICT (key) DO NOTHING;
CREATE TABLE IF NOT EXISTS schema_version (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO schema_version (store, version) VALUES ('message', 14);
INSERT INTO schema_version (store, version) VALUES ('message', 14) ON CONFLICT (store) DO NOTHING;
`
// Initial PostgreSQL schema for user store (from user/manager_postgres_schema.go)
@@ -146,7 +146,7 @@ const (
INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created)
VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT)
ON CONFLICT (id) DO NOTHING;
INSERT INTO schema_version (store, version) VALUES ('user', 6);
INSERT INTO schema_version (store, version) VALUES ('user', 6) ON CONFLICT (store) DO NOTHING;
`
// Initial PostgreSQL schema for web push store (from webpush/store_postgres.go)
@@ -174,7 +174,7 @@ const (
store TEXT PRIMARY KEY,
version INT NOT NULL
);
INSERT INTO schema_version (store, version) VALUES ('webpush', 1);
INSERT INTO schema_version (store, version) VALUES ('webpush', 1) ON CONFLICT (store) DO NOTHING;
`
)
@@ -185,6 +185,7 @@ var flags = []cli.Flag{
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"auth_file"}, Usage: "SQLite user/auth database file path"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "web-push-file", Aliases: []string{"web_push_file"}, Usage: "SQLite web push database file path"}),
&cli.BoolFlag{Name: "create-schema", Usage: "create initial PostgreSQL schema before importing"},
&cli.BoolFlag{Name: "pre-import", Usage: "pre-import messages while ntfy is still running (only imports messages)"},
}
func main() {
@@ -207,10 +208,17 @@ func execImport(c *cli.Context) error {
cacheFile := c.String("cache-file")
authFile := c.String("auth-file")
webPushFile := c.String("web-push-file")
preImport := c.Bool("pre-import")
if databaseURL == "" {
return fmt.Errorf("database-url must be set (via --database-url or config file)")
}
if preImport {
if cacheFile == "" {
return fmt.Errorf("--cache-file must be set when using --pre-import")
}
return execPreImport(c, databaseURL, cacheFile)
}
if cacheFile == "" && authFile == "" && webPushFile == "" {
return fmt.Errorf("at least one of --cache-file, --auth-file, or --web-push-file must be set")
}
@@ -261,7 +269,8 @@ func execImport(c *cli.Context) error {
if err := verifySchemaVersion(pgDB, "message", expectedMessageSchemaVersion); err != nil {
return err
}
if err := importMessages(cacheFile, pgDB); err != nil {
sinceTime := maxMessageTime(pgDB)
if err := importMessages(cacheFile, pgDB, sinceTime); err != nil {
return fmt.Errorf("cannot import messages: %w", err)
}
}
@@ -300,6 +309,54 @@ func execImport(c *cli.Context) error {
return nil
}
func execPreImport(c *cli.Context, databaseURL, cacheFile string) error {
fmt.Println("pgimport - PRE-IMPORT mode (ntfy can keep running)")
fmt.Println()
fmt.Println("Source:")
printSource(" Cache file: ", cacheFile)
fmt.Println()
fmt.Println("Target:")
fmt.Printf(" Database URL: %s\n", maskPassword(databaseURL))
fmt.Println()
fmt.Println("This will pre-import messages into PostgreSQL while ntfy is still running.")
fmt.Println("After this completes, stop ntfy and run pgimport again without --pre-import")
fmt.Println("to import remaining messages, users, and web push subscriptions.")
fmt.Print("Continue? (y/n): ")
var answer string
fmt.Scanln(&answer)
if strings.TrimSpace(strings.ToLower(answer)) != "y" {
fmt.Println("Aborted.")
return nil
}
fmt.Println()
pgHost, err := pg.Open(databaseURL)
if err != nil {
return fmt.Errorf("cannot connect to PostgreSQL: %w", err)
}
pgDB := pgHost.DB
defer pgDB.Close()
if c.Bool("create-schema") {
if err := createSchema(pgDB, cacheFile, "", ""); err != nil {
return fmt.Errorf("cannot create schema: %w", err)
}
}
if err := verifySchemaVersion(pgDB, "message", expectedMessageSchemaVersion); err != nil {
return err
}
if err := importMessages(cacheFile, pgDB, 0); err != nil {
return fmt.Errorf("cannot import messages: %w", err)
}
fmt.Println()
fmt.Println("Pre-import complete. Now stop ntfy and run pgimport again without --pre-import")
fmt.Println("to import any remaining messages, users, and web push subscriptions.")
return nil
}
func createSchema(pgDB *sql.DB, cacheFile, authFile, webPushFile string) error {
fmt.Println("Creating initial PostgreSQL schema ...")
// User schema must be created before message schema, because message_stats and
@@ -645,16 +702,41 @@ func importUserPhones(sqlDB, pgDB *sql.DB) (int, error) {
// Message import
func importMessages(sqliteFile string, pgDB *sql.DB) error {
const preImportTimeDelta = 30 // seconds to subtract from max time to account for in-flight messages
// maxMessageTime returns the maximum message time in PostgreSQL minus a small buffer,
// or 0 if there are no messages yet. This is used after a --pre-import run to only
// import messages that arrived since the pre-import.
func maxMessageTime(pgDB *sql.DB) int64 {
var maxTime sql.NullInt64
if err := pgDB.QueryRow(`SELECT MAX(time) FROM message`).Scan(&maxTime); err != nil || !maxTime.Valid || maxTime.Int64 == 0 {
return 0
}
sinceTime := maxTime.Int64 - preImportTimeDelta
if sinceTime < 0 {
return 0
}
fmt.Printf("Pre-imported messages detected (max time: %d), importing delta (since time %d) ...\n", maxTime.Int64, sinceTime)
return sinceTime
}
func importMessages(sqliteFile string, pgDB *sql.DB, sinceTime int64) error {
sqlDB, err := openSQLite(sqliteFile)
if err != nil {
fmt.Printf("Skipping message import: %s\n", err)
return nil
}
defer sqlDB.Close()
fmt.Printf("Importing messages from %s ...\n", sqliteFile)
rows, err := sqlDB.Query(`SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published FROM messages`)
query := `SELECT mid, sequence_id, time, event, expires, topic, message, title, priority, tags, click, icon, actions, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_deleted, sender, user, content_type, encoding, published FROM messages`
var rows *sql.Rows
if sinceTime > 0 {
fmt.Printf("Importing messages from %s (since time %d) ...\n", sqliteFile, sinceTime)
rows, err = sqlDB.Query(query+` WHERE time >= ?`, sinceTime)
} else {
fmt.Printf("Importing messages from %s ...\n", sqliteFile)
rows, err = sqlDB.Query(query)
}
if err != nil {
return fmt.Errorf("querying messages: %w", err)
}
@@ -837,7 +919,9 @@ func importWebPush(sqliteFile string, pgDB *sql.DB) error {
}
func toUTF8(s string) string {
return strings.ToValidUTF8(s, "\uFFFD")
s = strings.ToValidUTF8(s, "\uFFFD")
s = strings.ReplaceAll(s, "\x00", "")
return s
}
// Verification

View File

@@ -288,33 +288,41 @@ func (a *Manager) ChangeTier(username, tier string) error {
t, err := a.Tier(tier)
if err != nil {
return err
} else if err := a.checkReservationsLimit(username, t.ReservationLimit); err != nil {
return err
}
if _, err := a.db.Exec(a.queries.updateUserTier, tier, username); err != nil {
return err
}
return nil
return db.ExecTx(a.db, func(tx *sql.Tx) error {
if err := a.checkReservationsLimitTx(tx, username, t.ReservationLimit); err != nil {
return err
}
if _, err := tx.Exec(a.queries.updateUserTier, tier, username); err != nil {
return err
}
return nil
})
}
// ResetTier removes the tier from the given user
func (a *Manager) ResetTier(username string) error {
if !AllowedUsername(username) && username != Everyone && username != "" {
return ErrInvalidArgument
} else if err := a.checkReservationsLimit(username, 0); err != nil {
return err
}
_, err := a.db.Exec(a.queries.deleteUserTier, username)
return err
return db.ExecTx(a.db, func(tx *sql.Tx) error {
if err := a.checkReservationsLimitTx(tx, username, 0); err != nil {
return err
}
if _, err := tx.Exec(a.queries.deleteUserTier, username); err != nil {
return err
}
return nil
})
}
func (a *Manager) checkReservationsLimit(username string, reservationsLimit int64) error {
u, err := a.User(username)
func (a *Manager) checkReservationsLimitTx(tx *sql.Tx, username string, reservationsLimit int64) error {
u, err := a.userTx(tx, username)
if err != nil {
return err
}
if u.Tier != nil && reservationsLimit < u.Tier.ReservationLimit {
reservations, err := a.Reservations(username)
reservations, err := a.reservationsTx(tx, username)
if err != nil {
return err
} else if int64(len(reservations)) > reservationsLimit {
@@ -388,7 +396,11 @@ func (a *Manager) writeUserStatsQueue() error {
// User returns the user with the given username if it exists, or ErrUserNotFound otherwise
func (a *Manager) User(username string) (*User, error) {
rows, err := a.db.Query(a.queries.selectUserByName, username)
return a.userTx(a.db, username)
}
func (a *Manager) userTx(tx db.Querier, username string) (*User, error) {
rows, err := tx.Query(a.queries.selectUserByName, username)
if err != nil {
return nil, err
}
@@ -415,7 +427,7 @@ func (a *Manager) userByToken(token string) (*User, error) {
// UserByStripeCustomer returns the user with the given Stripe customer ID if it exists, or ErrUserNotFound otherwise
func (a *Manager) UserByStripeCustomer(customerID string) (*User, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectUserByStripeCustomerID, customerID)
rows, err := a.db.Query(a.queries.selectUserByStripeCustomerID, customerID)
if err != nil {
return nil, err
}
@@ -642,7 +654,7 @@ func (a *Manager) AllowReservation(username string, topic string) error {
// - Furthermore, the query prioritizes more specific permissions (longer!) over more generic ones, e.g. "test*" > "*"
// - It also prioritizes write permissions over read permissions
func (a *Manager) authorizeTopicAccess(usernameOrEveryone, topic string) (read, write, found bool, err error) {
rows, err := a.db.Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)
rows, err := a.db.ReadOnly().Query(a.queries.selectTopicPerms, Everyone, usernameOrEveryone, topic)
if err != nil {
return false, false, false, err
}
@@ -713,16 +725,35 @@ func (a *Manager) Grants(username string) ([]Grant, error) {
// AddReservation creates two access control entries for the given topic: one with full read/write
// access for the given user, and one for Everyone with the given permission. Both entries are
// created atomically in a single transaction.
func (a *Manager) AddReservation(username string, topic string, everyone Permission) error {
// created atomically in a single transaction. If limit is > 0, the reservation count is checked
// inside the transaction and ErrTooManyReservations is returned if the limit would be exceeded.
func (a *Manager) AddReservation(username string, topic string, everyone Permission, limit int64) error {
if !AllowedUsername(username) || username == Everyone || !AllowedTopic(topic) {
return ErrInvalidArgument
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
if err := a.addReservationAccessTx(tx, username, topic, true, true, username); err != nil {
if limit > 0 {
hasReservation, err := a.hasReservationTx(tx, username, topic)
if err != nil {
return err
}
if !hasReservation {
count, err := a.reservationsCountTx(tx, username)
if err != nil {
return err
}
if count >= limit {
return ErrTooManyReservations
}
}
}
if _, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topic), true, true, username, username, false); err != nil {
return err
}
return a.addReservationAccessTx(tx, Everyone, topic, everyone.IsRead(), everyone.IsWrite(), username)
if _, err := tx.Exec(a.queries.upsertUserAccess, Everyone, toSQLWildcard(topic), everyone.IsRead(), everyone.IsWrite(), username, username, false); err != nil {
return err
}
return nil
})
}
@@ -740,10 +771,7 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
}
return db.ExecTx(a.db, func(tx *sql.Tx) error {
for _, topic := range topics {
if err := a.resetTopicAccessTx(tx, username, topic); err != nil {
return err
}
if err := a.resetTopicAccessTx(tx, Everyone, topic); err != nil {
if err := a.removeReservationAccessTx(tx, username, topic); err != nil {
return err
}
}
@@ -753,7 +781,11 @@ func (a *Manager) RemoveReservations(username string, topics ...string) error {
// Reservations returns all user-owned topics, and the associated everyone-access
func (a *Manager) Reservations(username string) ([]Reservation, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectUserReservations, Everyone, username)
return a.reservationsTx(a.db.ReadOnly(), username)
}
func (a *Manager) reservationsTx(tx db.Querier, username string) ([]Reservation, error) {
rows, err := tx.Query(a.queries.selectUserReservations, Everyone, username)
if err != nil {
return nil, err
}
@@ -779,7 +811,11 @@ func (a *Manager) Reservations(username string) ([]Reservation, error) {
// HasReservation returns true if the given topic access is owned by the user
func (a *Manager) HasReservation(username, topic string) (bool, error) {
rows, err := a.db.Query(a.queries.selectUserHasReservation, username, escapeUnderscore(topic))
return a.hasReservationTx(a.db, username, topic)
}
func (a *Manager) hasReservationTx(tx db.Querier, username, topic string) (bool, error) {
rows, err := tx.Query(a.queries.selectUserHasReservation, username, escapeUnderscore(topic))
if err != nil {
return false, err
}
@@ -796,7 +832,11 @@ func (a *Manager) HasReservation(username, topic string) (bool, error) {
// ReservationsCount returns the number of reservations owned by this user
func (a *Manager) ReservationsCount(username string) (int64, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectUserReservationsCount, username)
return a.reservationsCountTx(a.db, username)
}
func (a *Manager) reservationsCountTx(tx db.Querier, username string) (int64, error) {
rows, err := tx.Query(a.queries.selectUserReservationsCount, username)
if err != nil {
return 0, err
}
@@ -828,6 +868,30 @@ func (a *Manager) ReservationOwner(topic string) (string, error) {
return ownerUserID, nil
}
// RemoveExcessReservations removes reservations that exceed the given limit for the user.
// It returns the list of topics whose reservations were removed. The read and removal are
// performed atomically in a single transaction to avoid issues with stale replica data.
func (a *Manager) RemoveExcessReservations(username string, limit int64) ([]string, error) {
return db.QueryTx(a.db, func(tx *sql.Tx) ([]string, error) {
reservations, err := a.reservationsTx(tx, username)
if err != nil {
return nil, err
}
if int64(len(reservations)) <= limit {
return []string{}, nil
}
removedTopics := make([]string, 0)
for i := int64(len(reservations)) - 1; i >= limit; i-- {
topic := reservations[i].Topic
if err := a.removeReservationAccessTx(tx, username, topic); err != nil {
return nil, err
}
removedTopics = append(removedTopics, topic)
}
return removedTopics, nil
})
}
// otherAccessCount returns the number of access entries for the given topic that are not owned by the user
func (a *Manager) otherAccessCount(username, topic string) (int, error) {
rows, err := a.db.Query(a.queries.selectOtherAccessCount, escapeUnderscore(topic), escapeUnderscore(topic), username)
@@ -845,14 +909,11 @@ func (a *Manager) otherAccessCount(username, topic string) (int, error) {
return count, nil
}
func (a *Manager) addReservationAccessTx(tx *sql.Tx, username, topic string, read, write bool, ownerUsername string) error {
if !AllowedUsername(username) && username != Everyone {
return ErrInvalidArgument
} else if !AllowedTopicPattern(topic) {
return ErrInvalidArgument
func (a *Manager) removeReservationAccessTx(tx *sql.Tx, username, topic string) error {
if err := a.resetTopicAccessTx(tx, username, topic); err != nil {
return err
}
_, err := tx.Exec(a.queries.upsertUserAccess, username, toSQLWildcard(topic), read, write, ownerUsername, ownerUsername, false)
return err
return a.resetTopicAccessTx(tx, Everyone, topic)
}
func (a *Manager) resetUserAccessTx(tx *sql.Tx, username string) error {
@@ -1134,7 +1195,7 @@ func (a *Manager) Tiers() ([]*Tier, error) {
// Tier returns a Tier based on the code, or ErrTierNotFound if it does not exist
func (a *Manager) Tier(code string) (*Tier, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectTierByCode, code)
rows, err := a.db.Query(a.queries.selectTierByCode, code)
if err != nil {
return nil, err
}
@@ -1144,7 +1205,7 @@ func (a *Manager) Tier(code string) (*Tier, error) {
// TierByStripePrice returns a Tier based on the Stripe price ID, or ErrTierNotFound if it does not exist
func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) {
rows, err := a.db.ReadOnly().Query(a.queries.selectTierByPriceID, priceID, priceID)
rows, err := a.db.Query(a.queries.selectTierByPriceID, priceID, priceID)
if err != nil {
return nil, err
}

View File

@@ -226,7 +226,7 @@ func TestManager_MarkUserRemoved_RemoveDeletedUsers(t *testing.T) {
// Create user, add reservations and token
require.Nil(t, a.AddUser("user", "pass", RoleAdmin, false))
require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead))
require.Nil(t, a.AddReservation("user", "mytopic", PermissionRead, 0))
u, err := a.User("user")
require.Nil(t, err)
@@ -439,8 +439,8 @@ func TestManager_Reservations(t *testing.T) {
a := newTestManager(t, newManager, PermissionDenyAll)
require.Nil(t, a.AddUser("phil", "phil", RoleUser, false))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll))
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead))
require.Nil(t, a.AddReservation("ben", "ztopic_", PermissionDenyAll, 0))
require.Nil(t, a.AddReservation("ben", "readme", PermissionRead, 0))
require.Nil(t, a.AllowAccess("ben", "something-else", PermissionRead))
reservations, err := a.Reservations("ben")
@@ -523,7 +523,7 @@ func TestManager_ChangeRoleFromTierUserToAdmin(t *testing.T) {
}))
require.Nil(t, a.AddUser("ben", "ben", RoleUser, false))
require.Nil(t, a.ChangeTier("ben", "pro"))
require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll))
require.Nil(t, a.AddReservation("ben", "mytopic", PermissionDenyAll, 0))
ben, err := a.User("ben")
require.Nil(t, err)
@@ -1076,7 +1076,7 @@ func TestManager_Tier_Change_And_Reset(t *testing.T) {
// Add 10 reservations (pro tier allows that)
for i := 0; i < 4; i++ {
require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite))
require.Nil(t, a.AddReservation("phil", fmt.Sprintf("topic%d", i), PermissionWrite, 0))
}
// Downgrading will not work (too many reservations)
@@ -2118,7 +2118,7 @@ func TestStoreAuthorizeTopicAccessDenyAll(t *testing.T) {
func TestStoreReservations(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionRead))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionRead, 0))
reservations, err := manager.Reservations("phil")
require.Nil(t, err)
@@ -2133,8 +2133,8 @@ func TestStoreReservations(t *testing.T) {
func TestStoreReservationsCount(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddReservation("phil", "topic1", PermissionReadWrite))
require.Nil(t, manager.AddReservation("phil", "topic2", PermissionReadWrite))
require.Nil(t, manager.AddReservation("phil", "topic1", PermissionReadWrite, 0))
require.Nil(t, manager.AddReservation("phil", "topic2", PermissionReadWrite, 0))
count, err := manager.ReservationsCount("phil")
require.Nil(t, err)
@@ -2145,7 +2145,7 @@ func TestStoreReservationsCount(t *testing.T) {
func TestStoreHasReservation(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite, 0))
has, err := manager.HasReservation("phil", "mytopic")
require.Nil(t, err)
@@ -2160,7 +2160,7 @@ func TestStoreHasReservation(t *testing.T) {
func TestStoreReservationOwner(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite))
require.Nil(t, manager.AddReservation("phil", "mytopic", PermissionReadWrite, 0))
owner, err := manager.ReservationOwner("mytopic")
require.Nil(t, err)
@@ -2172,6 +2172,26 @@ func TestStoreReservationOwner(t *testing.T) {
})
}
func TestStoreAddReservationWithLimit(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
// Adding reservations within limit succeeds
require.Nil(t, manager.AddReservation("phil", "topic1", PermissionReadWrite, 2))
require.Nil(t, manager.AddReservation("phil", "topic2", PermissionRead, 2))
// Adding a third reservation exceeds the limit
require.Equal(t, ErrTooManyReservations, manager.AddReservation("phil", "topic3", PermissionRead, 2))
// Updating an existing reservation within the limit succeeds
require.Nil(t, manager.AddReservation("phil", "topic1", PermissionRead, 2))
reservations, err := manager.Reservations("phil")
require.Nil(t, err)
require.Len(t, reservations, 2)
})
}
func TestStoreTiers(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
tier := &Tier{
@@ -2431,7 +2451,7 @@ func TestStoreOtherAccessCount(t *testing.T) {
forEachStoreBackend(t, func(t *testing.T, manager *Manager) {
require.Nil(t, manager.AddUser("phil", "mypass", RoleUser, false))
require.Nil(t, manager.AddUser("ben", "benpass", RoleUser, false))
require.Nil(t, manager.AddReservation("ben", "mytopic", PermissionReadWrite))
require.Nil(t, manager.AddReservation("ben", "mytopic", PermissionReadWrite, 0))
count, err := manager.otherAccessCount("phil", "mytopic")
require.Nil(t, err)

View File

@@ -17,6 +17,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/gabriel-vasile/mimetype"
"golang.org/x/term"
@@ -434,3 +435,22 @@ func Int(v int) *int {
func Time(v time.Time) *time.Time {
return &v
}
// SanitizeUTF8 ensures a string is safe to store in PostgreSQL by handling two cases:
//
// 1. Invalid UTF-8 sequences: Some clients send Latin-1/ISO-8859-1 encoded text (e.g. accented
// characters like é, ñ, ß) in HTTP headers or SMTP messages. Go treats these as raw bytes in
// strings, but PostgreSQL rejects them. Any invalid UTF-8 byte is replaced with the Unicode
// replacement character (U+FFFD, "<22>") so the message is still delivered rather than lost.
//
// 2. NUL bytes (0x00): These are valid in UTF-8 but PostgreSQL TEXT columns reject them.
// They are stripped entirely.
func SanitizeUTF8(s string) string {
if !utf8.ValidString(s) {
s = strings.ToValidUTF8(s, "\xef\xbf\xbd") // U+FFFD
}
if strings.ContainsRune(s, 0) {
s = strings.ReplaceAll(s, "\x00", "")
}
return s
}

36
web/package-lock.json generated
View File

@@ -3642,9 +3642,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -3766,9 +3766,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001777",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz",
"integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==",
"version": "1.0.30001779",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz",
"integrity": "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA==",
"dev": true,
"funding": [
{
@@ -4203,9 +4203,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.307",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
"integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==",
"version": "1.5.313",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz",
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==",
"dev": true,
"license": "ISC"
},
@@ -4324,9 +4324,9 @@
}
},
"node_modules/es-iterator-helpers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.0.tgz",
"integrity": "sha512-04cg8iJFDOxWcYlu0GFFWgs7vtaEPCmr5w1nrj9V3z3axu/48HCMwK6VMp45Zh3ZB+xLP1ifbJfrq86+1ypKKQ==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz",
"integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7043,9 +7043,9 @@
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
@@ -8307,9 +8307,9 @@
}
},
"node_modules/terser": {
"version": "5.46.0",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz",
"integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==",
"version": "5.46.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {

View File

@@ -120,7 +120,7 @@
"publish_dialog_priority_low": "Prioridad baja",
"publish_dialog_priority_high": "Prioridad alta",
"publish_dialog_delay_label": "Retraso",
"publish_dialog_title_placeholder": "Título de la notificación, por ejemplo, Alerta de espacio en disco",
"publish_dialog_title_placeholder": "Título de la notificación, ej. Alerta de espacio en disco",
"publish_dialog_details_examples_description": "Para ver ejemplos y una descripción detallada de todas las funciones de envío, consulte la <docsLink>documentación</docsLink>.",
"publish_dialog_attach_placeholder": "Adjuntar un archivo por URL, por ejemplo, https://f-droid.org/F-Droid.apk",
"publish_dialog_filename_placeholder": "Nombre del archivo adjunto",

View File

@@ -63,9 +63,10 @@ func (s *Store) UpsertSubscription(endpoint string, auth, p256dh, userID string,
} else if err != nil {
return err
}
// Insert or update subscription
// Insert or update subscription, and read back the actual ID (which may differ from
// the generated one if another request for the same endpoint raced us and inserted first)
updatedAt, warnedAt := time.Now().Unix(), 0
if _, err := tx.Exec(s.queries.upsertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt); err != nil {
if err := tx.QueryRow(s.queries.upsertSubscription, subscriptionID, endpoint, auth, p256dh, userID, subscriberIP.String(), updatedAt, warnedAt).Scan(&subscriptionID); err != nil {
return err
}
// Replace all subscription topics

View File

@@ -53,6 +53,7 @@ const (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (endpoint)
DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
RETURNING id
`
postgresUpdateSubscriptionWarningSentQuery = `UPDATE webpush_subscription SET warned_at = $1 WHERE id = $2`
postgresUpdateSubscriptionUpdatedAtQuery = `UPDATE webpush_subscription SET updated_at = $1 WHERE endpoint = $2`

View File

@@ -56,8 +56,9 @@ const (
sqliteUpsertSubscriptionQuery = `
INSERT INTO subscription (id, endpoint, key_auth, key_p256dh, user_id, subscriber_ip, updated_at, warned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (endpoint)
ON CONFLICT (endpoint)
DO UPDATE SET key_auth = excluded.key_auth, key_p256dh = excluded.key_p256dh, user_id = excluded.user_id, subscriber_ip = excluded.subscriber_ip, updated_at = excluded.updated_at, warned_at = excluded.warned_at
RETURNING id
`
sqliteUpdateSubscriptionWarningSentQuery = `UPDATE subscription SET warned_at = ? WHERE id = ?`
sqliteUpdateSubscriptionUpdatedAtQuery = `UPDATE subscription SET updated_at = ? WHERE endpoint = ?`