Compare commits

...

60 Commits

Author SHA1 Message Date
Philipp Heckel
7fb7ba2fa5 Merge branch 'main' of github.com:binwiederhier/ntfy into main 2022-02-03 22:33:30 -05:00
Philipp Heckel
3902599c52 More tests; enough! 2022-02-03 22:26:22 -05:00
Philipp Heckel
4972407145 More polishing, more docs; the only thing left are tests for access.go 2022-02-03 20:20:50 -05:00
Philipp Heckel
d714af43c9 More docs, more tests, more docs 2022-02-03 20:07:23 -05:00
Philipp Heckel
29c2fc5472 CLI tests for 'ntfy user' 2022-02-03 16:10:15 -05:00
Philipp Heckel
1c9766b8fd More docs 2022-02-03 13:40:19 -05:00
Philipp Heckel
68351230f3 Docs docs docs docs docs 2022-02-02 22:46:48 -05:00
Philipp Heckel
0ad85262c1 More docs; this will never end 2022-02-02 19:26:17 -05:00
Philipp Heckel
1552d8103e More docs docs docs 2022-02-01 23:39:57 -05:00
Philipp Heckel
c3a2331b59 WIP: Docs 2022-02-01 21:07:42 -05:00
Philipp Heckel
5cf92c55c6 Docs and minor improvements to "ntfy access" 2022-02-01 16:40:33 -05:00
Philipp Heckel
e56eb0c178 Allow 'ntfy access --reset' 2022-02-01 12:23:11 -05:00
Philipp Heckel
44bc13eb2c Update deps 2022-02-01 12:11:52 -05:00
Philipp Heckel
a77f89d302 Test Firebase stuff 2022-02-01 08:47:01 -05:00
Philipp Heckel
b1bbbf0103 Simplify again 2022-01-31 20:12:47 -05:00
Philipp Heckel
c2f31b9c9f Move firebase to seperate file 2022-01-31 20:06:48 -05:00
Philipp Heckel
198e2cfd90 Do not forward messages to Firebase if topic is not world-readable 2022-01-31 19:33:22 -05:00
Philipp Heckel
936e95fd9e Rename Topic to TopicPattern in Grant 2022-01-31 11:47:30 -05:00
Philipp Heckel
c56814e7da Add wildcard access control 2022-01-31 11:44:58 -05:00
Philipp Heckel
631ade5430 Support multiple topics in auth 2022-01-27 12:49:05 -05:00
Philipp Heckel
e61a0c2f78 Add /auth endpoint and tests 2022-01-25 23:04:09 -05:00
Philipp Heckel
89957e7058 Docblocking 2022-01-25 22:30:53 -05:00
Philipp Heckel
26dde0f286 Fix test 2022-01-25 22:04:54 -05:00
Philipp Heckel
7d9f687768 Tests 2022-01-25 21:57:28 -05:00
Philipp C. Heckel
0a0fea1c2f Merge pull request #118 from xnumad/patch-1
Docs clarification
2022-01-24 18:35:15 -05:00
xnumad
cb4970be59 Docs clarification 2022-01-25 00:29:25 +01:00
Philipp Heckel
460162737a More auth 2022-01-24 00:54:28 -05:00
Philipp Heckel
393f95aeac Auth CLI, continued 2022-01-23 23:02:39 -05:00
Philipp Heckel
03a4e3e8e9 More CLI for access control 2022-01-23 15:30:30 -05:00
Philipp Heckel
243d549975 Command help 2022-01-23 01:00:38 -05:00
Philipp Heckel
e309775ac1 More auth CLi 2022-01-23 00:54:18 -05:00
Philipp Heckel
f388fd9c90 Move to package 2022-01-23 00:02:16 -05:00
Philipp Heckel
b908f07355 . 2022-01-22 23:07:55 -05:00
Philipp Heckel
1287594505 Simplify tables 2022-01-22 23:01:20 -05:00
Philipp Heckel
86b20e8ccd All the auth things are working now 2022-01-22 14:47:27 -05:00
Philipp Heckel
2181227a6e WIP: Auth in 80 lines of code :-) 2022-01-21 22:22:27 -05:00
Philipp Heckel
aab705f4a4 Fix baseURL check 2022-01-21 14:17:59 -05:00
Philipp Heckel
8af9a97518 Update server.yml docs 2022-01-20 20:40:12 -05:00
Philipp Heckel
9fac75b831 Check base URL format 2022-01-20 13:20:04 -05:00
Philipp Heckel
c83b5c6e73 Update another instance of a broken link; related to #106 2022-01-18 11:59:13 -05:00
Philipp Heckel
a75326ff69 Fixed docs link; closes #106 2022-01-18 11:55:39 -05:00
Philipp Heckel
df6611e8de Store encoding in cache database 2022-01-17 19:14:51 -05:00
Philipp Heckel
7e817f408c Send encoding in Firebase (even though that doesn't make a ton of sense right now) 2022-01-17 13:49:02 -05:00
Philipp Heckel
4ceb058a40 Handle binary messages for UnifiedPush 2022-01-17 13:28:07 -05:00
Philipp Heckel
4710812c24 Bump version 2022-01-16 21:57:15 -05:00
Philipp Heckel
eb37c47ff5 Typo 2022-01-16 21:56:26 -05:00
Philipp Heckel
e80c2c1a57 Merge branch 'main' of github.com:binwiederhier/ntfy into main 2022-01-16 17:55:18 -05:00
Philipp Heckel
75f8607d75 Bail out early if HTTP is spoken to /ws endpoint 2022-01-16 17:54:15 -05:00
Philipp C. Heckel
828a286809 Merge pull request #95 from binwiederhier/websockets
Websockets; working
2022-01-16 00:21:31 -05:00
Philipp Heckel
9b0e7eedb2 Fix data race 2022-01-16 00:07:32 -05:00
Philipp Heckel
df4585af6b Attachment comment 2022-01-15 23:53:40 -05:00
Philipp Heckel
91d40dcc91 Docs 2022-01-15 23:52:21 -05:00
Philipp Heckel
2b6363474e Combine things, move stuff 2022-01-15 23:17:46 -05:00
Philipp Heckel
707c58a120 Do not print ugly WS error; tests 2022-01-15 22:33:35 -05:00
Philipp Heckel
846ee0fb2d Websockets; working 2022-01-15 13:23:35 -05:00
Philipp Heckel
cdc9c0d62c Docs 2022-01-14 21:22:43 -05:00
Philipp Heckel
b079cb99a4 Merge branch 'main' into unix-socket 2022-01-14 20:16:12 -05:00
Philipp Heckel
a75f74b471 Bump version; update docs 2022-01-14 12:23:58 -05:00
Philipp Heckel
e50779664d Remove peaking, addresses #93 2022-01-14 12:13:14 -05:00
Philipp Heckel
0b0595384e Listen on Unix socket 2022-01-10 16:28:13 -05:00
39 changed files with 3487 additions and 627 deletions

View File

@@ -61,6 +61,8 @@ nfpms:
type: dir
- dst: /var/cache/ntfy/attachments
type: dir
- dst: /var/lib/ntfy
type: dir
- dst: /usr/share/ntfy/logo.png
src: server/static/img/ntfy.png
scripts:

View File

@@ -80,7 +80,7 @@ vet:
go vet ./...
lint:
which golint || go get -u golang.org/x/lint/golint
which golint || go install golang.org/x/lint/golint@latest
go list ./... | grep -v /vendor/ | xargs -L1 golint -set_exit_status
staticcheck: .PHONY

122
auth/auth.go Normal file
View File

@@ -0,0 +1,122 @@
// Package auth deals with authentication and authorization against topics
package auth
import (
"errors"
"regexp"
)
// Auther is a generic interface to implement password-based authentication and authorization
type Auther interface {
// Authenticate checks username and password and returns a user if correct. The method
// returns in constant-ish time, regardless of whether the user exists or the password is
// correct or incorrect.
Authenticate(username, password string) (*User, error)
// Authorize returns nil if the given user has access to the given topic using the desired
// permission. The user param may be nil to signal an anonymous user.
Authorize(user *User, topic string, perm Permission) error
}
// Manager is an interface representing user and access management
type Manager interface {
// AddUser adds a user with the given username, password and role. The password should be hashed
// before it is stored in a persistence layer.
AddUser(username, password string, role Role) error
// RemoveUser deletes the user with the given username. The function returns nil on success, even
// if the user did not exist in the first place.
RemoveUser(username string) error
// Users returns a list of users. It always also returns the Everyone user ("*").
Users() ([]*User, error)
// User returns the user with the given username if it exists, or ErrNotFound otherwise.
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
User(username string) (*User, error)
// ChangePassword changes a user's password
ChangePassword(username, password string) error
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
// all existing access control entries (Grant) are removed, since they are no longer needed.
ChangeRole(username string, role Role) error
// AllowAccess adds or updates an entry in th access control list for a specific user. It controls
// read/write access to a topic. The parameter topicPattern may include wildcards (*).
AllowAccess(username string, topicPattern string, read bool, write bool) error
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
ResetAccess(username string, topicPattern string) error
// DefaultAccess returns the default read/write access if no access control entry matches
DefaultAccess() (read bool, write bool)
}
// User is a struct that represents a user
type User struct {
Name string
Hash string // password hash (bcrypt)
Role Role
Grants []Grant
}
// Grant is a struct that represents an access control entry to a topic
type Grant struct {
TopicPattern string // May include wildcard (*)
AllowRead bool
AllowWrite bool
}
// Permission represents a read or write permission to a topic
type Permission int
// Permissions to a topic
const (
PermissionRead = Permission(1)
PermissionWrite = Permission(2)
)
// Role represents a user's role, either admin or regular user
type Role string
// User roles
const (
RoleAdmin = Role("admin")
RoleUser = Role("user")
RoleAnonymous = Role("anonymous")
)
// Everyone is a special username representing anonymous users
const (
Everyone = "*"
)
var (
allowedUsernameRegex = regexp.MustCompile(`^[-_.@a-zA-Z0-9]+$`) // Does not include Everyone (*)
allowedTopicPatternRegex = regexp.MustCompile(`^[-_*A-Za-z0-9]{1,64}$`) // Adds '*' for wildcards!
)
// AllowedRole returns true if the given role can be used for new users
func AllowedRole(role Role) bool {
return role == RoleUser || role == RoleAdmin
}
// AllowedUsername returns true if the given username is valid
func AllowedUsername(username string) bool {
return allowedUsernameRegex.MatchString(username)
}
// AllowedTopicPattern returns true if the given topic pattern is valid; this includes the wildcard character (*)
func AllowedTopicPattern(username string) bool {
return allowedTopicPatternRegex.MatchString(username)
}
// Error constants used by the package
var (
ErrUnauthenticated = errors.New("unauthenticated")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidArgument = errors.New("invalid argument")
ErrNotFound = errors.New("not found")
)

399
auth/auth_sqlite.go Normal file
View File

@@ -0,0 +1,399 @@
package auth
import (
"database/sql"
"errors"
"fmt"
_ "github.com/mattn/go-sqlite3" // SQLite driver
"golang.org/x/crypto/bcrypt"
"strings"
)
const (
bcryptCost = 11
intentionalSlowDownHash = "$2a$11$eX15DeF27FwAgXt9wqJF0uAUMz74XywJcGBH3kP93pzKYv6ATk2ka" // Cost should match bcryptCost
)
// Auther-related queries
const (
createAuthTablesQueries = `
BEGIN;
CREATE TABLE IF NOT EXISTS user (
user TEXT NOT NULL PRIMARY KEY,
pass TEXT NOT NULL,
role TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS access (
user TEXT NOT NULL,
topic TEXT NOT NULL,
read INT NOT NULL,
write INT NOT NULL,
PRIMARY KEY (topic, user)
);
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
version INT NOT NULL
);
COMMIT;
`
selectUserQuery = `SELECT pass, role FROM user WHERE user = ?`
selectTopicPermsQuery = `
SELECT read, write
FROM access
WHERE user IN ('*', ?) AND ? LIKE topic
ORDER BY user DESC
`
)
// Manager-related queries
const (
insertUserQuery = `INSERT INTO user (user, pass, role) VALUES (?, ?, ?)`
selectUsernamesQuery = `SELECT user FROM user ORDER BY role, user`
updateUserPassQuery = `UPDATE user SET pass = ? WHERE user = ?`
updateUserRoleQuery = `UPDATE user SET role = ? WHERE user = ?`
deleteUserQuery = `DELETE FROM user WHERE user = ?`
upsertUserAccessQuery = `
INSERT INTO access (user, topic, read, write)
VALUES (?, ?, ?, ?)
ON CONFLICT (user, topic) DO UPDATE SET read=excluded.read, write=excluded.write
`
selectUserAccessQuery = `SELECT topic, read, write FROM access WHERE user = ?`
deleteAllAccessQuery = `DELETE FROM access`
deleteUserAccessQuery = `DELETE FROM access WHERE user = ?`
deleteTopicAccessQuery = `DELETE FROM access WHERE user = ? AND topic = ?`
)
// Schema management queries
const (
currentSchemaVersion = 1
insertSchemaVersion = `INSERT INTO schemaVersion VALUES (1, ?)`
selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
)
// SQLiteAuth is an implementation of Auther and Manager. It stores users and access control list
// in a SQLite database.
type SQLiteAuth struct {
db *sql.DB
defaultRead bool
defaultWrite bool
}
var _ Auther = (*SQLiteAuth)(nil)
var _ Manager = (*SQLiteAuth)(nil)
// NewSQLiteAuth creates a new SQLiteAuth instance
func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) {
db, err := sql.Open("sqlite3", filename)
if err != nil {
return nil, err
}
if err := setupAuthDB(db); err != nil {
return nil, err
}
return &SQLiteAuth{
db: db,
defaultRead: defaultRead,
defaultWrite: defaultWrite,
}, nil
}
// Authenticate checks username and password and returns a user if correct. The method
// returns in constant-ish time, regardless of whether the user exists or the password is
// correct or incorrect.
func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
if username == Everyone {
return nil, ErrUnauthenticated
}
user, err := a.User(username)
if err != nil {
bcrypt.CompareHashAndPassword([]byte(intentionalSlowDownHash),
[]byte("intentional slow-down to avoid timing attacks"))
return nil, ErrUnauthenticated
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Hash), []byte(password)); err != nil {
return nil, ErrUnauthenticated
}
return user, nil
}
// Authorize returns nil if the given user has access to the given topic using the desired
// permission. The user param may be nil to signal an anonymous user.
func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error {
if user != nil && user.Role == RoleAdmin {
return nil // Admin can do everything
}
username := Everyone
if user != nil {
username = user.Name
}
// Select the read/write permissions for this user/topic combo. The query may return two
// rows (one for everyone, and one for the user), but prioritizes the user. The value for
// user.Name may be empty (= everyone).
rows, err := a.db.Query(selectTopicPermsQuery, username, topic)
if err != nil {
return err
}
defer rows.Close()
if !rows.Next() {
return a.resolvePerms(a.defaultRead, a.defaultWrite, perm)
}
var read, write bool
if err := rows.Scan(&read, &write); err != nil {
return err
} else if err := rows.Err(); err != nil {
return err
}
return a.resolvePerms(read, write, perm)
}
func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error {
if perm == PermissionRead && read {
return nil
} else if perm == PermissionWrite && write {
return nil
}
return ErrUnauthorized
}
// AddUser adds a user with the given username, password and role. The password should be hashed
// before it is stored in a persistence layer.
func (a *SQLiteAuth) AddUser(username, password string, role Role) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return err
}
if _, err = a.db.Exec(insertUserQuery, username, hash, role); err != nil {
return err
}
return nil
}
// RemoveUser deletes the user with the given username. The function returns nil on success, even
// if the user did not exist in the first place.
func (a *SQLiteAuth) RemoveUser(username string) error {
if !AllowedUsername(username) {
return ErrInvalidArgument
}
if _, err := a.db.Exec(deleteUserQuery, username); err != nil {
return err
}
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
return err
}
return nil
}
// Users returns a list of users. It always also returns the Everyone user ("*").
func (a *SQLiteAuth) Users() ([]*User, error) {
rows, err := a.db.Query(selectUsernamesQuery)
if err != nil {
return nil, err
}
defer rows.Close()
usernames := make([]string, 0)
for rows.Next() {
var username string
if err := rows.Scan(&username); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
usernames = append(usernames, username)
}
rows.Close()
users := make([]*User, 0)
for _, username := range usernames {
user, err := a.User(username)
if err != nil {
return nil, err
}
users = append(users, user)
}
everyone, err := a.everyoneUser()
if err != nil {
return nil, err
}
users = append(users, everyone)
return users, nil
}
// User returns the user with the given username if it exists, or ErrNotFound otherwise.
// You may also pass Everyone to retrieve the anonymous user and its Grant list.
func (a *SQLiteAuth) User(username string) (*User, error) {
if username == Everyone {
return a.everyoneUser()
}
rows, err := a.db.Query(selectUserQuery, username)
if err != nil {
return nil, err
}
defer rows.Close()
var hash, role string
if !rows.Next() {
return nil, ErrNotFound
}
if err := rows.Scan(&hash, &role); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
grants, err := a.readGrants(username)
if err != nil {
return nil, err
}
return &User{
Name: username,
Hash: hash,
Role: Role(role),
Grants: grants,
}, nil
}
func (a *SQLiteAuth) everyoneUser() (*User, error) {
grants, err := a.readGrants(Everyone)
if err != nil {
return nil, err
}
return &User{
Name: Everyone,
Hash: "",
Role: RoleAnonymous,
Grants: grants,
}, nil
}
func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) {
rows, err := a.db.Query(selectUserAccessQuery, username)
if err != nil {
return nil, err
}
defer rows.Close()
grants := make([]Grant, 0)
for rows.Next() {
var topic string
var read, write bool
if err := rows.Scan(&topic, &read, &write); err != nil {
return nil, err
} else if err := rows.Err(); err != nil {
return nil, err
}
grants = append(grants, Grant{
TopicPattern: fromSQLWildcard(topic),
AllowRead: read,
AllowWrite: write,
})
}
return grants, nil
}
// ChangePassword changes a user's password
func (a *SQLiteAuth) ChangePassword(username, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
return err
}
if _, err := a.db.Exec(updateUserPassQuery, hash, username); err != nil {
return err
}
return nil
}
// ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
// all existing access control entries (Grant) are removed, since they are no longer needed.
func (a *SQLiteAuth) ChangeRole(username string, role Role) error {
if !AllowedUsername(username) || !AllowedRole(role) {
return ErrInvalidArgument
}
if _, err := a.db.Exec(updateUserRoleQuery, string(role), username); err != nil {
return err
}
if role == RoleAdmin {
if _, err := a.db.Exec(deleteUserAccessQuery, username); err != nil {
return err
}
}
return nil
}
// AllowAccess adds or updates an entry in th access control list for a specific user. It controls
// read/write access to a topic. The parameter topicPattern may include wildcards (*).
func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool, write bool) error {
if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) {
return ErrInvalidArgument
}
if _, err := a.db.Exec(upsertUserAccessQuery, username, toSQLWildcard(topicPattern), read, write); err != nil {
return err
}
return nil
}
// ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
// empty) for an entire user. The parameter topicPattern may include wildcards (*).
func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error {
if !AllowedUsername(username) && username != Everyone && username != "" {
return ErrInvalidArgument
} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
return ErrInvalidArgument
}
if username == "" && topicPattern == "" {
_, err := a.db.Exec(deleteAllAccessQuery, username)
return err
} else if topicPattern == "" {
_, err := a.db.Exec(deleteUserAccessQuery, username)
return err
}
_, err := a.db.Exec(deleteTopicAccessQuery, username, toSQLWildcard(topicPattern))
return err
}
// DefaultAccess returns the default read/write access if no access control entry matches
func (a *SQLiteAuth) DefaultAccess() (read bool, write bool) {
return a.defaultRead, a.defaultWrite
}
func toSQLWildcard(s string) string {
return strings.ReplaceAll(s, "*", "%")
}
func fromSQLWildcard(s string) string {
return strings.ReplaceAll(s, "%", "*")
}
func setupAuthDB(db *sql.DB) error {
// If 'schemaVersion' table does not exist, this must be a new database
rowsSV, err := db.Query(selectSchemaVersionQuery)
if err != nil {
return setupNewAuthDB(db)
}
defer rowsSV.Close()
// If 'schemaVersion' table exists, read version and potentially upgrade
schemaVersion := 0
if !rowsSV.Next() {
return errors.New("cannot determine schema version: database file may be corrupt")
}
if err := rowsSV.Scan(&schemaVersion); err != nil {
return err
}
rowsSV.Close()
// Do migrations
if schemaVersion == currentSchemaVersion {
return nil
}
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
}
func setupNewAuthDB(db *sql.DB) error {
if _, err := db.Exec(createAuthTablesQueries); err != nil {
return err
}
if _, err := db.Exec(insertSchemaVersion, currentSchemaVersion); err != nil {
return err
}
return nil
}

241
auth/auth_sqlite_test.go Normal file
View File

@@ -0,0 +1,241 @@
package auth_test
import (
"github.com/stretchr/testify/require"
"heckel.io/ntfy/auth"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSQLiteAuth_FullScenario_Default_DenyAll(t *testing.T) {
a := newTestAuth(t, false, false)
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair!
require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false))
require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true))
require.Nil(t, a.AllowAccess(auth.Everyone, "up*", false, true)) // Everyone can write to /up*
phil, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
require.Equal(t, "phil", phil.Name)
require.True(t, strings.HasPrefix(phil.Hash, "$2a$11$"))
require.Equal(t, auth.RoleAdmin, phil.Role)
require.Equal(t, []auth.Grant{}, phil.Grants)
ben, err := a.Authenticate("ben", "ben")
require.Nil(t, err)
require.Equal(t, "ben", ben.Name)
require.True(t, strings.HasPrefix(ben.Hash, "$2a$11$"))
require.Equal(t, auth.RoleUser, ben.Role)
require.Equal(t, []auth.Grant{
{"mytopic", true, true},
{"readme", true, false},
{"writeme", false, true},
{"everyonewrite", false, false},
}, ben.Grants)
notben, err := a.Authenticate("ben", "this is wrong")
require.Nil(t, notben)
require.Equal(t, auth.ErrUnauthenticated, err)
// Admin can do everything
require.Nil(t, a.Authorize(phil, "sometopic", auth.PermissionWrite))
require.Nil(t, a.Authorize(phil, "mytopic", auth.PermissionRead))
require.Nil(t, a.Authorize(phil, "readme", auth.PermissionWrite))
require.Nil(t, a.Authorize(phil, "writeme", auth.PermissionWrite))
require.Nil(t, a.Authorize(phil, "announcements", auth.PermissionWrite))
require.Nil(t, a.Authorize(phil, "everyonewrite", auth.PermissionWrite))
// User cannot do everything
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite))
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead))
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionWrite))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "writeme", auth.PermissionRead))
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionRead))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "everyonewrite", auth.PermissionWrite))
require.Nil(t, a.Authorize(ben, "announcements", auth.PermissionRead))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "announcements", auth.PermissionWrite))
// Everyone else can do barely anything
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionRead))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "sometopicnotinthelist", auth.PermissionWrite))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionRead))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "mytopic", auth.PermissionWrite))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionRead))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "readme", auth.PermissionWrite))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionRead))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "writeme", auth.PermissionWrite))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(nil, "announcements", auth.PermissionWrite))
require.Nil(t, a.Authorize(nil, "announcements", auth.PermissionRead))
require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionRead))
require.Nil(t, a.Authorize(nil, "everyonewrite", auth.PermissionWrite))
require.Nil(t, a.Authorize(nil, "up1234", auth.PermissionWrite)) // Wildcard permission
require.Nil(t, a.Authorize(nil, "up5678", auth.PermissionWrite))
}
func TestSQLiteAuth_AddUser_Invalid(t *testing.T) {
a := newTestAuth(t, false, false)
require.Equal(t, auth.ErrInvalidArgument, a.AddUser(" invalid ", "pass", auth.RoleAdmin))
require.Equal(t, auth.ErrInvalidArgument, a.AddUser("validuser", "pass", "invalid-role"))
}
func TestSQLiteAuth_AddUser_Timing(t *testing.T) {
a := newTestAuth(t, false, false)
start := time.Now().UnixMilli()
require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin))
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, int64(100)) // Ideally should be > 200ms, but let's not make a brittle
}
func TestSQLiteAuth_Authenticate_Timing(t *testing.T) {
a := newTestAuth(t, false, false)
require.Nil(t, a.AddUser("user", "pass", auth.RoleAdmin))
// Timing a correct attempt
start := time.Now().UnixMilli()
_, err := a.Authenticate("user", "pass")
require.Nil(t, err)
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, int64(100)) // Ideally should be > 200ms, but let's not make a brittle
// Timing an incorrect attempt
start = time.Now().UnixMilli()
_, err = a.Authenticate("user", "INCORRECT")
require.Equal(t, auth.ErrUnauthenticated, err)
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, int64(100)) // Ideally should be > 200ms, but let's not make a brittle
// Timing a non-existing user attempt
start = time.Now().UnixMilli()
_, err = a.Authenticate("DOES-NOT-EXIST", "hithere")
require.Equal(t, auth.ErrUnauthenticated, err)
require.GreaterOrEqual(t, time.Now().UnixMilli()-start, int64(100)) // Ideally should be > 200ms, but let's not make a brittle
}
func TestSQLiteAuth_UserManagement(t *testing.T) {
a := newTestAuth(t, false, false)
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
require.Nil(t, a.AllowAccess("ben", "everyonewrite", false, false)) // How unfair!
require.Nil(t, a.AllowAccess(auth.Everyone, "announcements", true, false))
require.Nil(t, a.AllowAccess(auth.Everyone, "everyonewrite", true, true))
// Query user details
phil, err := a.User("phil")
require.Nil(t, err)
require.Equal(t, "phil", phil.Name)
require.True(t, strings.HasPrefix(phil.Hash, "$2a$11$"))
require.Equal(t, auth.RoleAdmin, phil.Role)
require.Equal(t, []auth.Grant{}, phil.Grants)
ben, err := a.User("ben")
require.Nil(t, err)
require.Equal(t, "ben", ben.Name)
require.True(t, strings.HasPrefix(ben.Hash, "$2a$11$"))
require.Equal(t, auth.RoleUser, ben.Role)
require.Equal(t, []auth.Grant{
{"mytopic", true, true},
{"readme", true, false},
{"writeme", false, true},
{"everyonewrite", false, false},
}, ben.Grants)
everyone, err := a.User(auth.Everyone)
require.Nil(t, err)
require.Equal(t, "*", everyone.Name)
require.Equal(t, "", everyone.Hash)
require.Equal(t, auth.RoleAnonymous, everyone.Role)
require.Equal(t, []auth.Grant{
{"announcements", true, false},
{"everyonewrite", true, true},
}, everyone.Grants)
// Ben: Before revoking
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
require.Nil(t, a.AllowAccess("ben", "writeme", false, true))
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionRead))
require.Nil(t, a.Authorize(ben, "mytopic", auth.PermissionWrite))
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead))
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite))
// Revoke access for "ben" to "mytopic", then check again
require.Nil(t, a.ResetAccess("ben", "mytopic"))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionWrite)) // Revoked
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "mytopic", auth.PermissionRead)) // Revoked
require.Nil(t, a.Authorize(ben, "readme", auth.PermissionRead)) // Unchanged
require.Nil(t, a.Authorize(ben, "writeme", auth.PermissionWrite)) // Unchanged
// Revoke rest of the access
require.Nil(t, a.ResetAccess("ben", ""))
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "readme", auth.PermissionRead)) // Revoked
require.Equal(t, auth.ErrUnauthorized, a.Authorize(ben, "wrtiteme", auth.PermissionWrite)) // Revoked
// User list
users, err := a.Users()
require.Nil(t, err)
require.Equal(t, 3, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "ben", users[1].Name)
require.Equal(t, "*", users[2].Name)
// Remove user
require.Nil(t, a.RemoveUser("ben"))
_, err = a.User("ben")
require.Equal(t, auth.ErrNotFound, err)
users, err = a.Users()
require.Nil(t, err)
require.Equal(t, 2, len(users))
require.Equal(t, "phil", users[0].Name)
require.Equal(t, "*", users[1].Name)
}
func TestSQLiteAuth_ChangePassword(t *testing.T) {
a := newTestAuth(t, false, false)
require.Nil(t, a.AddUser("phil", "phil", auth.RoleAdmin))
_, err := a.Authenticate("phil", "phil")
require.Nil(t, err)
require.Nil(t, a.ChangePassword("phil", "newpass"))
_, err = a.Authenticate("phil", "phil")
require.Equal(t, auth.ErrUnauthenticated, err)
_, err = a.Authenticate("phil", "newpass")
require.Nil(t, err)
}
func TestSQLiteAuth_ChangeRole(t *testing.T) {
a := newTestAuth(t, false, false)
require.Nil(t, a.AddUser("ben", "ben", auth.RoleUser))
require.Nil(t, a.AllowAccess("ben", "mytopic", true, true))
require.Nil(t, a.AllowAccess("ben", "readme", true, false))
ben, err := a.User("ben")
require.Nil(t, err)
require.Equal(t, auth.RoleUser, ben.Role)
require.Equal(t, 2, len(ben.Grants))
require.Nil(t, a.ChangeRole("ben", auth.RoleAdmin))
ben, err = a.User("ben")
require.Nil(t, err)
require.Equal(t, auth.RoleAdmin, ben.Role)
require.Equal(t, 0, len(ben.Grants))
}
func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuth {
filename := filepath.Join(t.TempDir(), "user.db")
a, err := auth.NewSQLiteAuth(filename, defaultRead, defaultWrite)
require.Nil(t, err)
return a
}

View File

@@ -5,6 +5,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"heckel.io/ntfy/util"
"io"
@@ -17,9 +18,10 @@ import (
// Event type constants
const (
MessageEvent = "message"
KeepaliveEvent = "keepalive"
OpenEvent = "open"
MessageEvent = "message"
KeepaliveEvent = "keepalive"
OpenEvent = "open"
PollRequestEvent = "poll_request"
)
const (
@@ -36,14 +38,16 @@ type Client struct {
// Message is a struct that represents a ntfy message
type Message struct { // TODO combine with server.message
ID string
Event string
Time int64
Topic string
Message string
Title string
Priority int
Tags []string
ID string
Event string
Time int64
Topic string
Message string
Title string
Priority int
Tags []string
Click string
Attachment *Attachment
// Additional fields
TopicURL string
@@ -51,6 +55,16 @@ type Message struct { // TODO combine with server.message
Raw string
}
// Attachment represents a message attachment
type Attachment struct {
Name string `json:"name"`
Type string `json:"type,omitempty"`
Size int64 `json:"size,omitempty"`
Expires int64 `json:"expires,omitempty"`
URL string `json:"url"`
Owner string `json:"-"` // IP address of uploader, used for rate limiting
}
type subscription struct {
ID string
topicURL string
@@ -93,13 +107,13 @@ func (c *Client) PublishReader(topic string, body io.Reader, options ...PublishO
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected response %d from server", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, errors.New(strings.TrimSpace(string(b)))
}
m, err := toMessage(string(b), topicURL, "")
if err != nil {
return nil, err
@@ -238,6 +252,13 @@ func performSubscribeRequest(ctx context.Context, msgChan chan *Message, topicUR
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return err
}
return errors.New(strings.TrimSpace(string(b)))
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
m, err := toMessage(scanner.Text(), topicURL, subscriptionID)

View File

@@ -2,6 +2,7 @@ package client
import (
"fmt"
"heckel.io/ntfy/util"
"net/http"
"strings"
"time"
@@ -70,6 +71,11 @@ func WithEmail(email string) PublishOption {
return WithHeader("X-Email", email)
}
// WithBasicAuth adds the Authorization header for basic auth to the request
func WithBasicAuth(user, pass string) PublishOption {
return WithHeader("Authorization", util.BasicAuth(user, pass))
}
// WithNoCache instructs the server not to cache the message server-side
func WithNoCache() PublishOption {
return WithHeader("X-Cache", "no")

212
cmd/access.go Normal file
View File

@@ -0,0 +1,212 @@
package cmd
import (
"errors"
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
)
const (
userEveryone = "everyone"
)
var flagsAccess = append(
userCommandFlags(),
&cli.BoolFlag{Name: "reset", Aliases: []string{"r"}, Usage: "reset access for user (and topic)"},
)
var cmdAccess = &cli.Command{
Name: "access",
Usage: "Grant/revoke access to a topic, or show access",
UsageText: "ntfy access [USERNAME [TOPIC [PERMISSION]]]",
Flags: flagsAccess,
Before: initConfigFileInputSource("config", flagsAccess),
Action: execUserAccess,
Category: categoryServer,
Description: `Manage the access control list for the ntfy server.
This is a server-only command. It directly manages the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
to the related command 'ntfy user'.
The command allows you to show the access control list, as well as change it, depending on how
it is called.
Usage:
ntfy access # Shows access control list (alias: 'ntfy user list')
ntfy access USERNAME # Shows access control entries for USERNAME
ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC
Arguments:
USERNAME an existing user, as created with 'ntfy user add', or "everyone"/"*"
to define access rules for anonymous/unauthenticated clients
TOPIC name of a topic with optional wildcards, e.g. "mytopic*"
PERMISSION one of the following:
- read-write (alias: rw)
- read-only (aliases: read, ro)
- write-only (aliases: write, wo)
- deny (alias: none)
Examples:
ntfy access # Shows access control list (alias: 'ntfy user list')
ntfy access phil # Shows access for user phil
ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil
ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic
ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..."
ntfy access --reset # Reset entire access control list
ntfy access --reset phil # Reset all access for user phil
ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic
`,
}
func execUserAccess(c *cli.Context) error {
if c.NArg() > 3 {
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
}
manager, err := createAuthManager(c)
if err != nil {
return err
}
username := c.Args().Get(0)
if username == userEveryone {
username = auth.Everyone
}
topic := c.Args().Get(1)
perms := c.Args().Get(2)
reset := c.Bool("reset")
if reset {
if perms != "" {
return errors.New("too many arguments, please check 'ntfy access --help' for usage details")
}
return resetAccess(c, manager, username, topic)
} else if perms == "" {
if topic != "" {
return errors.New("invalid syntax, please check 'ntfy access --help' for usage details")
}
return showAccess(c, manager, username)
}
return changeAccess(c, manager, username, topic, perms)
}
func changeAccess(c *cli.Context, manager auth.Manager, username string, topic string, perms string) error {
if !util.InStringList([]string{"", "read-write", "rw", "read-only", "read", "ro", "write-only", "write", "wo", "none", "deny"}, perms) {
return errors.New("permission must be one of: read-write, read-only, write-only, or deny (or the aliases: read, ro, write, wo, none)")
}
read := util.InStringList([]string{"read-write", "rw", "read-only", "read", "ro"}, perms)
write := util.InStringList([]string{"read-write", "rw", "write-only", "write", "wo"}, perms)
user, err := manager.User(username)
if err == auth.ErrNotFound {
return fmt.Errorf("user %s does not exist", username)
} else if user.Role == auth.RoleAdmin {
return fmt.Errorf("user %s is an admin user, access control entries have no effect", username)
}
if err := manager.AllowAccess(username, topic, read, write); err != nil {
return err
}
if read && write {
fmt.Fprintf(c.App.ErrWriter, "granted read-write access to topic %s\n\n", topic)
} else if read {
fmt.Fprintf(c.App.ErrWriter, "granted read-only access to topic %s\n\n", topic)
} else if write {
fmt.Fprintf(c.App.ErrWriter, "granted write-only access to topic %s\n\n", topic)
} else {
fmt.Fprintf(c.App.ErrWriter, "revoked all access to topic %s\n\n", topic)
}
return showUserAccess(c, manager, username)
}
func resetAccess(c *cli.Context, manager auth.Manager, username, topic string) error {
if username == "" {
return resetAllAccess(c, manager)
} else if topic == "" {
return resetUserAccess(c, manager, username)
}
return resetUserTopicAccess(c, manager, username, topic)
}
func resetAllAccess(c *cli.Context, manager auth.Manager) error {
if err := manager.ResetAccess("", ""); err != nil {
return err
}
fmt.Fprintln(c.App.ErrWriter, "reset access for all users")
return nil
}
func resetUserAccess(c *cli.Context, manager auth.Manager, username string) error {
if err := manager.ResetAccess(username, ""); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s\n\n", username)
return showUserAccess(c, manager, username)
}
func resetUserTopicAccess(c *cli.Context, manager auth.Manager, username string, topic string) error {
if err := manager.ResetAccess(username, topic); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "reset access for user %s and topic %s\n\n", username, topic)
return showUserAccess(c, manager, username)
}
func showAccess(c *cli.Context, manager auth.Manager, username string) error {
if username == "" {
return showAllAccess(c, manager)
}
return showUserAccess(c, manager, username)
}
func showAllAccess(c *cli.Context, manager auth.Manager) error {
users, err := manager.Users()
if err != nil {
return err
}
return showUsers(c, manager, users)
}
func showUserAccess(c *cli.Context, manager auth.Manager, username string) error {
users, err := manager.User(username)
if err == auth.ErrNotFound {
return fmt.Errorf("user %s does not exist", username)
} else if err != nil {
return err
}
return showUsers(c, manager, []*auth.User{users})
}
func showUsers(c *cli.Context, manager auth.Manager, users []*auth.User) error {
for _, user := range users {
fmt.Fprintf(c.App.ErrWriter, "user %s (%s)\n", user.Name, user.Role)
if user.Role == auth.RoleAdmin {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to all topics (admin role)\n")
} else if len(user.Grants) > 0 {
for _, grant := range user.Grants {
if grant.AllowRead && grant.AllowWrite {
fmt.Fprintf(c.App.ErrWriter, "- read-write access to topic %s\n", grant.TopicPattern)
} else if grant.AllowRead {
fmt.Fprintf(c.App.ErrWriter, "- read-only access to topic %s\n", grant.TopicPattern)
} else if grant.AllowWrite {
fmt.Fprintf(c.App.ErrWriter, "- write-only access to topic %s\n", grant.TopicPattern)
} else {
fmt.Fprintf(c.App.ErrWriter, "- no access to topic %s\n", grant.TopicPattern)
}
}
} else {
fmt.Fprintf(c.App.ErrWriter, "- no topic-specific permissions\n")
}
if user.Name == auth.Everyone {
defaultRead, defaultWrite := manager.DefaultAccess()
if defaultRead && defaultWrite {
fmt.Fprintln(c.App.ErrWriter, "- read-write access to all (other) topics (server config)")
} else if defaultRead {
fmt.Fprintln(c.App.ErrWriter, "- read-only access to all (other) topics (server config)")
} else if defaultWrite {
fmt.Fprintln(c.App.ErrWriter, "- write-only access to all (other) topics (server config)")
} else {
fmt.Fprintln(c.App.ErrWriter, "- no access to any (other) topics (server config)")
}
}
}
return nil
}

87
cmd/access_test.go Normal file
View File

@@ -0,0 +1,87 @@
package cmd
import (
"fmt"
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/server"
"heckel.io/ntfy/test"
"testing"
)
func TestCLI_Access_Show(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, _, _, stderr := newTestApp()
require.Nil(t, runAccessCommand(app, conf))
require.Contains(t, stderr.String(), "user * (anonymous)\n- no topic-specific permissions\n- no access to any (other) topics (server config)")
}
func TestCLI_Access_Grant_And_Publish(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, _ := newTestApp()
stdin.WriteString("philpass\nphilpass\nbenpass\nbenpass")
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
require.Nil(t, runUserCommand(app, conf, "add", "ben"))
require.Nil(t, runAccessCommand(app, conf, "ben", "announcements", "rw"))
require.Nil(t, runAccessCommand(app, conf, "ben", "sometopic", "read"))
require.Nil(t, runAccessCommand(app, conf, "everyone", "announcements", "read"))
app, _, _, stderr := newTestApp()
require.Nil(t, runAccessCommand(app, conf))
expected := `user phil (admin)
- read-write access to all topics (admin role)
user ben (user)
- read-write access to topic announcements
- read-only access to topic sometopic
user * (anonymous)
- read-only access to topic announcements
- no access to any (other) topics (server config)
`
require.Equal(t, expected, stderr.String())
// See if access permissions match
app, _, _, _ = newTestApp()
require.Error(t, app.Run([]string{
"ntfy",
"publish",
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
}))
require.Nil(t, app.Run([]string{
"ntfy",
"publish",
"-u", "ben:benpass",
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
}))
require.Nil(t, app.Run([]string{
"ntfy",
"publish",
"-u", "phil:philpass",
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
}))
require.Nil(t, app.Run([]string{
"ntfy",
"subscribe",
"--poll",
fmt.Sprintf("http://127.0.0.1:%d/announcements", port),
}))
require.Error(t, app.Run([]string{
"ntfy",
"subscribe",
"--poll",
fmt.Sprintf("http://127.0.0.1:%d/something-else", port),
}))
}
func runAccessCommand(app *cli.App, conf *server.Config, args ...string) error {
userArgs := []string{
"ntfy",
"access",
"--auth-file=" + conf.AuthFile,
"--auth-default-access=" + confToDefaultAccess(conf),
}
return app.Run(append(userArgs, args...))
}

View File

@@ -14,6 +14,11 @@ var (
defaultClientUserConfigFile = "~/.config/ntfy/client.yml"
)
const (
categoryClient = "Client commands"
categoryServer = "Server commands"
)
// New creates a new CLI application
func New() *cli.App {
return &cli.App{
@@ -29,7 +34,12 @@ func New() *cli.App {
Before: initConfigFileInputSource("config", flagsServe), // DEPRECATED, see deprecation notice
Flags: flagsServe, // DEPRECATED, see deprecation notice
Commands: []*cli.Command{
// Server commands
cmdServe,
cmdUser,
cmdAccess,
// Client commands
cmdPublish,
cmdSubscribe,
},

View File

@@ -5,8 +5,6 @@ import (
"encoding/json"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"io"
"log"
"os"
"strings"
"testing"
@@ -15,7 +13,7 @@ import (
// This only contains helpers so far
func TestMain(m *testing.M) {
log.SetOutput(io.Discard)
// log.SetOutput(io.Discard)
os.Exit(m.Run())
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/client"
"heckel.io/ntfy/util"
"io"
"os"
"path/filepath"
@@ -15,22 +16,25 @@ var cmdPublish = &cli.Command{
Name: "publish",
Aliases: []string{"pub", "send", "trigger"},
Usage: "Send message via a ntfy server",
UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]",
UsageText: "ntfy send [OPTIONS..] TOPIC [MESSAGE]\n NTFY_TOPIC=.. ntfy send [OPTIONS..] -P [MESSAGE]",
Action: execPublish,
Category: categoryClient,
Flags: []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, Usage: "message title"},
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, Usage: "comma separated list of tags and emojis"},
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, Usage: "delay/schedule message"},
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, Usage: "URL to open when notification is clicked"},
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, Usage: "URL to send as an external attachment"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, Usage: "Filename for the attachment"},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, Usage: "File to upload as an attachment"},
&cli.StringFlag{Name: "email", Aliases: []string{"e-mail", "mail", "e"}, Usage: "also send to e-mail address"},
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, Usage: "do not cache message server-side"},
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, Usage: "do not forward message to Firebase"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, Usage: "do print message"},
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG"}, Usage: "client config file"},
&cli.StringFlag{Name: "title", Aliases: []string{"t"}, EnvVars: []string{"NTFY_TITLE"}, Usage: "message title"},
&cli.StringFlag{Name: "priority", Aliases: []string{"p"}, EnvVars: []string{"NTFY_PRIORITY"}, Usage: "priority of the message (1=min, 2=low, 3=default, 4=high, 5=max)"},
&cli.StringFlag{Name: "tags", Aliases: []string{"tag", "T"}, EnvVars: []string{"NTFY_TAGS"}, Usage: "comma separated list of tags and emojis"},
&cli.StringFlag{Name: "delay", Aliases: []string{"at", "in", "D"}, EnvVars: []string{"NTFY_DELAY"}, Usage: "delay/schedule message"},
&cli.StringFlag{Name: "click", Aliases: []string{"U"}, EnvVars: []string{"NTFY_CLICK"}, Usage: "URL to open when notification is clicked"},
&cli.StringFlag{Name: "attach", Aliases: []string{"a"}, EnvVars: []string{"NTFY_ATTACH"}, Usage: "URL to send as an external attachment"},
&cli.StringFlag{Name: "filename", Aliases: []string{"name", "n"}, EnvVars: []string{"NTFY_FILENAME"}, Usage: "Filename for the attachment"},
&cli.StringFlag{Name: "file", Aliases: []string{"f"}, EnvVars: []string{"NTFY_FILE"}, Usage: "File to upload as an attachment"},
&cli.StringFlag{Name: "email", Aliases: []string{"mail", "e"}, EnvVars: []string{"NTFY_EMAIL"}, Usage: "also send to e-mail address"},
&cli.StringFlag{Name: "user", Aliases: []string{"u"}, EnvVars: []string{"NTFY_USER"}, Usage: "username[:password] used to auth against the server"},
&cli.BoolFlag{Name: "no-cache", Aliases: []string{"C"}, EnvVars: []string{"NTFY_NO_CACHE"}, Usage: "do not cache message server-side"},
&cli.BoolFlag{Name: "no-firebase", Aliases: []string{"F"}, EnvVars: []string{"NTFY_NO_FIREBASE"}, Usage: "do not forward message to Firebase"},
&cli.BoolFlag{Name: "env-topic", Aliases: []string{"P"}, EnvVars: []string{"NTFY_ENV_TOPIC"}, Usage: "use topic from NTFY_TOPIC env variable"},
&cli.BoolFlag{Name: "quiet", Aliases: []string{"q"}, EnvVars: []string{"NTFY_QUIET"}, Usage: "do print message"},
},
Description: `Publish a message to a ntfy server.
@@ -45,9 +49,12 @@ Examples:
ntfy pub --click="https://reddit.com" redd 'New msg' # Opens Reddit when notification is clicked
ntfy pub --attach="http://some.tld/file.zip" files # Send ZIP archive from URL as attachment
ntfy pub --file=flower.jpg flowers 'Nice!' # Send image.jpg as attachment
ntfy pub -u phil:mypass secret Psst # Publish with username/password
NTFY_USER=phil:mypass ntfy pub secret Psst # Use env variables to set username/password
NTFY_TOPIC=mytopic ntfy pub -P "some message"" # Use NTFY_TOPIC variable as topic
cat flower.jpg | ntfy pub --file=- flowers 'Nice!' # Same as above, send image.jpg as attachment
ntfy trigger mywebhook # Sending without message, useful for webhooks
Please also check out the docs on publishing messages. Especially for the --tags and --delay options,
it has incredibly useful information: https://ntfy.sh/docs/publish/.
@@ -56,9 +63,6 @@ or ~/.config/ntfy/client.yml for all other users.`,
}
func execPublish(c *cli.Context) error {
if c.NArg() < 1 {
return errors.New("must specify topic, type 'ntfy publish --help' for help")
}
conf, err := loadConfig(c)
if err != nil {
return err
@@ -72,13 +76,25 @@ func execPublish(c *cli.Context) error {
filename := c.String("filename")
file := c.String("file")
email := c.String("email")
user := c.String("user")
noCache := c.Bool("no-cache")
noFirebase := c.Bool("no-firebase")
envTopic := c.Bool("env-topic")
quiet := c.Bool("quiet")
topic := c.Args().Get(0)
message := ""
if c.NArg() > 1 {
message = strings.Join(c.Args().Slice()[1:], " ")
var topic, message string
if envTopic {
topic = os.Getenv("NTFY_TOPIC")
if c.NArg() > 0 {
message = strings.Join(c.Args().Slice(), " ")
}
} else {
if c.NArg() < 1 {
return errors.New("must specify topic, type 'ntfy publish --help' for help")
}
topic = c.Args().Get(0)
if c.NArg() > 1 {
message = strings.Join(c.Args().Slice()[1:], " ")
}
}
var options []client.PublishOption
if title != "" {
@@ -111,6 +127,23 @@ func execPublish(c *cli.Context) error {
if noFirebase {
options = append(options, client.WithNoFirebase())
}
if user != "" {
var pass string
parts := strings.SplitN(user, ":", 2)
if len(parts) == 2 {
user = parts[0]
pass = parts[1]
} else {
fmt.Fprint(c.App.ErrWriter, "Enter Password: ")
p, err := util.ReadPassword(c.App.Reader)
if err != nil {
return err
}
pass = string(p)
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 20))
}
options = append(options, client.WithBasicAuth(user, pass))
}
var body io.Reader
if file == "" {
body = strings.NewReader(message)

View File

@@ -34,3 +34,39 @@ func TestCLI_Publish_Subscribe_Poll(t *testing.T) {
m = toMessage(t, stdout.String())
require.Equal(t, "some message", m.Message)
}
func TestCLI_Publish_All_The_Things(t *testing.T) {
s, port := test.StartServer(t)
defer test.StopServer(t, s, port)
topic := fmt.Sprintf("http://127.0.0.1:%d/mytopic", port)
app, _, stdout, _ := newTestApp()
require.Nil(t, app.Run([]string{
"ntfy", "publish",
"--title", "this is a title",
"--priority", "high",
"--tags", "tag1,tag2",
// No --delay, --email
"--click", "https://ntfy.sh",
"--attach", "https://f-droid.org/F-Droid.apk",
"--filename", "fdroid.apk",
"--no-cache",
"--no-firebase",
topic,
"some message",
}))
m := toMessage(t, stdout.String())
require.Equal(t, "message", m.Event)
require.Equal(t, "mytopic", m.Topic)
require.Equal(t, "some message", m.Message)
require.Equal(t, "this is a title", m.Title)
require.Equal(t, 4, m.Priority)
require.Equal(t, []string{"tag1", "tag2"}, m.Tags)
require.Equal(t, "https://ntfy.sh", m.Click)
require.Equal(t, "https://f-droid.org/F-Droid.apk", m.Attachment.URL)
require.Equal(t, "fdroid.apk", m.Attachment.Name)
require.Equal(t, int64(0), m.Attachment.Size)
require.Equal(t, "", m.Attachment.Owner)
require.Equal(t, int64(0), m.Attachment.Expires)
require.Equal(t, "", m.Attachment.Type)
}

View File

@@ -9,6 +9,7 @@ import (
"heckel.io/ntfy/util"
"log"
"math"
"strings"
"time"
)
@@ -17,11 +18,14 @@ var flagsServe = []cli.Flag{
altsrc.NewStringFlag(&cli.StringFlag{Name: "base-url", Aliases: []string{"B"}, EnvVars: []string{"NTFY_BASE_URL"}, Usage: "externally visible base URL for this host (e.g. https://ntfy.sh)"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-http", Aliases: []string{"l"}, EnvVars: []string{"NTFY_LISTEN_HTTP"}, Value: server.DefaultListenHTTP, Usage: "ip:port used to as HTTP listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-https", Aliases: []string{"L"}, EnvVars: []string{"NTFY_LISTEN_HTTPS"}, Usage: "ip:port used to as HTTPS listen address"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "listen-unix", Aliases: []string{"U"}, EnvVars: []string{"NTFY_LISTEN_UNIX"}, Usage: "listen on unix socket path"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "key-file", Aliases: []string{"K"}, EnvVars: []string{"NTFY_KEY_FILE"}, Usage: "private key file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cert-file", Aliases: []string{"E"}, EnvVars: []string{"NTFY_CERT_FILE"}, Usage: "certificate file, if listen-https is set"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "firebase-key-file", Aliases: []string{"F"}, EnvVars: []string{"NTFY_FIREBASE_KEY_FILE"}, Usage: "Firebase credentials file; if set additionally publish to FCM topic"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "cache-file", Aliases: []string{"C"}, EnvVars: []string{"NTFY_CACHE_FILE"}, Usage: "cache file used for message caching"}),
altsrc.NewDurationFlag(&cli.DurationFlag{Name: "cache-duration", Aliases: []string{"b"}, EnvVars: []string{"NTFY_CACHE_DURATION"}, Value: server.DefaultCacheDuration, Usage: "buffer messages for this time to allow `since` requests"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-cache-dir", EnvVars: []string{"NTFY_ATTACHMENT_CACHE_DIR"}, Usage: "cache directory for attached files"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-total-size-limit", Aliases: []string{"A"}, EnvVars: []string{"NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT"}, DefaultText: "5G", Usage: "limit of the on-disk attachment cache"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "attachment-file-size-limit", Aliases: []string{"Y"}, EnvVars: []string{"NTFY_ATTACHMENT_FILE_SIZE_LIMIT"}, DefaultText: "15M", Usage: "per-file attachment size limit (e.g. 300k, 2M, 100M)"}),
@@ -51,6 +55,7 @@ var cmdServe = &cli.Command{
Usage: "Run the ntfy server",
UsageText: "ntfy serve [OPTIONS..]",
Action: execServe,
Category: categoryServer,
Flags: flagsServe,
Before: initConfigFileInputSource("config", flagsServe),
Description: `Run the ntfy server and listen for incoming requests
@@ -72,11 +77,14 @@ func execServe(c *cli.Context) error {
baseURL := c.String("base-url")
listenHTTP := c.String("listen-http")
listenHTTPS := c.String("listen-https")
listenUnix := c.String("listen-unix")
keyFile := c.String("key-file")
certFile := c.String("cert-file")
firebaseKeyFile := c.String("firebase-key-file")
cacheFile := c.String("cache-file")
cacheDuration := c.Duration("cache-duration")
authFile := c.String("auth-file")
authDefaultAccess := c.String("auth-default-access")
attachmentCacheDir := c.String("attachment-cache-dir")
attachmentTotalSizeLimitStr := c.String("attachment-total-size-limit")
attachmentFileSizeLimitStr := c.String("attachment-file-size-limit")
@@ -121,6 +129,19 @@ func execServe(c *cli.Context) error {
return errors.New("if smtp-server-listen is set, smtp-server-domain must also be set")
} else if attachmentCacheDir != "" && baseURL == "" {
return errors.New("if attachment-cache-dir is set, base-url must also be set")
} else if baseURL != "" && !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return errors.New("if set, base-url must start with http:// or https://")
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
return errors.New("if set, auth-default-access must start set to 'read-write', 'read-only', 'write-only' or 'deny-all'")
}
// Default auth permissions
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
// Special case: Unset default
if listenHTTP == "-" {
listenHTTP = ""
}
// Convert sizes to bytes
@@ -148,11 +169,15 @@ func execServe(c *cli.Context) error {
conf.BaseURL = baseURL
conf.ListenHTTP = listenHTTP
conf.ListenHTTPS = listenHTTPS
conf.ListenUnix = listenUnix
conf.KeyFile = keyFile
conf.CertFile = certFile
conf.FirebaseKeyFile = firebaseKeyFile
conf.CacheFile = cacheFile
conf.CacheDuration = cacheDuration
conf.AuthFile = authFile
conf.AuthDefaultRead = authDefaultRead
conf.AuthDefaultWrite = authDefaultWrite
conf.AttachmentCacheDir = attachmentCacheDir
conf.AttachmentTotalSizeLimit = attachmentTotalSizeLimit
conf.AttachmentFileSizeLimit = attachmentFileSizeLimit

77
cmd/serve_test.go Normal file
View File

@@ -0,0 +1,77 @@
package cmd
import (
"fmt"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/client"
"heckel.io/ntfy/test"
"heckel.io/ntfy/util"
"math/rand"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
func init() {
rand.Seed(time.Now().UnixMilli())
}
func TestCLI_Serve_Unix_Curl(t *testing.T) {
sockFile := filepath.Join(t.TempDir(), "ntfy.sock")
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
go func() {
app, _, _, _ := newTestApp()
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, "--listen-http=-", "--listen-unix=" + sockFile})
require.Nil(t, err)
}()
for i := 0; i < 40 && !util.FileExists(sockFile); i++ {
time.Sleep(50 * time.Millisecond)
}
require.True(t, util.FileExists(sockFile))
cmd := exec.Command("curl", "-s", "--unix-socket", sockFile, "-d", "this is a message", "localhost/mytopic")
out, err := cmd.Output()
require.Nil(t, err)
m := toMessage(t, string(out))
require.Equal(t, "this is a message", m.Message)
}
func TestCLI_Serve_WebSocket(t *testing.T) {
port := 10000 + rand.Intn(20000)
go func() {
configFile := newEmptyFile(t) // Avoid issues with existing server.yml file on system
app, _, _, _ := newTestApp()
err := app.Run([]string{"ntfy", "serve", "--config=" + configFile, fmt.Sprintf("--listen-http=:%d", port)})
require.Nil(t, err)
}()
test.WaitForPortUp(t, port)
ws, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/mytopic/ws", port), nil)
require.Nil(t, err)
messageType, data, err := ws.ReadMessage()
require.Nil(t, err)
require.Equal(t, websocket.TextMessage, messageType)
require.Equal(t, "open", toMessage(t, string(data)).Event)
c := client.New(client.NewConfig())
_, err = c.Publish(fmt.Sprintf("http://127.0.0.1:%d/mytopic", port), "my message")
require.Nil(t, err)
messageType, data, err = ws.ReadMessage()
require.Nil(t, err)
require.Equal(t, websocket.TextMessage, messageType)
m := toMessage(t, string(data))
require.Equal(t, "my message", m.Message)
require.Equal(t, "mytopic", m.Topic)
}
func newEmptyFile(t *testing.T) string {
filename := filepath.Join(t.TempDir(), "empty")
require.Nil(t, os.WriteFile(filename, []byte{}, 0600))
return filename
}

View File

@@ -19,6 +19,7 @@ var cmdSubscribe = &cli.Command{
Usage: "Subscribe to one or more topics on a ntfy server",
UsageText: "ntfy subscribe [OPTIONS..] [TOPIC]",
Action: execSubscribe,
Category: categoryClient,
Flags: []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, Usage: "client config file"},
&cli.StringFlag{Name: "since", Aliases: []string{"s"}, Usage: "return events since `SINCE` (Unix timestamp, or all)"},

288
cmd/user.go Normal file
View File

@@ -0,0 +1,288 @@
package cmd
import (
"crypto/subtle"
"errors"
"fmt"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
"strings"
)
var flagsUser = userCommandFlags()
var cmdUser = &cli.Command{
Name: "user",
Usage: "Manage/show users",
UsageText: "ntfy user [list|add|remove|change-pass|change-role] ...",
Flags: flagsUser,
Before: initConfigFileInputSource("config", flagsUser),
Category: categoryServer,
Subcommands: []*cli.Command{
{
Name: "add",
Aliases: []string{"a"},
Usage: "Adds a new user",
UsageText: "ntfy user add [--role=admin|user] USERNAME",
Before: inheritRootReaderFunc,
Action: execUserAdd,
Flags: []cli.Flag{
&cli.StringFlag{Name: "role", Aliases: []string{"r"}, Value: string(auth.RoleUser), Usage: "user role"},
},
Description: `Add a new user to the ntfy user database.
A user can be either a regular user, or an admin. A regular user has no read or write access (unless
granted otherwise by the auth-default-access setting). An admin user has read and write access to all
topics.
Examples:
ntfy user add phil # Add regular user phil
ntfy user add --role=admin phil # Add admin user phil
`,
},
{
Name: "remove",
Aliases: []string{"del", "rm"},
Usage: "Removes a user",
UsageText: "ntfy user remove USERNAME",
Before: inheritRootReaderFunc,
Action: execUserDel,
Description: `Remove a user from the ntfy user database.
Example:
ntfy user del phil
`,
},
{
Name: "change-pass",
Aliases: []string{"chp"},
Usage: "Changes a user's password",
UsageText: "ntfy user change-pass USERNAME",
Before: inheritRootReaderFunc,
Action: execUserChangePass,
Description: `Change the password for the given user.
The new password will be read from STDIN, and it'll be confirmed by typing
it twice.
Example:
ntfy user change-pass phil
`,
},
{
Name: "change-role",
Aliases: []string{"chr"},
Usage: "Changes the role of a user",
UsageText: "ntfy user change-role USERNAME ROLE",
Before: inheritRootReaderFunc,
Action: execUserChangeRole,
Description: `Change the role for the given user to admin or user.
This command can be used to change the role of a user either from a regular user
to an admin user, or the other way around:
- admin: an admin has read/write access to all topics
- user: a regular user only has access to what was explicitly granted via 'ntfy access'
When changing the role of a user to "admin", all access control entries for that
user are removed, since they are no longer necessary.
Example:
ntfy user change-role phil admin # Make user phil an admin
ntfy user change-role phil user # Remove admin role from user phil
`,
},
{
Name: "list",
Aliases: []string{"l"},
Usage: "Shows a list of users",
Before: inheritRootReaderFunc,
Action: execUserList,
Description: `Shows a list of all configured users, including the everyone ('*') user.
This is a server-only command. It directly reads from the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined.
This command is an alias to calling 'ntfy access' (display access control list).
`,
},
},
Description: `Manage users of the ntfy server.
This is a server-only command. It directly manages the user.db as defined in the server config
file server.yml. The command only works if 'auth-file' is properly defined. Please also refer
to the related command 'ntfy access'.
The command allows you to add/remove/change users in the ntfy user database, as well as change
passwords or roles.
Examples:
ntfy user list # Shows list of users (alias: 'ntfy access')
ntfy user add phil # Add regular user phil
ntfy user add --role=admin phil # Add admin user phil
ntfy user del phil # Delete user phil
ntfy user change-pass phil # Change password for user phil
ntfy user change-role phil admin # Make user phil an admin
`,
}
func execUserAdd(c *cli.Context) error {
username := c.Args().Get(0)
role := auth.Role(c.String("role"))
if username == "" {
return errors.New("username expected, type 'ntfy user add --help' for help")
} else if username == userEveryone {
return errors.New("username not allowed")
} else if !auth.AllowedRole(role) {
return errors.New("role must be either 'user' or 'admin'")
}
manager, err := createAuthManager(c)
if err != nil {
return err
}
if user, _ := manager.User(username); user != nil {
return fmt.Errorf("user %s already exists", username)
}
password, err := readPasswordAndConfirm(c)
if err != nil {
return err
}
if err := manager.AddUser(username, password, role); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "user %s added with role %s\n", username, role)
return nil
}
func execUserDel(c *cli.Context) error {
username := c.Args().Get(0)
if username == "" {
return errors.New("username expected, type 'ntfy user del --help' for help")
} else if username == userEveryone {
return errors.New("username not allowed")
}
manager, err := createAuthManager(c)
if err != nil {
return err
}
if _, err := manager.User(username); err == auth.ErrNotFound {
return fmt.Errorf("user %s does not exist", username)
}
if err := manager.RemoveUser(username); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "user %s removed\n", username)
return nil
}
func execUserChangePass(c *cli.Context) error {
username := c.Args().Get(0)
if username == "" {
return errors.New("username expected, type 'ntfy user change-pass --help' for help")
} else if username == userEveryone {
return errors.New("username not allowed")
}
manager, err := createAuthManager(c)
if err != nil {
return err
}
if _, err := manager.User(username); err == auth.ErrNotFound {
return fmt.Errorf("user %s does not exist", username)
}
password, err := readPasswordAndConfirm(c)
if err != nil {
return err
}
if err := manager.ChangePassword(username, password); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "changed password for user %s\n", username)
return nil
}
func execUserChangeRole(c *cli.Context) error {
username := c.Args().Get(0)
role := auth.Role(c.Args().Get(1))
if username == "" || !auth.AllowedRole(role) {
return errors.New("username and new role expected, type 'ntfy user change-role --help' for help")
} else if username == userEveryone {
return errors.New("username not allowed")
}
manager, err := createAuthManager(c)
if err != nil {
return err
}
if _, err := manager.User(username); err == auth.ErrNotFound {
return fmt.Errorf("user %s does not exist", username)
}
if err := manager.ChangeRole(username, role); err != nil {
return err
}
fmt.Fprintf(c.App.ErrWriter, "changed role for user %s to %s\n", username, role)
return nil
}
func execUserList(c *cli.Context) error {
manager, err := createAuthManager(c)
if err != nil {
return err
}
users, err := manager.Users()
if err != nil {
return err
}
return showUsers(c, manager, users)
}
func createAuthManager(c *cli.Context) (auth.Manager, error) {
authFile := c.String("auth-file")
authDefaultAccess := c.String("auth-default-access")
if authFile == "" {
return nil, errors.New("option auth-file not set; auth is unconfigured for this server")
} else if !util.FileExists(authFile) {
return nil, errors.New("auth-file does not exist; please start the server at least once to create it")
} else if !util.InStringList([]string{"read-write", "read-only", "write-only", "deny-all"}, authDefaultAccess) {
return nil, errors.New("if set, auth-default-access must start set to 'read-write', 'read-only' or 'deny-all'")
}
authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite)
}
func readPasswordAndConfirm(c *cli.Context) (string, error) {
fmt.Fprint(c.App.ErrWriter, "password: ")
password, err := util.ReadPassword(c.App.Reader)
if err != nil {
return "", err
}
fmt.Fprintf(c.App.ErrWriter, "\r%s\rconfirm: ", strings.Repeat(" ", 25))
confirm, err := util.ReadPassword(c.App.Reader)
if err != nil {
return "", err
}
fmt.Fprintf(c.App.ErrWriter, "\r%s\r", strings.Repeat(" ", 25))
if subtle.ConstantTimeCompare(confirm, password) != 1 {
return "", errors.New("passwords do not match: try it again, but this time type slooowwwlly")
}
return string(password), nil
}
func userCommandFlags() []cli.Flag {
return []cli.Flag{
&cli.StringFlag{Name: "config", Aliases: []string{"c"}, EnvVars: []string{"NTFY_CONFIG_FILE"}, Value: "/etc/ntfy/server.yml", DefaultText: "/etc/ntfy/server.yml", Usage: "config file"},
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-file", Aliases: []string{"H"}, EnvVars: []string{"NTFY_AUTH_FILE"}, Usage: "auth database file used for access control"}),
altsrc.NewStringFlag(&cli.StringFlag{Name: "auth-default-access", Aliases: []string{"p"}, EnvVars: []string{"NTFY_AUTH_DEFAULT_ACCESS"}, Value: "read-write", Usage: "default permissions if no matching entries in the auth database are found"}),
}
}
// inheritRootReaderFunc is a workaround for a urfave/cli bug that makes subcommands not inherit the App.Reader.
// This bug was fixed in master, but not in v2.3.0.
func inheritRootReaderFunc(ctx *cli.Context) error {
for _, c := range ctx.Lineage() {
if c.App != nil && c.App.Reader != nil {
ctx.App.Reader = c.App.Reader
}
}
return nil
}

145
cmd/user_test.go Normal file
View File

@@ -0,0 +1,145 @@
package cmd
import (
"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
"heckel.io/ntfy/server"
"heckel.io/ntfy/test"
"path/filepath"
"testing"
)
func TestCLI_User_Add(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, stderr := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user")
}
func TestCLI_User_Add_Exists(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, stderr := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user")
app, stdin, _, _ = newTestApp()
stdin.WriteString("mypass\nmypass")
err := runUserCommand(app, conf, "add", "phil")
require.Error(t, err)
require.Contains(t, err.Error(), "user phil already exists")
}
func TestCLI_User_Add_Admin(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, stderr := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "--role=admin", "phil"))
require.Contains(t, stderr.String(), "user phil added with role admin")
}
func TestCLI_User_Add_Password_Mismatch(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
app, stdin, _, _ := newTestApp()
stdin.WriteString("mypass\nNOTMATCH")
err := runUserCommand(app, conf, "add", "phil")
require.Error(t, err)
require.Contains(t, err.Error(), "passwords do not match: try it again, but this time type slooowwwlly")
}
func TestCLI_User_ChangePass(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
// Add user
app, stdin, _, stderr := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user")
// Change pass
app, stdin, _, stderr = newTestApp()
stdin.WriteString("newpass\nnewpass")
require.Nil(t, runUserCommand(app, conf, "change-pass", "phil"))
require.Contains(t, stderr.String(), "changed password for user phil")
}
func TestCLI_User_ChangeRole(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
// Add user
app, stdin, _, stderr := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user")
// Change role
app, _, _, stderr = newTestApp()
require.Nil(t, runUserCommand(app, conf, "change-role", "phil", "admin"))
require.Contains(t, stderr.String(), "changed role for user phil to admin")
}
func TestCLI_User_Delete(t *testing.T) {
s, conf, port := newTestServerWithAuth(t)
defer test.StopServer(t, s, port)
// Add user
app, stdin, _, stderr := newTestApp()
stdin.WriteString("mypass\nmypass")
require.Nil(t, runUserCommand(app, conf, "add", "phil"))
require.Contains(t, stderr.String(), "user phil added with role user")
// Delete user
app, _, _, stderr = newTestApp()
require.Nil(t, runUserCommand(app, conf, "del", "phil"))
require.Contains(t, stderr.String(), "user phil removed")
// Delete user again (does not exist)
app, _, _, _ = newTestApp()
err := runUserCommand(app, conf, "del", "phil")
require.Error(t, err)
require.Contains(t, err.Error(), "user phil does not exist")
}
func newTestServerWithAuth(t *testing.T) (s *server.Server, conf *server.Config, port int) {
conf = server.NewConfig()
conf.AuthFile = filepath.Join(t.TempDir(), "user.db")
conf.AuthDefaultRead = false
conf.AuthDefaultWrite = false
s, port = test.StartServerWithConfig(t, conf)
return
}
func runUserCommand(app *cli.App, conf *server.Config, args ...string) error {
userArgs := []string{
"ntfy",
"user",
"--auth-file=" + conf.AuthFile,
"--auth-default-access=" + confToDefaultAccess(conf),
}
return app.Run(append(userArgs, args...))
}
func confToDefaultAccess(conf *server.Config) string {
var defaultAccess string
if conf.AuthDefaultRead && conf.AuthDefaultWrite {
defaultAccess = "read-write"
} else if conf.AuthDefaultRead && !conf.AuthDefaultWrite {
defaultAccess = "read-only"
} else if !conf.AuthDefaultRead && conf.AuthDefaultWrite {
defaultAccess = "write-only"
} else if !conf.AuthDefaultRead && !conf.AuthDefaultWrite {
defaultAccess = "deny-all"
}
return defaultAccess
}

View File

@@ -1,6 +1,6 @@
# Configuring the ntfy server
The ntfy server can be configured in three ways: using a config file (typically at `/etc/ntfy/server.yml`,
see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/config/server.yml)), via command line arguments
see [server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)), via command line arguments
or using environment variables.
## Quick start
@@ -16,6 +16,55 @@ You can immediately start [publishing messages](publish.md), or subscribe via th
the server further, check out the [config options table](#config-options) or simply type `ntfy serve --help` to
get a list of [command line options](#command-line-options).
## Example config
!!! info
Definitely check out the **[server.yml](https://github.com/binwiederhier/ntfy/blob/main/server/server.yml)** file.
It contains examples and detailed descriptions of all the settings.
The most basic settings are `base-url` (the external URL of the ntfy server), the HTTP/HTTPS listen address (`listen-http`
and `listen-https`), and socket path (`listen-unix`). All the other things are additional features.
Here are a few working sample configs:
=== "server.yml (HTTP-only, with cache + attachments)"
``` yaml
base-url: "http://ntfy.example.com"
cache-file: "/var/cache/ntfy/cache.db"
attachment-cache-dir: "/var/cache/ntfy/attachments"
```
=== "server.yml (HTTP+HTTPS, with cache + attachments)"
``` yaml
base-url: "http://ntfy.example.com"
listen-http: ":80"
listen-https: ":443"
key-file: "/etc/letsencrypt/live/ntfy.example.com.key"
cert-file: "/etc/letsencrypt/live/ntfy.example.com.crt"
cache-file: "/var/cache/ntfy/cache.db"
attachment-cache-dir: "/var/cache/ntfy/attachments"
```
=== "server.yml (ntfy.sh config)"
``` yaml
# All the things: Behind a proxy, Firebase, cache, attachments,
# SMTP publishing & receiving
base-url: "https://ntfy.sh"
listen-http: "127.0.0.1:2586"
firebase-key-file: "/etc/ntfy/firebase.json"
cache-file: "/var/cache/ntfy/cache.db"
behind-proxy: true
attachment-cache-dir: "/var/cache/ntfy/attachments"
smtp-sender-addr: "email-smtp.us-east-2.amazonaws.com:587"
smtp-sender-user: "AKIDEADBEEFAFFE12345"
smtp-sender-pass: "Abd13Kf+sfAk2DzifjafldkThisIsNotARealKeyOMG."
smtp-sender-from: "ntfy@ntfy.sh"
smtp-server-listen: ":25"
smtp-server-domain: "ntfy.sh"
smtp-server-addr-prefix: "ntfy-"
keepalive-interval: "45s"
```
## Message cache
If desired, ntfy can temporarily keep notifications in an in-memory or an on-disk cache. Caching messages for a short period
of time is important to allow [phones](subscribe/phone.md) and other devices with brittle Internet connections to be able to retrieve
@@ -72,6 +121,194 @@ Here's an example config using mostly the defaults (except for the cache directo
Please also refer to the [rate limiting](#rate-limiting) settings below, specifically `visitor-attachment-total-size-limit`
and `visitor-attachment-daily-bandwidth-limit`. Setting these conservatively is necessary to avoid abuse.
## Access control
By default, the ntfy server is open for everyone, meaning **everyone can read and write to any topic** (this is how
ntfy.sh is configured). To restrict access to your own server, you can optionally configure authentication and authorization.
ntfy's auth is implemented with a simple [SQLite](https://www.sqlite.org/)-based backend. It implements two roles
(`user` and `admin`) and per-topic `read` and `write` permissions using an [access control list (ACL)](https://en.wikipedia.org/wiki/Access-control_list).
Access control entries can be applied to users as well as the special everyone user (`*`), which represents anonymous API access.
To set up auth, simply **configure the following two options**:
* `auth-file` is the user/access database; it is created automatically if it doesn't already exist; suggested
location `/var/lib/ntfy/user.db` (easiest if deb/rpm package is used)
* `auth-default-access` defines the default/fallback access if no access control entry is found; it can be
set to `read-write` (default), `read-only`, `write-only` or `deny-all`.
Once configured, you can use the `ntfy user` command to [add or modify users](#users-and-roles), and the `ntfy access` command
lets you [modify the access control list](#access-control-list-acl) for specific users and topic patterns. Both of these
commands **directly edit the auth database** (as defined in `auth-file`), so they only work on the server, and only if the user
accessing them has the right permissions.
### Users and roles
The `ntfy user` command allows you to add/remove/change users in the ntfy user database, as well as change
passwords or roles (`user` or `admin`). In practice, you'll often just create one admin
user with `ntfy user add --role=admin ...` and be done with all this (see [example below](#example-private-instance)).
**Roles:**
* Role `user` (default): Users with this role have no special permissions. Manage access using `ntfy access`
(see [below](#access-control-list-acl)).
* Role `admin`: Users with this role can read/write to all topics. Granular access control is not necessary.
**Example commands** (type `ntfy user --help` or `ntfy user COMMAND --help` for more details):
```
ntfy user list # Shows list of users (alias: 'ntfy access')
ntfy user add phil # Add regular user phil
ntfy user add --role=admin phil # Add admin user phil
ntfy user del phil # Delete user phil
ntfy user change-pass phil # Change password for user phil
ntfy user change-role phil admin # Make user phil an admin
```
### Access control list (ACL)
The access control list (ACL) **manages access to topics for non-admin users, and for anonymous access (`everyone`/`*`)**.
Each entry represents the access permissions for a user to a specific topic or topic pattern.
The ACL can be displayed or modified with the `ntfy access` command:
```
ntfy access # Shows access control list (alias: 'ntfy user list')
ntfy access USERNAME # Shows access control entries for USERNAME
ntfy access USERNAME TOPIC PERMISSION # Allow/deny access for USERNAME to TOPIC
```
A `USERNAME` is an existing user, as created with `ntfy user add` (see [users and roles](#users-and-roles)), or the
anonymous user `everyone` or `*`, which represents clients that access the API without username/password.
A `TOPIC` is either a specific topic name (e.g. `mytopic`, or `phil_alerts`), or a wildcard pattern that matches any
number of topics (e.g. `alerts_*` or `ben-*`). Only the wildcard character `*` is supported. It stands for zero to any
number of characters.
A `PERMISSION` is any of the following supported permissions:
* `read-write` (alias: `rw`): Allows [publishing messages](publish.md) to the given topic, as well as
[subscribing](subscribe/api.md) and reading messages
* `read-only` (aliases: `read`, `ro`): Allows only subscribing and reading messages, but not publishing to the topic
* `write-only` (aliases: `write`, `wo`): Allows only publishing to the topic, but not subscribing to it
* `deny` (alias: `none`): Allows neither publishing nor subscribing to a topic
**Example commands** (type `ntfy access --help` for more details):
```
ntfy access # Shows entire access control list
ntfy access phil # Shows access for user phil
ntfy access phil mytopic rw # Allow read-write access to mytopic for user phil
ntfy access everyone mytopic rw # Allow anonymous read-write access to mytopic
ntfy access everyone "up*" write # Allow anonymous write-only access to topics "up..."
ntfy access --reset # Reset entire access control list
ntfy access --reset phil # Reset all access for user phil
ntfy access --reset phil mytopic # Reset access for user phil and topic mytopic
```
**Example ACL:**
```
$ ntfy access
user phil (admin)
- read-write access to all topics (admin role)
user ben (user)
- read-write access to topic garagedoor
- read-write access to topic alerts*
- read-only access to topic furnace
user * (anonymous)
- read-only access to topic announcements
- read-only access to topic server-stats
- no access to any (other) topics (server config)
```
In this example, `phil` has the role `admin`, so he has read-write access to all topics (no ACL entries are necessary).
User `ben` has three topic-specific entries. He can read, but not write to topic `furnace`, and has read-write access
to topic `garagedoor` and all topics starting with the word `alerts` (wildcards). Clients that are not authenticated
(called `*`/`everyone`) only have read access to the `announcements` and `server-stats` topics.
### Example: Private instance
The easiest way to configure a private instance is to set `auth-default-access` to `deny-all` in the `server.yml`:
=== "/etc/ntfy/server.yml"
``` yaml
auth-file "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
```
After that, simply create an `admin` user:
```
$ ntfy user add --role=admin phil
password: mypass
confirm: mypass
user phil added with role admin
```
Once you've done that, you can publish and subscribe using [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
with the given username/password. Be sure to use HTTPS to avoid eavesdropping and exposing your password. Here's a simple example:
=== "Command line (curl)"
```
curl \
-u phil:mypass \
-d "Look ma, with auth" \
https://ntfy.example.com/mysecrets
```
=== "ntfy CLI"
```
ntfy publish \
-u phil:mypass \
ntfy.example.com/mysecrets \
"Look ma, with auth"
```
=== "HTTP"
``` http
POST /mysecrets HTTP/1.1
Host: ntfy.example.com
Authorization: Basic cGhpbDpteXBhc3M=
Look ma, with auth
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.example.com/mysecrets', {
method: 'POST', // PUT works too
body: 'Look ma, with auth',
headers: {
'Authorization': 'Basic cGhpbDpteXBhc3M='
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
strings.NewReader("Look ma, with auth"))
req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=")
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
data="Look ma, with auth",
headers={
"Authorization": "Basic cGhpbDpteXBhc3M="
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
'Content-Type: text/plain\r\n' .
'Authorization: Basic cGhpbDpteXBhc3M=',
'content' => 'Look ma, with auth'
]
]));
```
## E-mail notifications
To allow forwarding messages via e-mail, you can configure an **SMTP server for outgoing messages**. Once configured,
you can set the `X-Email` header to [send messages via e-mail](publish.md#e-mail-notifications) (e.g.
@@ -194,6 +431,8 @@ or the root domain:
proxy_redirect off;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 3m;
@@ -225,6 +464,8 @@ or the root domain:
proxy_redirect off;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 3m;
@@ -477,38 +718,41 @@ Each config option can be set in the config file `/etc/ntfy/server.yml` (e.g. `l
CLI option (e.g. `--listen-http :80`. Here's a list of all available options. Alternatively, you can set an environment
variable before running the `ntfy` command (e.g. `export NTFY_LISTEN_HTTP=:80`).
| Config option | Env variable | Format | Default | Description |
|--------------------------------------------|-------------------------------------------------|------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 55s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
| Config option | Env variable | Format | Default | Description |
|--------------------------------------------|-------------------------------------------------|-----------------------------------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `base-url` | `NTFY_BASE_URL` | *URL* | - | Public facing base URL of the service (e.g. `https://ntfy.sh`) |
| `listen-http` | `NTFY_LISTEN_HTTP` | `[host]:port` | `:80` | Listen address for the HTTP web server |
| `listen-https` | `NTFY_LISTEN_HTTPS` | `[host]:port` | - | Listen address for the HTTPS web server. If set, you also need to set `key-file` and `cert-file`. |
| `listen-unix` | `NTFY_LISTEN_UNIX` | *filename* | - | Path to a Unix socket to listen on |
| `key-file` | `NTFY_KEY_FILE` | *filename* | - | HTTPS/TLS private key file, only used if `listen-https` is set. |
| `cert-file` | `NTFY_CERT_FILE` | *filename* | - | HTTPS/TLS certificate file, only used if `listen-https` is set. |
| `firebase-key-file` | `NTFY_FIREBASE_KEY_FILE` | *filename* | - | If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app. This is optional and only required to save battery when using the Android app. See [Firebase (FCM](#firebase-fcm). |
| `cache-file` | `NTFY_CACHE_FILE` | *filename* | - | If set, messages are cached in a local SQLite database instead of only in-memory. This allows for service restarts without losing messages in support of the since= parameter. See [message cache](#message-cache). |
| `cache-duration` | `NTFY_CACHE_DURATION` | *duration* | 12h | Duration for which messages will be buffered before they are deleted. This is required to support the `since=...` and `poll=1` parameter. Set this to `0` to disable the cache entirely. |
| `auth-file` | `NTFY_AUTH_FILE` | *filename* | - | Auth database file used for access control. If set, enables authentication and access control. See [access control](#access-control). |
| `auth-default-access` | `NTFY_AUTH_DEFAULT_ACCESS` | `read-write`, `read-only`, `write-only`, `deny-all` | - | Default permissions if no matching entries in the auth database are found. Default is `read-write`. |
| `behind-proxy` | `NTFY_BEHIND_PROXY` | *bool* | false | If set, the X-Forwarded-For header is used to determine the visitor IP address instead of the remote address of the connection. |
| `attachment-cache-dir` | `NTFY_ATTACHMENT_CACHE_DIR` | *directory* | - | Cache directory for attached files. To enable attachments, this has to be set. |
| `attachment-total-size-limit` | `NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 5G | Limit of the on-disk attachment cache directory. If the limits is exceeded, new attachments will be rejected. |
| `attachment-file-size-limit` | `NTFY_ATTACHMENT_FILE_SIZE_LIMIT` | *size* | 15M | Per-file attachment size limit (e.g. 300k, 2M, 100M). Larger attachment will be rejected. |
| `attachment-expiry-duration` | `NTFY_ATTACHMENT_EXPIRY_DURATION` | *duration* | 3h | Duration after which uploaded attachments will be deleted (e.g. 3h, 20h). Strongly affects `visitor-attachment-total-size-limit`. |
| `smtp-sender-addr` | `NTFY_SMTP_SENDER_ADDR` | `host:port` | - | SMTP server address to allow email sending |
| `smtp-sender-user` | `NTFY_SMTP_SENDER_USER` | *string* | - | SMTP user; only used if e-mail sending is enabled |
| `smtp-sender-pass` | `NTFY_SMTP_SENDER_PASS` | *string* | - | SMTP password; only used if e-mail sending is enabled |
| `smtp-sender-from` | `NTFY_SMTP_SENDER_FROM` | *e-mail address* | - | SMTP sender e-mail address; only used if e-mail sending is enabled |
| `smtp-server-listen` | `NTFY_SMTP_SERVER_LISTEN` | `[ip]:port` | - | Defines the IP address and port the SMTP server will listen on, e.g. `:25` or `1.2.3.4:25` |
| `smtp-server-domain` | `NTFY_SMTP_SERVER_DOMAIN` | *domain name* | - | SMTP server e-mail domain, e.g. `ntfy.sh` |
| `smtp-server-addr-prefix` | `NTFY_SMTP_SERVER_ADDR_PREFIX` | `[ip]:port` | - | Optional prefix for the e-mail addresses to prevent spam, e.g. `ntfy-` |
| `keepalive-interval` | `NTFY_KEEPALIVE_INTERVAL` | *duration* | 45s | Interval in which keepalive messages are sent to the client. This is to prevent intermediaries closing the connection for inactivity. Note that the Android app has a hardcoded timeout at 77s, so it should be less than that. |
| `manager-interval` | `$NTFY_MANAGER_INTERVAL` | *duration* | 1m | Interval in which the manager prunes old messages, deletes topics and prints the stats. |
| `global-topic-limit` | `NTFY_GLOBAL_TOPIC_LIMIT` | *number* | 15,000 | Rate limiting: Total number of topics before the server rejects new topics. |
| `visitor-subscription-limit` | `NTFY_VISITOR_SUBSCRIPTION_LIMIT` | *number* | 30 | Rate limiting: Number of subscriptions per visitor (IP address) |
| `visitor-attachment-total-size-limit` | `NTFY_VISITOR_ATTACHMENT_TOTAL_SIZE_LIMIT` | *size* | 100M | Rate limiting: Total storage limit used for attachments per visitor, for all attachments combined. Storage is freed after attachments expire. See `attachment-expiry-duration`. |
| `visitor-attachment-daily-bandwidth-limit` | `NTFY_VISITOR_ATTACHMENT_DAILY_BANDWIDTH_LIMIT` | *size* | 500M | Rate limiting: Total daily attachment download/upload traffic limit per visitor. This is to protect your bandwidth costs from exploding. |
| `visitor-request-limit-burst` | `NTFY_VISITOR_REQUEST_LIMIT_BURST` | *number* | 60 | Rate limiting: Allowed GET/PUT/POST requests per second, per visitor. This setting is the initial bucket of requests each visitor has |
| `visitor-request-limit-replenish` | `NTFY_VISITOR_REQUEST_LIMIT_REPLENISH` | *duration* | 10s | Rate limiting: Strongly related to `visitor-request-limit-burst`: The rate at which the bucket is refilled |
| `visitor-email-limit-burst` | `NTFY_VISITOR_EMAIL_LIMIT_BURST` | *number* | 16 | Rate limiting:Initial limit of e-mails per visitor |
| `visitor-email-limit-replenish` | `NTFY_VISITOR_EMAIL_LIMIT_REPLENISH` | *duration* | 1h | Rate limiting: Strongly related to `visitor-email-limit-burst`: The rate at which the bucket is refilled |
The format for a *duration* is: `<number>(smh)`, e.g. 30s, 20m or 1h.
The format for a *size* is: `<number>(GMK)`, e.g. 1G, 200M or 4000k.
@@ -522,6 +766,9 @@ NAME:
USAGE:
ntfy serve [OPTIONS..]
CATEGORY:
Server commands
DESCRIPTION:
Run the ntfy server and listen for incoming requests
@@ -537,16 +784,19 @@ OPTIONS:
--base-url value, -B value externally visible base URL for this host (e.g. https://ntfy.sh) [$NTFY_BASE_URL]
--listen-http value, -l value ip:port used to as HTTP listen address (default: ":80") [$NTFY_LISTEN_HTTP]
--listen-https value, -L value ip:port used to as HTTPS listen address [$NTFY_LISTEN_HTTPS]
--listen-unix value, -U value listen on unix socket path [$NTFY_LISTEN_UNIX]
--key-file value, -K value private key file, if listen-https is set [$NTFY_KEY_FILE]
--cert-file value, -E value certificate file, if listen-https is set [$NTFY_CERT_FILE]
--firebase-key-file value, -F value Firebase credentials file; if set additionally publish to FCM topic [$NTFY_FIREBASE_KEY_FILE]
--cache-file value, -C value cache file used for message caching [$NTFY_CACHE_FILE]
--cache-duration since, -b since buffer messages for this time to allow since requests (default: 12h0m0s) [$NTFY_CACHE_DURATION]
--auth-file value, -H value auth database file used for access control [$NTFY_AUTH_FILE]
--auth-default-access value, -p value default permissions if no matching entries in the auth database are found (default: "read-write") [$NTFY_AUTH_DEFAULT_ACCESS]
--attachment-cache-dir value cache directory for attached files [$NTFY_ATTACHMENT_CACHE_DIR]
--attachment-total-size-limit value, -A value limit of the on-disk attachment cache (default: 5G) [$NTFY_ATTACHMENT_TOTAL_SIZE_LIMIT]
--attachment-file-size-limit value, -Y value per-file attachment size limit (e.g. 300k, 2M, 100M) (default: 15M) [$NTFY_ATTACHMENT_FILE_SIZE_LIMIT]
--attachment-expiry-duration value, -X value duration after which uploaded attachments will be deleted (e.g. 3h, 20h) (default: 3h) [$NTFY_ATTACHMENT_EXPIRY_DURATION]
--keepalive-interval value, -k value interval of keepalive messages (default: 55s) [$NTFY_KEEPALIVE_INTERVAL]
--keepalive-interval value, -k value interval of keepalive messages (default: 45s) [$NTFY_KEEPALIVE_INTERVAL]
--manager-interval value, -m value interval of for message pruning and stats printing (default: 1m0s) [$NTFY_MANAGER_INTERVAL]
--smtp-sender-addr value SMTP server address (host:port) for outgoing emails [$NTFY_SMTP_SENDER_ADDR]
--smtp-sender-user value SMTP user (if e-mail sending is enabled) [$NTFY_SMTP_SENDER_USER]

View File

@@ -26,21 +26,21 @@ deb/rpm packages.
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_x86_64.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_x86_64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
```
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_armv7.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
```
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.tar.gz
wget https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_arm64.tar.gz
sudo tar -C /usr/bin -zxf ntfy_*.tar.gz ntfy
sudo ./ntfy serve
```
@@ -88,7 +88,7 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_amd64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_amd64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -96,7 +96,7 @@ Manually installing the .deb file:
=== "armv7/armhf"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_armv7.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -104,7 +104,7 @@ Manually installing the .deb file:
=== "arm64"
```bash
wget https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.deb
wget https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_arm64.deb
sudo dpkg -i ntfy_*.deb
sudo systemctl enable ntfy
sudo systemctl start ntfy
@@ -114,21 +114,21 @@ Manually installing the .deb file:
=== "x86_64/amd64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_amd64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_amd64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "armv7/armhf"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_armv7.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_armv7.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```
=== "arm64"
```bash
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.12.0/ntfy_1.12.0_linux_arm64.rpm
sudo rpm -ivh https://github.com/binwiederhier/ntfy/releases/download/v1.13.0/ntfy_1.13.0_linux_arm64.rpm
sudo systemctl enable ntfy
sudo systemctl start ntfy
```

View File

@@ -752,12 +752,11 @@ Here's what that looks like on Android:
### Attach file from a URL
Instead of sending a local file to your phone, you can use **an external URL** to specify where the attachment is hosted.
This could be a Google Drive or Dropbox link, or any other publicly available URL. The ntfy server will briefly probe
the URL to retrieve type and size for you. Since the files are externally hosted, the expiration or size limits from
above do not apply here.
This could be a Dropbox link, a file from social media, or any other publicly available URL. Since the files are
externally hosted, the expiration or size limits from above do not apply here.
To attach an external file, simple pass the `X-Attach` header or query parameter (or any of its aliases `Attach` or `a`)
to specify the attachment URL. It can be any type of file. Here's an example showing how to upload an image:
to specify the attachment URL. It can be any type of file. Here's an example showing how to attach an APK file:
=== "Command line (curl)"
```
@@ -942,6 +941,81 @@ title `You've Got Mail` to topic `sometopic` (see [ntfy.sh/sometopic](https://nt
## Advanced features
### Authentication
Depending on whether the server is configured to support [access control](config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
your password.
Here's a simple example:
=== "Command line (curl)"
```
curl \
-u phil:mypass \
-d "Look ma, with auth" \
https://ntfy.example.com/mysecrets
```
=== "ntfy CLI"
```
ntfy publish \
-u phil:mypass \
ntfy.example.com/mysecrets \
"Look ma, with auth"
```
=== "HTTP"
``` http
POST /mysecrets HTTP/1.1
Host: ntfy.example.com
Authorization: Basic cGhpbDpteXBhc3M=
Look ma, with auth
```
=== "JavaScript"
``` javascript
fetch('https://ntfy.example.com/mysecrets', {
method: 'POST', // PUT works too
body: 'Look ma, with auth',
headers: {
'Authorization': 'Basic cGhpbDpteXBhc3M='
}
})
```
=== "Go"
``` go
req, _ := http.NewRequest("POST", "https://ntfy.example.com/mysecrets",
strings.NewReader("Look ma, with auth"))
req.Header.Set("Authorization", "Basic cGhpbDpteXBhc3M=")
http.DefaultClient.Do(req)
```
=== "Python"
``` python
requests.post("https://ntfy.example.com/mysecrets",
data="Look ma, with auth",
headers={
"Authorization": "Basic cGhpbDpteXBhc3M="
})
```
=== "PHP"
``` php-inline
file_get_contents('https://ntfy.example.com/mysecrets', false, stream_context_create([
'http' => [
'method' => 'POST', // PUT also works
'header' =>
'Content-Type: text/plain\r\n' .
'Authorization: Basic cGhpbDpteXBhc3M=',
'content' => 'Look ma, with auth'
]
]));
```
### Message caching
!!! info
If `Cache: no` is used, messages will only be delivered to connected subscribers, and won't be re-delivered if a
@@ -1097,39 +1171,41 @@ in the control of the user. ntfy can act as a **UnifiedPush distributor**, forwa
When publishing messages to a topic, apps using ntfy as a UnifiedPush distributor can set the `X-UnifiedPush` header or query
parameter (or any of its aliases `unifiedpush` or `up`) to `1` to [disable Firebase](#disable-firebase). As of today, this
option is equivalent to `Firebase: no`, but was introduced to allow future flexibility.
option is mostly equivalent to `Firebase: no`, but was introduced to allow future flexibility. The flag additionally
enables auto-detection of the message encoding. If the message is binary, it'll be encoded as base64.
## Limitations
There are a few limitations to the API to prevent abuse and to keep the server healthy. Almost all of these settings
are configurable via the server side [rate limiting settings](config.md#rate-limiting). Most of these limits you won't run into,
but just in case, let's list them all:
| Limit | Description |
|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. |
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. |
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. |
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
| Limit | Description |
|----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Message length** | Each message can be up to 4,096 bytes long. Longer messages are treated as [attachments](#attachments). |
| **Requests** | By default, the server is configured to allow 60 requests per visitor at once, and then refills the your allowed requests bucket at a rate of one request per 10 seconds. |
| **E-mails** | By default, the server is configured to allow sending 16 e-mails per visitor at once, and then refills the your allowed e-mail bucket at a rate of one per hour. |
| **Subscription limit** | By default, the server allows each visitor to keep 30 connections to the server open. |
| **Attachment size limit** | By default, the server allows attachments up to 15 MB in size, up to 100 MB in total per visitor and up to 5 GB across all visitors. |
| **Attachment expiry** | By default, the server deletes attachments after 3 hours and thereby frees up space from the total visitor attachment limit. |
| **Attachment bandwidth** | By default, the server allows 500 MB of GET/PUT/POST traffic for attachments per visitor in a 24 hour period. Traffic exceeding that is rejected. |
| **Total number of topics** | By default, the server is configured to allow 15,000 topics. The ntfy.sh server has higher limits though. |
## List of all parameters
The following is a list of all parameters that can be passed when publishing a message. Parameter names are **case-insensitive**,
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
| Parameter | Aliases (case-insensitive) | Description |
|---|---|---|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, currently equivalent to `Firebase: no` |
| Parameter | Aliases (case-insensitive) | Description |
|-----------------|--------------------------------------------|-----------------------------------------------------------------------------------------------|
| `X-Message` | `Message`, `m` | Main body of the message as shown in the notification |
| `X-Title` | `Title`, `t` | [Message title](#message-title) |
| `X-Priority` | `Priority`, `prio`, `p` | [Message priority](#message-priority) |
| `X-Tags` | `Tags`, `Tag`, `ta` | [Tags and emojis](#tags-emojis) |
| `X-Delay` | `Delay`, `X-At`, `At`, `X-In`, `In` | Timestamp or duration for [delayed delivery](#scheduled-delivery) |
| `X-Click` | `Click` | URL to open when [notification is clicked](#click-action) |
| `X-Attach` | `Attach`, `a` | URL to send as an [attachment](#attachments), as an alternative to PUT/POST-ing an attachment |
| `X-Filename` | `Filename`, `file`, `f` | Optional [attachment](#attachments) filename, as it appears in the client |
| `X-Email` | `X-E-Mail`, `Email`, `E-Mail`, `mail`, `e` | E-mail address for [e-mail notifications](#e-mail-notifications) |
| `X-Cache` | `Cache` | Allows disabling [message caching](#message-caching) |
| `X-Firebase` | `Firebase` | Allows disabling [sending to Firebase](#disable-firebase) |
| `X-UnifiedPush` | `UnifiedPush`, `up` | [UnifiedPush](#unifiedpush) publish option, only to be used by UnifiedPush apps |
| `Authorization` | - | If supported by the server, you can [login to access](#authentication) protected topics |

View File

@@ -3,7 +3,11 @@ You can create and subscribe to a topic in the [web UI](web.md), via the [phone
or in your own app or script by subscribing the API. This page describes how to subscribe via API. You may also want to
check out the page that describes how to [publish messages](../publish.md).
The subscription API relies on a simple HTTP GET request with a streaming HTTP response, i.e **you open a GET request and
You can consume the subscription API as either a **[simple HTTP stream (JSON, SSE or raw)](#http-stream)**, or
**[via WebSockets](#websockets)**. Both are incredibly simple to use.
## HTTP stream
The HTTP stream-based API relies on a simple GET request with a streaming HTTP response, i.e **you open a GET request and
the connection stays open forever**, sending messages back as they come in. There are three different API endpoints, which
only differ in the response format:
@@ -12,7 +16,7 @@ only differ in the response format:
can be used with [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource)
* [Raw stream](#subscribe-as-raw-stream): `<topic>/raw` returns messages as raw text, with one line per message
## Subscribe as JSON stream
### Subscribe as JSON stream
Here are a few examples of how to consume the JSON endpoint (`<topic>/json`). For almost all languages, **this is the
recommended way to subscribe to a topic**. The notable exception is JavaScript, for which the
[SSE/EventSource stream](#subscribe-as-sse-stream) is much easier to work with.
@@ -80,7 +84,7 @@ recommended way to subscribe to a topic**. The notable exception is JavaScript,
fclose($fp);
```
## Subscribe as SSE stream
### Subscribe as SSE stream
Using [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) in JavaScript, you can consume
notifications via a [Server-Sent Events (SSE)](https://en.wikipedia.org/wiki/Server-sent_events) stream. It's incredibly
easy to use. Here's what it looks like. You may also want to check out the [live example](/example.html).
@@ -125,7 +129,7 @@ easy to use. Here's what it looks like. You may also want to check out the [live
};
```
## Subscribe as raw stream
### Subscribe as raw stream
The `/raw` endpoint will output one line per message, and **will only include the message body**. It's useful for extremely
simple scripts, and doesn't include all the data. Additional fields such as [priority](../publish.md#message-priority),
[tags](../publish.md#tags--emojis--) or [message title](../publish.md#message-title) are not included in this output
@@ -184,6 +188,51 @@ format. Keepalive messages are sent as empty lines.
fclose($fp);
```
## WebSockets
You may also subscribe to topics via [WebSockets](https://en.wikipedia.org/wiki/WebSocket), which is also widely
supported in many languages. Most notably, WebSockets are natively supported in JavaScript. On the command line,
I recommend [websocat](https://github.com/vi/websocat), a fantastic tool similar to `socat` or `curl`, but specifically
for WebSockets.
The WebSockets endpoint is available at `<topic>/ws` and returns messages as JSON objects similar to the
[JSON stream endpoint](#subscribe-as-json-stream).
=== "Command line (websocat)"
```
$ websocat wss://ntfy.sh/mytopic/ws
{"id":"qRHUCCvjj8","time":1642307388,"event":"open","topic":"mytopic"}
{"id":"eOWoUBJ14x","time":1642307754,"event":"message","topic":"mytopic","message":"hi there"}
```
=== "HTTP"
``` http
GET /disk-alerts/ws HTTP/1.1
Host: ntfy.sh
Upgrade: websocket
Connection: Upgrade
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
...
```
=== "Go"
``` go
import "github.com/gorilla/websocket"
ws, _, _ := websocket.DefaultDialer.Dial("wss://ntfy.sh/mytopic/ws", nil)
messageType, data, err := ws.ReadMessage()
...
```
=== "JavaScript"
``` javascript
const socket = new WebSocket('wss://ntfy.sh/mytopic/ws');
socket.addEventListener('message', function (event) {
console.log(event.data);
});
```
## Advanced features
### Poll for messages
@@ -229,12 +278,12 @@ $ curl "ntfy.sh/alerts/json?priority=high&tags=zfs-error"
Available filters (all case-insensitive):
| Filter variable | Alias | Example | Description |
|---|---|---|---|
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
| Filter variable | Alias | Example | Description |
|-----------------|---------------------------|------------------------------------|-------------------------------------------------------------------------|
| `message` | `X-Message`, `m` | `ntfy.sh/mytopic?message=lalala` | Only return messages that match this exact message string |
| `title` | `X-Title`, `t` | `ntfy.sh/mytopic?title=some+title` | Only return messages that match this exact title string |
| `priority` | `X-Priority`, `prio`, `p` | `ntfy.sh/mytopic?p=high,urgent` | Only return messages that match *any priority listed* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | `ntfy.sh/mytopic?tags=error,alert` | Only return messages that match *all listed tags* (comma-separated) |
### Subscribe to multiple topics
It's possible to subscribe to multiple topics in one HTTP call by providing a comma-separated list of topics
@@ -247,37 +296,70 @@ $ curl -s ntfy.sh/mytopic1,mytopic2/json
{"id":"Cm02DsxUHb","time":1637182643,"event":"message","topic":"mytopic2","message":"for topic 2"}
```
### Authentication
Depending on whether the server is configured to support [access control](../config.md#access-control), some topics
may be read/write protected so that only users with the correct credentials can subscribe or publish to them.
To publish/subscribe to protected topics, you can use [Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication)
with a valid username/password. For your self-hosted server, **be sure to use HTTPS to avoid eavesdropping** and exposing
your password.
```
curl -u phil:mypass -s "https://ntfy.example.com/mytopic/json"
```
## JSON message format
Both the [`/json` endpoint](#subscribe-as-json-stream) and the [`/sse` endpoint](#subscribe-as-sse-stream) return a JSON
format of the message. It's very straight forward:
| Field | Required | Type | Example | Description |
|---|---|---|---|---|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
| `time` | ✔️ | *int* | `1635528741` | Message date time, as Unix time stamp |
| `event` | ✔️ | `open`, `keepalive` or `message` | `message` | Message type, typically you'd be only interested in `message` |
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
**Message**:
| Field | Required | Type | Example | Description |
|--------------|----------|---------------------------------------------------|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `id` | ✔️ | *string* | `hwQ2YpKdmg` | Randomly chosen message identifier |
| `time` | ✔️ | *number* | `1635528741` | Message date time, as Unix time stamp |
| `event` | ✔️ | `open`, `keepalive`, `message`, or `poll_request` | `message` | Message type, typically you'd be only interested in `message` |
| `topic` | ✔️ | *string* | `topic1,topic2` | Comma-separated list of topics the message is associated with; only one for all `message` events, but may be a list in `open` events |
| `message` | - | *string* | `Some message` | Message body; always present in `message` events |
| `title` | - | *string* | `Some title` | Message [title](../publish.md#message-title); if not set defaults to `ntfy.sh/<topic>` |
| `tags` | - | *string array* | `["tag1","tag2"]` | List of [tags](../publish.md#tags-emojis) that may or not map to emojis |
| `priority` | - | *1, 2, 3, 4, or 5* | `4` | Message [priority](../publish.md#message-priority) with 1=min, 3=default and 5=max |
| `click` | - | *URL* | `https://example.com` | Website opened when notification is [clicked](../publish.md#click-action) |
| `attachment` | - | *JSON object* | *see below* | Details about an attachment (name, URL, size, ...) |
**Attachment** (part of the message, see [attachments](../publish.md#attachments) for details):
| Field | Required | Type | Example | Description |
|-----------|----------|-------------|--------------------------------|-----------------------------------------------------------------------------------------------------------|
| `name` | ✔️ | *string* | `attachment.jpg` | Name of the attachment, can be overridden with `X-Filename`, see [attachments](../publish.md#attachments) |
| `url` | ✔️ | *URL* | `https://example.com/file.jpg` | URL of the attachment |
| `type` | - | *mime type* | `image/jpeg` | Mime type of the attachment, only defined if attachment was uploaded to ntfy server |
| `size` | - | *number* | `33848` | Size of the attachment in bytes, only defined if attachment was uploaded to ntfy server |
| `expires` | - | *number* | `1635528741` | Attachment expiry date as Unix time stamp, only defined if attachment was uploaded to ntfy server |
Here's an example for each message type:
=== "Notification message"
``` json
{
"id": "wze9zgqK41",
"time": 1638542110,
"id": "sPs71M8A2T",
"time": 1643935928,
"event": "message",
"topic": "phil_alerts",
"topic": "mytopic",
"priority": 5,
"tags": [
"warning",
"skull"
],
"click": "https://homecam.mynet.lan/incident/1234",
"attachment": {
"name": "camera.jpg",
"type": "image/png",
"size": 33848,
"expires": 1643946728,
"url": "https://ntfy.sh/file/sPs71M8A2T.png"
},
"title": "Unauthorized access detected",
"message": "Remote access to phils-laptop detected. Act right away."
"message": "Movement detected in the yard. You better go check"
}
```
@@ -313,15 +395,26 @@ Here's an example for each message type:
}
```
=== "Poll request message"
``` json
{
"id": "371sevb0pD",
"time": 1638542275,
"event": "poll_request",
"topic": "phil_alerts"
}
```
## List of all parameters
The following is a list of all parameters that can be passed when subscribing to a message. Parameter names are **case-insensitive**,
The following is a list of all parameters that can be passed **when subscribing to a message**. Parameter names are **case-insensitive**,
and can be passed as **HTTP headers** or **query parameters in the URL**. They are listed in the table in their canonical form.
| Parameter | Aliases (case-insensitive) | Description |
|---|---|---|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) |
| Parameter | Aliases (case-insensitive) | Description |
|-------------|----------------------------|---------------------------------------------------------------------------------|
| `poll` | `X-Poll`, `po` | Return cached messages and close connection |
| `scheduled` | `X-Scheduled`, `sched` | Include scheduled/delayed messages in message list |
| `message` | `X-Message`, `m` | Filter: Only return messages that match this exact message string |
| `title` | `X-Title`, `t` | Filter: Only return messages that match this exact title string |
| `priority` | `X-Priority`, `prio`, `p` | Filter: Only return messages that match *any priority listed* (comma-separated) |
| `tags` | `X-Tags`, `tag`, `ta` | Filter: Only return messages that match *all listed tags* (comma-separated) |

View File

@@ -103,16 +103,16 @@ The message fields are passed to the command as environment variables and can be
these are environment variables, you typically don't have to worry about quoting too much, as long as you enclose them
in double-quotes, you should be fine:
| Variable | Aliases | Description |
|---|---|---
| `$NTFY_ID` | `$id` | Unique message ID |
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
| `$NTFY_TOPIC` | `$topic` | Topic name |
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
| `$NTFY_RAW` | `$raw` | Raw JSON message |
| Variable | Aliases | Description |
|------------------|----------------------------|----------------------------------------|
| `$NTFY_ID` | `$id` | Unique message ID |
| `$NTFY_TIME` | `$time` | Unix timestamp of the message delivery |
| `$NTFY_TOPIC` | `$topic` | Topic name |
| `$NTFY_MESSAGE` | `$message`, `$m` | Message body |
| `$NTFY_TITLE` | `$title`, `$t` | Message title |
| `$NTFY_PRIORITY` | `$priority`, `$prio`, `$p` | Message priority (1=min, 5=max) |
| `$NTFY_TAGS` | `$tags`, `$tag`, `$ta` | Message tags (comma separated list) |
| `$NTFY_RAW` | `$raw` | Raw JSON message |
### Subscribe to multiple topics
```

34
go.mod
View File

@@ -4,48 +4,54 @@ go 1.17
require (
cloud.google.com/go/firestore v1.6.1 // indirect
cloud.google.com/go/storage v1.18.2 // indirect
cloud.google.com/go/storage v1.19.0 // indirect
firebase.google.com/go v3.13.0+incompatible
github.com/BurntSushi/toml v0.4.1 // indirect
github.com/BurntSushi/toml v1.0.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/emersion/go-smtp v0.15.0
github.com/mattn/go-sqlite3 v1.14.9
github.com/gabriel-vasile/mimetype v1.4.0
github.com/gorilla/websocket v1.4.2
github.com/mattn/go-sqlite3 v1.14.11
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6
github.com/stretchr/testify v1.7.0
github.com/urfave/cli/v2 v2.3.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11
google.golang.org/api v0.63.0
google.golang.org/api v0.67.0
gopkg.in/yaml.v2 v2.4.0
)
require (
cloud.google.com/go v0.99.0 // indirect
cloud.google.com/go v0.100.2 // indirect
cloud.google.com/go/compute v1.2.0 // indirect
cloud.google.com/go/iam v0.1.1 // indirect
github.com/AlekSi/pointer v1.0.0 // indirect
github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.1.2 // indirect
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 // indirect
github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe // indirect
github.com/cncf/xds/go v0.0.0-20220121163655-4a2b9fdd466b // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac // indirect
github.com/envoyproxy/go-control-plane v0.10.1 // indirect
github.com/envoyproxy/protoc-gen-validate v0.6.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.0 // indirect
github.com/envoyproxy/protoc-gen-validate v0.6.3 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.6 // indirect
github.com/google/go-cmp v0.5.7 // indirect
github.com/googleapis/gax-go/v2 v2.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
go.opencensus.io v0.23.0 // indirect
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d // indirect
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 // indirect
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 // indirect
golang.org/x/text v0.3.7 // indirect
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect
google.golang.org/grpc v1.43.0 // indirect
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f // indirect
google.golang.org/grpc v1.44.0 // indirect
google.golang.org/protobuf v1.27.1 // indirect
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

58
go.sum
View File

@@ -27,16 +27,26 @@ cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.99.0 h1:y/cM2iqGgGi5D5DQZl6D9STN/3dR/Vx5Mp8s752oJTY=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y=
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
cloud.google.com/go/compute v1.1.0 h1:pyPhehLfZ6pVzRgJmXGYvCY4K7WSWRhVw0AwhgVvS84=
cloud.google.com/go/compute v1.1.0/go.mod h1:2NIffxgWfORSI7EOYMFatGTfjMLnqrOKBEyYb6NoRgA=
cloud.google.com/go/compute v1.2.0 h1:EKki8sSdvDU0OO9mAXGwPXOTOgPz2l08R0/IutDH11I=
cloud.google.com/go/compute v1.2.0/go.mod h1:xlogom/6gr8RJGBe7nT2eGsQYAFUbbv8dbC29qE3Xmw=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.6.1 h1:8rBq3zRjnHx8UtBvaOWqBB1xq9jH6/wltfQLlTMh2Fw=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
cloud.google.com/go/iam v0.1.1 h1:4CapQyNFjiksks1/x7jsvsygFPhihslYk5GptIrlX68=
cloud.google.com/go/iam v0.1.1/go.mod h1:CKqrcnI/suGpybEHxZ7BMehL0oA4LpdyJdUlTl9jVMw=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -48,6 +58,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.18.2 h1:5NQw6tOn3eMm0oE8vTkfjau18kjL79FlMjy/CHTpmoY=
cloud.google.com/go/storage v1.18.2/go.mod h1:AiIj7BWXyhO5gGVmYJ+S8tbkCx3yb0IMjua8Aw4naVM=
cloud.google.com/go/storage v1.19.0 h1:XOQSnPJD8hRtZJ3VdCyK0mBZsGGImrzPAMbSWcHSe6Q=
cloud.google.com/go/storage v1.19.0/go.mod h1:6rgiTRjOqI/Zd9YKimub5TIB4d+p3LH33V3ZE1DMuUM=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
@@ -56,6 +68,8 @@ github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QK
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.0.0 h1:dtDWrepsVPfW9H/4y7dDgFc2MBUSeJhlaDtK13CxFlU=
github.com/BurntSushi/toml v1.0.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
@@ -76,6 +90,8 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 h1:hzAQntlaYRkVSFEfj9OTWlVV1H155FMD8BTKktLv0QI=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk=
github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
@@ -83,6 +99,8 @@ github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490 h1:KwaoQzs/WeUxxJqiJsZ4euOly1Az/IgZXXSxlD/UBNk=
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20220121163655-4a2b9fdd466b h1:+CVhWLkTEEGdjn4cRvVCk6epN2T8eVyrpE/s1U1Y/Cg=
github.com/cncf/xds/go v0.0.0-20220121163655-4a2b9fdd466b/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -91,6 +109,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -106,6 +126,8 @@ github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPO
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v0.6.2 h1:JiO+kJTpmYGjEodY7O1Zk8oZcNz1+f30UtwtXoFUPzE=
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
github.com/envoyproxy/protoc-gen-validate v0.6.3 h1:HkntewfZJ9RofA/FX38zBCeIAqlLDFLbAI6eTpZqFJw=
github.com/envoyproxy/protoc-gen-validate v0.6.3/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
@@ -161,6 +183,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -189,6 +213,8 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.1.1 h1:dp3bWCh+PPO1zjRRiCSczJav13sBvG4UhNyVTa1KqdU=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@@ -205,8 +231,11 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA=
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.11 h1:gt+cp9c0XGqe9S/wAHTL3n/7MqY+siPWgWJgqdsFrzQ=
github.com/mattn/go-sqlite3 v1.14.11/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6 h1:oDSPaYiL2dbjcArLrFS8ANtwgJMyOLzvQCZon+XmFsk=
github.com/olebedev/when v0.0.0-20211212231525-59bd4edcf9d6/go.mod h1:DPucAeQGDPUzYUt+NaWw6qsF5SFapWWToxEiVDh2aV0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -253,6 +282,7 @@ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -356,6 +386,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -408,7 +439,15 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486 h1:5hpz5aRr+W1erYCL5JRhSUBJRph7l9XkNveoExlrKYk=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9 h1:XfKQ4OlFl8okEOr5UvAqFRVj8pY/4yfcXrddB8qAbU0=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27 h1:XDXtA5hveEEV8JB2l7nhMTp3t3cHp9ZpwcdjqyEWLlo=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -514,6 +553,12 @@ google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUb
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.63.0 h1:n2bqqK895ygnBpdPDYetfy23K7fJ22wsrZKCyfuRkkA=
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
google.golang.org/api v0.64.0/go.mod h1:931CdxA8Rm4t6zqTFGSsgwbAEZ2+GMYurbndwSimebM=
google.golang.org/api v0.65.0/go.mod h1:ArYhxgGadlWmqO1IqVujw6Cs8IdD33bTmzKo2Sh+cbg=
google.golang.org/api v0.66.0 h1:CbGy4LEiXCVCiNEDFgGpWOVwsDT7E2Qej1ZvN1P7KPg=
google.golang.org/api v0.66.0/go.mod h1:I1dmXYpX7HGwz/ejRxwQp2qj5bFAz93HiCU1C1oYd9M=
google.golang.org/api v0.67.0 h1:lYaaLa+x3VVUhtosaK9xihwQ9H9KRa557REHwwZ2orM=
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -586,6 +631,17 @@ google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa h1:I0YcKz0I7OAhddo7ya8kMnvprhcWM045PmkBdMO9zN0=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211223182754-3ac035c7e7cb/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220107163113-42d7afdf6368/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220111164026-67b88f271998/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220114231437-d2e6a121cae0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220118154757-00ab72f36ad5/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350 h1:YxHp5zqIcAShDEvRr5/0rVESVS+njYF68PSdazrNLJo=
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220201184016-50beb8ab5c44/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f h1:w9Sx4FBkwsN0jMZz8E42tMdmhZ5b2Z/vFx2LKAxxI9o=
google.golang.org/genproto v0.0.0-20220203182621-f4ae394cde3f/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -614,6 +670,8 @@ google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9K
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.43.0 h1:Eeu7bZtDZ2DpRCsLhUlcrLnvYaMK1Gz86a+hMVvELmM=
google.golang.org/grpc v1.43.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.44.0 h1:weqSxi/TMs1SqFRMHCtBgXRs8k3X39QIDEZ0pRcttUg=
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=

View File

@@ -8,8 +8,8 @@ if [ "$1" = "configure" ] || [ "$1" -ge 1 ]; then
if [ -d /run/systemd/system ]; then
# Create ntfy user/group
id ntfy >/dev/null 2>&1 || useradd --system --no-create-home ntfy
chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments
chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments
chown ntfy.ntfy /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy
chmod 700 /var/cache/ntfy /var/cache/ntfy/attachments /var/lib/ntfy
# Hack to change permissions on cache file
configfile="/etc/ntfy/server.yml"

View File

@@ -29,30 +29,31 @@ const (
attachment_expires INT NOT NULL,
attachment_url TEXT NOT NULL,
attachment_owner TEXT NOT NULL,
encoding TEXT NOT NULL,
published INT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_topic ON messages (topic);
COMMIT;
`
insertMessageQuery = `
INSERT INTO messages (id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO messages (id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
pruneMessagesQuery = `DELETE FROM messages WHERE time < ? AND published = 1`
selectMessagesSinceTimeQuery = `
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages
WHERE topic = ? AND time >= ? AND published = 1
ORDER BY time ASC
`
selectMessagesSinceTimeIncludeScheduledQuery = `
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages
WHERE topic = ? AND time >= ?
ORDER BY time ASC
`
selectMessagesDueQuery = `
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner
SELECT id, time, topic, message, title, priority, tags, click, attachment_name, attachment_type, attachment_size, attachment_expires, attachment_url, attachment_owner, encoding
FROM messages
WHERE time <= ? AND published = 0
`
@@ -66,7 +67,7 @@ const (
// Schema management queries
const (
currentSchemaVersion = 3
currentSchemaVersion = 4
createSchemaVersionTableQuery = `
CREATE TABLE IF NOT EXISTS schemaVersion (
id INT PRIMARY KEY,
@@ -103,6 +104,10 @@ const (
ALTER TABLE messages ADD COLUMN attachment_url TEXT NOT NULL DEFAULT('');
COMMIT;
`
// 3 -> 4
migrate3To4AlterMessagesTableQuery = `
ALTER TABLE messages ADD COLUMN encoding TEXT NOT NULL DEFAULT('');
`
)
type sqliteCache struct {
@@ -116,7 +121,7 @@ func newSqliteCache(filename string) (*sqliteCache, error) {
if err != nil {
return nil, err
}
if err := setupDB(db); err != nil {
if err := setupCacheDB(db); err != nil {
return nil, err
}
return &sqliteCache{
@@ -156,6 +161,7 @@ func (c *sqliteCache) AddMessage(m *message) error {
attachmentExpires,
attachmentURL,
attachmentOwner,
m.Encoding,
published,
)
return err
@@ -278,8 +284,25 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
for rows.Next() {
var timestamp, attachmentSize, attachmentExpires int64
var priority int
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner string
if err := rows.Scan(&id, &timestamp, &topic, &msg, &title, &priority, &tagsStr, &click, &attachmentName, &attachmentType, &attachmentSize, &attachmentExpires, &attachmentURL, &attachmentOwner); err != nil {
var id, topic, msg, title, tagsStr, click, attachmentName, attachmentType, attachmentURL, attachmentOwner, encoding string
err := rows.Scan(
&id,
&timestamp,
&topic,
&msg,
&title,
&priority,
&tagsStr,
&click,
&attachmentName,
&attachmentType,
&attachmentSize,
&attachmentExpires,
&attachmentURL,
&attachmentOwner,
&encoding,
)
if err != nil {
return nil, err
}
var tags []string
@@ -308,6 +331,7 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
Tags: tags,
Click: click,
Attachment: att,
Encoding: encoding,
})
}
if err := rows.Err(); err != nil {
@@ -316,11 +340,11 @@ func readMessages(rows *sql.Rows) ([]*message, error) {
return messages, nil
}
func setupDB(db *sql.DB) error {
func setupCacheDB(db *sql.DB) error {
// If 'messages' table does not exist, this must be a new database
rowsMC, err := db.Query(selectMessagesCountQuery)
if err != nil {
return setupNewDB(db)
return setupNewCacheDB(db)
}
rowsMC.Close()
@@ -347,11 +371,13 @@ func setupDB(db *sql.DB) error {
return migrateFrom1(db)
} else if schemaVersion == 2 {
return migrateFrom2(db)
} else if schemaVersion == 3 {
return migrateFrom3(db)
}
return fmt.Errorf("unexpected schema version found: %d", schemaVersion)
}
func setupNewDB(db *sql.DB) error {
func setupNewCacheDB(db *sql.DB) error {
if _, err := db.Exec(createMessagesTableQuery); err != nil {
return err
}
@@ -397,5 +423,16 @@ func migrateFrom2(db *sql.DB) error {
if _, err := db.Exec(updateSchemaVersion, 3); err != nil {
return err
}
return migrateFrom3(db)
}
func migrateFrom3(db *sql.DB) error {
log.Print("Migrating cache database schema: from 3 to 4")
if _, err := db.Exec(migrate3To4AlterMessagesTableQuery); err != nil {
return err
}
if _, err := db.Exec(updateSchemaVersion, 4); err != nil {
return err
}
return nil // Update this when a new version is added
}

View File

@@ -8,7 +8,7 @@ import (
const (
DefaultListenHTTP = ":80"
DefaultCacheDuration = 12 * time.Hour
DefaultKeepaliveInterval = 55 * time.Second // Not too frequently to save battery (Android read timeout is 77s!)
DefaultKeepaliveInterval = 45 * time.Second // Not too frequently to save battery (Android read timeout used to be 77s!)
DefaultManagerInterval = time.Minute
DefaultAtSenderInterval = 10 * time.Second
DefaultMinDelay = 10 * time.Second
@@ -49,11 +49,15 @@ type Config struct {
BaseURL string
ListenHTTP string
ListenHTTPS string
ListenUnix string
KeyFile string
CertFile string
FirebaseKeyFile string
CacheFile string
CacheDuration time.Duration
AuthFile string
AuthDefaultRead bool
AuthDefaultWrite bool
AttachmentCacheDir string
AttachmentTotalSizeLimit int64
AttachmentFileSizeLimit int64
@@ -90,11 +94,15 @@ func NewConfig() *Config {
BaseURL: "",
ListenHTTP: DefaultListenHTTP,
ListenHTTPS: "",
ListenUnix: "",
KeyFile: "",
CertFile: "",
FirebaseKeyFile: "",
CacheFile: "",
CacheDuration: DefaultCacheDuration,
AuthFile: "",
AuthDefaultRead: true,
AuthDefaultWrite: true,
AttachmentCacheDir: "",
AttachmentTotalSizeLimit: DefaultAttachmentTotalSizeLimit,
AttachmentFileSizeLimit: DefaultAttachmentFileSizeLimit,

52
server/errors.go Normal file
View File

@@ -0,0 +1,52 @@
package server
import (
"encoding/json"
"net/http"
)
// errHTTP is a generic HTTP error for any non-200 HTTP error
type errHTTP struct {
Code int `json:"code,omitempty"`
HTTPCode int `json:"http"`
Message string `json:"error"`
Link string `json:"link,omitempty"`
}
func (e errHTTP) Error() string {
return e.Message
}
func (e errHTTP) JSON() string {
b, _ := json.Marshal(&e)
return string(b)
}
var (
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachments not allowed", ""}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""}
errHTTPBadRequestWebSocketsUpgradeHeaderMissing = &errHTTP{40016, http.StatusBadRequest, "invalid request: client not using the websocket protocol", ""}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication"}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
)

View File

@@ -4,13 +4,14 @@ import (
"bytes"
"context"
"embed"
"encoding/base64"
"encoding/json"
"errors"
firebase "firebase.google.com/go"
"firebase.google.com/go/messaging"
"fmt"
"github.com/emersion/go-smtp"
"google.golang.org/api/option"
"github.com/gorilla/websocket"
"golang.org/x/sync/errgroup"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
"html/template"
"io"
@@ -18,7 +19,9 @@ import (
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
@@ -28,42 +31,24 @@ import (
"unicode/utf8"
)
// TODO add "max messages in a topic" limit
// TODO implement "since=<ID>"
// Server is the main server, providing the UI and API for ntfy
type Server struct {
config *Config
httpServer *http.Server
httpsServer *http.Server
smtpServer *smtp.Server
smtpBackend *smtpBackend
topics map[string]*topic
visitors map[string]*visitor
firebase subscriber
mailer mailer
messages int64
cache cache
fileCache *fileCache
closeChan chan bool
mu sync.Mutex
}
// errHTTP is a generic HTTP error for any non-200 HTTP error
type errHTTP struct {
Code int `json:"code,omitempty"`
HTTPCode int `json:"http"`
Message string `json:"error"`
Link string `json:"link,omitempty"`
}
func (e errHTTP) Error() string {
return e.Message
}
func (e errHTTP) JSON() string {
b, _ := json.Marshal(&e)
return string(b)
config *Config
httpServer *http.Server
httpsServer *http.Server
unixListener net.Listener
smtpServer *smtp.Server
smtpBackend *smtpBackend
topics map[string]*topic
visitors map[string]*visitor
firebase subscriber
mailer mailer
messages int64
auth auth.Auther
cache cache
fileCache *fileCache
closeChan chan bool
mu sync.Mutex
}
type indexPage struct {
@@ -71,37 +56,24 @@ type indexPage struct {
CacheDuration time.Duration
}
type sinceTime time.Time
func (t sinceTime) IsAll() bool {
return t == sinceAllMessages
}
func (t sinceTime) IsNone() bool {
return t == sinceNoMessages
}
func (t sinceTime) Time() time.Time {
return time.Time(t)
}
var (
sinceAllMessages = sinceTime(time.Unix(0, 0))
sinceNoMessages = sinceTime(time.Unix(1, 0))
)
// handleFunc extends the normal http.HandlerFunc to be able to easily return errors
type handleFunc func(http.ResponseWriter, *http.Request, *visitor) error
var (
// If changed, don't forget to update Android App and auth_sqlite.go
topicRegex = regexp.MustCompile(`^[-_A-Za-z0-9]{1,64}$`) // No /!
topicPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}$`) // Regex must match JS & Android app!
jsonPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/json$`)
ssePathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/sse$`)
rawPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/raw$`)
wsPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/ws$`)
authPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
publishPathRegex = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/(publish|send|trigger)$`)
staticRegex = regexp.MustCompile(`^/static/.+`)
docsRegex = regexp.MustCompile(`^/docs(|/.*)$`)
fileRegex = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
disallowedTopics = []string{"docs", "static", "file"}
disallowedTopics = []string{"docs", "static", "file"} // If updated, also update in Android app
attachURLRegex = regexp.MustCompile(`^https?://`)
templateFnMap = template.FuncMap{
@@ -122,52 +94,26 @@ var (
//go:embed docs
docsStaticFs embed.FS
docsStaticCached = &util.CachingEmbedFS{ModTime: time.Now(), FS: docsStaticFs}
errHTTPBadRequestEmailDisabled = &errHTTP{40001, http.StatusBadRequest, "e-mail notifications are not enabled", "https://ntfy.sh/docs/config/#e-mail-notifications"}
errHTTPBadRequestDelayNoCache = &errHTTP{40002, http.StatusBadRequest, "cannot disable cache for delayed message", ""}
errHTTPBadRequestDelayNoEmail = &errHTTP{40003, http.StatusBadRequest, "delayed e-mail notifications are not supported", ""}
errHTTPBadRequestDelayCannotParse = &errHTTP{40004, http.StatusBadRequest, "invalid delay parameter: unable to parse delay", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestDelayTooSmall = &errHTTP{40005, http.StatusBadRequest, "invalid delay parameter: too small, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestDelayTooLarge = &errHTTP{40006, http.StatusBadRequest, "invalid delay parameter: too large, please refer to the docs", "https://ntfy.sh/docs/publish/#scheduled-delivery"}
errHTTPBadRequestPriorityInvalid = &errHTTP{40007, http.StatusBadRequest, "invalid priority parameter", "https://ntfy.sh/docs/publish/#message-priority"}
errHTTPBadRequestSinceInvalid = &errHTTP{40008, http.StatusBadRequest, "invalid since parameter", "https://ntfy.sh/docs/subscribe/api/#fetch-cached-messages"}
errHTTPBadRequestTopicInvalid = &errHTTP{40009, http.StatusBadRequest, "invalid topic: path invalid", ""}
errHTTPBadRequestTopicDisallowed = &errHTTP{40010, http.StatusBadRequest, "invalid topic: topic name is disallowed", ""}
errHTTPBadRequestMessageNotUTF8 = &errHTTP{40011, http.StatusBadRequest, "invalid message: message must be UTF-8 encoded", ""}
errHTTPBadRequestAttachmentTooLarge = &errHTTP{40012, http.StatusBadRequest, "invalid request: attachment too large, or bandwidth limit reached", ""}
errHTTPBadRequestAttachmentURLInvalid = &errHTTP{40013, http.StatusBadRequest, "invalid request: attachment URL is invalid", ""}
errHTTPBadRequestAttachmentURLPeakGeneral = &errHTTP{40014, http.StatusBadRequest, "invalid request: attachment URL peak failed", ""}
errHTTPBadRequestAttachmentURLPeakNon2xx = &errHTTP{40015, http.StatusBadRequest, "invalid request: attachment URL peak failed with non-2xx status code", ""}
errHTTPBadRequestAttachmentsDisallowed = &errHTTP{40016, http.StatusBadRequest, "invalid request: attachments not allowed", ""}
errHTTPBadRequestAttachmentsExpiryBeforeDelivery = &errHTTP{40017, http.StatusBadRequest, "invalid request: attachment expiry before delayed delivery date", ""}
errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", ""}
errHTTPTooManyRequestsLimitRequests = &errHTTP{42901, http.StatusTooManyRequests, "limit reached: too many requests, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitEmails = &errHTTP{42902, http.StatusTooManyRequests, "limit reached: too many emails, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitSubscriptions = &errHTTP{42903, http.StatusTooManyRequests, "limit reached: too many active subscriptions, please be nice", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsLimitTotalTopics = &errHTTP{42904, http.StatusTooManyRequests, "limit reached: the total number of topics on the server has been reached, please contact the admin", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPTooManyRequestsAttachmentBandwidthLimit = &errHTTP{42905, http.StatusTooManyRequests, "too many requests: daily bandwidth limit reached", "https://ntfy.sh/docs/publish/#limitations"}
errHTTPInternalError = &errHTTP{50001, http.StatusInternalServerError, "internal server error", ""}
errHTTPInternalErrorInvalidFilePath = &errHTTP{50002, http.StatusInternalServerError, "internal server error: invalid file path", ""}
)
const (
firebaseControlTopic = "~control" // See Android if changed
emptyMessageBody = "triggered" // Used if message body is empty
defaultAttachmentMessage = "You received a file: %s" // Used if message body is empty, and there is an attachment
fcmMessageLimit = 4000 // see maybeTruncateFCMMessage for details
encodingBase64 = "base64"
)
// WebSocket constants
const (
wsWriteWait = 2 * time.Second
wsBufferSize = 1024
wsReadLimit = 64 // We only ever receive PINGs
wsPongWait = 15 * time.Second
)
// New instantiates a new Server. It creates the cache and adds a Firebase
// subscriber (if configured).
func New(conf *Config) (*Server, error) {
var firebaseSubscriber subscriber
if conf.FirebaseKeyFile != "" {
var err error
firebaseSubscriber, err = createFirebaseSubscriber(conf)
if err != nil {
return nil, err
}
}
var mailer mailer
if conf.SMTPSenderAddr != "" {
mailer = &smtpSender{config: conf}
@@ -187,6 +133,21 @@ func New(conf *Config) (*Server, error) {
return nil, err
}
}
var auther auth.Auther
if conf.AuthFile != "" {
auther, err = auth.NewSQLiteAuth(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite)
if err != nil {
return nil, err
}
}
var firebaseSubscriber subscriber
if conf.FirebaseKeyFile != "" {
var err error
firebaseSubscriber, err = createFirebaseSubscriber(conf.FirebaseKeyFile, auther)
if err != nil {
return nil, err
}
}
return &Server{
config: conf,
cache: cache,
@@ -194,6 +155,7 @@ func New(conf *Config) (*Server, error) {
firebase: firebaseSubscriber,
mailer: mailer,
topics: topics,
auth: auther,
visitors: make(map[string]*visitor),
}, nil
}
@@ -207,105 +169,55 @@ func createCache(conf *Config) (cache, error) {
return newMemCache(), nil
}
func createFirebaseSubscriber(conf *Config) (subscriber, error) {
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(conf.FirebaseKeyFile))
if err != nil {
return nil, err
}
msg, err := fb.Messaging(context.Background())
if err != nil {
return nil, err
}
return func(m *message) error {
var data map[string]string // Matches https://ntfy.sh/docs/subscribe/api/#json-message-format
switch m.Event {
case keepaliveEvent, openEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
}
case messageEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"click": m.Click,
"title": m.Title,
"message": m.Message,
}
if m.Attachment != nil {
data["attachment_name"] = m.Attachment.Name
data["attachment_type"] = m.Attachment.Type
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
data["attachment_url"] = m.Attachment.URL
}
}
var androidConfig *messaging.AndroidConfig
if m.Priority >= 4 {
androidConfig = &messaging.AndroidConfig{
Priority: "high",
}
}
_, err := msg.Send(context.Background(), maybeTruncateFCMMessage(&messaging.Message{
Topic: m.Topic,
Data: data,
Android: androidConfig,
}))
return err
}, nil
}
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
s, err := json.Marshal(m)
if err != nil {
return m
}
if len(s) > fcmMessageLimit {
over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
message, ok := m.Data["message"]
if ok && len(message) > over {
m.Data["truncated"] = "1"
m.Data["message"] = message[:len(message)-over]
}
}
return m
}
// Run executes the main server. It listens on HTTP (+ HTTPS, if configured), and starts
// a manager go routine to print stats and prune messages.
func (s *Server) Run() error {
listenStr := fmt.Sprintf("%s/http", s.config.ListenHTTP)
var listenStr string
if s.config.ListenHTTP != "" {
listenStr += fmt.Sprintf(" %s[http]", s.config.ListenHTTP)
}
if s.config.ListenHTTPS != "" {
listenStr += fmt.Sprintf(" %s/https", s.config.ListenHTTPS)
listenStr += fmt.Sprintf(" %s[https]", s.config.ListenHTTPS)
}
if s.config.ListenUnix != "" {
listenStr += fmt.Sprintf(" %s[unix]", s.config.ListenUnix)
}
if s.config.SMTPServerListen != "" {
listenStr += fmt.Sprintf(" %s/smtp", s.config.SMTPServerListen)
listenStr += fmt.Sprintf(" %s[smtp]", s.config.SMTPServerListen)
}
log.Printf("Listening on %s", listenStr)
log.Printf("Listening on%s", listenStr)
mux := http.NewServeMux()
mux.HandleFunc("/", s.handle)
errChan := make(chan error)
s.mu.Lock()
s.closeChan = make(chan bool)
s.httpServer = &http.Server{Addr: s.config.ListenHTTP, Handler: mux}
go func() {
errChan <- s.httpServer.ListenAndServe()
}()
if s.config.ListenHTTP != "" {
s.httpServer = &http.Server{Addr: s.config.ListenHTTP, Handler: mux}
go func() {
errChan <- s.httpServer.ListenAndServe()
}()
}
if s.config.ListenHTTPS != "" {
s.httpsServer = &http.Server{Addr: s.config.ListenHTTPS, Handler: mux}
go func() {
errChan <- s.httpsServer.ListenAndServeTLS(s.config.CertFile, s.config.KeyFile)
}()
}
if s.config.ListenUnix != "" {
go func() {
var err error
s.mu.Lock()
os.Remove(s.config.ListenUnix)
s.unixListener, err = net.Listen("unix", s.config.ListenUnix)
if err != nil {
errChan <- err
return
}
s.mu.Unlock()
httpServer := &http.Server{Handler: mux}
errChan <- httpServer.Serve(s.unixListener)
}()
}
if s.config.SMTPServerListen != "" {
go func() {
errChan <- s.runSMTPServer()
@@ -314,7 +226,7 @@ func (s *Server) Run() error {
s.mu.Unlock()
go s.runManager()
go s.runAtSender()
go s.runFirebaseKeepliver()
go s.runFirebaseKeepaliver()
return <-errChan
}
@@ -329,6 +241,9 @@ func (s *Server) Stop() {
if s.httpsServer != nil {
s.httpsServer.Close()
}
if s.unixListener != nil {
s.unixListener.Close()
}
if s.smtpServer != nil {
s.smtpServer.Close()
}
@@ -337,46 +252,54 @@ func (s *Server) Stop() {
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
if err := s.handleInternal(w, r); err != nil {
var e *errHTTP
var ok bool
if e, ok = err.(*errHTTP); !ok {
e = errHTTPInternalError
if websocket.IsWebSocketUpgrade(r) {
log.Printf("[%s] WS %s %s - %s", r.RemoteAddr, r.Method, r.URL.Path, err.Error())
return // Do not attempt to write to upgraded connection
}
log.Printf("[%s] %s - %d - %d - %s", r.RemoteAddr, r.Method, e.HTTPCode, e.Code, err.Error())
httpErr, ok := err.(*errHTTP)
if !ok {
httpErr = errHTTPInternalError
}
log.Printf("[%s] HTTP %s %s - %d - %d - %s", r.RemoteAddr, r.Method, r.URL.Path, httpErr.HTTPCode, httpErr.Code, err.Error())
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
w.WriteHeader(e.HTTPCode)
io.WriteString(w, e.JSON()+"\n")
w.WriteHeader(httpErr.HTTPCode)
io.WriteString(w, httpErr.JSON()+"\n")
}
}
func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request) error {
v := s.visitor(r)
if r.Method == http.MethodGet && r.URL.Path == "/" {
return s.handleHome(w, r)
} else if r.Method == http.MethodGet && r.URL.Path == "/example.html" {
return s.handleExample(w, r)
} else if r.Method == http.MethodHead && r.URL.Path == "/" {
return s.handleEmpty(w, r)
return s.handleEmpty(w, r, v)
} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
return s.handleStatic(w, r)
} else if r.Method == http.MethodGet && docsRegex.MatchString(r.URL.Path) {
return s.handleDocs(w, r)
} else if r.Method == http.MethodGet && fileRegex.MatchString(r.URL.Path) && s.config.AttachmentCacheDir != "" {
return s.withRateLimit(w, r, s.handleFile)
return s.limitRequests(s.handleFile)(w, r, v)
} else if r.Method == http.MethodOptions {
return s.handleOptions(w, r)
} else if r.Method == http.MethodGet && topicPathRegex.MatchString(r.URL.Path) {
return s.handleTopic(w, r)
} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && topicPathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handlePublish)
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && publishPathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handlePublish)
return s.limitRequests(s.authWrite(s.handlePublish))(w, r, v)
} else if r.Method == http.MethodGet && jsonPathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeJSON)
return s.limitRequests(s.authRead(s.handleSubscribeJSON))(w, r, v)
} else if r.Method == http.MethodGet && ssePathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeSSE)
return s.limitRequests(s.authRead(s.handleSubscribeSSE))(w, r, v)
} else if r.Method == http.MethodGet && rawPathRegex.MatchString(r.URL.Path) {
return s.withRateLimit(w, r, s.handleSubscribeRaw)
return s.limitRequests(s.authRead(s.handleSubscribeRaw))(w, r, v)
} else if r.Method == http.MethodGet && wsPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authRead(s.handleSubscribeWS))(w, r, v)
} else if r.Method == http.MethodGet && authPathRegex.MatchString(r.URL.Path) {
return s.limitRequests(s.authRead(s.handleTopicAuth))(w, r, v)
}
return errHTTPNotFound
}
@@ -389,7 +312,7 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) error {
}
func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
unifiedpush := readParam(r, "x-unifiedpush", "unifiedpush", "up") == "1" // see PUT/POST too!
unifiedpush := readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see PUT/POST too!
if unifiedpush {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
@@ -399,10 +322,17 @@ func (s *Server) handleTopic(w http.ResponseWriter, r *http.Request) error {
return s.handleHome(w, r)
}
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request) error {
func (s *Server) handleEmpty(_ http.ResponseWriter, _ *http.Request, _ *visitor) error {
return nil
}
func (s *Server) handleTopicAuth(w http.ResponseWriter, _ *http.Request, _ *visitor) error {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
_, err := io.WriteString(w, `{"success":true}`+"\n")
return err
}
func (s *Server) handleExample(w http.ResponseWriter, _ *http.Request) error {
_, err := io.WriteString(w, exampleSource)
return err
@@ -455,14 +385,11 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
return err
}
m := newDefaultMessage(t.ID, "")
cache, firebase, email, err := s.parsePublishParams(r, v, m)
cache, firebase, email, unifiedpush, err := s.parsePublishParams(r, v, m)
if err != nil {
return err
}
if err := maybePeakAttachmentURL(m); err != nil {
return err
}
if err := s.handlePublishBody(r, v, m, body); err != nil {
if err := s.handlePublishBody(r, v, m, body, unifiedpush); err != nil {
return err
}
if m.Message == "" {
@@ -498,37 +425,51 @@ func (s *Server) handlePublish(w http.ResponseWriter, r *http.Request, v *visito
if err := json.NewEncoder(w).Encode(m); err != nil {
return err
}
s.inc(&s.messages)
s.mu.Lock()
s.messages++
s.mu.Unlock()
return nil
}
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, err error) {
cache = readParam(r, "x-cache", "cache") != "no"
firebase = readParam(r, "x-firebase", "firebase") != "no"
func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (cache bool, firebase bool, email string, unifiedpush bool, err error) {
cache = readBoolParam(r, true, "x-cache", "cache")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
m.Title = readParam(r, "x-title", "title", "t")
m.Click = readParam(r, "x-click", "click")
attach := readParam(r, "x-attach", "attach", "a")
filename := readParam(r, "x-filename", "filename", "file", "f")
attach := readParam(r, "x-attach", "attach", "a")
if attach != "" || filename != "" {
m.Attachment = &attachment{}
}
if attach != "" {
if !attachURLRegex.MatchString(attach) {
return false, false, "", errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
}
if filename != "" {
m.Attachment.Name = filename
}
if attach != "" {
if !attachURLRegex.MatchString(attach) {
return false, false, "", false, errHTTPBadRequestAttachmentURLInvalid
}
m.Attachment.URL = attach
if m.Attachment.Name == "" {
u, err := url.Parse(m.Attachment.URL)
if err == nil {
m.Attachment.Name = path.Base(u.Path)
if m.Attachment.Name == "." || m.Attachment.Name == "/" {
m.Attachment.Name = ""
}
}
}
if m.Attachment.Name == "" {
m.Attachment.Name = "attachment"
}
}
email = readParam(r, "x-email", "x-e-mail", "email", "e-mail", "mail", "e")
if email != "" {
if err := v.EmailAllowed(); err != nil {
return false, false, "", errHTTPTooManyRequestsLimitEmails
return false, false, "", false, errHTTPTooManyRequestsLimitEmails
}
}
if s.mailer == nil && email != "" {
return false, false, "", errHTTPBadRequestEmailDisabled
return false, false, "", false, errHTTPBadRequestEmailDisabled
}
messageStr := readParam(r, "x-message", "message", "m")
if messageStr != "" {
@@ -536,7 +477,7 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
}
m.Priority, err = util.ParsePriority(readParam(r, "x-priority", "priority", "prio", "p"))
if err != nil {
return false, false, "", errHTTPBadRequestPriorityInvalid
return false, false, "", false, errHTTPBadRequestPriorityInvalid
}
tagsStr := readParam(r, "x-tags", "tags", "tag", "ta")
if tagsStr != "" {
@@ -548,66 +489,65 @@ func (s *Server) parsePublishParams(r *http.Request, v *visitor, m *message) (ca
delayStr := readParam(r, "x-delay", "delay", "x-at", "at", "x-in", "in")
if delayStr != "" {
if !cache {
return false, false, "", errHTTPBadRequestDelayNoCache
return false, false, "", false, errHTTPBadRequestDelayNoCache
}
if email != "" {
return false, false, "", errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
return false, false, "", false, errHTTPBadRequestDelayNoEmail // we cannot store the email address (yet)
}
delay, err := util.ParseFutureTime(delayStr, time.Now())
if err != nil {
return false, false, "", errHTTPBadRequestDelayCannotParse
return false, false, "", false, errHTTPBadRequestDelayCannotParse
} else if delay.Unix() < time.Now().Add(s.config.MinDelay).Unix() {
return false, false, "", errHTTPBadRequestDelayTooSmall
return false, false, "", false, errHTTPBadRequestDelayTooSmall
} else if delay.Unix() > time.Now().Add(s.config.MaxDelay).Unix() {
return false, false, "", errHTTPBadRequestDelayTooLarge
return false, false, "", false, errHTTPBadRequestDelayTooLarge
}
m.Time = delay.Unix()
}
unifiedpush := readParam(r, "x-unifiedpush", "unifiedpush", "up") == "1" // see GET too!
unifiedpush = readBoolParam(r, false, "x-unifiedpush", "unifiedpush", "up") // see GET too!
if unifiedpush {
firebase = false
unifiedpush = true
}
return cache, firebase, email, nil
}
func readParam(r *http.Request, names ...string) string {
for _, name := range names {
value := r.Header.Get(name)
if value != "" {
return strings.TrimSpace(value)
}
}
for _, name := range names {
value := r.URL.Query().Get(strings.ToLower(name))
if value != "" {
return strings.TrimSpace(value)
}
}
return ""
return cache, firebase, email, unifiedpush, nil
}
// handlePublishBody consumes the PUT/POST body and decides whether the body is an attachment or the message.
//
// 1. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
// 1. curl -T somebinarydata.bin "ntfy.sh/mytopic?up=1"
// If body is binary, encode as base64, if not do not encode
// 2. curl -H "Attach: http://example.com/file.jpg" ntfy.sh/mytopic
// Body must be a message, because we attached an external URL
// 2. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
// 3. curl -T short.txt -H "Filename: short.txt" ntfy.sh/mytopic
// Body must be attachment, because we passed a filename
// 3. curl -T file.txt ntfy.sh/mytopic
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 4. curl -T file.txt ntfy.sh/mytopic
// If file.txt is <= 4096 (message limit) and valid UTF-8, treat it as a message
// 5. curl -T file.txt ntfy.sh/mytopic
// If file.txt is > message limit, treat it as an attachment
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser) error {
if m.Attachment != nil && m.Attachment.URL != "" {
return s.handleBodyAsMessage(m, body) // Case 1
func (s *Server) handlePublishBody(r *http.Request, v *visitor, m *message, body *util.PeakedReadCloser, unifiedpush bool) error {
if unifiedpush {
return s.handleBodyAsMessageAutoDetect(m, body) // Case 1
} else if m.Attachment != nil && m.Attachment.URL != "" {
return s.handleBodyAsTextMessage(m, body) // Case 2
} else if m.Attachment != nil && m.Attachment.Name != "" {
return s.handleBodyAsAttachment(r, v, m, body) // Case 2
return s.handleBodyAsAttachment(r, v, m, body) // Case 3
} else if !body.LimitReached && utf8.Valid(body.PeakedBytes) {
return s.handleBodyAsMessage(m, body) // Case 3
return s.handleBodyAsTextMessage(m, body) // Case 4
}
return s.handleBodyAsAttachment(r, v, m, body) // Case 4
return s.handleBodyAsAttachment(r, v, m, body) // Case 5
}
func (s *Server) handleBodyAsMessage(m *message, body *util.PeakedReadCloser) error {
func (s *Server) handleBodyAsMessageAutoDetect(m *message, body *util.PeakedReadCloser) error {
if utf8.Valid(body.PeakedBytes) {
m.Message = string(body.PeakedBytes) // Do not trim
} else {
m.Message = base64.StdEncoding.EncodeToString(body.PeakedBytes)
m.Encoding = encodingBase64
}
return nil
}
func (s *Server) handleBodyAsTextMessage(m *message, body *util.PeakedReadCloser) error {
if !utf8.Valid(body.PeakedBytes) {
return errHTTPBadRequestMessageNotUTF8
}
@@ -669,7 +609,7 @@ func (s *Server) handleSubscribeJSON(w http.ResponseWriter, r *http.Request, v *
}
return buf.String(), nil
}
return s.handleSubscribe(w, r, v, "json", "application/x-ndjson", encoder)
return s.handleSubscribeHTTP(w, r, v, "application/x-ndjson", encoder)
}
func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -683,7 +623,7 @@ func (s *Server) handleSubscribeSSE(w http.ResponseWriter, r *http.Request, v *v
}
return fmt.Sprintf("data: %s\n", buf.String()), nil
}
return s.handleSubscribe(w, r, v, "sse", "text/event-stream", encoder)
return s.handleSubscribeHTTP(w, r, v, "text/event-stream", encoder)
}
func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *visitor) error {
@@ -693,33 +633,25 @@ func (s *Server) handleSubscribeRaw(w http.ResponseWriter, r *http.Request, v *v
}
return "\n", nil // "keepalive" and "open" events just send an empty line
}
return s.handleSubscribe(w, r, v, "raw", "text/plain", encoder)
return s.handleSubscribeHTTP(w, r, v, "text/plain", encoder)
}
func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visitor, format string, contentType string, encoder messageEncoder) error {
func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v *visitor, contentType string, encoder messageEncoder) error {
if err := v.SubscriptionAllowed(); err != nil {
return errHTTPTooManyRequestsLimitSubscriptions
}
defer v.RemoveSubscription()
topicsStr := strings.TrimSuffix(r.URL.Path[1:], "/"+format) // Hack
topicIDs := util.SplitNoEmpty(topicsStr, ",")
topics, err := s.topicsFromIDs(topicIDs...)
topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
if err != nil {
return err
}
poll := readParam(r, "x-poll", "poll", "po") == "1"
scheduled := readParam(r, "x-scheduled", "scheduled", "sched") == "1"
since, err := parseSince(r, poll)
if err != nil {
return err
}
messageFilter, titleFilter, priorityFilter, tagsFilter, err := parseQueryFilters(r)
poll, since, scheduled, filters, err := parseSubscribeParams(r)
if err != nil {
return err
}
var wlock sync.Mutex
sub := func(msg *message) error {
if !passesQueryFilter(msg, messageFilter, titleFilter, priorityFilter, tagsFilter) {
if !filters.Pass(msg) {
return nil
}
m, err := encoder(msg)
@@ -769,42 +701,122 @@ func (s *Server) handleSubscribe(w http.ResponseWriter, r *http.Request, v *visi
}
}
func parseQueryFilters(r *http.Request) (messageFilter string, titleFilter string, priorityFilter []int, tagsFilter []string, err error) {
messageFilter = readParam(r, "x-message", "message", "m")
titleFilter = readParam(r, "x-title", "title", "t")
tagsFilter = util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
priorityFilter = make([]int, 0)
for _, p := range util.SplitNoEmpty(readParam(r, "x-priority", "priority", "prio", "p"), ",") {
priority, err := util.ParsePriority(p)
if err != nil {
return "", "", nil, nil, err
}
priorityFilter = append(priorityFilter, priority)
func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *visitor) error {
if r.Header.Get("Upgrade") != "websocket" {
return errHTTPBadRequestWebSocketsUpgradeHeaderMissing
}
return
if err := v.SubscriptionAllowed(); err != nil {
return errHTTPTooManyRequestsLimitSubscriptions
}
defer v.RemoveSubscription()
topics, topicsStr, err := s.topicsFromPath(r.URL.Path)
if err != nil {
return err
}
poll, since, scheduled, filters, err := parseSubscribeParams(r)
if err != nil {
return err
}
upgrader := &websocket.Upgrader{
ReadBufferSize: wsBufferSize,
WriteBufferSize: wsBufferSize,
CheckOrigin: func(r *http.Request) bool {
return true // We're open for business!
},
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return err
}
defer conn.Close()
var wlock sync.Mutex
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error {
pongWait := s.config.KeepaliveInterval + wsPongWait
conn.SetReadLimit(wsReadLimit)
if err := conn.SetReadDeadline(time.Now().Add(pongWait)); err != nil {
return err
}
conn.SetPongHandler(func(appData string) error {
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
for {
_, _, err := conn.NextReader()
if err != nil {
return err
}
}
})
g.Go(func() error {
ping := func() error {
wlock.Lock()
defer wlock.Unlock()
if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {
return err
}
return conn.WriteMessage(websocket.PingMessage, nil)
}
for {
select {
case <-ctx.Done():
return nil
case <-time.After(s.config.KeepaliveInterval):
v.Keepalive()
if err := ping(); err != nil {
return err
}
}
}
})
sub := func(msg *message) error {
if !filters.Pass(msg) {
return nil
}
wlock.Lock()
defer wlock.Unlock()
if err := conn.SetWriteDeadline(time.Now().Add(wsWriteWait)); err != nil {
return err
}
return conn.WriteJSON(msg)
}
w.Header().Set("Access-Control-Allow-Origin", "*") // CORS, allow cross-origin requests
if poll {
return s.sendOldMessages(topics, since, scheduled, sub)
}
subscriberIDs := make([]int, 0)
for _, t := range topics {
subscriberIDs = append(subscriberIDs, t.Subscribe(sub))
}
defer func() {
for i, subscriberID := range subscriberIDs {
topics[i].Unsubscribe(subscriberID) // Order!
}
}()
if err := sub(newOpenMessage(topicsStr)); err != nil { // Send out open message
return err
}
if err := s.sendOldMessages(topics, since, scheduled, sub); err != nil {
return err
}
err = g.Wait()
if err != nil && websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
return nil // Normal closures are not errors
}
return err
}
func passesQueryFilter(msg *message, messageFilter string, titleFilter string, priorityFilter []int, tagsFilter []string) bool {
if msg.Event != messageEvent {
return true // filters only apply to messages
func parseSubscribeParams(r *http.Request) (poll bool, since sinceTime, scheduled bool, filters *queryFilter, err error) {
poll = readBoolParam(r, false, "x-poll", "poll", "po")
scheduled = readBoolParam(r, false, "x-scheduled", "scheduled", "sched")
since, err = parseSince(r, poll)
if err != nil {
return
}
if messageFilter != "" && msg.Message != messageFilter {
return false
filters, err = parseQueryFilters(r)
if err != nil {
return
}
if titleFilter != "" && msg.Title != titleFilter {
return false
}
messagePriority := msg.Priority
if messagePriority == 0 {
messagePriority = 3 // For query filters, default priority (3) is the same as "not set" (0)
}
if len(priorityFilter) > 0 && !util.InIntList(priorityFilter, messagePriority) {
return false
}
if len(tagsFilter) > 0 && !util.InStringListAll(msg.Tags, tagsFilter) {
return false
}
return true
return
}
func (s *Server) sendOldMessages(topics []*topic, since sinceTime, scheduled bool, sub subscriber) error {
@@ -865,6 +877,19 @@ func (s *Server) topicFromPath(path string) (*topic, error) {
return topics[0], nil
}
func (s *Server) topicsFromPath(path string) ([]*topic, string, error) {
parts := strings.Split(path, "/")
if len(parts) < 2 {
return nil, "", errHTTPBadRequestTopicInvalid
}
topicIDs := util.SplitNoEmpty(parts[1], ",")
topics, err := s.topicsFromIDs(topicIDs...)
if err != nil {
return nil, "", errHTTPBadRequestTopicInvalid
}
return topics, parts[1], nil
}
func (s *Server) topicsFromIDs(ids ...string) ([]*topic, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -994,7 +1019,7 @@ func (s *Server) runAtSender() {
}
}
func (s *Server) runFirebaseKeepliver() {
func (s *Server) runFirebaseKeepaliver() {
if s.firebase == nil {
return
}
@@ -1036,12 +1061,48 @@ func (s *Server) sendDelayedMessages() error {
return nil
}
func (s *Server) withRateLimit(w http.ResponseWriter, r *http.Request, handler func(w http.ResponseWriter, r *http.Request, v *visitor) error) error {
v := s.visitor(r)
if err := v.RequestAllowed(); err != nil {
return errHTTPTooManyRequestsLimitRequests
func (s *Server) limitRequests(next handleFunc) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if err := v.RequestAllowed(); err != nil {
return errHTTPTooManyRequestsLimitRequests
}
return next(w, r, v)
}
}
func (s *Server) authWrite(next handleFunc) handleFunc {
return s.withAuth(next, auth.PermissionWrite)
}
func (s *Server) authRead(next handleFunc) handleFunc {
return s.withAuth(next, auth.PermissionRead)
}
func (s *Server) withAuth(next handleFunc, perm auth.Permission) handleFunc {
return func(w http.ResponseWriter, r *http.Request, v *visitor) error {
if s.auth == nil {
return next(w, r, v)
}
topics, _, err := s.topicsFromPath(r.URL.Path)
if err != nil {
return err
}
var user *auth.User // may stay nil if no auth header!
username, password, ok := r.BasicAuth()
if ok {
if user, err = s.auth.Authenticate(username, password); err != nil {
log.Printf("authentication failed: %s", err.Error())
return errHTTPUnauthorized
}
}
for _, t := range topics {
if err := s.auth.Authorize(user, t.ID, perm); err != nil {
log.Printf("unauthorized: %s", err.Error())
return errHTTPForbidden
}
}
return next(w, r, v)
}
return handler(w, r, v)
}
// visitor creates or retrieves a rate.Limiter for the given visitor.
@@ -1065,9 +1126,3 @@ func (s *Server) visitor(r *http.Request) *visitor {
v.Keepalive()
return v
}
func (s *Server) inc(counter *int64) {
s.mu.Lock()
defer s.mu.Unlock()
*counter++
}

View File

@@ -1,20 +1,28 @@
# ntfy server config file
# Public facing base URL of the service (e.g. https://ntfy.sh or https://ntfy.example.com)
# This setting is currently only used by the e-mail sending feature (outgoing mail only).
# This setting is currently only used by the attachments and e-mail sending feature (outgoing mail only).
#
# base-url:
# Listen address for the HTTP & HTTPS web server. If "listen-https" is set, you must also
# set "key-file" and "cert-file". Format: <hostname>:<port>
# set "key-file" and "cert-file". Format: [<ip>]:<port>, e.g. "1.2.3.4:8080".
#
# To listen on all interfaces, you may omit the IP address, e.g. ":443".
# To disable HTTP, set "listen-http" to "-".
#
# listen-http: ":80"
# listen-https:
# Listen on a Unix socket, e.g. /var/lib/ntfy/ntfy.sock
# This can be useful to avoid port issues on local systems, and to simplify permissions.
#
# listen-unix: <socket-path>
# Path to the private key & cert file for the HTTPS web server. Not used if "listen-https" is not set.
#
# key-file:
# cert-file:
# key-file: <filename>
# cert-file: <filename>
# If set, also publish messages to a Firebase Cloud Messaging (FCM) topic for your app.
# This is optional and only required to save battery when using the Android app.
@@ -24,20 +32,40 @@
# If set, messages are cached in a local SQLite database instead of only in-memory. This
# allows for service restarts without losing messages in support of the since= parameter.
#
# The "cache-duration" parameter defines the duration for which messages will be buffered
# before they are deleted. This is required to support the "since=..." and "poll=1" parameter.
# To disable the cache entirely (on-disk/in-memory), set "cache-duration" to 0.
# The cache file is created automatically, provided that the correct permissions are set.
#
# Note: If you are running ntfy with systemd, make sure this cache file is owned by the
# ntfy user and group by running: chown ntfy.ntfy <filename>.
# Debian/RPM package users:
# Use /var/cache/ntfy/cache.db as cache file to avoid permission issues. The package
# creates this folder for you.
#
# Check your permissions:
# If you are running ntfy with systemd, make sure this cache file is owned by the
# ntfy user and group by running: chown ntfy.ntfy <filename>.
#
# cache-file: <filename>
# Duration for which messages will be buffered before they are deleted.
# This is required to support the "since=..." and "poll=1" parameter.
#
# You can disable the cache entirely by setting this to 0.
#
# cache-duration: "12h"
# If set, access to the ntfy server and API can be controlled on a granular level using
# the 'ntfy user' and 'ntfy access' commands. See the --help pages for details, or check the docs.
#
# - auth-file is the SQLite user/access database; it is created automatically if it doesn't already exist
# - auth-default-access defines the default/fallback access if no access control entry is found; it can be
# set to "read-write" (default), "read-only", "write-only" or "deny-all".
#
# Debian/RPM package users:
# Use /var/lib/ntfy/user.db as user database to avoid permission issues. The package
# creates this folder for you.
#
# Check your permissions:
# If you are running ntfy with systemd, make sure this user database file is owned by the
# ntfy user and group by running: chown ntfy.ntfy <filename>.
#
# auth-file: <filename>
# auth-default-access: "read-write"
# If set, the X-Forwarded-For header is used to determine the visitor IP address
# instead of the remote address of the connection.
#
@@ -91,7 +119,7 @@
#
# Note that the Android app has a hardcoded timeout at 77s, so it should be less than that.
#
# keepalive-interval: "30s"
# keepalive-interval: "45s"
# Interval in which the manager prunes old messages, deletes topics
# and prints the stats.

113
server/server_firebase.go Normal file
View File

@@ -0,0 +1,113 @@
package server
import (
"context"
"encoding/json"
firebase "firebase.google.com/go"
"firebase.google.com/go/messaging"
"fmt"
"google.golang.org/api/option"
"heckel.io/ntfy/auth"
"strings"
)
const (
fcmMessageLimit = 4000
)
// maybeTruncateFCMMessage performs best-effort truncation of FCM messages.
// The docs say the limit is 4000 characters, but during testing it wasn't quite clear
// what fields matter; so we're just capping the serialized JSON to 4000 bytes.
func maybeTruncateFCMMessage(m *messaging.Message) *messaging.Message {
s, err := json.Marshal(m)
if err != nil {
return m
}
if len(s) > fcmMessageLimit {
over := len(s) - fcmMessageLimit + 16 // = len("truncated":"1",), sigh ...
message, ok := m.Data["message"]
if ok && len(message) > over {
m.Data["truncated"] = "1"
m.Data["message"] = message[:len(message)-over]
}
}
return m
}
func createFirebaseSubscriber(credentialsFile string, auther auth.Auther) (subscriber, error) {
fb, err := firebase.NewApp(context.Background(), nil, option.WithCredentialsFile(credentialsFile))
if err != nil {
return nil, err
}
msg, err := fb.Messaging(context.Background())
if err != nil {
return nil, err
}
return func(m *message) error {
fbm, err := toFirebaseMessage(m, auther)
if err != nil {
return err
}
_, err = msg.Send(context.Background(), fbm)
return err
}, nil
}
func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
switch m.Event {
case keepaliveEvent, openEvent:
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
}
case messageEvent:
allowForward := true
if auther != nil {
allowForward = auther.Authorize(nil, m.Topic, auth.PermissionRead) == nil
}
if allowForward {
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
"priority": fmt.Sprintf("%d", m.Priority),
"tags": strings.Join(m.Tags, ","),
"click": m.Click,
"title": m.Title,
"message": m.Message,
"encoding": m.Encoding,
}
if m.Attachment != nil {
data["attachment_name"] = m.Attachment.Name
data["attachment_type"] = m.Attachment.Type
data["attachment_size"] = fmt.Sprintf("%d", m.Attachment.Size)
data["attachment_expires"] = fmt.Sprintf("%d", m.Attachment.Expires)
data["attachment_url"] = m.Attachment.URL
}
} else {
// If anonymous read for a topic is not allowed, we cannot send the message along
// via Firebase. Instead, we send a "poll_request" message, asking the client to poll.
data = map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": pollRequestEvent,
"topic": m.Topic,
}
}
}
var androidConfig *messaging.AndroidConfig
if m.Priority >= 4 {
androidConfig = &messaging.AndroidConfig{
Priority: "high",
}
}
return maybeTruncateFCMMessage(&messaging.Message{
Topic: m.Topic,
Data: data,
Android: androidConfig,
}), nil
}

View File

@@ -0,0 +1,170 @@
package server
import (
"encoding/json"
"errors"
"firebase.google.com/go/messaging"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/auth"
"strings"
"testing"
)
type testAuther struct {
Allow bool
}
func (t testAuther) Authenticate(_, _ string) (*auth.User, error) {
return nil, errors.New("not used")
}
func (t testAuther) Authorize(_ *auth.User, _ string, _ auth.Permission) error {
if t.Allow {
return nil
}
return errors.New("unauthorized")
}
func TestToFirebaseMessage_Keepalive(t *testing.T) {
m := newKeepaliveMessage("mytopic")
fbm, err := toFirebaseMessage(m, nil)
require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic)
require.Nil(t, fbm.Android)
require.Equal(t, map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
}, fbm.Data)
}
func TestToFirebaseMessage_Open(t *testing.T) {
m := newOpenMessage("mytopic")
fbm, err := toFirebaseMessage(m, nil)
require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic)
require.Nil(t, fbm.Android)
require.Equal(t, map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": m.Event,
"topic": m.Topic,
}, fbm.Data)
}
func TestToFirebaseMessage_Message_Normal_Allowed(t *testing.T) {
m := newDefaultMessage("mytopic", "this is a message")
m.Priority = 4
m.Tags = []string{"tag 1", "tag2"}
m.Click = "https://google.com"
m.Title = "some title"
m.Attachment = &attachment{
Name: "some file.jpg",
Type: "image/jpeg",
Size: 12345,
Expires: 98765543,
URL: "https://example.com/file.jpg",
Owner: "some-owner",
}
fbm, err := toFirebaseMessage(m, &testAuther{Allow: true})
require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic)
require.Equal(t, &messaging.AndroidConfig{
Priority: "high",
}, fbm.Android)
require.Equal(t, map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": "message",
"topic": "mytopic",
"priority": "4",
"tags": strings.Join(m.Tags, ","),
"click": "https://google.com",
"title": "some title",
"message": "this is a message",
"encoding": "",
"attachment_name": "some file.jpg",
"attachment_type": "image/jpeg",
"attachment_size": "12345",
"attachment_expires": "98765543",
"attachment_url": "https://example.com/file.jpg",
}, fbm.Data)
}
func TestToFirebaseMessage_Message_Normal_Not_Allowed(t *testing.T) {
m := newDefaultMessage("mytopic", "this is a message")
m.Priority = 5
fbm, err := toFirebaseMessage(m, &testAuther{Allow: false}) // Not allowed!
require.Nil(t, err)
require.Equal(t, "mytopic", fbm.Topic)
require.Equal(t, &messaging.AndroidConfig{
Priority: "high",
}, fbm.Android)
require.Equal(t, "", fbm.Data["message"])
require.Equal(t, "", fbm.Data["priority"])
require.Equal(t, map[string]string{
"id": m.ID,
"time": fmt.Sprintf("%d", m.Time),
"event": "poll_request",
"topic": "mytopic",
}, fbm.Data)
}
func TestMaybeTruncateFCMMessage(t *testing.T) {
origMessage := strings.Repeat("this is a long string", 300)
origFCMMessage := &messaging.Message{
Topic: "mytopic",
Data: map[string]string{
"id": "abcdefg",
"time": "1641324761",
"event": "message",
"topic": "mytopic",
"priority": "0",
"tags": "",
"title": "",
"message": origMessage,
},
Android: &messaging.AndroidConfig{
Priority: "high",
},
}
origMessageLength := len(origFCMMessage.Data["message"])
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
require.Greater(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
truncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
truncatedMessageLength := len(truncatedFCMMessage.Data["message"])
serializedTruncatedFCMMessage, _ := json.Marshal(truncatedFCMMessage)
require.Equal(t, fcmMessageLimit, len(serializedTruncatedFCMMessage))
require.Equal(t, "1", truncatedFCMMessage.Data["truncated"])
require.NotEqual(t, origMessageLength, truncatedMessageLength)
}
func TestMaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
origMessage := "not really a long string"
origFCMMessage := &messaging.Message{
Topic: "mytopic",
Data: map[string]string{
"id": "abcdefg",
"time": "1641324761",
"event": "message",
"topic": "mytopic",
"priority": "0",
"tags": "",
"title": "",
"message": origMessage,
},
}
origMessageLength := len(origFCMMessage.Data["message"])
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
require.LessOrEqual(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
notTruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
notTruncatedMessageLength := len(notTruncatedFCMMessage.Data["message"])
serializedNotTruncatedFCMMessage, _ := json.Marshal(notTruncatedFCMMessage)
require.Equal(t, origMessageLength, notTruncatedMessageLength)
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
}

View File

@@ -3,11 +3,13 @@ package server
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"firebase.google.com/go/messaging"
"fmt"
"github.com/stretchr/testify/require"
"heckel.io/ntfy/auth"
"heckel.io/ntfy/util"
"math/rand"
"net/http"
"net/http/httptest"
"os"
@@ -523,6 +525,127 @@ func TestServer_SubscribeWithQueryFilters(t *testing.T) {
require.Equal(t, keepaliveEvent, messages[2].Event)
}
func TestServer_Auth_Success_Admin(t *testing.T) {
c := newTestConfig(t)
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
s := newTestServer(t, c)
manager := s.auth.(auth.Manager)
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
"Authorization": basicAuth("phil:phil"),
})
require.Equal(t, 200, response.Code)
require.Equal(t, `{"success":true}`+"\n", response.Body.String())
}
func TestServer_Auth_Success_User(t *testing.T) {
c := newTestConfig(t)
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
c.AuthDefaultRead = false
c.AuthDefaultWrite = false
s := newTestServer(t, c)
manager := s.auth.(auth.Manager)
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
require.Nil(t, manager.AllowAccess("ben", "mytopic", true, true))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
"Authorization": basicAuth("ben:ben"),
})
require.Equal(t, 200, response.Code)
}
func TestServer_Auth_Success_User_MultipleTopics(t *testing.T) {
c := newTestConfig(t)
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
c.AuthDefaultRead = false
c.AuthDefaultWrite = false
s := newTestServer(t, c)
manager := s.auth.(auth.Manager)
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
require.Nil(t, manager.AllowAccess("ben", "mytopic", true, true))
require.Nil(t, manager.AllowAccess("ben", "anothertopic", true, true))
response := request(t, s, "GET", "/mytopic,anothertopic/auth", "", map[string]string{
"Authorization": basicAuth("ben:ben"),
})
require.Equal(t, 200, response.Code)
response = request(t, s, "GET", "/mytopic,anothertopic,NOT-THIS-ONE/auth", "", map[string]string{
"Authorization": basicAuth("ben:ben"),
})
require.Equal(t, 403, response.Code)
}
func TestServer_Auth_Fail_InvalidPass(t *testing.T) {
c := newTestConfig(t)
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
c.AuthDefaultRead = false
c.AuthDefaultWrite = false
s := newTestServer(t, c)
manager := s.auth.(auth.Manager)
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
"Authorization": basicAuth("phil:INVALID"),
})
require.Equal(t, 401, response.Code)
}
func TestServer_Auth_Fail_Unauthorized(t *testing.T) {
c := newTestConfig(t)
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
c.AuthDefaultRead = false
c.AuthDefaultWrite = false
s := newTestServer(t, c)
manager := s.auth.(auth.Manager)
require.Nil(t, manager.AddUser("ben", "ben", auth.RoleUser))
require.Nil(t, manager.AllowAccess("ben", "sometopic", true, true)) // Not mytopic!
response := request(t, s, "GET", "/mytopic/auth", "", map[string]string{
"Authorization": basicAuth("ben:ben"),
})
require.Equal(t, 403, response.Code)
}
func TestServer_Auth_Fail_CannotPublish(t *testing.T) {
c := newTestConfig(t)
c.AuthFile = filepath.Join(t.TempDir(), "user.db")
c.AuthDefaultRead = true // Open by default
c.AuthDefaultWrite = true // Open by default
s := newTestServer(t, c)
manager := s.auth.(auth.Manager)
require.Nil(t, manager.AddUser("phil", "phil", auth.RoleAdmin))
require.Nil(t, manager.AllowAccess(auth.Everyone, "private", false, false))
require.Nil(t, manager.AllowAccess(auth.Everyone, "announcements", true, false))
response := request(t, s, "PUT", "/mytopic", "test", nil)
require.Equal(t, 200, response.Code)
response = request(t, s, "GET", "/mytopic/json?poll=1", "", nil)
require.Equal(t, 200, response.Code)
response = request(t, s, "PUT", "/announcements", "test", nil)
require.Equal(t, 403, response.Code) // Cannot write as anonymous
response = request(t, s, "PUT", "/announcements", "test", map[string]string{
"Authorization": basicAuth("phil:phil"),
})
require.Equal(t, 200, response.Code)
response = request(t, s, "GET", "/announcements/json?poll=1", "", nil)
require.Equal(t, 200, response.Code) // Anonymous read allowed
response = request(t, s, "GET", "/private/json?poll=1", "", nil)
require.Equal(t, 403, response.Code) // Anonymous read not allowed
}
/*
func TestServer_Curl_Publish_Poll(t *testing.T) {
s, port := test.StartServer(t)
@@ -624,61 +747,55 @@ func TestServer_UnifiedPushDiscovery(t *testing.T) {
require.Equal(t, `{"unifiedpush":{"version":1}}`+"\n", response.Body.String())
}
func TestServer_MaybeTruncateFCMMessage(t *testing.T) {
origMessage := strings.Repeat("this is a long string", 300)
origFCMMessage := &messaging.Message{
Topic: "mytopic",
Data: map[string]string{
"id": "abcdefg",
"time": "1641324761",
"event": "message",
"topic": "mytopic",
"priority": "0",
"tags": "",
"title": "",
"message": origMessage,
},
Android: &messaging.AndroidConfig{
Priority: "high",
},
}
origMessageLength := len(origFCMMessage.Data["message"])
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
require.Greater(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
func TestServer_PublishUnifiedPushBinary_AndPoll(t *testing.T) {
b := make([]byte, 12) // Max length
_, err := rand.Read(b)
require.Nil(t, err)
truncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
truncatedMessageLength := len(truncatedFCMMessage.Data["message"])
serializedTruncatedFCMMessage, _ := json.Marshal(truncatedFCMMessage)
require.Equal(t, fcmMessageLimit, len(serializedTruncatedFCMMessage))
require.Equal(t, "1", truncatedFCMMessage.Data["truncated"])
require.NotEqual(t, origMessageLength, truncatedMessageLength)
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "base64", m.Encoding)
b2, err := base64.StdEncoding.DecodeString(m.Message)
require.Nil(t, err)
require.Equal(t, b, b2)
response = request(t, s, "GET", "/mytopic/json?poll=1", string(b), nil)
require.Equal(t, 200, response.Code)
m = toMessage(t, response.Body.String())
require.Equal(t, "base64", m.Encoding)
b2, err = base64.StdEncoding.DecodeString(m.Message)
require.Nil(t, err)
require.Equal(t, b, b2)
}
func TestServer_MaybeTruncateFCMMessage_NotTooLong(t *testing.T) {
origMessage := "not really a long string"
origFCMMessage := &messaging.Message{
Topic: "mytopic",
Data: map[string]string{
"id": "abcdefg",
"time": "1641324761",
"event": "message",
"topic": "mytopic",
"priority": "0",
"tags": "",
"title": "",
"message": origMessage,
},
}
origMessageLength := len(origFCMMessage.Data["message"])
serializedOrigFCMMessage, _ := json.Marshal(origFCMMessage)
require.LessOrEqual(t, len(serializedOrigFCMMessage), fcmMessageLimit) // Pre-condition
func TestServer_PublishUnifiedPushBinary_Truncated(t *testing.T) {
b := make([]byte, 5000) // Longer than max length
_, err := rand.Read(b)
require.Nil(t, err)
notTruncatedFCMMessage := maybeTruncateFCMMessage(origFCMMessage)
notTruncatedMessageLength := len(notTruncatedFCMMessage.Data["message"])
serializedNotTruncatedFCMMessage, _ := json.Marshal(notTruncatedFCMMessage)
require.Equal(t, origMessageLength, notTruncatedMessageLength)
require.Equal(t, len(serializedOrigFCMMessage), len(serializedNotTruncatedFCMMessage))
require.Equal(t, "", notTruncatedFCMMessage.Data["truncated"])
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic?up=1", string(b), nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "base64", m.Encoding)
b2, err := base64.StdEncoding.DecodeString(m.Message)
require.Nil(t, err)
require.Equal(t, 4096, len(b2))
require.Equal(t, b[:4096], b2)
}
func TestServer_PublishUnifiedPushText(t *testing.T) {
s := newTestServer(t, newTestConfig(t))
response := request(t, s, "PUT", "/mytopic?up=1", "this is a unifiedpush text message", nil)
require.Equal(t, 200, response.Code)
m := toMessage(t, response.Body.String())
require.Equal(t, "", m.Encoding)
require.Equal(t, "this is a unifiedpush text message", m.Message)
}
func TestServer_PublishAttachment(t *testing.T) {
@@ -743,10 +860,10 @@ func TestServer_PublishAttachmentExternalWithoutFilename(t *testing.T) {
msg := toMessage(t, response.Body.String())
require.Equal(t, "You received a file: Pink_flower.jpg", msg.Message)
require.Equal(t, "Pink_flower.jpg", msg.Attachment.Name)
require.Equal(t, "image/jpeg", msg.Attachment.Type)
require.Equal(t, int64(190173), msg.Attachment.Size)
require.Equal(t, int64(0), msg.Attachment.Expires)
require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL)
require.Equal(t, "", msg.Attachment.Type)
require.Equal(t, int64(0), msg.Attachment.Size)
require.Equal(t, int64(0), msg.Attachment.Expires)
require.Equal(t, "", msg.Attachment.Owner)
// Slightly unrelated cross-test: make sure we don't add an owner for external attachments
@@ -764,10 +881,10 @@ func TestServer_PublishAttachmentExternalWithFilename(t *testing.T) {
msg := toMessage(t, response.Body.String())
require.Equal(t, "This is a custom message", msg.Message)
require.Equal(t, "some file.jpg", msg.Attachment.Name)
require.Equal(t, "image/jpeg", msg.Attachment.Type)
require.Equal(t, int64(190173), msg.Attachment.Size)
require.Equal(t, int64(0), msg.Attachment.Expires)
require.Equal(t, "https://upload.wikimedia.org/wikipedia/commons/f/fd/Pink_flower.jpg", msg.Attachment.URL)
require.Equal(t, "", msg.Attachment.Type)
require.Equal(t, int64(0), msg.Attachment.Size)
require.Equal(t, int64(0), msg.Attachment.Expires)
require.Equal(t, "", msg.Attachment.Owner)
}
@@ -814,7 +931,7 @@ func TestServer_PublishAttachmentExpiryBeforeDelivery(t *testing.T) {
err := toHTTPError(t, response.Body.String())
require.Equal(t, 400, response.Code)
require.Equal(t, 400, err.HTTPCode)
require.Equal(t, 40017, err.Code)
require.Equal(t, 40015, err.Code)
}
func TestServer_PublishAttachmentTooLargeBodyVisitorAttachmentTotalSizeLimit(t *testing.T) {
@@ -993,3 +1110,7 @@ func firebaseServiceAccountFile(t *testing.T) string {
t.SkipNow()
return ""
}
func basicAuth(s string) string {
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(s)))
}

View File

@@ -2,14 +2,16 @@ package server
import (
"heckel.io/ntfy/util"
"net/http"
"time"
)
// List of possible events
const (
openEvent = "open"
keepaliveEvent = "keepalive"
messageEvent = "message"
openEvent = "open"
keepaliveEvent = "keepalive"
messageEvent = "message"
pollRequestEvent = "poll_request"
)
const (
@@ -28,6 +30,7 @@ type message struct {
Attachment *attachment `json:"attachment,omitempty"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Encoding string `json:"encoding,omitempty"` // empty for raw UTF-8, or "base64" for encoded bytes
}
type attachment struct {
@@ -70,3 +73,72 @@ func newKeepaliveMessage(topic string) *message {
func newDefaultMessage(topic, msg string) *message {
return newMessage(messageEvent, topic, msg)
}
type sinceTime time.Time
func (t sinceTime) IsAll() bool {
return t == sinceAllMessages
}
func (t sinceTime) IsNone() bool {
return t == sinceNoMessages
}
func (t sinceTime) Time() time.Time {
return time.Time(t)
}
var (
sinceAllMessages = sinceTime(time.Unix(0, 0))
sinceNoMessages = sinceTime(time.Unix(1, 0))
)
type queryFilter struct {
Message string
Title string
Tags []string
Priority []int
}
func parseQueryFilters(r *http.Request) (*queryFilter, error) {
messageFilter := readParam(r, "x-message", "message", "m")
titleFilter := readParam(r, "x-title", "title", "t")
tagsFilter := util.SplitNoEmpty(readParam(r, "x-tags", "tags", "tag", "ta"), ",")
priorityFilter := make([]int, 0)
for _, p := range util.SplitNoEmpty(readParam(r, "x-priority", "priority", "prio", "p"), ",") {
priority, err := util.ParsePriority(p)
if err != nil {
return nil, err
}
priorityFilter = append(priorityFilter, priority)
}
return &queryFilter{
Message: messageFilter,
Title: titleFilter,
Tags: tagsFilter,
Priority: priorityFilter,
}, nil
}
func (q *queryFilter) Pass(msg *message) bool {
if msg.Event != messageEvent {
return true // filters only apply to messages
}
if q.Message != "" && msg.Message != q.Message {
return false
}
if q.Title != "" && msg.Title != q.Title {
return false
}
messagePriority := msg.Priority
if messagePriority == 0 {
messagePriority = 3 // For query filters, default priority (3) is the same as "not set" (0)
}
if len(q.Priority) > 0 && !util.InIntList(q.Priority, messagePriority) {
return false
}
if len(q.Tags) > 0 && !util.InStringListAll(msg.Tags, q.Tags) {
return false
}
return true
}

View File

@@ -1,69 +1,30 @@
package server
import (
"fmt"
"heckel.io/ntfy/util"
"io"
"net/http"
"net/url"
"path"
"strconv"
"time"
"strings"
)
const (
peakAttachmentTimeout = 2500 * time.Millisecond
peakAttachmentReadBytes = 128
)
func maybePeakAttachmentURL(m *message) error {
return maybePeakAttachmentURLInternal(m, peakAttachmentTimeout)
func readBoolParam(r *http.Request, defaultValue bool, names ...string) bool {
value := strings.ToLower(readParam(r, names...))
if value == "" {
return defaultValue
}
return value == "1" || value == "yes" || value == "true"
}
func maybePeakAttachmentURLInternal(m *message, timeout time.Duration) error {
if m.Attachment == nil || m.Attachment.URL == "" {
return nil
}
client := http.Client{
Timeout: timeout,
Transport: &http.Transport{
DisableCompression: true, // Disable "Accept-Encoding: gzip", otherwise we won't get the Content-Length
Proxy: http.ProxyFromEnvironment,
},
}
req, err := http.NewRequest(http.MethodGet, m.Attachment.URL, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "ntfy")
resp, err := client.Do(req)
if err != nil {
return errHTTPBadRequestAttachmentURLPeakGeneral
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return errHTTPBadRequestAttachmentURLPeakNon2xx
}
if size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64); err == nil {
m.Attachment.Size = size
}
buf := make([]byte, peakAttachmentReadBytes)
io.ReadFull(resp.Body, buf) // Best effort: We don't care about the error
mimeType, ext := util.DetectContentType(buf, m.Attachment.URL)
m.Attachment.Type = resp.Header.Get("Content-Type")
if m.Attachment.Type == "" {
m.Attachment.Type = mimeType
}
if m.Attachment.Name == "" {
u, err := url.Parse(m.Attachment.URL)
if err != nil {
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
} else {
m.Attachment.Name = path.Base(u.Path)
if m.Attachment.Name == "." || m.Attachment.Name == "/" {
m.Attachment.Name = fmt.Sprintf("attachment%s", ext)
}
func readParam(r *http.Request, names ...string) string {
for _, name := range names {
value := r.Header.Get(name)
if value != "" {
return strings.TrimSpace(value)
}
}
return nil
for _, name := range names {
value := r.URL.Query().Get(strings.ToLower(name))
if value != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@@ -2,18 +2,28 @@ package server
import (
"github.com/stretchr/testify/require"
"net/http"
"testing"
)
func TestMaybePeakAttachmentURL_Success(t *testing.T) {
m := &message{
Attachment: &attachment{
URL: "https://ntfy.sh/static/img/ntfy.png",
},
}
require.Nil(t, maybePeakAttachmentURL(m))
require.Equal(t, "ntfy.png", m.Attachment.Name)
require.Equal(t, int64(3627), m.Attachment.Size)
require.Equal(t, "image/png", m.Attachment.Type)
require.Equal(t, int64(0), m.Attachment.Expires)
func TestReadBoolParam(t *testing.T) {
r, _ := http.NewRequest("GET", "https://ntfy.sh/mytopic?up=1&firebase=no", nil)
up := readBoolParam(r, false, "x-up", "up")
firebase := readBoolParam(r, true, "x-firebase", "firebase")
require.Equal(t, true, up)
require.Equal(t, false, firebase)
r, _ = http.NewRequest("GET", "https://ntfy.sh/mytopic", nil)
r.Header.Set("X-Up", "yes")
r.Header.Set("X-Firebase", "0")
up = readBoolParam(r, false, "x-up", "up")
firebase = readBoolParam(r, true, "x-firebase", "firebase")
require.Equal(t, true, up)
require.Equal(t, false, firebase)
r, _ = http.NewRequest("GET", "https://ntfy.sh/mytopic", nil)
up = readBoolParam(r, false, "x-up", "up")
firebase = readBoolParam(r, true, "x-up", "up")
require.Equal(t, false, up)
require.Equal(t, true, firebase)
}

View File

@@ -5,6 +5,7 @@ import (
"heckel.io/ntfy/server"
"math/rand"
"net/http"
"path/filepath"
"testing"
"time"
)
@@ -22,6 +23,8 @@ func StartServer(t *testing.T) (*server.Server, int) {
func StartServerWithConfig(t *testing.T, conf *server.Config) (*server.Server, int) {
port := 10000 + rand.Intn(20000)
conf.ListenHTTP = fmt.Sprintf(":%d", port)
conf.AttachmentCacheDir = t.TempDir()
conf.CacheFile = filepath.Join(t.TempDir(), "cache.db")
s, err := server.New(conf)
if err != nil {
t.Fatal(err)

View File

@@ -1,9 +1,12 @@
package util
import (
"encoding/base64"
"errors"
"fmt"
"github.com/gabriel-vasile/mimetype"
"golang.org/x/term"
"io"
"math/rand"
"os"
"regexp"
@@ -202,3 +205,44 @@ func ParseSize(s string) (int64, error) {
return int64(value), nil
}
}
// ReadPassword will read a password from STDIN. If the terminal supports it, it will not print the
// input characters to the screen. If not, it'll just read using normal readline semantics (useful for testing).
func ReadPassword(in io.Reader) ([]byte, error) {
// If in is a file and a character device (a TTY), use term.ReadPassword
if f, ok := in.(*os.File); ok {
stat, err := f.Stat()
if err != nil {
return nil, err
}
if (stat.Mode() & os.ModeCharDevice) == os.ModeCharDevice {
password, err := term.ReadPassword(int(f.Fd())) // This is always going to be 0
if err != nil {
return nil, err
}
return password, nil
}
}
// Fallback: Manually read util \n if found, see #69 for details why this is so manual
password := make([]byte, 0)
buf := make([]byte, 1)
for {
_, err := in.Read(buf)
if err == io.EOF || buf[0] == '\n' {
break
} else if err != nil {
return nil, err
} else if len(password) > 10240 {
return nil, errors.New("passwords this long are not supported")
}
password = append(password, buf[0])
}
return password, nil
}
// BasicAuth encodes the Authorization header value for basic auth
func BasicAuth(user, pass string) string {
return fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", user, pass))))
}